docs(protocol): put the realtime subscribe fences on the shape SubscribeMessageSchema declares - #17747
Merged
Conversation
…ibeMessageSchema declares
Six subscribe/unsubscribe sites on realtime-protocol.mdx carried every
subscription field at the TOP LEVEL, in snake_case, where the declared contract
nests them and spells them camelCase. SubscribeMessageSchema declares exactly
`type` plus a `subscription` object on top of the three BaseWebSocketMessage
fields; EventSubscriptionSchema declares subscriptionId / events / objects /
filters / channels. The fences copied verbatim parse against no schema.
The mismatch was not one thing, and each axis is corrected separately:
subscription_id -> subscription.subscriptionId nesting + snake/camel, and
the schema declares uuid(),
so "sub_1" does not parse
object (string) -> subscription.objects (array) nesting + singular/plural
+ scalar/array
filter -> subscription.filters nesting + singular/plural
events verbs -> event patterns "created" -> data.record.created
(absent) -> messageId, timestamp both required by
BaseWebSocketMessage
Two page keys map to nothing at all: `record_id` and `query` are declared
nowhere in the protocol, so the narrowest scope the contract can express is
object + event pattern. Those two sections now say so and move the narrowing to
the client rather than showing fields no schema carries.
Second, independent defect in the same bullet list: the page described `filter`
as "Optional filter (same syntax as HTTP API filters)" while the schema's own
docblock says the opposite in as many words -- NOT YET ENFORCED, no runtime
evaluates a payload filter, matchesSubscription matches on object name and event
type only, so a subscription carrying filters receives every event its patterns
match. The page promised enforcement the contract explicitly disclaims; it now
carries that disclaimer.
Measured on this tree, fork (a): the page is wrong, not the schema.
SubscribeMessageSchema, UnsubscribeMessageSchema and EventSubscriptionSchema
have zero consumers outside packages/spec's own declaration, unit test and type
pin; handleUpgrade is deliberately unimplemented platform-wide and
no-channel-route.pin.test.ts asserts it is not a function. `subscription_id`
returns 0 across 6120 tracked package source files with four lit controls on the
same corpus (subscriptionId 40, eventTypes 27, handleUpgrade 18,
EventSubscriptionSchema 13). Nothing produces these messages, so no top-level
field can be real.
Prose only. packages/spec/src/api/websocket.zod.ts is byte-unchanged -- blob
3348dd7 on both sides.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
os-bill
marked this pull request as ready for review
September 12, 2026 02:17
This was referenced Sep 12, 2026
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.
Part of #17184 — the second half. The first half (the four undeclared message types) landed as #17700; this PR does not revisit it.
packages/spec/src/api/websocket.zod.tsis byte-unchanged: blob3348dd76745b61e83801e2e8bae48c6756c5a69con both sides (git rev-parse HEAD:<path>vsgit hash-object <path>, and an emptygit diff HEAD -- <path>). Prose only, one file.The defect
Six subscribe/unsubscribe sites carried every subscription field at the top level, in snake_case, where the declared contract nests them and spells them camelCase.
SubscribeMessageSchema(websocket.zod.ts:278–:281) declares exactlytype: 'subscribe'plus asubscriptionobject, on top of the threeBaseWebSocketMessagefields;EventSubscriptionSchema(:102–:117) declaressubscriptionId/events/objects/filters/channels. A fence copied verbatim parses against no schema in the file.The mismatch is not one thing, and each axis was verified separately:
subscription_idsubscription.subscriptionIdz.string().uuid(), so"sub_1"does not parseobject, a stringsubscription.objects, an arrayfiltersubscription.filtersevents: ["created", …]z.array(EventPatternSchema)DataEventTypepublishesdata.record.createdmessageId,timestampBaseWebSocketMessage— an axis not in the cardTwo page keys map to nothing at all
record_idandqueryare declared nowhere in the protocol. The narrowest scope the contract can express is object + event pattern, so those two sections now say so and move the narrowing to the client rather than showing fields no schema carries.Second, independent defect in the same bullet list
The page described⚠️ NOT YET ENFORCED — no runtime evaluates a payload filter.
filteras "Optional filter (same syntax as HTTP API filters)". The schema's docblock (:106–:115) says the opposite in as many words — "matchesSubscriptionmatches on object name and event type only … a subscription carryingfiltersreceives every event its patterns match." The page promised enforcement the contract explicitly disclaims; it now carries that disclaimer.Fence census — the round's first deliverable
Enumerated on this tree, not inherited from the dispatch's page-wide counts.
subscribetype,subscription_id,object,events,filterunsubscribetype,subscription_idsubscribetype,subscription_id,object,record_id,eventssubscribetype,subscription_id,object,query,eventssubscribetype,subscription_id,object,eventssubscribetype,subscription_id(+sub_${Date.now()})⭐ The card named four sections. The census found six sites: Unsubscribe (#2) and the JS client snippet (#6) were not on the card's list. #6 was also missed by a first pass that matched only JSON
"type": "subscribe"— it uses the single-quoted JS form, and the census was widened to catch it.After the edit all six carry exactly
messageId/type/timestamp/subscription(orrequestfor unsubscribe).Fork verdict: (a) the page is wrong — every fence
Producer evidence on this tree, all six fences landing on the same verdict:
SubscribeMessageSchema,UnsubscribeMessageSchemaandEventSubscriptionSchemahave zero consumers outsidepackages/spec's own declaration, its unit test and the type-alias pin.IRealtimeService.handleUpgradeis deliberately unimplemented platform-wide —service-realtime/src/no-channel-route.pin.test.ts:49asserts it is not a function.subscription_id→ 0 across 6120 trackedpackages/**source files (nodist/), with four lit controls on the same corpus and the same call:subscriptionId40,eventTypes27,handleUpgrade18,EventSubscriptionSchema13.record_idhits are the generic DB-column convention: zero files carry bothrecord_idandWebSocket.Nothing produces or consumes a WebSocket subscribe message, so no top-level field on one can be real. Reading (b) — the schema is incomplete — would need a producer, and there is none.
objectui: reported, not counted. All WS-subscription probes return 0 there, but the schema-side control is also dark (
subscriptionId0,EventSubscription0) — only the genericuseState(2262) lights, which proves the corpus is real source and nothing more. A zero whose control does not light is not a reading.cloudis not checked out here: NOT MEASURED.Verification
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(it reads the change set from the merge base itself), not hand-listed.PREREQUISITE NOT MET/ "build first" (exit 3 and exit 1) — NOT MEASURED, not red. After building@objectstack/lint...,@objectstack/specand@objectstack/client-react...all five are green. Exit codes captured before any pipe.pnpm exec fumadocs-mdxexit 0.authorable-surface.base.jsonincluded.git status --porcelainshows exactly one file.check:nul-bytesexit 0, plus a directgrep -naPsweep of the edited file.skip-changeset, and a conflict with the dispatch to flagcontent/docs/**ships in no published package'sfiles[]— 0 of 70 publishable packages mentioncontent/ordocs/there, with all 70 shippingdistas the positive control.@objectstack/docs, which owns the page, isprivate: true— there is no publishable package to bump. Naming any other package would ship a release note for a package this PR does not touch.WebSocketMessageTypedoes not declare #17700, the first half of this very card, merged with no changeset.By the standing criterion — nothing published moves — this is the textbook
skip-changesetcase, whichlint.ymlstates in those words. Happy to add a changeset if the maintainer reads it the other way.Scope
Confined to the subscribe/unsubscribe fences and their Parameters bullet lists. ⛔ Not
websocket.zod.ts, ⛔ not the message-type sections #17700 corrected, ⛔ notcontent/docs/releases/, ⛔ no other page.One finding deliberately left out and filed separately: the
type: "event"fences on this same page carry top-levelsubscription_id/event/data/changes/reasonwhereEventMessageSchemadeclaressubscriptionId/eventName/payload/userIdand nochangesorreason. Same defect class, different message family — it is not this round's declared face, and it is not silently widened into.🤖 Generated with Claude Code
https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Generated by Claude Code