Files
J3vbandClaude Fable 5 63c87df487 refactor(b3-8): settings/audit family behind SettingsService (S-09, family 1) (#1477)
* feat(service): settings family — SettingsService over the Store seam

The B3-8 settings/audit family's service: List, Patch (whitelist,
boolean normalization, the require_2fa preconditions incl. the TOTP
census and the unrelated-key guard, atomic apply, one audit row per
changed key) and Setting (the read the hub and the backup scheduler
consume; wraps db.ErrNotFound as the store reports it). db gains
ApplySettings — the handler's raw upsert loop as one hand-written
transactional wrapper where raw SQL belongs — and Store carries it.

parseSettingsPatchBool duplicates auth.go's parseBooleanSettingValue
with the admin surface's own pinned error wording; both messages are
test-pinned, so the twins stay separate.

Service-level characterization in settings_test.go mirrors the
admin/api_test.go PATCH rows and adds the service-only contracts
(ErrNotFound wrap, audit rows, multi-key apply).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* refactor(admin): settings handlers thin over SettingsService; scheduler reads via it

handleGetSettings/handlePatchSettings become adapters (decode, delegate,
map ErrBadRequest to 400 with the service's prefix-free message); the
whitelist and every precondition now live only in the service, so
admin/types.go's copy is gone. MaintainBackups reads backup_schedule and
backup_retention through the service — its backup mechanics keep the
handle — and the maintenance chain threads Settings from the runtime the
hub stage built. NewHandler/NewAdminAPI gain the settings parameter;
all 207 construction sites wired via the newTestSettingsService helper.

Behavior parity pinned by the existing TestAdminAPI_*Settings* rows
(all green); the only unpinned change is the PATCH 500 path collapsing
its four stage-specific internal messages into one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* refactor(ws): hub settings cache reads through a SettingsReader

The hub's server_name/motd cache consumes a consumer-side SettingsReader
interface (service.SettingsService satisfies it; HubOptions.Settings is
required and validated like DB and Limiter — the RequiredCollaborators
pin gains the refusal case). hub_settings.go no longer touches db at
all, so the import pin from the B3-5 finisher goes, and its allowlist
row goes with it; the thinned admin settings handler's row is deleted
too — two allowlist rows down, the settings family's persistence now
lives only in db/ and service/.

Test helpers (both ws package namespaces) default the reader over the
test database; newBareHub wires it explicitly; production passes
Services.Settings from StartRuntime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* docs(boundaries,b3): settings/audit family re-measure and evidence

The backup pair takes its forecast boundary disposition; the family's
two deleted rows and the disposition counts (28/18/15 -> 24/18/17)
re-derived from the tool. Family evidence block appended to the B3-8
section; README B3 row records B3-5 complete and the family opened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* fix(service): prefix-free ErrBadRequest wraps for the pinned admin bodies

The %.0w rework was meant to ride the service commit but was left
unstaged: with the plain %w wrap the PATCH error bodies carry a
'bad request: ' prefix the admin pins reject. Zero-width wrapping keeps
errors.Is(ErrBadRequest) while err.Error() stays exactly the pinned
message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* test(app): lifecycle hub fixtures wire the required Settings reader

The two direct ws.NewHub sites in lifecycle_test predate Settings
becoming required; race across internal/app is green again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* test(db): cover ApplySettings — the db coverage floor caught the gap

CI's coverage floor failed db at 78.9% against 79.3%: ApplySettings was
exercised only from service tests, which do not count toward db's own
figure. Four db-side rows cover the apply, the empty no-op, the
in-transaction failure rollback and the begin failure, using the
package's full-migration opener.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* chore(coverage): raise the service floor to the branch's measured 69.2

The settings family's tested service code raised the Linux figure from
the 67.8 floor to 69.2; the ratchet raises the floor in the same PR
(service is not in the run-varying set). db stays at 79.3 — this PR
restores its figure (79.5 with the ApplySettings tests), it did not set
out to raise it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 15:13:38 +00:00

69 KiB
Raw Permalink Blame History

Server boundaries — database-call and lifecycle inventory

Written: 2026-08-29 (B3-0), measured at dev ad4defc2. Re-measured: 2026-08-30 (B3-2) — the first table and the auth slice's after-state at fe1d11b8 (pre-squash; the squash SHA is in the plan's B3-2 evidence block); 2026-08-30 (B3-3) — the first table again, and the hub lifecycle section's after-state rows, on feat/b3-3-lifecycle; 2026-08-31 (B3-4) — the construction-and-setters after-state, on feat/b3-4-hub-options; 2026-08-31 (B3-5, first split PR) — the first table, on feat/b3-5-ws-split (handshake auth and fresh-connect rows moved with their code); 2026-08-31 (B3-5, second split PR) — the first table, on feat/b3-5-ws-split-2 (the reconnect replay family split out of serve.go's row into a new ws/replay.go row; the registry move added no row — that family makes no db use); 2026-08-31 (B3-5, third split PR) — the first table, on feat/b3-5-ws-split-3 (the visibility responsibility gathered from hub_broadcast.go, serve.go and hub.go into a new ws/hub_visibility.go row; hub_broadcast.go's row now carries only the member/presence payload reads); 2026-08-31 (B3-5, fourth split PR) — the first table, on feat/b3-5-ws-split-4 (voice broadcast leftovers joined voice_broadcast.go's code with no row change — that file makes no db use — and the presence coalescer split into a new ws/hub_presence.go adapter row, its only db use the pure BroadcastStatus helper; hub_broadcast.go's row is down to the member payload reads); 2026-08-31 (B3-5, finisher PR) — the first table, on feat/b3-5-ws-split-5 (hub.go's GetSetting calls left with the settings cache for hub_settings.go; that file's persistence runs through the h.db field, which needs no import, so it pins the db import — a documented var _ *db.DB — to stay on the rule's and this table's books: the rule and rows track importers, and a field-calling file without the pin would be invisible to both, which a review flagged on the finisher PR. hub.go and the new hub_options.go are type-only boundary rows holding/validating the handle. The disposition counts were also re-derived from the tool's summary: boundary had been stale at 12 since the seed-profile row landed); 2026-08-31 (B3-8, settings/audit family) — the first table, on feat/b3-8-settings-family: the family's persistence now lives only in db/ and service/. The thinned admin/handlers_settings.go and the reader-backed ws/hub_settings.go (pin removed — the file no longer touches db at all) stop importing db, so both rows are deleted; the backup pair takes the disposition this table forecast — boundary — now that settings-ops owns the settings (backup_maintenance.go reads schedule/retention through the service and keeps only backup mechanics; handlers_backup.go owns the handle for VACUUM INTO, the WAL checkpoint and close-and-swap restore). settings-ops disappears from the move targets: 28 → 24 move, 15 → 17 boundary. Owner: the B3 plan, plans/b3-server-architecture-guardrails-2026-08-29.md. Regenerate the first table: cd Server && go run ./cmd/dbinventory and paste its output between the markers below. The tool exits non-zero when a file imports db without a row, or a row names a file that no longer imports it — the same two failures go test ./invariants/ reports.

