Repository navigation
Commit 139fca3
docs(protocol): put the realtime event fences on the shape EventMessageSchema declares (#18183)
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` / `timestamp` —
**camelCase 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 #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 #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 `safeParse`d 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 fffe3e5, 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` `safeParse`s 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:
- **A JSON fence is in no example-checking gate's population.**
`check:yaml-examples` already 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 to `json` would have caught this
defect and #17184's automatically. Successor: that gate's own follow-up
lane.
- **`"details": { "subscription_id": "sub_1" }`** in the `error` fence
near the top of this page. Not class (a): `ErrorMessageSchema.details`
is `z.unknown().optional()`, so the example parses — it is a spelling
inconsistency inside a free bag, in the `error` family #17186 owns, not
a defect. Left alone.
- **The `/api/v1/realtime/events` event-log section** shows
`subscription_id` / `event` / `reason` too, 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.
- **The Event Types table** still lists the bare verbs `created` /
`updated` / `deleted` / `restored` while the fences now spell
`data.record.*`. That is the `eventName` **vocabulary** question, which
#17749 explicitly puts out of scope and which is already tracked in
`packages/spec/liveness/realtime_subscription.json` — note that
`restored` is in no enum at all.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 7e05b9d commit 139fca3
1 file changed
Lines changed: 140 additions & 51 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
341 | 341 | | |
342 | 342 | | |
343 | 343 | | |
344 | | - | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
345 | 349 | | |
346 | 350 | | |
347 | 351 | | |
348 | 352 | | |
| 353 | + | |
349 | 354 | | |
350 | | - | |
351 | | - | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
352 | 358 | | |
353 | | - | |
354 | | - | |
355 | | - | |
356 | | - | |
357 | | - | |
358 | | - | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
359 | 374 | | |
360 | 375 | | |
361 | 376 | | |
362 | 377 | | |
363 | 378 | | |
364 | 379 | | |
365 | 380 | | |
| 381 | + | |
366 | 382 | | |
367 | | - | |
368 | | - | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
369 | 386 | | |
370 | | - | |
371 | | - | |
372 | | - | |
373 | | - | |
374 | | - | |
375 | | - | |
376 | | - | |
377 | | - | |
378 | | - | |
379 | | - | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
380 | 403 | | |
381 | 404 | | |
382 | 405 | | |
383 | 406 | | |
384 | 407 | | |
385 | 408 | | |
386 | 409 | | |
| 410 | + | |
387 | 411 | | |
388 | | - | |
389 | | - | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
390 | 415 | | |
391 | | - | |
392 | | - | |
393 | | - | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
394 | 424 | | |
395 | 425 | | |
396 | 426 | | |
397 | 427 | | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
398 | 455 | | |
399 | 456 | | |
400 | 457 | | |
| |||
492 | 549 | | |
493 | 550 | | |
494 | 551 | | |
495 | | - | |
| 552 | + | |
496 | 553 | | |
497 | 554 | | |
| 555 | + | |
498 | 556 | | |
499 | | - | |
500 | | - | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
501 | 560 | | |
502 | | - | |
503 | | - | |
504 | | - | |
505 | | - | |
506 | | - | |
507 | | - | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
508 | 570 | | |
509 | 571 | | |
510 | 572 | | |
511 | | - | |
| 573 | + | |
512 | 574 | | |
513 | 575 | | |
| 576 | + | |
514 | 577 | | |
515 | | - | |
516 | | - | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
517 | 581 | | |
518 | | - | |
519 | | - | |
520 | | - | |
521 | | - | |
522 | | - | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
523 | 591 | | |
524 | 592 | | |
525 | 593 | | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
526 | 601 | | |
527 | 602 | | |
528 | 603 | | |
| |||
731 | 806 | | |
732 | 807 | | |
733 | 808 | | |
734 | | - | |
735 | | - | |
| 809 | + | |
736 | 810 | | |
| 811 | + | |
737 | 812 | | |
738 | | - | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
739 | 816 | | |
740 | | - | |
741 | | - | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
742 | 828 | | |
743 | | - | |
744 | | - | |
745 | | - | |
746 | 829 | | |
747 | 830 | | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
748 | 837 | | |
749 | 838 | | |
750 | 839 | | |
| |||
0 commit comments