Files
OwnCord/docs/architecture/websocket.md
T
Claude 63b522494c refactor: move the protocol schema to protocol/schema.json (RL-09)
The WebSocket message-type schema is the one artifact in this repository that
neither component owns: `Server/ws/message_types.go` and
`Client/src/lib/protocolTypes.ts` are both generated from it, and neither may
be hand-edited. It nonetheless lived at `docs/protocol-schema.json` — filed
under the directory for prose, whose own README calls it "Reference" material
— and its generator lived at `Server/scripts/genprotocol/`, i.e. inside one of
the two consumers. Ownership was legible from neither location.

The obvious fix — move the generator to the repository root alongside the
schema, so the whole tool is at the cross-component boundary — is wrong here.
The generator is a Go `package main`, and Go modules are directory-rooted:
`Server/go.mod` roots at `Server/`, so a root-level Go program needs a second
module or a `go.work`. That second module would sit outside every path filter
this repository already has — `golangci-lint` runs with `working-directory:
Server/` (ci.yml), `go vet ./...` runs from `Server/` (scripts/run.mjs,
.githooks/pre-commit), `.githooks/pre-commit` selects Go files with
`^Server/.*\.go$`, `.githooks/pre-push` sets `server_changed` on `^Server/`,
setup-go caches on `Server/go.sum`, and dependabot has one gomod block for
`/Server`. Six gates would silently stop covering the generator, each failing
open. The schema is data and moves freely; the generator is Go and stays where
the Go toolchain already runs.

Done instead:
- `docs/protocol-schema.json` -> `protocol/schema.json`. A new top-level
  `protocol/` is the cross-component boundary, with a `README.md` naming the
  two generated consumers, the one command, and the four gates.
- `Server/scripts/genprotocol/` -> `Server/cmd/genprotocol/`, the module's
  conventional home for an executable. This also empties `Server/scripts/` of
  Go entry points except `seed.go`, which RL-10 moves next.
- `Server/cmd/` added to `Server/.dockerignore` and `Server/.air.toml`, which
  both already excluded `Server/scripts/`. Without this the move would have
  silently widened the Docker build context and the air watch set.

27 files, 115 insertions, 76 deletions. Two runtime path resolvers re-pointed
(`cmd/genprotocol/main.go:41` `-schema` default, `ws/protocol_contract_test.go:67`
`filepath.Join`); two git-hook grep patterns (`pre-commit:53`, `pre-push:57`);
eight generator call sites across five files (Makefile x2, scripts/run.mjs x2,
pre-commit x2, ci-check skill, bughunt-fix.js); two broken relative markdown
links (docs/README.md:47, docs/protocol.md:1497); two generated files
regenerated, header lines only, zero constants changed; two ledger prose hits
plus a `render-ledger.mjs` re-render. No new verify was written: the
regenerate-and-diff check is already enforced three times (CI `make
protocol-verify`, `.githooks/pre-commit`, `npm run check:server`) and
`ws/protocol_contract_test.go` independently checks the schema against the
constants a fourth time.

Verified: both directions, for both resolvers. With `protocol/schema.json`
removed, `go test ./ws/ -run TestProtocol` fails with `reading protocol schema
at /home/user/OwnCord/protocol/schema.json: no such file or directory` (two
tests) and `go run ./cmd/genprotocol` exits 1 with `read schema: open
../protocol/schema.json: no such file or directory`; with the file restored
both pass. So the new path is genuinely resolved, not merely spelled in a
comment. The hook patterns were exercised directly: the pre-commit pattern
matches `protocol/schema.json` and `Server/cmd/genprotocol/main.go` and no
longer matches `docs/protocol-schema.json`; the pre-push pattern matches
`protocol/schema.json`. `go run ./cmd/genprotocol` twice in a row leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the committed outputs are exactly what the generator emits.
`go build ./...` and `go vet ./...` pass; `npx prettier --check .`,
`npm run typecheck` and `npm run lint` pass; `node .superpowers/render-ledger.mjs
--check` reports 348 findings valid.

Not included: the four dated `docs/audit-*.md` files, the older
`docs/plans/*`, and `CHANGELOG.md` keep the old path — they are point-in-time
records, and `.prettierignore` and `scripts/check-doc-counts.mjs` already
treat them as deliberately unmaintained. The B1 plan itself keeps its own
wording, since it states intent rather than current state. `Server/scripts/`
is not deleted: it still holds `seed.go` (RL-10), `k6/`, `toxiproxy/` and two
shell scripts. `Server/telemetry/metrics.go:19` declares a scope for a
`Server/voice` package that does not exist — spotted here, unrelated to this
move, left for RL-13's sweep to carry forward verbatim rather than fixed
inside a relocation. No `seed:` Make target was added.

Refs RL-09, L-09
2026-08-26 19:37:11 +00:00

5.5 KiB

WebSocket / Real-time Engine

Verified against: commit 5630aa1, 2026-08-04

The Server/ws package (~9.5k LOC production code, the largest in the server) implements the real-time engine: a single Hub owning all client connections, a topic-based pub/sub, a monotonic sequence counter, a 3-tier reconnect replay pipeline, and a single typed (V2) command dispatch.