This is the inventory the roadmap's B3 entry gate asks for ("hotspots and direct database call sites have an owned inventory") and the evidence its exit gate consumes ("every direct database use above the domain layer is justified or removed"). It answers three questions for every production Go file above the domain layer: does it import db; what does it use db for; and what happens to that use — one of four dispositions from the layout-refactor supplement:

Disposition Meaning Rows
move persistence or a domain decision that belongs behind a service; Family names the service B3-8 (or B3-2) builds 24
adapter a transport adapter that uses db types or pure helpers only — response shapes, status helpers — no persistence call 18
boundary an explicit composition or transaction boundary that legitimately owns a handle (process entry, CLIs, health probe) 17
remove the import is unnecessary and goes 0

The rows live in code, not only here: Server/invariants/db_import_boundary.go holds them as DBImportAllow, the db-import-boundary rule fails any new importer that has no row, and TestDBImportAllowIsLive fails any row whose file stopped importing db. The db surface only shrinks — B3-2 deleted the two auth handler rows (28 → 26 move), B3-8 deletes a family's rows as it moves. A B3-5 file split can spread one row's code across two rows without adding any new db use (serve.goreplay.go, then the visibility gather into hub_visibility.go; the finisher turned hub.go type-only while hub_settings.go took its move row — 26 → 28 move across the series with the calls behind them only moved), so it is the surface, not the row count, that ratchets.

