diff --git a/Server/invariants/db_import_boundary.go b/Server/invariants/db_import_boundary.go index 7e8a7972..34e0688b 100644 --- a/Server/invariants/db_import_boundary.go +++ b/Server/invariants/db_import_boundary.go @@ -91,9 +91,11 @@ var DBImportAllow = map[string]DBImportEntry{ "ws/eventstore.go": {"adapter", "", "PersistedEvent type; store is an interface"}, "ws/handlers.go": {"move", "channel", "channel, role, session-ban and DM reads in command handlers"}, "ws/handlers_chat.go": {"adapter", "", "pure NewDMChannelInfo helper"}, - "ws/hub.go": {"move", "settings-ops", "GetSetting at construction"}, + "ws/hub.go": {"boundary", "", "Hub state holds the handle the families read through; no calls"}, + "ws/hub_options.go": {"boundary", "", "construction validates and stores the handle; no calls"}, "ws/hub_broadcast.go": {"move", "channel", "member broadcast payloads read the user and role they announce"}, "ws/hub_presence.go": {"adapter", "", "presence coalescer; pure BroadcastStatus helper and the MemberSummary shape"}, + "ws/hub_settings.go": {"move", "settings-ops", "settings cache reads server name and MOTD through h.db; import pinned so the rule sees it"}, "ws/hub_visibility.go": {"move", "channel", "visibility and audience resolution reads channels, overrides, participants, users"}, "ws/hub_sweep.go": {"move", "voice", "stale-voice sweep reads and leaves"}, "ws/messages.go": {"adapter", "", "wire types + pure status helpers"}, diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 90f3726b..6d868c9d 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -3,10 +3,7 @@ package ws import ( "context" - "errors" - "fmt" "log/slog" - "os" "sync" "sync/atomic" "time" @@ -140,204 +137,6 @@ type Hub struct { presenceFlushArmed bool } -// HubOptions carries everything a Hub needs before Run starts (S-11 / B3-4). -// The four pre-Run setters this struct replaced (SetLiveKit, -// SetLiveKitProcess, SetPluginRegistry, ConfigureReplay) were all guarded by -// rejectIfRunning — construction-phase wiring pretending to be mutable state. -// What genuinely IS mutable after Run stays a setter: the event persister, -// event store and plugin event sink are atomic hot-swaps that the app wires -// after the dispatch loop starts (internal/app/persistence.go), and -// SetPendingVoiceModFlags is per-user runtime state. -type HubOptions struct { - // DB and Limiter are required: every dispatch path reads the database, - // and the handler deps capture the limiter at registration. NewHub - // refuses to build a Hub without them. - DB *db.DB - Limiter *auth.RateLimiter - - // Services is the domain layer V2 handlers delegate to. Production - // always passes it; nil is the degraded fixture many ws tests build, - // where handlers keep their direct-DB fallback paths. - Services *service.Services - - // LiveKit is the voice token signer; nil means voice is not configured - // and every voice join is refused. LiveKitProcess is the supervised - // companion SFU — it requires LiveKit, because a process no client can - // sign tokens for is unusable, and the voice_join guard reads a non-nil - // process's IsRunning to fail closed while it is down. - LiveKit *LiveKitClient - LiveKitProcess *LiveKitProcess - - // PluginRegistry enables plugin slash-command dispatch; nil disables it. - // The plugin event sink is NOT here: it consumes the built hub's - // broadcaster, so it stays the two-phase SetPluginEventSink. - PluginRegistry *plugin.Registry - - // Replay budget (event_persistence.replay_ring_size / replay_cold_limit). - // Zero keeps the compiled-in defaults; negative is refused rather than - // silently ignored. - ReplayRingSize int - ReplayColdLimit int -} - -// NewHub creates a Hub ready to be started with Run, validating that the -// required collaborators are present — before B3-4, construction always -// succeeded and a missing collaborator surfaced as a later panic or a -// silently refused setter call. It also initializes the settings cache from -// the database. If opts.Services is non-nil, V2 handlers receive service -// references for business logic delegation. -func NewHub(opts HubOptions) (*Hub, error) { - if opts.DB == nil { - return nil, errors.New("ws: HubOptions.DB is required (every dispatch path reads it)") - } - if opts.Limiter == nil { - return nil, errors.New("ws: HubOptions.Limiter is required (handler deps capture it at registration)") - } - if opts.LiveKitProcess != nil && opts.LiveKit == nil { - return nil, errors.New("ws: HubOptions.LiveKitProcess without LiveKit — a supervised SFU no client can sign tokens for") - } - if opts.ReplayRingSize < 0 || opts.ReplayColdLimit < 0 { - return nil, fmt.Errorf("ws: negative replay budget (ring %d, cold %d)", opts.ReplayRingSize, opts.ReplayColdLimit) - } - - database, limiter, svc := opts.DB, opts.Limiter, opts.Services - - ringSize := 1000 - if opts.ReplayRingSize > 0 { - ringSize = opts.ReplayRingSize - } - - reg := NewHandlerRegistry() - - h := &Hub{ - clients: make(map[int64]*Client), - db: database, - limiter: limiter, - broadcast: make(chan broadcastMsg, 1024), - clientEvents: make(chan clientEvent, 64), - stop: make(chan struct{}), - pubsub: NewPubSub(), - topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second), - replayBuf: NewEventRingBuffer(ringSize), - registry: reg, - permChecker: permissions.NewChecker(database), - settingsName: "OwnCord Server", - settingsMotd: "Welcome!", - voiceKeyHolders: make(map[int64]int64), - fatalFn: func() { os.Exit(1) }, - livekit: opts.LiveKit, - lkProcess: opts.LiveKitProcess, - pluginRegistry: opts.PluginRegistry, - } - if opts.ReplayColdLimit > 0 { - h.coldReplayLimit = opts.ReplayColdLimit - } - - // V2 handler registrations (need Hub fields for deps). - registerPingHandler(reg, PingDeps{Limiter: h.limiter}) - - chatDeps := ChatDeps{ - Limiter: h.limiter, - } - presenceDeps := PresenceDeps{ - Limiter: h.limiter, - } - reactionDeps := ReactionDeps{} - callDeps := CallDeps{Limiter: h.limiter} - if svc != nil { - chatDeps.MessageSvc = svc.Messages - presenceDeps.ChannelSvc = svc.Channels - reactionDeps.MessageSvc = svc.Messages - callDeps.DMSvc = svc.DMs - h.messageSvc = svc.Messages - h.perms = svc.Permissions - // So @here's offline narrowing can tell a disconnected idle/dnd reader - // (users.status keeps their last *chosen* value across a disconnect) - // from one who is actually still connected — the same live-connection - // rule presentableMembers applies to the members array. - svc.Messages.SetOnlineChecker(h.IsUserConnected) - // So every DM payload DMService builds (GET/POST /dms, POST - // /dms/group, PATCH /dms/{id}, and every broadcastDMOpen refresh) - // applies the same live-connection rule instead of only the ready - // payload's presentableDMChannels doing so (OC-0304). - svc.DMs.SetOnlineChecker(h.IsUserConnected) - } - - registerChatHandlers(reg, chatDeps) - registerPresenceHandlers(reg, presenceDeps) - registerReactionHandlers(reg, reactionDeps) - registerCallHandlers(reg, callDeps) - // Phase C Step 9 — plugin slash commands. The registry closure predates - // B3-4 (the registry used to arrive via a post-construction setter); it - // stays a closure for the nil-interface reason below. MessageSvc gates - // broadcasts. - reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{ - // A nil registry must yield a nil interface, not a typed-nil - // *plugin.Registry — the handler's "no plugins loaded" check is an - // interface comparison. - Registry: func() CommandDispatcher { - if h.pluginRegistry == nil { - return nil - } - return h.pluginRegistry - }, - MessageSvc: h.messageSvc, - Limiter: h.limiter, - }) - registerVoiceControlsV2(reg, VoiceDeps{ - DB: h.db, - Limiter: h.limiter, - Permissions: h.permChecker, - PermSvc: h.perms, - LiveKit: h.livekit, - TokenGen: h, // Hub delegates to h.livekit at call time - KeyHolder: h, - Mod: h, - }) - - h.refreshSettingsLocked(context.Background()) - return h, nil -} - -// getCachedSettings returns server_name and motd, refreshing the cache if stale. -func (h *Hub) getCachedSettings(ctx context.Context) (string, string) { - h.settingsMu.RLock() - if time.Since(h.settingsLastUpdate) < settingsCacheTTL { - name, motd := h.settingsName, h.settingsMotd - h.settingsMu.RUnlock() - return name, motd - } - h.settingsMu.RUnlock() - - h.settingsMu.Lock() - defer h.settingsMu.Unlock() - // Double-check after acquiring write lock. - if time.Since(h.settingsLastUpdate) < settingsCacheTTL { - return h.settingsName, h.settingsMotd - } - h.refreshSettingsLocked(ctx) - return h.settingsName, h.settingsMotd -} - -// refreshSettingsLocked reloads server_name and motd from the DB. -// Caller must hold settingsMu (write lock) or call during init. -func (h *Hub) refreshSettingsLocked(ctx context.Context) { - if h.db == nil { - return - } - // The refresh serves the hub-wide settings cache, not the connection that - // happened to trigger it — a dying connection's ctx must not fail the - // fetches (the TTL stamp below would then pin stale values for 30s). - ctx = context.WithoutCancel(ctx) - if name, err := h.db.GetSetting(ctx, "server_name"); err == nil { - h.settingsName = name - } - if motd, err := h.db.GetSetting(ctx, "motd"); err == nil { - h.settingsMotd = motd - } - h.settingsLastUpdate = time.Now() -} - // Run starts the hub's dispatch loop. It blocks until Stop is called. // Must be called in its own goroutine. // @@ -545,16 +344,6 @@ func (h *Hub) ConnRejectCount() uint64 { return h.connRejects.Load() } -// maxColdReplayLimit returns the effective persisted-replay cap. The budget -// arrives via HubOptions (B3-4): the dispatch loop reads replayBuf unlocked, -// so the ring is sized exactly once, at construction. -func (h *Hub) maxColdReplayLimit() int { - if h.coldReplayLimit > 0 { - return h.coldReplayLimit - } - return maxColdReplay -} - // EventPersisterStats returns the attached persister's lifetime counters. // ok is false when event persistence is disabled (no persister attached). func (h *Hub) EventPersisterStats() (persisted, dropped, flushes, errs uint64, ok bool) { diff --git a/Server/ws/hub_options.go b/Server/ws/hub_options.go new file mode 100644 index 00000000..0a8b6a98 --- /dev/null +++ b/Server/ws/hub_options.go @@ -0,0 +1,174 @@ +package ws + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/J3vb/OwnCord/Server/auth" + "github.com/J3vb/OwnCord/Server/db" + "github.com/J3vb/OwnCord/Server/permissions" + "github.com/J3vb/OwnCord/Server/plugin" + "github.com/J3vb/OwnCord/Server/service" +) + +// HubOptions carries everything a Hub needs before Run starts (S-11 / B3-4). +// The four pre-Run setters this struct replaced (SetLiveKit, +// SetLiveKitProcess, SetPluginRegistry, ConfigureReplay) were all guarded by +// rejectIfRunning — construction-phase wiring pretending to be mutable state. +// What genuinely IS mutable after Run stays a setter: the event persister, +// event store and plugin event sink are atomic hot-swaps that the app wires +// after the dispatch loop starts (internal/app/persistence.go), and +// SetPendingVoiceModFlags is per-user runtime state. +type HubOptions struct { + // DB and Limiter are required: every dispatch path reads the database, + // and the handler deps capture the limiter at registration. NewHub + // refuses to build a Hub without them. + DB *db.DB + Limiter *auth.RateLimiter + + // Services is the domain layer V2 handlers delegate to. Production + // always passes it; nil is the degraded fixture many ws tests build, + // where handlers keep their direct-DB fallback paths. + Services *service.Services + + // LiveKit is the voice token signer; nil means voice is not configured + // and every voice join is refused. LiveKitProcess is the supervised + // companion SFU — it requires LiveKit, because a process no client can + // sign tokens for is unusable, and the voice_join guard reads a non-nil + // process's IsRunning to fail closed while it is down. + LiveKit *LiveKitClient + LiveKitProcess *LiveKitProcess + + // PluginRegistry enables plugin slash-command dispatch; nil disables it. + // The plugin event sink is NOT here: it consumes the built hub's + // broadcaster, so it stays the two-phase SetPluginEventSink. + PluginRegistry *plugin.Registry + + // Replay budget (event_persistence.replay_ring_size / replay_cold_limit). + // Zero keeps the compiled-in defaults; negative is refused rather than + // silently ignored. + ReplayRingSize int + ReplayColdLimit int +} + +// NewHub creates a Hub ready to be started with Run, validating that the +// required collaborators are present — before B3-4, construction always +// succeeded and a missing collaborator surfaced as a later panic or a +// silently refused setter call. It also initializes the settings cache from +// the database. If opts.Services is non-nil, V2 handlers receive service +// references for business logic delegation. +func NewHub(opts HubOptions) (*Hub, error) { + if opts.DB == nil { + return nil, errors.New("ws: HubOptions.DB is required (every dispatch path reads it)") + } + if opts.Limiter == nil { + return nil, errors.New("ws: HubOptions.Limiter is required (handler deps capture it at registration)") + } + if opts.LiveKitProcess != nil && opts.LiveKit == nil { + return nil, errors.New("ws: HubOptions.LiveKitProcess without LiveKit — a supervised SFU no client can sign tokens for") + } + if opts.ReplayRingSize < 0 || opts.ReplayColdLimit < 0 { + return nil, fmt.Errorf("ws: negative replay budget (ring %d, cold %d)", opts.ReplayRingSize, opts.ReplayColdLimit) + } + + database, limiter, svc := opts.DB, opts.Limiter, opts.Services + + ringSize := 1000 + if opts.ReplayRingSize > 0 { + ringSize = opts.ReplayRingSize + } + + reg := NewHandlerRegistry() + + h := &Hub{ + clients: make(map[int64]*Client), + db: database, + limiter: limiter, + broadcast: make(chan broadcastMsg, 1024), + clientEvents: make(chan clientEvent, 64), + stop: make(chan struct{}), + pubsub: NewPubSub(), + topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second), + replayBuf: NewEventRingBuffer(ringSize), + registry: reg, + permChecker: permissions.NewChecker(database), + settingsName: "OwnCord Server", + settingsMotd: "Welcome!", + voiceKeyHolders: make(map[int64]int64), + fatalFn: func() { os.Exit(1) }, + livekit: opts.LiveKit, + lkProcess: opts.LiveKitProcess, + pluginRegistry: opts.PluginRegistry, + } + if opts.ReplayColdLimit > 0 { + h.coldReplayLimit = opts.ReplayColdLimit + } + + // V2 handler registrations (need Hub fields for deps). + registerPingHandler(reg, PingDeps{Limiter: h.limiter}) + + chatDeps := ChatDeps{ + Limiter: h.limiter, + } + presenceDeps := PresenceDeps{ + Limiter: h.limiter, + } + reactionDeps := ReactionDeps{} + callDeps := CallDeps{Limiter: h.limiter} + if svc != nil { + chatDeps.MessageSvc = svc.Messages + presenceDeps.ChannelSvc = svc.Channels + reactionDeps.MessageSvc = svc.Messages + callDeps.DMSvc = svc.DMs + h.messageSvc = svc.Messages + h.perms = svc.Permissions + // So @here's offline narrowing can tell a disconnected idle/dnd reader + // (users.status keeps their last *chosen* value across a disconnect) + // from one who is actually still connected — the same live-connection + // rule presentableMembers applies to the members array. + svc.Messages.SetOnlineChecker(h.IsUserConnected) + // So every DM payload DMService builds (GET/POST /dms, POST + // /dms/group, PATCH /dms/{id}, and every broadcastDMOpen refresh) + // applies the same live-connection rule instead of only the ready + // payload's presentableDMChannels doing so (OC-0304). + svc.DMs.SetOnlineChecker(h.IsUserConnected) + } + + registerChatHandlers(reg, chatDeps) + registerPresenceHandlers(reg, presenceDeps) + registerReactionHandlers(reg, reactionDeps) + registerCallHandlers(reg, callDeps) + // Phase C Step 9 — plugin slash commands. The registry closure predates + // B3-4 (the registry used to arrive via a post-construction setter); it + // stays a closure for the nil-interface reason below. MessageSvc gates + // broadcasts. + reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{ + // A nil registry must yield a nil interface, not a typed-nil + // *plugin.Registry — the handler's "no plugins loaded" check is an + // interface comparison. + Registry: func() CommandDispatcher { + if h.pluginRegistry == nil { + return nil + } + return h.pluginRegistry + }, + MessageSvc: h.messageSvc, + Limiter: h.limiter, + }) + registerVoiceControlsV2(reg, VoiceDeps{ + DB: h.db, + Limiter: h.limiter, + Permissions: h.permChecker, + PermSvc: h.perms, + LiveKit: h.livekit, + TokenGen: h, // Hub delegates to h.livekit at call time + KeyHolder: h, + Mod: h, + }) + + h.refreshSettingsLocked(context.Background()) + return h, nil +} diff --git a/Server/ws/hub_settings.go b/Server/ws/hub_settings.go new file mode 100644 index 00000000..f198312d --- /dev/null +++ b/Server/ws/hub_settings.go @@ -0,0 +1,53 @@ +package ws + +import ( + "context" + "time" + + "github.com/J3vb/OwnCord/Server/db" +) + +// The db-import-boundary rule and the boundaries-doc inventory track files by +// their db import. This file's persistence runs through the h.db field, which +// needs no import — so the import is pinned here deliberately, keeping the +// settings-ops reads on the inventory's books instead of invisible to it. +var _ *db.DB + +// getCachedSettings returns server_name and motd, refreshing the cache if stale. +func (h *Hub) getCachedSettings(ctx context.Context) (string, string) { + h.settingsMu.RLock() + if time.Since(h.settingsLastUpdate) < settingsCacheTTL { + name, motd := h.settingsName, h.settingsMotd + h.settingsMu.RUnlock() + return name, motd + } + h.settingsMu.RUnlock() + + h.settingsMu.Lock() + defer h.settingsMu.Unlock() + // Double-check after acquiring write lock. + if time.Since(h.settingsLastUpdate) < settingsCacheTTL { + return h.settingsName, h.settingsMotd + } + h.refreshSettingsLocked(ctx) + return h.settingsName, h.settingsMotd +} + +// refreshSettingsLocked reloads server_name and motd from the DB. +// Caller must hold settingsMu (write lock) or call during init. +func (h *Hub) refreshSettingsLocked(ctx context.Context) { + if h.db == nil { + return + } + // The refresh serves the hub-wide settings cache, not the connection that + // happened to trigger it — a dying connection's ctx must not fail the + // fetches (the TTL stamp below would then pin stale values for 30s). + ctx = context.WithoutCancel(ctx) + if name, err := h.db.GetSetting(ctx, "server_name"); err == nil { + h.settingsName = name + } + if motd, err := h.db.GetSetting(ctx, "motd"); err == nil { + h.settingsMotd = motd + } + h.settingsLastUpdate = time.Now() +} diff --git a/Server/ws/replay.go b/Server/ws/replay.go index 3a22553b..b49dac7e 100644 --- a/Server/ws/replay.go +++ b/Server/ws/replay.go @@ -484,3 +484,13 @@ func (h *Hub) liveVoiceEventsSince(ctx context.Context, afterSeq uint64, chID in } return filtered } + +// maxColdReplayLimit returns the effective persisted-replay cap. The budget +// arrives via HubOptions (B3-4): the dispatch loop reads replayBuf unlocked, +// so the ring is sized exactly once, at construction. +func (h *Hub) maxColdReplayLimit() int { + if h.coldReplayLimit > 0 { + return h.coldReplayLimit + } + return maxColdReplay +} diff --git a/docs/architecture/server-boundaries.md b/docs/architecture/server-boundaries.md index 2f0b80ed..f2fb3a2c 100644 --- a/docs/architecture/server-boundaries.md +++ b/docs/architecture/server-boundaries.md @@ -20,7 +20,18 @@ 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). +`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). **Owner:** the B3 plan, [plans/b3-server-architecture-guardrails-2026-08-29.md](../plans/b3-server-architecture-guardrails-2026-08-29.md). **Regenerate the first table:** `cd Server && go run ./cmd/dbinventory` and @@ -40,7 +51,7 @@ happens to that use — one of four dispositions from the | ----------- | --------------------------------------------------------------------------------------------------------------------- | ---: | | `move` | persistence or a domain decision that belongs behind a service; **Family** names the service B3-8 (or B3-2) builds | 28 | | `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) | 12 | +| `boundary` | an explicit composition or transaction boundary that legitimately owns a handle (process entry, CLIs, health probe) | 15 | | `remove` | the import is unnecessary and goes | 0 | The rows live in code, not only here: `Server/invariants/db_import_boundary.go` @@ -50,8 +61,10 @@ 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`: 26 → 28 `move` rows while the calls behind -them only moved), so it is the surface, not the row count, that ratchets. +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 @@ -127,9 +140,11 @@ which is a row worth reading, and none exists today. | `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×2` | — | `GetSetting×2` | calls | move | settings-ops | GetSetting at construction | +| `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_settings.go` | `DB` | — | `GetSetting×2` | calls | move | settings-ops | settings cache reads server name and MOTD through h.db; import pinned so the rule sees it | | `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 | @@ -141,8 +156,8 @@ which is a row worth reading, and none exists today. | `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 16, api 10, auth 2, cmd/gendocs 1, cmd/seed 2, internal/app 6, plugin 1, ws 20); 19 are type-only; 0 unlisted. -Dispositions: adapter 18, boundary 13, move 28. Move targets: auth 7, channel 7, connection 2, role 1, settings-ops 4, upload 2, user 2, voice 3. +61 files import `db` outside `db/` and `service/` (. 1, admin 16, api 10, auth 2, cmd/gendocs 1, cmd/seed 2, internal/app 6, plugin 1, ws 22); 21 are type-only; 0 unlisted. +Dispositions: adapter 18, boundary 15, move 28. Move targets: auth 7, channel 7, connection 2, role 1, settings-ops 4, upload 2, user 2, voice 3. diff --git a/docs/plans/README.md b/docs/plans/README.md index 2ef0cff9..d54ced27 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -10,22 +10,22 @@ authority**. ## Active — these drive current work -| Plan | State | -| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [beta-product-requirements-2026-08-23](beta-product-requirements-2026-08-23.md) | Approved beta scope, frozen. 57 `BPR-*` requirements. **Amended 2026-08-31:** BPR-032 reworded to the slim single-current-epoch policy (owner decision; the scope HP-2 accepted). | -| [repo-health-roadmap-2026-08-23](repo-health-roadmap-2026-08-23.md) | Phase order and gates, B0–B10. **B0, B1 and B2 complete** (HP-0, HP-1 and HP-2 all accepted); **B3 in progress** — the B3 plan row below tracks it. B4–B10 not started. **Amended 2026-08-28:** dated lines in B3–B10, a "Phase execution pattern" section, and a truthful status header. **Amended 2026-08-29:** B3/B7/B9 lines binding the layout-refactor supplement below; current slice updated for B2-2. **Amended 2026-08-31:** B1 exit reworded to identical-tree integration evidence; B6/B8/B10 `server-info` and epoch-window lines aligned with the B2-2 slim decision; B10 gains the BPR-051 comprehension-read row (owner decisions 2026-08-31). | -| [repo-health-issue-register-2026-08-23](repo-health-issue-register-2026-08-23.md) | 116 planning rows. Public-safe; not a replacement for the ledger. **Amended 2026-08-31:** OC-0349–0375 and OC-0379 enumerated with owner-approved phases; G-03 closure evidence reworded to identical-tree integration evidence; BG-07 re-scoped to BPR-032 as amended. | -| [beta-requirements-traceability-2026-08-23](beta-requirements-traceability-2026-08-23.md) | Requirement → phase → evidence map. No row is release-qualified. | -| [b0-baseline-2026-08-25](b0-baseline-2026-08-25.md) | **Supersedes the roadmap's "current evidence snapshot."** B0 measurements and dispositions. | -| [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 through B1-8 all done.** B1 execution plan. Re-verifies every RL-\* claim against HEAD; several are refuted. | -| [hp-0-scorecard-2026-08-25](hp-0-scorecard-2026-08-25.md) | **HP-0 accepted 2026-08-25.** The single baseline-acceptance artifact. Part-closes `R-08`. | -| [hp-1-scorecard-2026-08-27](hp-1-scorecard-2026-08-27.md) | **HP-1 accepted 2026-08-27.** Structural-diff proofs for the flatten and module rename, plus the B1 exit gate. | -| [b2-protocol-trust-compat-2026-08-28](b2-protocol-trust-compat-2026-08-28.md) | **B2 complete — HP-2 accepted 2026-08-29.** B2-0, B2-1, B2-8 done 2026-08-28; B2-2 (B2-3/B2-4 folded in), B2-5, B2-6, B2-7, B2-9 done 2026-08-29. Scorecard below. | -| [hp-2-scorecard-2026-08-29](hp-2-scorecard-2026-08-29.md) | **HP-2 accepted 2026-08-29.** Seven questions answered with commands; B2 exit gate, nine conditions met (1 at the slim epoch scope, 4 with one E2EE gap disclaimed). Owner follow-ups that do not gate B3: BPR-051 reader line, SEC-01/SEC-04 advisory IDs. | -| [b3-server-architecture-guardrails-2026-08-29](b3-server-architecture-guardrails-2026-08-29.md) | **B3 in progress from 2026-08-29.** Execution plan: B3-0 inventory → B3-1/B3-2 auth slice → HP-3 → lifecycle, hub options, `ws` split, families; guardrails and the alpha dataset beside the slice. B3-0 (inventory + `db-import-boundary` rule) and B3-1 (auth characterization) merged 2026-08-29; B3-2 (auth vertical slice) merged 2026-08-30; B3-9 (five of the six B3-tagged findings; OC-0323 rides B3-8) merged 2026-08-30; HP-3 accepted 2026-08-30; B3-3 (lifecycle extraction into `Server/internal/app/`) merged 2026-08-30 (#1464); B3-4 (hub constructor options) merged 2026-08-31 (#1470); B3-7 (deterministic alpha dataset) merged 2026-08-31 (#1469); B3-5 (`ws` split) in progress — split PR 1 (handshake auth + fresh-connect) merged 2026-08-31 (#1472); split PR 2 (replay family + connection registry) merged 2026-08-31 (#1473); split PR 3 (visibility gather) merged 2026-08-31 (#1474); split PR 4 (voice leftovers + presence coalescer) opened 2026-08-31. | -| [hp-3-scorecard-2026-08-29](hp-3-scorecard-2026-08-29.md) | **HP-3 accepted 2026-08-30 by the owner.** Five questions on the auth vertical slice answered with commands: frozen set green at every pre-squash SHA, `api` db importers 12 → 10, B2 contracts unchanged, the pattern written as D4 in server.md, guardrails as they exist. | -| [b3-bench-baseline-2026-08-30](b3-bench-baseline-2026-08-30.md) | **Recorded 2026-08-30, not gated.** The six B3-6 `Benchmark*` through `benchstat` at `ec8ef24a`, produced by `make bench-baseline`. Nothing in CI reads these numbers; the performance gate is B6's. Regenerating writes a new dated file — replace this row and delete the superseded document, so only the newest baseline is kept. | -| [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 1–6 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. | +| Plan | State | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [beta-product-requirements-2026-08-23](beta-product-requirements-2026-08-23.md) | Approved beta scope, frozen. 57 `BPR-*` requirements. **Amended 2026-08-31:** BPR-032 reworded to the slim single-current-epoch policy (owner decision; the scope HP-2 accepted). | +| [repo-health-roadmap-2026-08-23](repo-health-roadmap-2026-08-23.md) | Phase order and gates, B0–B10. **B0, B1 and B2 complete** (HP-0, HP-1 and HP-2 all accepted); **B3 in progress** — the B3 plan row below tracks it. B4–B10 not started. **Amended 2026-08-28:** dated lines in B3–B10, a "Phase execution pattern" section, and a truthful status header. **Amended 2026-08-29:** B3/B7/B9 lines binding the layout-refactor supplement below; current slice updated for B2-2. **Amended 2026-08-31:** B1 exit reworded to identical-tree integration evidence; B6/B8/B10 `server-info` and epoch-window lines aligned with the B2-2 slim decision; B10 gains the BPR-051 comprehension-read row (owner decisions 2026-08-31). | +| [repo-health-issue-register-2026-08-23](repo-health-issue-register-2026-08-23.md) | 116 planning rows. Public-safe; not a replacement for the ledger. **Amended 2026-08-31:** OC-0349–0375 and OC-0379 enumerated with owner-approved phases; G-03 closure evidence reworded to identical-tree integration evidence; BG-07 re-scoped to BPR-032 as amended. | +| [beta-requirements-traceability-2026-08-23](beta-requirements-traceability-2026-08-23.md) | Requirement → phase → evidence map. No row is release-qualified. | +| [b0-baseline-2026-08-25](b0-baseline-2026-08-25.md) | **Supersedes the roadmap's "current evidence snapshot."** B0 measurements and dispositions. | +| [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 through B1-8 all done.** B1 execution plan. Re-verifies every RL-\* claim against HEAD; several are refuted. | +| [hp-0-scorecard-2026-08-25](hp-0-scorecard-2026-08-25.md) | **HP-0 accepted 2026-08-25.** The single baseline-acceptance artifact. Part-closes `R-08`. | +| [hp-1-scorecard-2026-08-27](hp-1-scorecard-2026-08-27.md) | **HP-1 accepted 2026-08-27.** Structural-diff proofs for the flatten and module rename, plus the B1 exit gate. | +| [b2-protocol-trust-compat-2026-08-28](b2-protocol-trust-compat-2026-08-28.md) | **B2 complete — HP-2 accepted 2026-08-29.** B2-0, B2-1, B2-8 done 2026-08-28; B2-2 (B2-3/B2-4 folded in), B2-5, B2-6, B2-7, B2-9 done 2026-08-29. Scorecard below. | +| [hp-2-scorecard-2026-08-29](hp-2-scorecard-2026-08-29.md) | **HP-2 accepted 2026-08-29.** Seven questions answered with commands; B2 exit gate, nine conditions met (1 at the slim epoch scope, 4 with one E2EE gap disclaimed). Owner follow-ups that do not gate B3: BPR-051 reader line, SEC-01/SEC-04 advisory IDs. | +| [b3-server-architecture-guardrails-2026-08-29](b3-server-architecture-guardrails-2026-08-29.md) | **B3 in progress from 2026-08-29.** Execution plan: B3-0 inventory → B3-1/B3-2 auth slice → HP-3 → lifecycle, hub options, `ws` split, families; guardrails and the alpha dataset beside the slice. B3-0 (inventory + `db-import-boundary` rule) and B3-1 (auth characterization) merged 2026-08-29; B3-2 (auth vertical slice) merged 2026-08-30; B3-9 (five of the six B3-tagged findings; OC-0323 rides B3-8) merged 2026-08-30; HP-3 accepted 2026-08-30; B3-3 (lifecycle extraction into `Server/internal/app/`) merged 2026-08-30 (#1464); B3-4 (hub constructor options) merged 2026-08-31 (#1470); B3-7 (deterministic alpha dataset) merged 2026-08-31 (#1469); B3-5 (`ws` split) in progress — split PR 1 (handshake auth + fresh-connect) merged 2026-08-31 (#1472); split PR 2 (replay family + connection registry) merged 2026-08-31 (#1473); split PR 3 (visibility gather) merged 2026-08-31 (#1474); split PR 4 (voice leftovers + presence coalescer) merged 2026-08-31 (#1475); finisher PR (hub.go < 400 + exit evidence) opened 2026-08-31 — B3-5 complete when it merges; B3-8 families next. | +| [hp-3-scorecard-2026-08-29](hp-3-scorecard-2026-08-29.md) | **HP-3 accepted 2026-08-30 by the owner.** Five questions on the auth vertical slice answered with commands: frozen set green at every pre-squash SHA, `api` db importers 12 → 10, B2 contracts unchanged, the pattern written as D4 in server.md, guardrails as they exist. | +| [b3-bench-baseline-2026-08-30](b3-bench-baseline-2026-08-30.md) | **Recorded 2026-08-30, not gated.** The six B3-6 `Benchmark*` through `benchstat` at `ec8ef24a`, produced by `make bench-baseline`. Nothing in CI reads these numbers; the performance gate is B6's. Regenerating writes a new dated file — replace this row and delete the superseded document, so only the newest baseline is kept. | +| [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 1–6 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. | ## Partially implemented diff --git a/docs/plans/b3-server-architecture-guardrails-2026-08-29.md b/docs/plans/b3-server-architecture-guardrails-2026-08-29.md index 214e91af..9b44baa4 100644 --- a/docs/plans/b3-server-architecture-guardrails-2026-08-29.md +++ b/docs/plans/b3-server-architecture-guardrails-2026-08-29.md @@ -925,6 +925,54 @@ that responsibility 6's size target needed: construction/options and the replay-limit accessor), `voice_broadcast.go` 38 → 115, `hub_presence.go` 153. +**Evidence — finisher PR and B3-5 exit, 2026-08-31** — branch +`feat/b3-5-ws-split-5` from `dev` `df717e48` (split PR 4's squash). + +- **Move 8** — one pure-move commit closes `hub.go`'s size target: + `HubOptions` and `NewHub` → new `hub_options.go`; `getCachedSettings` + and `refreshSettingsLocked` → new `hub_settings.go`; + `maxColdReplayLimit` → `replay.go`, beside the family that reads it + (211 lines out, 229 in). Residue: the new files' scaffolding plus + duplicates of five imports `hub.go` retains (`auth`, `db`, + `permissions`, `plugin`, `service`); `errors`, `fmt` and `os` moved + outright. Gate results in the PR's test plan. +- **Inventory**: `hub.go`'s `GetSetting` calls left with the settings + cache, so its row turns type-only `boundary` (the handle the families + read through); `hub_options.go` is a type-only `boundary` row + (validates and stores the handle). `hub_settings.go` reads through the + `h.db` field, which needs no import — Codex's P2 on the finisher PR + caught that this made the settings-ops item invisible to the + import-tracking rule and table — so the file pins the `db` import + (a documented `var _ *db.DB`) and keeps its `move`/`settings-ops` row, + `GetSetting×2` visible. The disposition counts were re-derived from + the tool's summary (`boundary` had been stale at 12 since the + seed-profile row landed). + +**B3-5 exit.** All seven responsibilities relocated across five squash +merges — #1472 (handshake auth; fresh-connect), #1473 (replay; registry), +#1474 (visibility), #1475 (voice leftovers; presence coalescer split), +plus this finisher — every move a pure move with its normalised-diff +residue listed, `git diff -M --summary` recording no file-level rename +(all function-level motion between files), and the +`-tags deadlock -count=10 ./ws/` + `-race` pass green after every move +with `TestEpoch1Fixtures` inside those runs. The plan's named destination +`registry.go` was already the message-type `HandlerRegistry`, so the +connection registry lives in `hub_registry.go` (recorded in split PR 2). +Exit sizes, measured on this branch: + +| File | Before B3-5 | After | Target | +| ------------------ | ----------: | ----: | ------- | +| `serve.go` | 990 | 183 | < 500 ✓ | +| `hub.go` | 866 | 361 | < 400 ✓ | +| `hub_broadcast.go` | 1032 | 424 | < 500 ✓ | + +New files: `replay.go` 496, `hub_registry.go` 253, `hub_visibility.go` +487, `hub_presence.go` 153, `hub_options.go` 174, `hub_settings.go` 45; +grown files: `serve_auth.go` 105 → 207, `serve_ready.go` 375 → 564, +`voice_broadcast.go` 38 → 115. The `db`-inventory file table in +`server-boundaries.md` was re-measured in every split PR; no new `db` +call was added anywhere in the series. + ## B3-6 — Permanent guardrails Roadmap workstreams 1, 2, 3, 10, 11, 13, 14, 15, 16. Runs beside B3-0..B3-2;