Skip to content

docs(realtime): flatten the WebSocket error fences onto the declared ErrorMessageSchema - #17186

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-16935-realtime-error-envelope
Sep 9, 2026
Merged

docs(realtime): flatten the WebSocket error fences onto the declared ErrorMessageSchema#17186
baozhoutao merged 1 commit into
mainfrom
claude/issue-16935-realtime-error-envelope

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #16935

Clause-②: no — the diff is content/docs/** only; no published package's files[] ships content/, so the published surface is byte-unchanged (measured: 70 non-private packages, zero whose files[] names content).

What was wrong

ErrorMessageSchema (packages/spec/src/api/websocket.zod.ts:365) is the only schema on this tree that governs a type: "error" WebSocket message, and it is flatmessageId / type / timestamp from BaseWebSocketMessage (:269), then a top-level code and message, plus an optional details bag. content/docs/protocol/kernel/realtime-protocol.mdx wrapped code and message in a nested error object in four fences, so a client written from any of them reads msg.error.code and gets undefined on every realtime error message.

Per the dispatch ruling on the card: the two readings the card proposed do not lead to opposite edits for the envelope half — even if WebSocket message rate limiting shipped tomorrow it would emit a flat message, because that schema is the only declaration of the message type. The nested shape is wrong under both readings.

The four fences, flattened

pre-change line code now
:268 FORBIDDEN flat; subscription_id moved into details
:670 TOO_MANY_SUBSCRIPTIONS flat; current / max moved into details
:1025 TOKEN_EXPIRED flat; expires_at moved into details
:1051 RATE_LIMITED flat; the unbacked retry_after / limit / window dropped (see below)

Field spellings inside details are relocated verbatim, not renamed — no key on this page was invented or normalised. One consumer snippet was destructuring the nested shape and is corrected in the same hunk:

-  if (msg.error?.code === 'TOKEN_EXPIRED') {
+  if (msg.type === 'error' && msg.code === 'TOKEN_EXPIRED') {

Verification — every fence parsed against the built schema

Not a gate (there is none for a JSON fence), so this is a one-off run, before and after, against packages/spec/dist/api/index.mjs after a real build. Script lived in the scratchpad and is not checked in.

### AFTER  — this branch
  fence@L273  code=FORBIDDEN               safeParse=PASS
  fence@L675  code=TOO_MANY_SUBSCRIPTIONS  safeParse=PASS
  fence@L1029 code=TOKEN_EXPIRED           safeParse=PASS
  fence@L1068 code=RATE_LIMITED            safeParse=PASS
  (4 fence(s) with type "error")

### BEFORE — origin/main fd5cff209f (negative control, same script, same build)
  fence@L266  code=FORBIDDEN               safeParse=FAIL invalid_type@messageId invalid_type@timestamp invalid_type@code invalid_type@message
  fence@L668  code=TOO_MANY_SUBSCRIPTIONS  safeParse=FAIL invalid_type@messageId invalid_type@timestamp invalid_type@code invalid_type@message
  fence@L1023 code=TOKEN_EXPIRED           safeParse=FAIL invalid_type@messageId invalid_type@timestamp invalid_type@code invalid_type@message
  fence@L1049 code=RATE_LIMITED            safeParse=FAIL invalid_type@messageId invalid_type@timestamp invalid_type@code invalid_type@message
  (4 fence(s) with type "error")

4/4 pass after, 4/4 fail before, same script and same built schema for both legs — so the pass is a measurement, not a script that stopped looking. The messageId values are real v4-shaped UUIDs; z.string().uuid() is what rejects them otherwise.

Rate limiting — marked not implemented, no shape invented

Per the dispatch: the section is now behind a Callout type="warn" saying nothing rate-limits WebSocket messages today, and the prose budget is relabelled Planned limits (not enforced). No producer emits RATE_LIMITED on any realtime path, and there is no WebSocket transport for such a message to arrive on — packages/spec/CHANGELOG.md records that "no WebSocket server is mounted (#2462); the protocol is a future wire contract", which the page's own top-of-page callout already says.

The callout also points at the HTTP surface, which does have a live rate-limit shape under a different code (RATE_LIMIT_EXCEEDED, details.retryAfterSeconds) — that being the reason #15855 carved this page out by construction rather than by preference.

Readings, remeasured on fd5cff209f (file counts over packages/**):

reading result positive control
RATE_LIMITED 5 files, none a realtime emitter RATE_LIMIT_EXCEEDED returns 15 files

auth_error at :220 — measured, deliberately untouched, filed separately

The dispatch fenced this one out and asked for a measurement rather than a rewrite. Measured live against the built spec:

error          DECLARED
auth_error     NOT DECLARED
auth           NOT DECLARED
auth_success   NOT DECLARED
subscribed     NOT DECLARED

(WebSocketMessageType.safeParse(t) over the built dist; error is the positive control on the same corpus and the same call.) Grep legs on the same tree: auth_error over packages/** returns one line and it is not a hit — packages/spec/src/identity/protocol.ts:136 is OAUTH_ERROR: 'oauth_error', matched as a substring; auth_success returns zero across the whole repo outside content/docs. Positive control for both: ErrorMessageSchema returns 11 lines across 6 files.

So auth_error has no declaration. Rewriting the fence to type: "error" would change the protocol rather than the prose, so it is left byte-unchanged here. The finding turned out to be wider than one fence — auth, auth_success, auth_error and subscribed are all outside WebSocketMessageType, and unlike this card's envelope half the two readings there genuinely do diverge (fix the page vs. grow the enum) — so it is filed as #17184 rather than folded in.

Gates

node scripts/pm/dispatch-gates.mjs --commands derived 39 families for this diff; all 39 were run and all 39 exited 0. Reconciliation:

Run reconciliation — 39 derived, 39 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 39 derived famil(ies) accounted for — 39 run, 0 NOT-MEASURED.

Four of them first returned PREREQUISITE NOT MET / not-built (check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift) — nothing measured, not failures. They were re-run to a real verdict after turbo run build --filter='@objectstack/lint...' --filter=@objectstack/client-react --filter=@objectstack/client and pnpm --filter @objectstack/spec build, both through scripts/pm/os-verify-lock.sh (VERDICT command-exit 0 on each). Exit codes were captured by redirect before any pipe.

No package build closure is owed: the diff touches no package, so there is no '<pkg>^...' closure and no package test / typecheck in scope. packages/spec was built only because four of the derived gates read its dist.

pnpm lint (eslint . --no-inline-config) is CI's whole-repo run; the diff is a single .mdx file, which that config does not lint, so nothing was narrowed away here.

Not in scope, not touched


Generated by Claude Code

`ErrorMessageSchema` (packages/spec/src/api/websocket.zod.ts) is the only schema
on this tree governing a `type: "error"` WebSocket message, and it is flat:
`messageId` / `type` / `timestamp` from `BaseWebSocketMessage`, then top-level
`code` and `message`, plus an optional `details` bag. The realtime protocol page
wrapped those fields in a nested `error` object in four fences, so a client
written from any of them reads `msg.error.code` and gets `undefined`.

Flatten all four (subscribe FORBIDDEN, TOO_MANY_SUBSCRIPTIONS, TOKEN_EXPIRED,
RATE_LIMITED) onto the declared envelope, move the extra per-fence context into
`details`, and fix the one consumer snippet that destructured the nested shape.

Mark the WebSocket rate-limiting section as not implemented: no producer emits
`RATE_LIMITED` on any realtime path and there is no WebSocket transport to carry
it, so the stated per-connection budget is planned, not enforced.

The `type: "auth_error"` fence is deliberately left alone: `auth_error` is not a
member of `WebSocketMessageType`, so `ErrorMessageSchema` does not govern it and
rewriting it would change the protocol rather than the prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 9, 2026
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation labels Sep 9, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 9, 2026 15:34
@baozhoutao
baozhoutao enabled auto-merge September 9, 2026 15:34
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit c3756ff Sep 9, 2026
37 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-16935-realtime-error-envelope branch September 9, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] realtime-protocol.mdx teaches a WebSocket rate-limit error shape that no producer emits and no schema declares

2 participants