How the measurement works

Server/cmd/dbinventory is syntactic (go/parser + go/ast, no type information), like the invariants package. It records three things per file:

  • db.* types — selectors that db declares as types (db.User, db.Channel, …). A file whose only use is types is type-only: it shapes data, it does not persist.
  • db.* funcs and sentinels — package-level functions (db.WriteAudit(), db.Open()) and values (db.ErrNotFound). Pure helpers such as db.StatusForViewer() and db.BroadcastStatus() land here too; they are computations over already-loaded rows, not queries.
  • *db.DB method calls — calls whose receiver is an identifier declared *db.DB in the file (parameter, result, var, or assigned from db.Open*), or a selector whose final field is declared *db.DB anywhere in the same package (h.db.X, s.deps.DB.X). This is the persistence surface the dispositions are about.

A shape the walker cannot see (a *db.DB reaching a file through an untyped interface, say) would show up as a row with an import and nothing recorded — which is a row worth reading, and none exists today.

Database-call inventory

File db.* types db.* funcs and sentinels *db.DB method calls Shape Disposition Family Why
admin/admin.go DB type-only boundary holds the handle for the admin mux; no calls
admin/api.go DB type-only boundary passes the handle to handlers; no calls
admin/backup_maintenance.go DB×3 CheckBackupIntegrity() ErrNotFound×2 WriteAudit()×2 BackupToSafe calls boundary scheduled backup mechanics on the maintenance tick; settings via the service
admin/handlers_backup.go DB×5 CheckBackupIntegrity()×2 WriteAudit()×2 BackupToSafe×2 Close LogAudit SQLDb calls boundary backup create/list/delete/restore owns the handle: VACUUM INTO, WAL checkpoint, close-and-swap
admin/handlers_channel_perms.go Channel ChannelRoleOverride×2 ChannelUserOverride×2 DB×8 Role×2 User×2 WriteAudit()×4 DeleteChannelOverride DeleteChannelUserOverride GetChannel GetChannelPermissions×2 GetRoleByID×3 GetUserByID GetUserChannelPermissions×2 ListChannelRoleOverrides ListChannelUserOverrides ListUserIDsByRole×2 UpsertChannelOverride UpsertChannelUserOverride calls move channel override CRUD decides permission policy in the handler
admin/handlers_channels.go Channel×2 ChannelUpdate×2 DB×6 WriteAudit()×3 AdminCreateChannel AdminDeleteChannel AdminUpdateChannel×2 GetAuditLog GetChannel×4 ListChannels calls move channel channel CRUD + audit
admin/handlers_roles.go DB×4 GetRoleByID ListRoles calls move role two reads; service/role.go already owns the writes
admin/handlers_tokens.go DB×3 User WriteAudit()×2 CreateAPIToken GetOwnerUser GetUserByUsername ListAPITokens RevokeAPIToken calls move auth API-token CRUD duplicated in token_cli.go
admin/handlers_users.go DB×4 Role User GetServerStats GetUserByID×2 ListAllUsers calls move user user list, stats, lookups
admin/helpers.go Role×2 User type-only adapter Role/User types in response helpers
admin/logstream.go DB×3 type-only boundary handle threaded to the SSE stream's auth check; no calls
admin/middleware.go DB×2 Role×2 type-only move auth owner gate re-reads the role — OC-0345
admin/setup_handler.go DB×4 ErrConflict WriteAudit()×2 CreateChannel×2 CreateInvite CreateOwnerIfEmpty CreateSession GetSetting×3 UserCount calls move auth first-run owner creation (setup sub-family)
admin/setup_wizard.go DB BeginTx calls move auth BeginTx for the wizard; setup sub-family
admin/types.go Channel×3 DB Role User UserWithRole GetRoleByID calls adapter response DTOs; the one GetRoleByID moves with handlers_users
api/channel_handler.go DB MessageAPIResponse×2 MessageSearchResult×2 ReactionUser User×8 type-only adapter response types only; service owns the calls
api/dm_handler.go DB DMChannelInfo DMUser×2 User×8 NewDMChannelInfo() StatusForViewer() calls adapter DM response types + pure status helpers
api/emoji_handler.go DB Emoji×3 User×2 type-only adapter Emoji/User types only
api/gif_handler.go DB type-only adapter handle in the signature, unused for calls
api/invite_handler.go DB Invite User×2 type-only adapter Invite/User types only
api/middleware.go DB Role Session User DeleteSession TouchAPIToken TouchSession calls move auth session/API-token touch and revoke
api/plugins_handler.go Auditor×2 WriteAudit() calls adapter db.Auditor is the seam; WriteAudit only
api/profile_handler.go DB×2 Session×2 User×7 CreateAttachment calls move upload avatar upload creates the attachment row
api/router.go DB×4 PingRead SQLDb calls boundary health probe (PingRead, SQLDb); hub construction left in B3-3
api/upload_handler.go AttachmentAccess×2 DB×5 Role User×3 CreateAttachment GetAttachmentWithChannel IsAvatarFileURL IsDMParticipant QueryRowContext calls move upload attachment access + a raw QueryRowContext
auth/helpers.go User type-only adapter db.User type in a helper signature
auth/resolve.go APIToken Role×2 Session×2 User×2 type-only adapter Session/APIToken/Role/User types; resolution is injected
cmd/gendocs/main.go DB×3 Migrate() Open() Close×2 QueryContext×2 calls boundary docs generator migrates its own in-memory catalog
cmd/seed/main.go DB×6 Migrate() Open() Close CreateChannel CreateMessage×2 CreateUser GetOrCreateDMChannel GetUserByUsername ListChannels QueryRowContext calls boundary developer seeding tool owns its handle
cmd/seed/profile_alpha.go DB×3 Migrate() BeginTx ExecContext×2 QueryRowContext calls boundary the alpha profile writes through the handle main.go owns
internal/app/app.go AuditWriter DB type-only boundary the App holds the handle for its lifetime; no calls
internal/app/database.go DB×2 Migrate() OpenWithMaxReaders() ClearAllVoiceStates ResetAllUserStatuses calls boundary opens the handle, migrates, clears stale state at boot
internal/app/hub.go DB type-only boundary hands the handle to the hub and the service layer it builds
internal/app/maintenance.go DB×3 DeleteExpiredSessions DeleteOrphanedAttachments calls boundary periodic worker: expired sessions, backups, orphan attachments
internal/app/persistence.go AuditWriter×2 DB×4 ErrNotFound NewAuditWriter() GetMaxEventSeq GetSetting SetAuditWriter SetSetting calls boundary event persister, audit writer and the boot seq seed own the handle
internal/app/plugins.go DB type-only boundary passes the handle to the plugin registry as its store; no calls
plugin/pluginstore.go PluginRow×3 type-only adapter PluginRow type only; the store is injected
token_cli.go DB×3 User Migrate() OpenShared() WriteAudit()×3 Close CreateAPIToken GetOwnerUser GetUserByUsername ListAPITokens RevokeAPIToken RevokeAPITokenByLabel calls move auth API-token CLI duplicates admin/handlers_tokens.go
ws/client.go User×2 type-only adapter db.User type on the connection
ws/deps.go Channel DB×5 GetRoleForUser×3 IsDMParticipant calls move channel role and DM-membership reads behind the hub's deps
ws/event.go BroadcastStatus() calls adapter pure BroadcastStatus helper
ws/event_persister.go PersistedEvent×2 type-only adapter PersistedEvent type; store is an interface
ws/eventstore.go PersistedEvent×3 type-only adapter PersistedEvent type; store is an interface
ws/handlers.go User GetChannel GetRoleForUser GetSessionWithBanStatus IsDMParticipant calls move channel channel, role, session-ban and DM reads in command handlers
ws/handlers_chat.go NewDMChannelInfo() calls adapter pure NewDMChannelInfo helper
ws/hub.go DB type-only boundary Hub state holds the handle the families read through; no calls
ws/hub_broadcast.go Channel×2 Emoji Role GetRoleForUser GetUserByID calls move channel member broadcast payloads read the user and role they announce
ws/hub_options.go DB type-only boundary construction validates and stores the handle; no calls
ws/hub_presence.go BroadcastStatus() calls adapter presence coalescer; pure BroadcastStatus helper and the MemberSummary shape
ws/hub_sweep.go User GetAllVoiceStates GetChannel GetChannelVoiceStates GetSessionsWithBanStatusBatch LeaveVoiceChannelIfMatch×2 calls move voice stale-voice sweep reads and leaves
ws/hub_visibility.go Channel×2 ChannelOverride DB User GetChannel×2 GetChannelOverridesFor GetDMParticipantIDs GetRoleByID GetUserByID GetUserDMChannelIDs ListChannels×2 calls move channel visibility and audience resolution reads channels, overrides, participants, users
ws/messages.go Channel×4 DMChannelInfo×2 DMUser×2 Emoji Role×3 User×2 VoiceState BroadcastStatus() StatusForViewer() calls adapter wire types + pure status helpers
ws/replay.go DB×2 PersistedEvent type-only move connection reconnect replay selection and delivery; serve.go's row split with its code in B3-5
ws/serve.go DB×3 ConnectStatus() GetRoleByID GetUserByID UpdateUserStatus calls move connection connect/disconnect lifecycle; B3-5 splits it by family first
ws/serve_auth.go DB×2 User StatusOffline WriteAudit() GetRoleByID GetSessionByTokenHash GetUserByID MarkUserDisconnected calls move auth handshake auth: session, user and role lookups, connect audit, failed-handshake teardown
ws/serve_pumps.go StatusOffline MarkUserDisconnected calls move user MarkUserDisconnected on pump exit
ws/serve_ready.go Channel×10 ChannelOverride×6 ChannelUnread×2 DB×7 DMChannelInfo×4 MemberSummary×3 Role×4 User VoiceState×5 StatusOffline×3 GetAllVoiceStates GetChannelOverridesFor GetChannelUnreadCounts GetRoleByID GetUserByID GetUserDMChannels GetVoiceState LeaveVoiceChannelIfMatch ListChannels ListMembers ListRoles calls move channel ready snapshot and fresh-connect: channels, overrides, unreads, DMs, members, stale-voice cleanup
ws/voice_join.go Channel×3 ChannelOverride VoiceState×5 ErrChannelFull GetChannel×2 GetChannelOverridesFor GetChannelVoiceStates GetRoleForUser GetVoiceState×6 JoinVoiceChannel JoinVoiceChannelIfCapacity LeaveVoiceChannelIfMatch SetVoiceServerDeafen SetVoiceServerMute calls move voice voice state reads and writes
ws/voice_moderation.go Role VoiceState×3 WriteAudit() CountChannelVoiceUsers GetChannel×2 GetRoleForUser GetVoiceState×2 SetVoiceServerDeafen×2 SetVoiceServerMute×2 calls move voice mute/deafen/move persist voice state

