Skip to content

docs(protocol): put the realtime event fences on the shape EventMessageSchema declares - #18183

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-17749-realtime-event-fences
Sep 14, 2026
Merged

claude[bot] merged 1 commit into
mainfrom
claude/issue-17749-realtime-event-fences

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #17749

Clause-②: no
Prose only, one file, and no new key reaches any published payload. packages/spec/src/api/websocket.zod.ts is byte-unchanged — blob 3348dd76745b61e83801e2e8bae48c6756c5a69c at the merge base fffe3e5e7, at this branch's HEAD, and on disk (git rev-parse BASE:path, git rev-parse HEAD:path, git hash-object path — three identical hashes).

The premise was checked first, from the runtime — not from either document

The card reports snake_case on the page against camelCase in the schema, systematically. That is the signature of two documents written against different wire formats, so before repairing anything: what does the server actually put on the wire for a realtime event?

It puts nothing on any wire — there is no realtime transport, and the only shape the platform produces is camelCase.

  • IRealtimeService.handleUpgrade is deliberately unimplemented platform-wide; packages/services/service-realtime/src/no-channel-route.pin.test.ts:49 asserts it is not a function, and getChannelRoute is not implemented either, so discovery reports enabled: false.
  • EventMessageSchema has zero consumers outside packages/spec's own declaration, its unit test (src/api/websocket.test.ts) and the type-alias pin. Nothing constructs an event message anywhere.
  • What the platform does publish is the in-process RealtimeEventPayload envelope, built at packages/objectql/src/engine.ts (publishDataEvent / publishBulkDataEvent) and handed to IRealtimeService.publish. Its keys are type / object / payload / timestamp, and payload is a DataEvent whose keys are id / type / object / recordId / changes / after / userId / organizationId / timestampcamelCase throughout, matching the schema's spelling convention and matching the page on nothing.
  • There is no serialisation layer that renames keys: the only toSnakeCase in the tree is packages/cli/src/commands/generate.ts, which derives metadata machine names during scaffolding and never touches a message.
  • subscription_id returns 0 across tracked packages/** sources (no dist/), with three lit controls on the same corpus and the same call — subscriptionId 51, handleUpgrade 21, EventMessageSchema 21. The quoted-literal form ('subscription_id' / "subscription_id") is also 0, with its own same-form control ('subscriptionId' / "subscriptionId") at 0 as well — reported as a dark control, so that second reading carries no weight; the unquoted one does.

So the schema is the only declared contract for a type: "event" message, the producer that exists corroborates its camelCase spelling on every axis, and the page is the stale side. The repair stays in docs; ⛔ nothing in packages/spec is touched.

Confirming the two dispatch readings: websocket.zod.ts last moved at 8fd246d27 (2026-09-08T22:18Z), before the card, so the schema side is as the card describes. The page last moved at ec739e671 (2026-09-12T02:18Z), after the card's 9bd4344e4b measurement, so the card's line numbers are stale by construction and were not used.

Fence census — enumerated by content, on this tree

Six, and six is still right. Enumerated by literal match, both fence dialects, because the round that landed #17747 learned that a JS-quoted type: 'event' hides from a JSON-only sweep:

  • "type": "event" (JSON form): 6 hits, at lines 349 / 366 / 387 / 498 / 514 / 737 on the pre-edit tree.
  • type: 'event' (JS form): 0 hits. A widened regex over both quotings and both separators returns the same six lines and nothing else.
  • Firing control on the same file and the same calls: "type": "subscribe" 4, type: 'subscribe' 2.

A second, independent census run with a parser (walk every json fence, `JSON.parse` it, keep those whose `type` is `event`) found only **3** — because two of the six carried `//` comments inside the JSON and did not parse at all, and the sixth sat in a javascript fence. That disagreement is itself the reason the enumeration had to be by content.

# pre-edit lines section top-level keys
1 347–361 Receiving Events, created type, subscription_id, event, object, data
2 364–382 Receiving Events, updated + changes
3 385–396 Receiving Events, deleted type, subscription_id, event, object, data
4 496–509 Query Results, entered + reason; invalid JSON (a // comment)
5 512–524 Query Results, exited + reason; invalid JSON (a // comment)
6 734–746 Permission Enforcement type, event, object, data, reason; in a ```javascript fence

The repair

Every fence now carries exactly the declared envelope — messageId / type / timestamp from BaseWebSocketMessage, then subscriptionId (a real UUID, since the schema declares z.string().uuid() and "sub_1" does not parse), eventName, object and payload, with userId where it reads naturally. The record body moved inside payload, shaped as the DataEvent the engine actually publishes.

Three things the page taught that no schema declares are now called out rather than silently dropped:

  • changes is not a top-level key. It is real one level down, on the DataEvent inside payload, and it carries the write's own patch — a flat { field: newValue } map (publishDataEvent passes hookContext.input.data), not the old / new pairs the page showed. The post-state is payload.after.
  • reason is declared nowhere in the protocol. The two entered_query / exited_query fences described a signal the contract cannot send — the same finding docs(protocol): put the realtime subscribe fences on the shape SubscribeMessageSchema declares #17747 recorded in prose while deliberately leaving these two event fences alone. They are now ordinary data.record.updated messages, with a note that the two are identical in shape and that deriving the transition is the client's job, which is exactly what the query callout docs(protocol): put the realtime subscribe fences on the shape SubscribeMessageSchema declares #17747 added one paragraph above already prescribes.
  • subscription_id / event / data are not fields of this envelope at all.

Wording, section shape (a declaring paragraph, a Callout type="warn", then a field list) and event-name spelling (data.record.created, from the closed DataEventType enum) follow the neighbouring subscribe/unsubscribe fences #17747 landed on this page, rather than inventing a second style.

Fence 6 also changes language from javascript to json: its content was a bare JSON object with // comments, which is neither valid JS nor valid JSON.

Verification

The repaired fences parse against the real schema — 0/3 before, 6/6 after. A throwaway script imported EventMessageSchema from the freshly built packages/spec/dist/api/index.mjs, walked every ```json fence on the page, and safeParsed each one whose `type` is `event`:

AFTER  (this branch)        event fences that are valid JSON: 6
  line 351 PARSE OK · 379 PARSE OK · 408 PARSE OK · 553 PARSE OK · 574 PARSE OK · 809 PARSE OK
  RESULT 6/6 parse against EventMessageSchema

BEFORE (merge base fffe3e5e7, control)   event fences that are valid JSON: 3
  line 347 PARSE FAILED -> messageId: invalid_type | timestamp: invalid_type | subscriptionId: invalid_type | eventName: invalid_type | payload: invalid_type
  line 364 PARSE FAILED -> (same five)
  line 385 PARSE FAILED -> (same five)
  RESULT 0/3 parse against EventMessageSchema

The control leg fires, and it fires on the five axes the card named. The script was deleted after the run — it is a one-off measurement, not a gate (see the note below).

Also measured: all 22 ```json fences on the page are now valid JSON (21 before, of which 2 were invalid); the undeclared-key set across the six event fences is now empty (before: subscription_id, `event`, `data`, `changes`).

Gates — 40 derived, 40 run, 40 green, 0 NOT-MEASURED. Family derived mechanically with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (it reads the change set from the merge base itself; committed 1, working tree 0, untracked 0), never hand-listed. Each command's exit code was captured into a variable before any pipe, then reconciled back:

✓ dispatch-gates --ran: 40 derived famil(ies) accounted for — 40 run,
  0 NOT-MEASURED (a DERIVED zero — all 40 recorded an exit code and none of them is 3).

Prerequisite builds ran first, under the shared verify lock (scripts/pm/os-verify-lock.sh, VERDICT command-exit 0): @objectstack/lint..., @objectstack/spec, @objectstack/client-react.... No gate returned exit 3 or a PREREQUISITE NOT MET line.

MDX compiles: pnpm exec fumadocs-mdx in apps/docs — exit 0, [MDX] generated files. No tracked artifact moved: git status --porcelain is empty and the diff is one file, 140 insertions / 51 deletions.

No control characters: check:nul-bytes exit 0 (inside the 40), plus a direct sweep of the edited file with a firing control — grep -naP over the file exits 1 (no hits) while the identical call over a seeded file exits 0 and prints the hit.

Repo-wide lint is CI's, and the narrowing here is a measurement, not a skip: the edited file is outside eslint's population entirely. eslint --no-inline-config --format json on it reports, from eslint's own config resolution, File ignored because no matching configuration was supplied, and every files: glob in eslint.config.mjs names only {ts,tsx,mts,cts,js,jsx,mjs,cjs}. Positive control: the same invocation on packages/services/service-realtime/src/in-memory-realtime-adapter.ts reports 1 file with 0 messages and no ignore notice. So this diff moves zero files eslint examines, and no invariance argument about type-aware linting is needed.

Every number above was taken on the final commit, 0bae112e0.

skip-changeset

Nothing published moves, measured rather than asserted: across 70 publishable packages declaring a files[], 0 name content/ or docs, with all 70 naming dist as the positive control. @objectstack/docs, which owns the page, is private: true, so there is no publishable package to bump — and naming another would ship a release note for a package this PR does not touch. Same surface and same reading as #17700 and #17747, both of which landed with no changeset.

Scope, and one thing deliberately not built

Confined to the six type: "event" fences and their surrounding prose. ⛔ Not websocket.zod.ts (byte-identical, proved above), ⛔ not the subscribe/unsubscribe fences #17747 corrected, ⛔ not content/docs/releases/, ⛔ no other page.

No gate was invented for this card, and here is why, measured. Two instruments read fenced examples in this repo: check:skill-examples compiles ts/tsx/typescript fences marked {/* os:check */}, and check:yaml-examples safeParses yaml/yml fences marked os:check-yaml DECL against a named spec schema. A ```json fence is in neither instrument's population — this page carries 0 markers of either kind. Making these six fences mechanically checkable therefore means extending a gate, which is out of scope here; it is recorded in the acceptance notes below instead.

Acceptance notes

Noted while working, ⛔ not filed and ⛔ not fixed here:

🤖 Generated with Claude Code

https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk


Generated by Claude Code

…geSchema declares

The six `type: "event"` fences on the realtime protocol page carried
top-level `subscription_id` / `event` / `data` / `changes` / `reason`
where `EventMessageSchema` declares `subscriptionId` / `eventName` /
`object` / `payload` / `userId` on top of the three required
`BaseWebSocketMessage` fields. A client written from them reads
`msg.data` and `msg.subscription_id` and gets `undefined` on both.

Prose only, one file. `packages/spec/src/api/websocket.zod.ts` is
untouched.

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 14, 2026
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 14, 2026
@claude
claude Bot marked this pull request as ready for review September 14, 2026 11:59
@claude
claude Bot added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit 139fca3 Sep 14, 2026
38 checks passed
@claude
claude Bot deleted the claude/issue-17749-realtime-event-fences branch September 14, 2026 12:10
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/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant