Files
OwnCord/docs/plans/v2-dispatch-migration.md
T
J3vbandClaude Fable 5 3e42c4a4f0 docs: close audit items 3 and 11 (channel-visibility + V2 dispatch)
- 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>
2026-07-20 11:44:32 +02:00

6.6 KiB
Raw Blame History

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.hasV2getCommandConstructorDispatchV2, 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_commandhandlePluginCommand (handlers_command.go),
  • voice_joinhandleVoiceJoin (handlers_voice.go),
  • voice_leavehandleVoiceLeave (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 12 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.goChatCommandCmd, VoiceJoinCmd, VoiceLeaveCmd + constructor registrations.
  • Server/ws/deps.goPluginDeps; VoiceDeps already sufficient.
  • Server/ws/event.go — extend Result with the voice join/leave side-effect fields; plugin_broadcast channel event.
  • Server/ws/handlers_command.go, handlers_voice.go — V2 handlers; drop V1 registration functions.
  • Server/ws/handlers.go — delete V1 fallback + hasV2 gate; apply new fields.
  • Server/ws/registry.go — delete the V1 map + methods.
  • Server/ws/hub.goNewHub registers 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 existing handler_v2_*_test.go style); assert parse errors, permission denials, rate-limit, and the emitted Result.
  • 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 neither docs/protocol-schema.json nor protocolTypes.ts changes — dispatcher.ts only consumes server→client messages, which both dispatch generations emit identically.
  • Not decomposing the Hub or reworking replay/seq (backlog 12).
  • handleVoiceLeave remains 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 runs h.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 runs h.handleVoiceJoin(c.ctx, c, env.Payload). handleVoiceJoin is unchanged (keeps its own rate-limit and re-reads the already-validated channel_id); the VoiceJoinCmd constructor 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.