59 files import db outside db/ and service/ (. 1, admin 15, api 10, auth 2, cmd/gendocs 1, cmd/seed 2, internal/app 6, plugin 1, ws 21); 21 are type-only; 0 unlisted. Dispositions: adapter 18, boundary 17, move 24. Move targets: auth 7, channel 7, connection 2, role 1, upload 2, user 2, voice 3.

Reading the table:

  • Type-only files (14) need no service; they stay adapter. The db types they use are the wire and response shapes. Whether those types should live outside db is a B3-8 question per family, not a boundary violation.
  • ws/serve_ready.go (48 references, 11 distinct queries) is the single heaviest reader: the ready snapshot reads channels, overrides, unreads, DM channels, members, roles and voice states in one place. B3-5 made it the "fresh-connect initialisation" file (handleFreshConnect and its stale-voice cleanup moved in from serve.go) and B3-8's channel family gives it a snapshot service.
  • Two raw SQL escapes exist above the domain layer: api/upload_handler.go (QueryRowContext) and admin/handlers_backup.go (SQLDb for VACUUM INTO). Both are move; the backup one may end as an explicit boundary once settings-ops owns backups — the row is decided when that family moves, not now.
  • Duplicated persistence is visible in the families: API-token CRUD in admin/handlers_tokens.go and token_cli.go; owner-role reads in admin/middleware.go (OC-0345) and api/middleware.go; voice state in ws/hub_sweep.go, voice_join.go, voice_moderation.go. One service per family removes each duplicate.
  • ws/serve.go and ws/replay.go are the connection rows: not a domain family but the connect/disconnect lifecycle that touches four of them. B3-5 splits it by responsibility first (replay.go carries the reconnect replay family since the second split PR — type-only, it passes the handle to the shared helpers still in serve.go); the pieces then join their families' rows.

