* 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>
69 KiB
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.go → replay.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 thatdbdeclares 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 asdb.StatusForViewer()anddb.BroadcastStatus()land here too; they are computations over already-loaded rows, not queries.*db.DBmethod calls — calls whose receiver is an identifier declared*db.DBin the file (parameter, result, var, or assigned fromdb.Open*), or a selector whose final field is declared*db.DBanywhere 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. Thedbtypes they use are the wire and response shapes. Whether those types should live outsidedbis 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 (handleFreshConnectand its stale-voice cleanup moved in fromserve.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) andadmin/handlers_backup.go(SQLDbforVACUUM INTO). Both aremove; the backup one may end as an explicitboundaryoncesettings-opsowns 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.goandtoken_cli.go; owner-role reads inadmin/middleware.go(OC-0345) andapi/middleware.go; voice state inws/hub_sweep.go,voice_join.go,voice_moderation.go. One service per family removes each duplicate. ws/serve.goandws/replay.goare theconnectionrows: not a domain family but the connect/disconnect lifecycle that touches four of them. B3-5 splits it by responsibility first (replay.gocarries the reconnect replay family since the second split PR — type-only, it passes the handle to the shared helpers still inserve.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 | runOpenDatabase → db.OpenWithMaxReaders :314-322 |
database.Close() :148 |
| 3 | runInitDatabase → db.Migrate :333-347 |
— |
| 4 | runInitTelemetry :369 |
telemetryStop() :156 |
| 5 | runInitPlugins :392 |
runClosePlugins :161 |
| 6 | api.NewRouter → hub 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 :629 → runShutdownServers :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 |
database — database.Close(), registered before the migration runs |
| 5 | migrate |
— |
| 6 | telemetry |
telemetry — bounded OTel shutdown |
| 7 | plugins |
plugins — registry.Close |
| 8 | hub |
hub — GracefulStopContext, 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
httpstep 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
hubstep is reached on every return fromRun, 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.