Message-type constants are generated: protocol/schema.json is the single source of truth, and Server/cmd/genprotocol emits both Server/ws/message_types.go and Client/src/lib/protocolTypes.ts from it (make protocol-generate; CI fails on drift via make protocol-verify). The one exception is the plugin command family (chat_command, command_reply, plugin_broadcast), declared by hand in Server/ws/handlers_command.go outside the schema — see protocol.md.

D4a — Connect, authenticate, replay

sequenceDiagram
    autonumber
    participant C as Client (ws.ts via Rust ws_proxy)
    participant S as ws.ServeWS
    participant H as Hub

    C->>S: WSS upgrade /api/v1/ws (Origin checked)
    Note over S: no HTTP AuthMiddleware —<br/>auth is in-band, 10s deadline
    C->>S: {type:"auth", payload:{token, last_seq}}
    S->>S: validate token hash → session expiry → user → ban
    S->>H: register (kicks previous conn of same user)
    S-->>C: auth_ok {user, server_name, motd, replay_source}

    alt last_seq within in-memory ring buffer (Tier 1)
        H-->>C: replay EventsSinceFiltered (perm-filtered, fail-closed)
    else last_seq within events table (Tier 2, max 5000)
        H-->>C: replay from cold-tier EventStore
    else too far behind, or channel visibility changed (Tier 3)
        H-->>C: full "ready" re-sync snapshot
    end

    loop steady state
        C->>H: chat_send / reaction_add / voice_join / …
        H-->>C: seq-stamped broadcasts (chat_message, presence, …)
        C->>S: ping (every 30s) → pong
    end

What this shows. Auth is deliberately in-band (the WS route mounts without AuthMiddleware). Every broadcast is assigned a monotonic seq under a dedicated mutex; the client reports its last_seq on reconnect and the hub picks the cheapest replay tier. A visibilityChangeSeq watermark forces a full re-sync whenever channel visibility changed while the client was away, so permission changes can never be replayed around. auth_ok.replay_source (none|buffer|db) reports which tier served the reconnect and feeds the ws_reconnect_tier_total metric.

D4b — Broadcast fanout and backpressure

flowchart LR
    EV["deliverBroadcast<br/>assign seq"] --> RB["EventRingBuffer<br/>(1000, Tier 1)"]
    EV --> EP["EventPersister<br/>async batched → events table<br/>(Tier 2; drops if queue full)"]
    EV --> PLG["plugin EventSink"]
    EV --> PS["PubSub topics<br/>global / channel:N / voice:N / user:N<br/>(per-topic 100 msg/s limit)"]
    PS --> CH{"per-client queues"}
    CH --> HI["sendHigh (64)<br/>DMs, mentions"]
    CH --> NO["send (256)<br/>chat, reactions"]
    CH --> LO["sendLow (64)<br/>typing, presence"]
    HI --> WP["writePump<br/>drains high-first"]
    NO --> WP
    LO --> WP
    WP -->|"high/normal full →<br/>disconnect (forces replay)"| X["client"]
    LO -.->|"low full → silently dropped"| X

What this shows. Overflow policy is intentional: dropping a chat message would corrupt state, so a full normal/high queue disconnects the client and the replay pipeline restores consistency; typing/presence are lossy by design. The global broadcast channel (1024) drops with a broadcastDrops counter when saturated.

D4c — Typed command dispatch

stateDiagram-v2
    [*] --> handleMessage
    handleMessage --> Unknown: no constructor for type
    handleMessage --> Parse: getCommandConstructor(type)
    Parse --> BadRequest: parse error
    Parse --> Dispatch: strict parse → Command
    Dispatch: DispatchV2 → Result{mutations, events, side-effects}
    Dispatch --> Apply: apply Result
    Apply: reply · EmitEvents · SetChannelID · JoinVoice/LeaveVoice
    Apply --> [*]
    Unknown --> [*]
    BadRequest --> [*]

What this shows. The V1→V2 strangler-fig migration is complete (audit A-2026-07-09, done 2026-07-20): every inbound type parses through its constructor into a typed Command, dispatches to a single V2 handler, and the handler's Result is applied by one applier. There is no second (V1) generation, no lenient parser, and no second registry. Handlers stay effect-light — the two hub-coupled voice routines (handleVoiceJoin/handleVoiceLeave, also called un-throttled on disconnect and channel switch) are triggered from the applier via Result.JoinVoice / Result.LeaveVoice rather than re-expressed as pure events.

The Hub also owns: stale-client sweep (90s), revoked-session sweep (30s, plus per-connection revalidation every 10 messages), stale-voice-state sweep (60s), panic containment on the run loop (3 panics/60s → stop), LiveKit client and optional managed subprocess, and the voice E2EE key-holder map (voice-e2ee.md). Many collaborators are attached post-construction via SetLiveKit / SetEventPersister / SetPluginRegistry setters that "must be called before Run" — temporal coupling noted in the audit.

Source of truth: Server/ws/hub.go, Server/ws/serve.go, Server/ws/client.go, Server/ws/handlers.go, Server/ws/command.go, Server/ws/pubsub.go, Server/ws/ringbuffer.go, Server/ws/event_persister.go, Server/ws/message_types.go, Server/migrations/014_events_table.sql.