Hub lifecycle inventory

Input to B3-3 (internal/app/) and B3-4 (constructor options). The before-state was measured at ad4defc2; the after-state rows are B3-3's, on feat/b3-3-lifecycle, and are what B3-4 starts from.

Construction and setters (S-11) — before B3-3

ws.NewHub(database, limiter, svc) was called once, inside api.NewRouter (Server/api/router.go:106) — not in main.go. The seven post-construction setters and where they were called:

Setter Declared Called from Required before Run?
SetPluginRegistry ws/hub_events.go:60 api/router.go:325 only when plugins are enabled — optional collaborator
SetPluginEventSink ws/hub_events.go:70 api/router.go:328 same
SetLiveKit ws/hub_livekit.go:10 api/router.go:342 yes for voice: every voice join needs the token signer; nil means voice silently fails
SetLiveKitProcess ws/hub_livekit.go:48 api/router.go:360 only when the supervised LiveKit process is configured
SetEventPersister ws/hub_events.go:40 main.go:453 yes when persistence is on: events emitted before it is set are not persisted
SetEventStore ws/hub_events.go:48 main.go:454 yes for replay: resume without a store answers a full ready
SetPendingVoiceModFlags ws/voice_moderation.go:599 voice moderation paths at runtime no — genuinely replaceable runtime state; stays a setter

