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
This commit is contained in:
Claude
2026-08-26 19:37:11 +00:00
parent 9ee306310f
commit 63b522494c
27 changed files with 115 additions and 76 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ golangci-lint run # CI pins v2.11.3
# Generated output must not be stale. These are what `make sqlc-verify` and
# `make protocol-verify` reduce to — make is not on PATH on a stock Windows box.
sqlc generate && git diff --exit-code db/dbgen
go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts
go run ./cmd/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts
```
Add `-tags wazero` to `go vet`/`go test` when you touched `plugin/`.
+4 -4
View File
@@ -1,12 +1,12 @@
---
name: protocol-change
description: Add or change a WebSocket message type in OwnCord. Use before editing docs/protocol-schema.json, Server/ws/message_types.go, or Client/src/lib/protocolTypes.ts.
description: Add or change a WebSocket message type in OwnCord. Use before editing protocol/schema.json, Server/ws/message_types.go, or Client/src/lib/protocolTypes.ts.
---
# protocol-change
`docs/protocol-schema.json` is the source of truth. Both constant files are
generated from it by `Server/scripts/genprotocol/`.
`protocol/schema.json` is the source of truth. Both constant files are
generated from it by `Server/cmd/genprotocol/`.
**The schema holds message-type NAMES only.** Route by what you are changing —
most payload work never touches it, and sending a field change through the
@@ -23,7 +23,7 @@ forwards the message raw, there is nothing to add. If it **re-serialises**, an
older server drops unknown JSON fields — so a field the server must forward is
NOT backward compatible with older servers.
1. Edit `docs/protocol-schema.json`.
1. Edit `protocol/schema.json`.
2. Run `make protocol-generate` from `Server/`.
3. Commit **both** outputs — `Server/ws/message_types.go` and
`Client/src/lib/protocolTypes.ts`. One run regenerates the
+1 -1
View File
@@ -506,7 +506,7 @@ const GATE_COMMANDS = {
` make sqlc-verify protocol-verify # generated output must not be stale. If make is not on PATH, ` +
`run the equivalent commands directly instead: ` +
`"sqlc generate && git diff --exit-code db/dbgen" and ` +
`"go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts" ` +
`"go run ./cmd/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts" ` +
`- a non-empty diff in either means generated code is stale and the gate fails`,
rust:
`From Client/src-tauri:\n` + ` cargo test\n` + ` cargo clippy --all-targets -- -D warnings`,
+3 -3
View File
@@ -50,11 +50,11 @@ if printf '%s\n' "$staged" | grep -qE '^Server/(db/queries/|migrations/|sqlc\.ya
fi
# Protocol schema changed -> regenerated Go + TS constants must be in the same commit.
if printf '%s\n' "$staged" | grep -qE '^(docs/protocol-schema\.json|Server/scripts/genprotocol/)'; then
if printf '%s\n' "$staged" | grep -qE '^(protocol/schema\.json|Server/cmd/genprotocol/)'; then
if command -v go >/dev/null 2>&1; then
(cd Server && go run ./scripts/genprotocol \
(cd Server && go run ./cmd/genprotocol \
&& git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts) \
|| fail "protocol constants are stale — run 'go run ./scripts/genprotocol' in Server/ and stage the result"
|| fail "protocol constants are stale — run 'go run ./cmd/genprotocol' in Server/ and stage the result"
else
printf 'pre-commit: WARNING: go not installed; skipping the protocol-constants check. CI will run it.\n' >&2
fi
+1 -1
View File
@@ -54,7 +54,7 @@ if [ "$changed" = "__all__" ]; then
else
if printf '%s\n' "$changed" | grep -q '^Server/'; then server_changed=1; fi
if printf '%s\n' "$changed" | grep -q '^Client/'; then client_changed=1; fi
if printf '%s\n' "$changed" | grep -q '^docs/protocol-schema\.json'; then
if printf '%s\n' "$changed" | grep -q '^protocol/schema\.json'; then
server_changed=1
client_changed=1
fi
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
run: make sqlc-install sqlc-verify
# Protocol message-type constants (Go + TS) must never drift from
# docs/protocol-schema.json — the single source of truth.
# protocol/schema.json — the single source of truth.
- name: Verify generated protocol constants (make protocol-verify)
if: matrix.os == 'ubuntu-latest'
run: make protocol-verify
+2 -2
View File
@@ -6306,7 +6306,7 @@ Server/ws/voice_e2ee.go:270-272 (direct publish, bypasses h.broadcast)
Server/ws/hub_broadcast.go:64-72 documents that publishing straight to pub/sub "would reintroduce exactly that kind of reordering".
**Suggested fix:** Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since docs/protocol-schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked.
**Suggested fix:** Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since protocol/schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked.
**Fixed:** `ccd9f39b69b202dc2858c8b02e97104e67f81aec` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass
@@ -8025,7 +8025,7 @@ The server deliberately withholds a mention badge for an @here from every reader
**Evidence:** mentions.ts:124-127 `export function highlightsCurrentUser(content, info) { if (info?.mentionsEveryone === true) return true; ... }` — no way to distinguish @here from @everyone. dispatcher.ts:634-649 `const isMention = highlightsCurrentUser(payload.content, {mentions: payload.mentions, mentionsEveryone: payload.mentions_everyone}); ... if (isMention) incrementMention(payload.channel_id, isDetached);`. Server/service/mentions.go:187 `if set.HereOnly && (db.BroadcastStatus(r.Status) == db.StatusOffline || (s.online != nil && !s.online(r.UserID))) { continue }`. Server/ws/messages.go:83 `MentionsEveryone bool \`json:"mentions_everyone"\`` is the only mention-scope field on the wire.
**Suggested fix:** Stop collapsing the two tokens on the wire, then apply the server's rule once on the client's replay path. (1) Add a `mentions_here` bool to chatMessagePayload/chatEditedPayload (Server/ws/messages.go:83 and :149) sourced from mentionSet.HereOnly (plumb it alongside MentionsEveryone through service/message.go's SendResult/EditResult and ws/handlers_chat.go), regenerating docs/protocol-schema.json -> message_types.go/protocolTypes.ts via the protocol-change skill. (2) In Client/src/lib/dispatcher.ts, hoist the existing `isReplayFrame` computation (currently dispatcher.ts:686-690) above the unread/mention block at 634-649 and gate the badge in that one place: treat a frame as a mention only when `payload.mentions.includes(me)` or (`payload.mentions_everyone && !(payload.mentions_here && isReplayFrame)`). That mirrors applyMentionCounts exactly — a here-only mention delivered in the reconnect burst is by definition one the reader was disconnected for — and leaves live delivery, @everyone, and direct mentions untouched. No change to mentions.ts's highlightsCurrentUser is needed for highlight rendering; only the badge increment must distinguish the two.
**Suggested fix:** Stop collapsing the two tokens on the wire, then apply the server's rule once on the client's replay path. (1) Add a `mentions_here` bool to chatMessagePayload/chatEditedPayload (Server/ws/messages.go:83 and :149) sourced from mentionSet.HereOnly (plumb it alongside MentionsEveryone through service/message.go's SendResult/EditResult and ws/handlers_chat.go), regenerating protocol/schema.json -> message_types.go/protocolTypes.ts via the protocol-change skill. (2) In Client/src/lib/dispatcher.ts, hoist the existing `isReplayFrame` computation (currently dispatcher.ts:686-690) above the unread/mention block at 634-649 and gate the badge in that one place: treat a frame as a mention only when `payload.mentions.includes(me)` or (`payload.mentions_everyone && !(payload.mentions_here && isReplayFrame)`). That mirrors applyMentionCounts exactly — a here-only mention delivered in the reconnect burst is by definition one the reader was disconnected for — and leaves live delivery, @everyone, and direct mentions untouched. No change to mentions.ts's highlightsCurrentUser is needed for highlight rendering; only the badge increment must distinguish the two.
**Fixed:** `6f6d0ae` · test `Client/tests/unit/dispatcher.test.ts` · revert-proof self-reported
+2 -2
View File
@@ -5134,7 +5134,7 @@
"test": "Client/tests/unit/livekit-e2ee.test.ts",
"revertProof": "pass"
},
"suggestedFix": "Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since docs/protocol-schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked.",
"suggestedFix": "Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since protocol/schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked.",
"fixedDate": "2026-08-20"
},
{
@@ -6426,7 +6426,7 @@
"lens": "flow-message",
"finder": "opus",
"confidence": "medium",
"suggestedFix": "Stop collapsing the two tokens on the wire, then apply the server's rule once on the client's replay path. (1) Add a `mentions_here` bool to chatMessagePayload/chatEditedPayload (Server/ws/messages.go:83 and :149) sourced from mentionSet.HereOnly (plumb it alongside MentionsEveryone through service/message.go's SendResult/EditResult and ws/handlers_chat.go), regenerating docs/protocol-schema.json -> message_types.go/protocolTypes.ts via the protocol-change skill. (2) In Client/src/lib/dispatcher.ts, hoist the existing `isReplayFrame` computation (currently dispatcher.ts:686-690) above the unread/mention block at 634-649 and gate the badge in that one place: treat a frame as a mention only when `payload.mentions.includes(me)` or (`payload.mentions_everyone && !(payload.mentions_here && isReplayFrame)`). That mirrors applyMentionCounts exactly — a here-only mention delivered in the reconnect burst is by definition one the reader was disconnected for — and leaves live delivery, @everyone, and direct mentions untouched. No change to mentions.ts's highlightsCurrentUser is needed for highlight rendering; only the badge increment must distinguish the two.",
"suggestedFix": "Stop collapsing the two tokens on the wire, then apply the server's rule once on the client's replay path. (1) Add a `mentions_here` bool to chatMessagePayload/chatEditedPayload (Server/ws/messages.go:83 and :149) sourced from mentionSet.HereOnly (plumb it alongside MentionsEveryone through service/message.go's SendResult/EditResult and ws/handlers_chat.go), regenerating protocol/schema.json -> message_types.go/protocolTypes.ts via the protocol-change skill. (2) In Client/src/lib/dispatcher.ts, hoist the existing `isReplayFrame` computation (currently dispatcher.ts:686-690) above the unread/mention block at 634-649 and gate the badge in that one place: treat a frame as a mention only when `payload.mentions.includes(me)` or (`payload.mentions_everyone && !(payload.mentions_here && isReplayFrame)`). That mirrors applyMentionCounts exactly — a here-only mention delivered in the reconnect burst is by definition one the reader was disconnected for — and leaves live delivery, @everyone, and direct mentions untouched. No change to mentions.ts's highlightsCurrentUser is needed for highlight rendering; only the badge increment must distinguish the two.",
"fix": {
"commit": "6f6d0ae",
"test": "Client/tests/unit/dispatcher.test.ts",
+1 -1
View File
@@ -14,7 +14,7 @@ CI fails on drift, and the next generator run silently discards your edit.
| Generated | Source of truth | Workflow |
| ---------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------- |
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `docs/protocol-schema.json` | `protocol-change` skill |
| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `protocol/schema.json` | `protocol-change` skill |
| `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
## Bug-hunt ledger
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by scripts/genprotocol from docs/protocol-schema.json; DO NOT EDIT.
// Code generated by cmd/genprotocol from protocol/schema.json; DO NOT EDIT.
//
// Shared WebSocket protocol message type constants — single source of truth
// for both Server (Go) and Client (TypeScript). Edit docs/protocol-schema.json
// for both Server (Go) and Client (TypeScript). Edit protocol/schema.json
// and run `make protocol-generate` in Server/.
//
// Usage: import { MessageType } from "@lib/protocolTypes";
+1 -1
View File
@@ -346,7 +346,7 @@ export function chatEchoHandlers(): Array<{ type: string; handler: string }> {
* Voice WS flow handlers for E2E testing.
* Simulates the server-side voice protocol defined in:
* docs/brain/06-Specs/PROTOCOL.md (voice_join, voice_leave, voice_token, voice_token_refresh)
* docs/protocol-schema.json (message type schemas)
* protocol/schema.json (message type schemas)
*
* When PROTOCOL.md voice message types change, update these handlers to match.
*/
+1 -1
View File
@@ -9,7 +9,7 @@ tmp_dir = "tmp"
bin = "./chatserver.exe"
cmd = "go build -o chatserver.exe -ldflags \"-s -w\" ."
delay = 1000
exclude_dir = ["tmp", "scripts", "migrations", "data", "admin/static"]
exclude_dir = ["tmp", "scripts", "cmd", "migrations", "data", "admin/static"]
exclude_file = []
exclude_regex = ["_test\\.go$"]
exclude_unchanged = false
+2 -1
View File
@@ -13,8 +13,9 @@ data/
# Local config (users provide their own via volume mount or env)
config.yaml
# Scripts (not needed in image)
# Developer tooling (not needed in image; the image builds the root package)
scripts/
cmd/
# Git metadata
.git
+1 -1
View File
@@ -72,7 +72,7 @@ linters:
excludes:
- G104 # unhandled errors — errcheck covers this better
- G304 # file path from variable — expected in file storage code
- G306 # WriteFile perms ≤0600 — our only hits are generated source files (genprotocol), which must stay world-readable or multi-stage container builds break
- G306 # WriteFile perms ≤0600 — our only hits are generated source files (cmd/genprotocol), which must stay world-readable or multi-stage container builds break
- G706 # log injection — false positive with slog structured logging (values are typed key-value pairs, not interpolated)
exclusions:
+2
View File
@@ -9,6 +9,8 @@ prometheus.
- `api/` REST handlers · `ws/` WebSocket hub · `auth/` sessions/TOTP ·
`permissions/` role checks · `service/` domain logic shared by both entry points
- `db/` hand-written query wrappers; `db/dbgen/` is generated (see `db-change`)
- `cmd/` executable tooling, one `package main` per subdirectory —
`cmd/genprotocol/` regenerates the protocol constants from `protocol/schema.json`
- `admin/` web admin panel · `updater/` self-update + signature verification ·
`plugin/` WASM plugin runtime (`-tags wazero`) · `telemetry/` OTel (`-tags otel`)
- `syncutil/` lock helpers that gain deadlock detection under `-tags deadlock`
+3 -3
View File
@@ -9,7 +9,7 @@
# sqlc-generate Regenerate type-safe Go from sqlc.yaml (db/dbgen).
# sqlc-verify Fail if the committed dbgen output is stale (used by CI).
# sqlc-install Install the pinned sqlc version into $GOBIN.
# protocol-generate Regenerate WS message-type constants (Go + TS) from docs/protocol-schema.json.
# protocol-generate Regenerate WS message-type constants (Go + TS) from ../protocol/schema.json.
# protocol-verify Fail if the committed protocol constants are stale (used by CI).
# otel-up Start Jaeger + Prometheus for local tracing development.
# otel-down Stop and remove the OTel dev containers.
@@ -82,10 +82,10 @@ sqlc-verify:
)
protocol-generate:
go run ./scripts/genprotocol
go run ./cmd/genprotocol
protocol-verify:
go run ./scripts/genprotocol
go run ./cmd/genprotocol
@git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts || ( \
echo "ERROR: generated protocol constants are stale. Run 'make protocol-generate' and commit the result." ; \
exit 1 ; \
@@ -1,10 +1,10 @@
// genprotocol generates the WebSocket protocol message-type constant files
// for both the Go server and the TypeScript client from the single source of
// truth at docs/protocol-schema.json.
// truth at protocol/schema.json.
//
// Usage (from the Server/ directory):
//
// go run ./scripts/genprotocol
// go run ./cmd/genprotocol
//
// or via make:
//
@@ -38,7 +38,7 @@ type schema struct {
}
func main() {
schemaPath := flag.String("schema", "../docs/protocol-schema.json", "path to protocol-schema.json")
schemaPath := flag.String("schema", "../protocol/schema.json", "path to the protocol schema")
goOut := flag.String("go-out", "ws/message_types.go", "path to the generated Go file")
tsOut := flag.String("ts-out", "../Client/src/lib/protocolTypes.ts", "path to the generated TypeScript file")
flag.Parse()
@@ -95,14 +95,14 @@ func validate(s schema) error {
}
func header(comment string) string {
return "// Code generated by scripts/genprotocol from docs/protocol-schema.json; DO NOT EDIT.\n" +
return "// Code generated by cmd/genprotocol from protocol/schema.json; DO NOT EDIT.\n" +
comment + "\n"
}
func renderGo(s schema) ([]byte, error) {
var b strings.Builder
b.WriteString(header("//\n// WebSocket protocol message type constants — single source of truth for\n" +
"// both Server (Go) and Client (TypeScript). Edit docs/protocol-schema.json\n" +
"// both Server (Go) and Client (TypeScript). Edit protocol/schema.json\n" +
"// and run `make protocol-generate` (see Server/Makefile)."))
b.WriteString("\npackage ws\n\n")
@@ -131,7 +131,7 @@ func renderGo(s schema) ([]byte, error) {
func renderTS(s schema) string {
var b strings.Builder
b.WriteString(header("//\n// Shared WebSocket protocol message type constants — single source of truth\n" +
"// for both Server (Go) and Client (TypeScript). Edit docs/protocol-schema.json\n" +
"// for both Server (Go) and Client (TypeScript). Edit protocol/schema.json\n" +
"// and run `make protocol-generate` in Server/.\n//\n" +
"// Usage: import { MessageType } from \"@lib/protocolTypes\";\n" +
"// ws.send({ type: MessageType.CHAT_SEND, payload: { ... } });"))
+1 -1
View File
@@ -3,7 +3,7 @@
//
// The wire protocol is the envelope format from docs/protocol.md: every
// client->server frame is {type, id?, payload:{...}} and the first frame MUST
// be an `auth` envelope. If you change docs/protocol-schema.json, grep this
// be an `auth` envelope. If you change protocol/schema.json, grep this
// script — it is not generated and CI does not run it, so it rots silently
// (it once drifted to pre-envelope framing and reported green while every
// auth failed).
+2 -2
View File
@@ -1,7 +1,7 @@
// Code generated by scripts/genprotocol from docs/protocol-schema.json; DO NOT EDIT.
// Code generated by cmd/genprotocol from protocol/schema.json; DO NOT EDIT.
//
// WebSocket protocol message type constants — single source of truth for
// both Server (Go) and Client (TypeScript). Edit docs/protocol-schema.json
// both Server (Go) and Client (TypeScript). Edit protocol/schema.json
// and run `make protocol-generate` (see Server/Makefile).
package ws
+14 -14
View File
@@ -1,10 +1,10 @@
package ws_test
// protocol_contract_test.go — locks docs/protocol-schema.json and the
// protocol_contract_test.go — locks protocol/schema.json and the
// generated ws/message_types.go together so the two cannot silently drift.
//
// message_types.go is documented as "Code generated by scripts/genprotocol
// from docs/protocol-schema.json; DO NOT EDIT" and CI runs
// message_types.go is documented as "Code generated by cmd/genprotocol
// from protocol/schema.json; DO NOT EDIT" and CI runs
// `make protocol-verify`, but that only re-runs the generator and diffs its
// output — it says nothing about message-type constants that exist in the ws
// package outside the generated file (e.g. a handler defining its own
@@ -41,7 +41,7 @@ import (
var knownUndocumentedConstants = map[string]string{}
// protocolSchemaEntry mirrors one element of the client_to_server /
// server_to_client arrays in docs/protocol-schema.json.
// server_to_client arrays in protocol/schema.json.
type protocolSchemaEntry struct {
Wire string `json:"wire"`
Go string `json:"go"`
@@ -54,8 +54,8 @@ type protocolSchema struct {
ServerToClient []protocolSchemaEntry `json:"server_to_client"`
}
// loadProtocolSchema locates and parses docs/protocol-schema.json relative to
// this test file (ws/ -> Server/ -> repo root -> docs/), so the test does not
// loadProtocolSchema locates and parses protocol/schema.json relative to
// this test file (ws/ -> Server/ -> repo root -> protocol/), so the test does not
// depend on the working directory `go test` happens to be invoked from.
func loadProtocolSchema(t *testing.T) protocolSchema {
t.Helper()
@@ -64,7 +64,7 @@ func loadProtocolSchema(t *testing.T) protocolSchema {
t.Fatal("runtime.Caller failed to resolve test file path")
}
wsDir := filepath.Dir(thisFile)
schemaPath := filepath.Join(wsDir, "..", "..", "docs", "protocol-schema.json")
schemaPath := filepath.Join(wsDir, "..", "..", "protocol", "schema.json")
raw, err := os.ReadFile(schemaPath)
if err != nil {
@@ -145,7 +145,7 @@ func loadGoMsgTypeConstants(t *testing.T) map[string]string {
}
// TestProtocolSchema_MatchesGeneratedGoConstants is the "schema -> code"
// direction: every wire constant docs/protocol-schema.json lists (in either
// direction: every wire constant protocol/schema.json lists (in either
// direction of traffic) must have a same-named Go constant in the ws package
// carrying exactly the schema's wire string.
func TestProtocolSchema_MatchesGeneratedGoConstants(t *testing.T) {
@@ -161,16 +161,16 @@ func TestProtocolSchema_MatchesGeneratedGoConstants(t *testing.T) {
continue
}
if got != e.Wire {
t.Errorf("%s: ws.%s = %q, want %q per protocol-schema.json", direction, e.Go, got, e.Wire)
t.Errorf("%s: ws.%s = %q, want %q per protocol/schema.json", direction, e.Go, got, e.Wire)
}
}
}
if len(schema.ClientToServer) == 0 {
t.Fatal("protocol-schema.json client_to_server is empty — schema failed to load")
t.Fatal("protocol/schema.json client_to_server is empty — schema failed to load")
}
if len(schema.ServerToClient) == 0 {
t.Fatal("protocol-schema.json server_to_client is empty — schema failed to load")
t.Fatal("protocol/schema.json server_to_client is empty — schema failed to load")
}
check("client_to_server", schema.ClientToServer)
@@ -179,7 +179,7 @@ func TestProtocolSchema_MatchesGeneratedGoConstants(t *testing.T) {
// TestProtocolSchema_NoUndocumentedGoConstants is the "code -> schema"
// direction: every MsgType* constant declared in the ws package must appear
// in docs/protocol-schema.json, except the documented exceptions in
// in protocol/schema.json, except the documented exceptions in
// knownUndocumentedConstants (see its doc comment). This is what catches a
// handler minting its own wire constant instead of adding it to the schema.
func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) {
@@ -200,7 +200,7 @@ func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) {
}
exceptWire, isException := knownUndocumentedConstants[name]
if !isException {
t.Errorf("ws.%s = %q is not in docs/protocol-schema.json and is not a documented exception "+
t.Errorf("ws.%s = %q is not in protocol/schema.json and is not a documented exception "+
"(knownUndocumentedConstants) — add it to the schema or the exception list", name, wire)
continue
}
@@ -219,7 +219,7 @@ func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) {
continue
}
if _, nowDocumented := documented[name]; nowDocumented {
t.Errorf("knownUndocumentedConstants lists %q but it is now in protocol-schema.json — remove it from the exception list", name)
t.Errorf("knownUndocumentedConstants lists %q but it is now in protocol/schema.json — remove it from the exception list", name)
}
}
}
+8 -8
View File
@@ -37,14 +37,14 @@ dated snapshots that were true when written and were never updated, and
These describe contracts the code implements. If one disagrees with the code,
the code is right and the document is a bug.
| Document | Covers |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [api.md](api.md) | REST API under `/api/v1`. |
| [protocol.md](protocol.md) | WebSocket protocol — frames, sequencing, reconnect. |
| [schema.md](schema.md) | SQLite schema and migrations. |
| [server-configuration.md](server-configuration.md) | Every server configuration option. |
| [credential-storage.md](credential-storage.md) | What the desktop client persists, and where. |
| [protocol-schema.json](protocol-schema.json) | **Generated-code source of truth.** `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated from it — never hand-edit either. |
| Document | Covers |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [api.md](api.md) | REST API under `/api/v1`. |
| [protocol.md](protocol.md) | WebSocket protocol — frames, sequencing, reconnect. |
| [schema.md](schema.md) | SQLite schema and migrations. |
| [server-configuration.md](server-configuration.md) | Every server configuration option. |
| [credential-storage.md](credential-storage.md) | What the desktop client persists, and where. |
| [../protocol/schema.json](../protocol/schema.json) | **Generated-code source of truth**, at the repository root because it is owned by neither side. `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated from it — never hand-edit either. See [../protocol/README.md](../protocol/README.md). |
## Architecture
+2 -2
View File
@@ -7,8 +7,8 @@ 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**: `docs/protocol-schema.json` is the
single source of truth, and `Server/scripts/genprotocol` emits both
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`).
+13 -13
View File
@@ -60,19 +60,19 @@ next section, and using them directly is equally correct.
**Make targets** (run from `Server/`):
| Command | Description |
| ------------------------ | ----------------------------------------------------------------------------------- |
| `make test` | Run the test suite the way CI does (`-race`, 20 min timeout) |
| `make test-deadlock` | Run the deadlock-detection pass CI also runs (`-tags deadlock`) |
| `make cover` | Per-package coverage (what CI uploads) + a function summary |
| `make cover-all` | Cross-package coverage — the honest number (also lists 0.0% functions) |
| `make sqlc-install` | Install the pinned sqlc version into `$GOBIN` |
| `make sqlc-generate` | Regenerate the type-safe Go query layer (`db/dbgen/`, SQLite engine) |
| `make sqlc-verify` | Fail if the committed `dbgen` output is stale (used by CI) |
| `make protocol-generate` | Regenerate the WS message-type constants (Go + TS) from `docs/protocol-schema.json` |
| `make protocol-verify` | Fail if the committed protocol constants are stale (used by CI) |
| `make otel-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development |
| `make otel-down` | Stop and remove the OTel dev containers |
| Command | Description |
| ------------------------ | ---------------------------------------------------------------------------------- |
| `make test` | Run the test suite the way CI does (`-race`, 20 min timeout) |
| `make test-deadlock` | Run the deadlock-detection pass CI also runs (`-tags deadlock`) |
| `make cover` | Per-package coverage (what CI uploads) + a function summary |
| `make cover-all` | Cross-package coverage — the honest number (also lists 0.0% functions) |
| `make sqlc-install` | Install the pinned sqlc version into `$GOBIN` |
| `make sqlc-generate` | Regenerate the type-safe Go query layer (`db/dbgen/`, SQLite engine) |
| `make sqlc-verify` | Fail if the committed `dbgen` output is stale (used by CI) |
| `make protocol-generate` | Regenerate the WS message-type constants (Go + TS) from `protocol/schema.json` |
| `make protocol-verify` | Fail if the committed protocol constants are stale (used by CI) |
| `make otel-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development |
| `make otel-down` | Stop and remove the OTel dev containers |
#### Client (Tauri v2)
+2 -2
View File
@@ -1494,7 +1494,7 @@ recipient from being flooded.
## Message Type Reference Table
The authoritative type inventory is [protocol-schema.json](protocol-schema.json),
The authoritative type inventory is [protocol/schema.json](../protocol/schema.json),
from which the Go and TypeScript constant files are generated
(`make protocol-generate` / verified in CI by `make protocol-verify`). The
tables below add per-type behavioral notes.
@@ -1578,7 +1578,7 @@ tables below add per-type behavioral notes.
### Plugin command types
Three wire types exist for the WASM plugin system. Since 2026-08-04 they are
listed in `protocol-schema.json` like every other type (closing DC-01), so
listed in `protocol/schema.json` like every other type (closing DC-01), so
the generated constants cover them and `make protocol-verify` plus the
`ws` package's protocol-contract test gate them against drift.
+36
View File
@@ -0,0 +1,36 @@
# `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 |
Two files are generated from it and must never be hand-edited:
- `Server/ws/message_types.go`
- `Client/src/lib/protocolTypes.ts`
## Changing the protocol
Edit `schema.json`, then regenerate both consumers with one command from the
repository root:
```bash
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.
The narrative protocol reference is [`docs/protocol.md`](../docs/protocol.md);
the blueprint is [`docs/architecture/websocket.md`](../docs/architecture/websocket.md).
@@ -1,5 +1,5 @@
{
"$comment": "Single source of truth for WebSocket protocol message-type constants. Server/ws/message_types.go and Client/src/lib/protocolTypes.ts are generated from this file — edit here, then run `make protocol-generate` in Server/. CI runs `make protocol-verify` to reject drift.",
"$comment": "Single source of truth for WebSocket protocol message-type constants. Server/ws/message_types.go and Client/src/lib/protocolTypes.ts are generated from this file — edit here, then run `npm run generate` from the repository root (or `make protocol-generate` in Server/). CI runs `make protocol-verify` to reject drift.",
"version": 1,
"client_to_server": [
{ "wire": "auth", "go": "MsgTypeAuth", "ts": "AUTH" },
+2 -2
View File
@@ -59,7 +59,7 @@ const tracked = (...patterns) => {
// `git diff --exit-code` after regenerating is what `make protocol-verify` and
// `make sqlc-verify` reduce to. Inlined so neither needs make.
const PROTOCOL_VERIFY = [
step("go", ["run", "./scripts/genprotocol"], "Server"),
step("go", ["run", "./cmd/genprotocol"], "Server"),
step(
"git",
["diff", "--exit-code", "ws/message_types.go", "../Client/src/lib/protocolTypes.ts"],
@@ -151,7 +151,7 @@ const TASKS = {
"check:hygiene": CHECK_HYGIENE,
check: [...CHECK_DOCS, ...CHECK_HYGIENE, ...CHECK_SERVER, ...CHECK_CLIENT, ...CHECK_RUST],
generate: [
step("go", ["run", "./scripts/genprotocol"], "Server"),
step("go", ["run", "./cmd/genprotocol"], "Server"),
optional(
"sqlc",
"sqlc",