- audit-2026-07-19.md: A-2026-07-07 and A-2026-07-09 → RESOLVED 2026-07-20 in the findings tables; §6 backlog rows 3 and 11 struck through as DONE (D9/D10) - plans/audit-2026-07-19-decisions.md: D9/D10 status → Implemented - plans/channel-visibility-unification.md, v2-dispatch-migration.md: status → implemented; v2 note records the applier-trigger shape the voice handlers actually landed with - architecture/server.md: WS box "V1+V2 dispatch" → "typed command dispatch" - architecture/websocket.md: intro + §D4c redrawn as the single typed path (no V1 fallback) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6.6 KiB
Finish the V2 Dispatch Migration (backlog item 11) — Design
Status: implemented 2026-07-20 (D10) Decision: D10 in audit-2026-07-19-decisions.md Closes: audit finding A-2026-07-09 (backlog §6 item 11)
Problem
ws.handleMessage runs a strangler-fig: for each inbound type it tries the
strict V2 typed path (registry.hasV2 → getCommandConstructor → DispatchV2,
returning a Result{}) and otherwise falls back to the lenient V1 path
(registry.Dispatch → imperative MessageHandler). Two parsers, two registries
(handlers / handlersV2), and per-type duplication must be kept in sync until
the migration completes.
Current state — only 3 types remain on V1
Everything else is already V2: chat send/edit/delete, reaction add/remove, ping, typing, presence, channel_focus, voice mute/deafen/camera/screenshare, voice E2EE announce/offer, voice_token_refresh. The V1 holdouts are:
chat_command→handlePluginCommand(handlers_command.go),voice_join→handleVoiceJoin(handlers_voice.go),voice_leave→handleVoiceLeave(handlers_voice.go).
Approach — port the 3, then delete the V1 machinery
1. chat_command (easiest). Add ChatCommandCmd + a constructor (parses
channel_id/command/args, keeps the maxCommandArgs guard) and a
PluginDeps{PluginRegistry, MessageSvc} deps struct. The V2 handler returns the
ephemeral command_reply via Result.Reply; the plugin Broadcast becomes a
channel-routed Result.Events entry (plugin_broadcast) gated by the same
MessageSvc.CanPost check it uses today.
2. voice_leave. Add VoiceLeaveCmd + constructor. The V2 handler does the
rate-limit that currently lives in the dispatch wrapper, then signals the leave.
handleVoiceLeave stays as a hub-internal routine — it is also called
un-throttled on disconnect and channel-switch (serve.go, voice_join.go), and
those callers must not go through dispatch. The V2 handler expresses its effect
through Result (emit the voice_leave event + clear the client's voice state),
which needs one small Result field for "leave voice" (see below).
3. voice_join (the effort driver). Add VoiceJoinCmd + constructor.
voice_token_refresh already proved the LiveKit/KeyHolder deps live in
VoiceDeps and that Reply + SetVoiceJoinToken fit Result. voice_join
additionally broadcasts voice_state (→ Result.Events), subscribes the voice
channel topic and sets client voice state (→ new Result fields mirroring the
existing SetChannelID / SetVoiceJoinToken appliers in handleMessage), and
persists the DB row. Extend Result with the 1–2 missing side-effect fields
rather than inventing a parallel mechanism — the applier switch in
handleMessage is already where these mutations run.
4. Delete V1. Once all types have a constructor + V2 handler, handleMessage
collapses to: parse envelope → getCommandConstructor(type) → not found = unknown
type → else DispatchV2 + apply Result. Remove the V1 fallback branch and the
hasV2 gate; delete HandlerRegistry.handlers, Register, Dispatch,
IsRegisteredV1, RegisteredTypes, the MessageHandler type, and the
V1-shadowing guard inside RegisterV2. registerVoiceHandlersV1 /
registerPluginCommandHandler fold into V2 registration in NewHub.
Files touched
Server/ws/command.go—ChatCommandCmd,VoiceJoinCmd,VoiceLeaveCmd+ constructor registrations.Server/ws/deps.go—PluginDeps;VoiceDepsalready sufficient.Server/ws/event.go— extendResultwith the voice join/leave side-effect fields;plugin_broadcastchannel event.Server/ws/handlers_command.go,handlers_voice.go— V2 handlers; drop V1 registration functions.Server/ws/handlers.go— delete V1 fallback +hasV2gate; apply new fields.Server/ws/registry.go— delete the V1 map + methods.Server/ws/hub.go—NewHubregisters only V2.- Docs:
docs/architecture/websocket.md§D4c (replace dual-dispatch diagram with the single typed path),docs/architecture/server.md(WS box "V1+V2 dispatch" → "typed command dispatch"),docs/audit-2026-07-19.md(A-2026-07-09 → RESOLVED).
Test plan
- Add V2 handler tests for
chat_command,voice_join,voice_leave(mirror the existinghandler_v2_*_test.gostyle); assert parse errors, permission denials, rate-limit, and the emittedResult. - Keep the existing plugin-command and voice tests green against the V2 path (behavior is unchanged); update the few tests that call the deleted V1 registry methods.
- A guard test asserting every registered type has a V2 handler and no V1 map remains (locks the migration shut so V1 can't creep back).
Non-goals
- No wire/protocol change. The envelope
{type, id, payload}is identical; V1→V2 is a server-internal handler-shape refactor. The client (src/lib/dispatcher.ts,ws.send) is untouched, and neitherdocs/protocol-schema.jsonnorprotocolTypes.tschanges —dispatcher.tsonly consumes server→client messages, which both dispatch generations emit identically. - Not decomposing the Hub or reworking replay/seq (backlog 12).
handleVoiceLeaveremains a hub-internal routine for the disconnect/switch callers; only its message dispatch moves to V2.
As implemented (2026-07-20)
The voice handlers landed with the applier-trigger shape rather than a
fully-pure port, because the effect is inseparable from the hub: handleVoiceJoin
itself calls handleVoiceLeave on a channel switch, and a pure (cmd, info, deps)
handler cannot invoke a hub routine. So both symmetric routines stay hub-internal
and the V2 handlers gate parse/rate-limit, then hand off via two new Result
side-effect fields the handleMessage applier acts on:
Result.LeaveVoice bool→ applier runsh.handleVoiceLeave(c.ctx, c)(matches the design's "one small Result field for leave voice"; the handler does the rate-limit that previously lived in the V1 dispatch wrapper).Result.JoinVoice bool→ applier runsh.handleVoiceJoin(c.ctx, c, env.Payload).handleVoiceJoinis unchanged (keeps its own rate-limit and re-reads the already-validatedchannel_id); theVoiceJoinCmdconstructor is the parse gate.
This keeps voice_join.go/voice_leave.go and parseChannelID (plus their
tests) untouched while still collapsing dispatch to the single typed path and
deleting all V1 machinery. chat_command was ported to a genuinely pure V2
handler (Result.Reply + a PluginBroadcastEvent on Result.Events, gated by
MessageService.CanPost) as the design described.