Two owners (the router and main.go) set collaborators on one hub, and the hub started (hub.Run, ws/hub.go:273) with no check that the required ones were present.

Construction and setters (S-11) — after B3-3

ws.NewHub is called from app.StartRuntime (Server/internal/app/hub.go), which also applies every setter that must land before hub.Run and then starts the dispatch goroutine. api.NewRouter takes the built hub as part of api.Runtime and returns only the handler and its cleanup.

Setter Called from (before) Called from (after) Still required before Run?
SetPluginRegistry api/router.go:325 internal/app/hub.go (wirePlugins) optional
SetPluginEventSink api/router.go:328 internal/app/hub.go (wirePlugins) optional
SetLiveKit api/router.go:342 internal/app/hub.go (startVoice) yes for voice
SetLiveKitProcess api/router.go:360 internal/app/hub.go (startVoice) when supervised
SetEventPersister main.go:453 internal/app/persistence.go (startEventPersister) yes when persistence is on
SetEventStore main.go:454 internal/app/persistence.go (startEventPersister) yes for replay
SetPendingVoiceModFlags voice moderation paths at runtime unchanged no

One owner: every row is now inside internal/app. Two of them are still in a second file — the persister and the store are set where the persister is built, one lifecycle stage after the hub, because both setters are explicitly safe to call after Run has started (ws/hub_events.go) and moving them earlier would reorder the boot. B3-4 is what collapses them into validated HubOptions at the single construction point; the router is no longer one of the places that has to change for it.

Construction and setters (S-11) — after B3-4

ws.NewHub(opts HubOptions) (*Hub, error) refuses to construct without its required collaborators (DB, Limiter) and validates option coherence (a LiveKitProcess without a LiveKit client is an error, as is a negative replay budget). The seven setters' final dispositions:

Former setter Now Why
SetLiveKit HubOptions.LiveKit was rejectIfRunning-guarded — construction wiring, never runtime state
SetLiveKitProcess HubOptions.LiveKitProcess same; the restart path relaunches the whole app, nothing re-sets a process on a live hub; requires LiveKit at validation
SetPluginRegistry HubOptions.PluginRegistry same
ConfigureReplay HubOptions.ReplayRingSize / ReplayColdLimit same — the dispatch loop reads the ring unlocked, so it is sized exactly once
SetEventPersister still a setter (ws/hub_events.go) atomic hot-swap; the persister is built one lifecycle stage after the hub and cannot exist before it
SetEventStore still a setter (ws/hub_events.go) same store wiring stage; atomic
SetPluginEventSink still a setter (ws/hub_events.go) the sink consumes the built hub's broadcaster — a genuine two-phase wire
SetPendingVoiceModFlags still a setter (ws/voice_moderation.go) per-user runtime state, the one the B3-0 table already kept

