* feat(b2-2): declare protocol_epoch in the schema and generate both constants protocol/schema.json gains protocol_epoch (1). genprotocol emits ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go constant to the schema so a stale regeneration fails the required check. * feat(b2-2): check the client's protocol epoch in the auth handshake The auth payload gains epoch (absent = 0). Outside [minClientEpoch, ProtocolEpoch] the server answers one auth_error with code protocol_epoch_unsupported, the client/server/min epochs, and a message naming which side to update, then closes 1008 like every other handshake failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep connecting; the epoch-1 fixtures are unchanged. * feat(b2-2): send the protocol epoch and offer the update on a refused connect ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended on purpose). On auth_error code protocol_epoch_unsupported with a newer server the dispatcher records the host in ui.store.updateRequiredHost and main.ts mounts the UpdateNotifier on the connect page, so a refused client gets the same Update Now banner it would have had on the main page. * feat(b2-2): withhold client releases newer than the server's protocol epoch The signed server-update manifest gains protocol_epoch (release.yml reads it from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the manifest and reads it; the client-update endpoint answers 204 when the release's epoch is newer than ws.ProtocolEpoch or the manifest does not verify. Releases without a manifest are epoch 0 and advertised as before. Docs: protocol.md Compatibility section, api.md, deployment.md, protocol README, CHANGELOG Unreleased. * docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it * ci: prove the protocol_epoch manifest read on every PR, not only at tag time * fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal Codex P1: on a first login or startup auto-login no overlay exists before auth_ok, so a refusal never re-rendered the connect page and the one-time read of updateRequiredHost missed it. The connect page now subscribes to it, and a later refusal replaces the banner. Codex P2: a refusal on reconnect went through the generic logout and deleted the stored credential although the token is still valid. clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it (the skip-auto-login flag is still set and, being sessionStorage, does not survive the relaunch the update triggers).
protocol/
The cross-component WebSocket contract. It lives at the repository root
because neither side owns it: schema.json is the single source of truth for
the message-type constants both the Go server and the TypeScript client
compile against.
| File | Role |
|---|---|
schema.json |
Source of truth. Every wire message type, both directions |
fixtures/epoch-1/ |
Frozen wire transcripts for protocol epoch 1. Regenerated by the test, never hand-edited |
Two files are generated from it and must never be hand-edited:
Server/ws/message_types.goClient/src/lib/protocolTypes.ts
Changing the protocol
schema.json also declares protocol_epoch, the one version number the auth
handshake negotiates on. Additive changes stay within an epoch; a breaking
change bumps it. The rules, and what a bump obliges, are in docs/protocol.md
under Compatibility.
Edit schema.json, then regenerate both consumers with one command from the
repository root:
npm run generate
(Equivalently, make protocol-generate or go run ./cmd/genprotocol from
Server/ — the generator is a Go program, so it lives where the Go toolchain
already runs.)
Three gates reject a stale regeneration and one gate checks the schema against
the constants independently — .githooks/pre-commit, make protocol-verify in
CI, npm run check:server, and Server/ws/protocol_contract_test.go. There is
nothing extra to run.
Fixtures
fixtures/epoch-1/ holds one JSON file per journey (fresh connect, chat send,
voice join, ...). Within a file, each connection has its own list of frames in
order; frames on different connections are not related to each other —
cross-connection interleaving is timing-dependent and is not part of the
contract. Volatile values — ids, seqs, timestamps, tokens, user ids — are
replaced by typed placeholders such as "<id:number>", so a refactor that
keeps the wire shape leaves the file untouched, while a renamed, added or
removed key, or a type change, shows up as a diff.
TestEpoch1Fixtures in Server/ws drives the real server in-process and
compares its output against these files. It runs under go test ./..., so
npm run check:server and the Server Build & Test CI check already cover
it — there is nothing extra to run.
Regenerate with:
go test ./ws -run TestEpoch1Fixtures -update
from Server/, then read the diff frame by frame before committing it.
A fixture's shape may only change deliberately — and an epoch bump is for
what older clients cannot process. Within an epoch, a change an older client
can ignore — a new key, a new optional field — is allowed: regenerate
fixtures/epoch-<n>/ in the same PR, read the diff frame by frame, and
document the addition in docs/protocol.md. A change an older client cannot
process — a key removed, renamed, or retyped, a frame dropped, or the order of
frames on one connection changed — needs a new fixtures/epoch-<n+1>/
directory, with the old one kept as the record of what earlier clients speak.
A diff of that kind in a change that does not bump the epoch is a protocol
break to revert, not a refactor to accept. (Epoch negotiation itself is an
additive change and stays on epoch 1.)
A diff confined to a seeded default value is not a protocol change. The
fixtures record real values wherever they are deterministic — role permission
masks and colours, motd, server_name, a channel's voice_max_video, the
voice_config preset — so a migration that changes a default mask moves a
fixture without touching the wire. Regenerate in the same PR and read the diff
frame by frame. Normalising those values away is not
the answer: a placeholder over a mask or over an enum such as
voice_config.threshold_mode would hide exactly the drift these files exist to
catch.
A value drawn from a fixed vocabulary the client switches on — threshold_mode,
quality, status, replay_source, a channel type — is shape, not a seeded
value: renaming or dropping a member of it is a protocol change even though
only a value moved.
The narrative protocol reference is docs/protocol.md;
the blueprint is docs/architecture/websocket.md.