docs(protocol): put the realtime event fences on the shape EventMessageSchema declares - #18183
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17749
Clause-②: no
Prose only, one file, and no new key reaches any published payload.
packages/spec/src/api/websocket.zod.tsis byte-unchanged — blob3348dd76745b61e83801e2e8bae48c6756c5a69cat the merge basefffe3e5e7, 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.handleUpgradeis deliberately unimplemented platform-wide;packages/services/service-realtime/src/no-channel-route.pin.test.ts:49asserts it is not a function, andgetChannelRouteis not implemented either, so discovery reportsenabled: false.EventMessageSchemahas zero consumers outsidepackages/spec's own declaration, its unit test (src/api/websocket.test.ts) and the type-alias pin. Nothing constructs aneventmessage anywhere.RealtimeEventPayloadenvelope, built atpackages/objectql/src/engine.ts(publishDataEvent/publishBulkDataEvent) and handed toIRealtimeService.publish. Its keys aretype/object/payload/timestamp, andpayloadis aDataEventwhose keys areid/type/object/recordId/changes/after/userId/organizationId/timestamp— camelCase throughout, matching the schema's spelling convention and matching the page on nothing.toSnakeCasein the tree ispackages/cli/src/commands/generate.ts, which derives metadata machine names during scaffolding and never touches a message.subscription_idreturns 0 across trackedpackages/**sources (nodist/), with three lit controls on the same corpus and the same call —subscriptionId51,handleUpgrade21,EventMessageSchema21. 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 inpackages/specis touched.Confirming the two dispatch readings:
websocket.zod.tslast moved at8fd246d27(2026-09-08T22:18Z), before the card, so the schema side is as the card describes. The page last moved atec739e671(2026-09-12T02:18Z), after the card's9bd4344e4bmeasurement, 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."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 ajavascript fence. That disagreement is itself the reason the enumeration had to be by content.type,subscription_id,event,object,datachangestype,subscription_id,event,object,datareason; invalid JSON (a//comment)reason; invalid JSON (a//comment)type,event,object,data,reason; in a ```javascript fenceThe repair
Every fence now carries exactly the declared envelope —
messageId/type/timestampfromBaseWebSocketMessage, thensubscriptionId(a real UUID, since the schema declaresz.string().uuid()and"sub_1"does not parse),eventName,objectandpayload, withuserIdwhere it reads naturally. The record body moved insidepayload, shaped as theDataEventthe engine actually publishes.Three things the page taught that no schema declares are now called out rather than silently dropped:
changesis not a top-level key. It is real one level down, on theDataEventinsidepayload, and it carries the write's own patch — a flat{ field: newValue }map (publishDataEventpasseshookContext.input.data), not theold/newpairs the page showed. The post-state ispayload.after.reasonis declared nowhere in the protocol. The twoentered_query/exited_queryfences 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 twoeventfences alone. They are now ordinarydata.record.updatedmessages, with a note that the two are identical in shape and that deriving the transition is the client's job, which is exactly what thequerycallout docs(protocol): put the realtime subscribe fences on the shape SubscribeMessageSchema declares #17747 added one paragraph above already prescribes.subscription_id/event/dataare 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 closedDataEventTypeenum) follow the neighbouring subscribe/unsubscribe fences #17747 landed on this page, rather than inventing a second style.Fence 6 also changes language from
javascript tojson: 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
EventMessageSchemafrom the freshly builtpackages/spec/dist/api/index.mjs, walked every ```json fence on the page, andsafeParsed each one whose `type` is `event`: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: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 aPREREQUISITE NOT METline.MDX compiles:
pnpm exec fumadocs-mdxinapps/docs— exit 0,[MDX] generated files. No tracked artifact moved:git status --porcelainis empty and the diff is one file, 140 insertions / 51 deletions.No control characters:
check:nul-bytesexit 0 (inside the 40), plus a direct sweep of the edited file with a firing control —grep -naPover 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 jsonon it reports, from eslint's own config resolution,File ignored because no matching configuration was supplied, and everyfiles:glob ineslint.config.mjsnames only{ts,tsx,mts,cts,js,jsx,mjs,cjs}. Positive control: the same invocation onpackages/services/service-realtime/src/in-memory-realtime-adapter.tsreports 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 namecontent/ordocs, with all 70 namingdistas the positive control.@objectstack/docs, which owns the page, isprivate: 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. ⛔ Notwebsocket.zod.ts(byte-identical, proved above), ⛔ not the subscribe/unsubscribe fences #17747 corrected, ⛔ notcontent/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-examplescompiles ts/tsx/typescript fences marked{/* os:check */}, andcheck:yaml-examplessafeParses yaml/yml fences markedos:check-yaml DECLagainst 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:
check:yaml-examplesalready validates yaml fences against a named schema and its own header says corpus-wide tagging is a follow-up card; extending the same marker mechanism tojsonwould have caught this defect and [finding] realtime-protocol.mdx documents four WebSocket message types thatWebSocketMessageTypedoes not declare (auth,auth_success,auth_error,subscribed) #17184's automatically. Successor: that gate's own follow-up lane."details": { "subscription_id": "sub_1" }in theerrorfence near the top of this page. Not class (a):ErrorMessageSchema.detailsisz.unknown().optional(), so the example parses — it is a spelling inconsistency inside a free bag, in theerrorfamily docs(realtime): flatten the WebSocket error fences onto the declared ErrorMessageSchema #17186 owns, not a defect. Left alone./api/v1/realtime/eventsevent-log section showssubscription_id/event/reasontoo, but it is an HTTP debug endpoint with no schema anywhere and the page already carries an explicit "not implemented … planned transport tooling" callout directly above it. Nothing to be wrong against. Left alone.created/updated/deleted/restoredwhile the fences now spelldata.record.*. That is theeventNamevocabulary question, which [finding] realtime-protocol.mdx: sixtype: "event"fences carry field shapesEventMessageSchemadoes not declare (subscription_id/event/data/changes/reason) #17749 explicitly puts out of scope and which is already tracked inpackages/spec/liveness/realtime_subscription.json— note thatrestoredis in no enum at all.🤖 Generated with Claude Code
https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Generated by Claude Code