rejectIfRunning was deleted with its last construction-phase caller. The single production call site is internal/app.StartRuntime; a missing collaborator is now a startHub error at boot instead of a later panic (TestNewHub_RequiredCollaborators, TestHub_LiveKitProcessRequiresClient).

Locks

Five locks on Hub, all syncutil (so the -tags deadlock pass sees them):

Lock Declared Guards (from the field comment)
mu ws/hub.go:25 the client registry and subscriptions
seqMu ws/hub.go:58 seq assignment + replay insertion + delivery order, serialised together
settingsMu ws/hub.go:122 the cached server settings
keyHolderMu ws/hub.go:130 the voice E2EE key-holder map
presenceMu ws/hub.go:135 presence coalescing state

The lock order is not written down anywhere — not in Server/CLAUDE.md, docs/architecture/websocket.md, or hub.go. It is proven only by the -tags deadlock -count=10 ./ws/ pass. B3-5 records the order in hub.go's package comment before it moves a single function, so every pure-move commit has something to be checked against.

Start, drain, stop — before B3-3 (main.go run, main.go:107)

Start order, then the defer stack that undid it (LIFO — the last started was the first stopped):

# Start (main.go) Stop (defer, in registration order — ran in reverse)
1 background context bgCancel() :118
2 runOpenDatabasedb.OpenWithMaxReaders :314-322 database.Close() :148
3 runInitDatabasedb.Migrate :333-347
4 runInitTelemetry :369 telemetryStop() :156
5 runInitPlugins :392 runClosePlugins :161
6 api.NewRouterhub built here :164 routerCleanup() :165, then hub.GracefulStop() :172
7 runStartEventPersistence :435 (sets persister/store) runStopEventPersistence :176
8 runStartAuditWriter :488 runStopAuditWriter :186
9 runStartACME :505 via runShutdownServers :667
10 runStartMaintenance :527 maintenanceStop() :207
11 signal.NotifyContext :214 stop() :215
12 runServeAndWait :629runShutdownServers :667 srv.Shutdown, acmeSrv.Shutdown, hub drain

Three facts B3-3's composite close had to preserve, each already encoded in a comment at the cited line: the audit writer's stop is registered after database.Close so it flushes before the handle goes (:183-186); event persistence stops before the LIFO-later database.Close so no prune is still running (:476); hub.GracefulStop must run even on an early return so the supervised LiveKit process is not orphaned (:168-172). Any early return err between steps 2 and 12 relied on this defer stack — there was no single close function, which is exactly what B3-3's failure-injection test now pins.

Start, drain, stop — after B3-3 (App.stages() / App.Close)

Server/internal/app/lifecycle.go declares the start sequence as a list, and App.Close walks the close step each stage registered in the reverse of that order. There is no defer stack and no second teardown path: App.Run closes on every return — a failed start, a serve error and a clean shutdown alike.

# Stage (App.stages()) Close step, and what it does
1 (in Run) bgCtx background-context — cancels bgCtx; registered first, so it runs last
2 data-dir
3 tls
4 database databasedatabase.Close(), registered before the migration runs
5 migrate
6 telemetry telemetry — bounded OTel shutdown
7 plugins pluginsregistry.Close
8 hub hubGracefulStopContext, the only caller of LiveKitProcess.Stop
9 router router — the rate-limiter cleanup goroutine
10 event-persistence event-persistence — drains the persister, cancels bgCtx, joins the pruner
11 audit-writer audit-writer — drains the audit queue
12 maintenance maintenance — joins the maintenance loop
13 acme — (shut down by the http step, in the order the drain requires)
14 http http — ACME shutdown, then in-flight handlers, then the hub, on one 30s budget
15 signals signals — unregisters the signal handler; registered last, so it runs first

Close order is therefore signals, http, maintenance, audit-writer, event-persistence, router, hub, plugins, telemetry, database, background-context. All three facts hold, and now hold because of the ordering rule rather than because of where a defer happened to sit:

  • the audit writer and event persistence both start after the database opens, so both stop before database.Close;
  • the http step runs first, so in-flight handlers drain while the hub and the event persister are still live — which is why ACME and the HTTP server start one stage after the maintenance loop rather than before it;
  • the hub step is reached on every return from Run, so a supervised livekit-server process is never orphaned (OC-0027).

App.Close reports the first error and still runs every later step: the steps below a failing one are the ones that release the database handle, the LiveKit process and the audit queue. internal/app/close_test.go pins the order, the first-error rule and idempotence; internal/app/lifecycle_failure_test.go fails each stage in turn — the table is generated from App.stages(), so a new stage is covered the day it is added — and asserts on every row that the error names the stage, no goroutine is left running, the database handle is closed and the listener is not left bound.

Auth slice — before-state dependency graph

The three files B3-2 moves, and what they depend on at ad4defc2. The after-state table follows it.

File Imports (module-internal) db symbols used
api/auth_handler.go auth, db, permissions, service types DB, Session, User; funcs WriteAudit, IsUniqueConstraintError; sentinels ErrLastAdmin, ErrNotFound; methods CreateSession, CreateUserWithInvite, DeleteAccount, DeleteSession, GetSetting, GetUserByID, GetUserByUsername, UpdateUserCustomStatus
api/totp_handler.go auth, db types DB, Session, User; func WriteAudit; methods DeleteOtherSessions, GetUserByID, UpdateUserTOTPSecret
auth/*.go (10 files) db (types only, in helpers.go, resolve.go), config, syncutil types User, Session, APIToken, Role — no method calls; auth is a leaf that computes and does not persist

Eleven distinct *db.DB methods across the two handlers (ten after de-duplicating GetUserByID). That was the upper bound set for the interface api/auth_deps.go declares in B3-2.

Auth slice — after-state dependency graph

Measured at fe1d11b8 (B3-2, pre-squash). The handlers import db nowhere; api goes from 12 db importers to 10.

File Imports (module-internal) db symbols used
api/auth_deps.go service none — nine-method AuthService interface naming service.Principal, RegisterInput, LoginInput, AuthResult, TOTPChangeResult
api/auth_handler.go auth, service none — auth for ValidateUsername/ValidatePasswordStrength; service for the interface's types, the Err* categories writeAuthError switches on, and SanitizeText (imported before B3-2 too)
api/totp_handler.go auth, service none — auth for ExtractBearerToken; service for TOTPChangeResult and the Err* categories
api/middleware.go auth, db, permissions, service (new) unchanged row (move, auth): gained principal(r), which reads the *db.User/*db.Session it already stores on the context and hands them to the handlers as service.Principal
api/profile_handler.go unchanged unchanged row (move, upload): now hosts userResponse/toUserResponse, the one converter that names db.User, which /me and the auth responses share with PATCH /users/me
service/auth.go auth, db, permissions funcs WriteAudit, IsUniqueConstraintError; sentinels ErrLastAdmin, ErrNotFound; the ten methods, through service.Store: CreateSession, CreateUserWithInvite, DeleteAccount, DeleteOtherSessions, DeleteSession, GetSetting, GetUserByID, GetUserByUsername, UpdateUserCustomStatus, UpdateUserTOTPSecret
auth/ratescale.go none — the auth rate multiplier, moved from api/constants.go so the route mounts and the service's login accounting read one value

Honest reading of the plan's target ("handlers importing neither db nor service directly"): met for db, not for service. Both handlers import service because the consumer-owned interface is expressed in the service's input and result types and its Err* values — the alternative, an api-side copy of every type, would have been a second definition of the same shapes. The dependency direction is still api → service → db; what the handlers no longer see is the database.

Client baselines are not here

The supplement's Phase 1 item 5 (client native-import, Rust-command, import-cycle, timer/listener, bundle, coverage and mutation baselines) is B7's entry work, recorded there. Nothing under Client/ was measured for this document.