Skip to content

Commit 139fca3

Browse files
claude[bot]claude
andauthored
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

File tree

‎content/docs/protocol/kernel/realtime-protocol.mdx‎

Lines changed: 140 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -341,60 +341,117 @@ for anything else. There is no nested `error` object — a client that reads `ms
341341

342342
### Receiving Events
343343

344-
When subscribed data changes, server pushes events:
344+
When subscribed data changes, the server pushes `event` messages.
345+
346+
**`EventMessageSchema` declares five fields of its own** — `subscriptionId`, `eventName`, `object`,
347+
`payload` and `userId` — on top of the three `BaseWebSocketMessage` fields every message carries.
348+
They are camelCase, and the record body travels **inside `payload`**, never at the top level.
345349

346350
**Created Event:**
347351
```json
348352
{
353+
"messageId": "a41d6c58-92b7-4f0e-8d3a-16b5c7e04912",
349354
"type": "event",
350-
"subscription_id": "sub_1",
351-
"event": "created",
355+
"timestamp": "2024-01-16T14:30:00Z",
356+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
357+
"eventName": "data.record.created",
352358
"object": "task",
353-
"data": {
354-
"id": "task_456",
355-
"title": "New task assigned to you",
356-
"status": "todo",
357-
"assignee_id": "user_123",
358-
"created_at": "2024-01-16T14:30:00Z"
359+
"userId": "user_123",
360+
"payload": {
361+
"id": "0c8b7a36-4e21-4d59-b7f2-3a9e5d1c8046",
362+
"type": "data.record.created",
363+
"object": "task",
364+
"recordId": "task_456",
365+
"after": {
366+
"id": "task_456",
367+
"title": "New task assigned to you",
368+
"status": "todo",
369+
"assignee_id": "user_123",
370+
"created_at": "2024-01-16T14:30:00Z"
371+
},
372+
"userId": "user_123",
373+
"timestamp": "2024-01-16T14:30:00Z"
359374
}
360375
}
361376
```
362377

363378
**Updated Event:**
364379
```json
365380
{
381+
"messageId": "b7e2f409-51ac-4d68-9b30-2c7a8e15d643",
366382
"type": "event",
367-
"subscription_id": "sub_1",
368-
"event": "updated",
383+
"timestamp": "2024-01-16T15:00:00Z",
384+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
385+
"eventName": "data.record.updated",
369386
"object": "task",
370-
"data": {
371-
"id": "task_456",
372-
"status": "in_progress",
373-
"updated_at": "2024-01-16T15:00:00Z"
374-
},
375-
"changes": {
376-
"status": {
377-
"old": "todo",
378-
"new": "in_progress"
379-
}
387+
"userId": "user_123",
388+
"payload": {
389+
"id": "5d3c9f71-8a04-4b62-91e7-0f6b2d8a3c15",
390+
"type": "data.record.updated",
391+
"object": "task",
392+
"recordId": "task_456",
393+
"changes": {
394+
"status": "in_progress"
395+
},
396+
"after": {
397+
"id": "task_456",
398+
"status": "in_progress",
399+
"updated_at": "2024-01-16T15:00:00Z"
400+
},
401+
"userId": "user_123",
402+
"timestamp": "2024-01-16T15:00:00Z"
380403
}
381404
}
382405
```
383406

384407
**Deleted Event:**
385408
```json
386409
{
410+
"messageId": "c9a04b17-6d38-4e52-8f71-4b2e6c9d0537",
387411
"type": "event",
388-
"subscription_id": "sub_1",
389-
"event": "deleted",
412+
"timestamp": "2024-01-16T16:00:00Z",
413+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
414+
"eventName": "data.record.deleted",
390415
"object": "task",
391-
"data": {
392-
"id": "task_456",
393-
"deleted_at": "2024-01-16T16:00:00Z"
416+
"userId": "user_123",
417+
"payload": {
418+
"id": "e18f2b05-7c46-4a93-b2d0-9e35c7a14f68",
419+
"type": "data.record.deleted",
420+
"object": "task",
421+
"recordId": "task_456",
422+
"userId": "user_123",
423+
"timestamp": "2024-01-16T16:00:00Z"
394424
}
395425
}
396426
```
397427

428+
<Callout type="warn">
429+
⛔ **`subscription_id`, `event` and `data` are not fields of this envelope, and `changes` and
430+
`reason` are not top-level fields either.** The declared names are `subscriptionId`, `eventName`
431+
and `payload`, so a client written from the old fences reads `msg.subscription_id` and `msg.data`
432+
and gets `undefined` on both. `changes` is real but sits **one level down**, on the `DataEvent`
433+
inside `payload`, and it carries the write's own patch — a flat `{ field: newValue }` map
434+
(`publishDataEvent`, `packages/objectql/src/engine.ts`), not the `old` / `new` pairs this page
435+
used to show; read the post-state from `payload.after`. `reason` is declared nowhere in the
436+
protocol at all — see **Subscribe to Query Results** below.
437+
</Callout>
438+
439+
**Fields:**
440+
- `messageId`: UUID for this message (`BaseWebSocketMessage`)
441+
- `timestamp`: ISO 8601 datetime the message was sent (`BaseWebSocketMessage`)
442+
- `subscriptionId`: **UUID** of the subscription this event belongs to — the schema declares
443+
`z.string().uuid()`, and it is the same UUID the subscribe message declared, so an opaque handle
444+
like `"sub_1"` does not parse
445+
- `eventName`: the event name, dot notation by convention. The platform-checked vocabulary is the
446+
closed `DataEventType` / `BulkDataEventType` enums (`packages/spec/src/api/events.zod.ts`) —
447+
`data.record.created` / `data.record.updated` / `data.record.deleted`, plus the two bulk siblings
448+
- `object`: optional object name the event relates to — the one key the old fences had right
449+
- `payload`: the event body, declared `z.unknown()` on the envelope. The platform's only publisher
450+
today is the ObjectQL engine, which emits a `DataEvent` for a single-row write and a
451+
`BulkDataEvent` for a predicate write; that is the shape shown above
452+
- `userId`: optional — the user who triggered the event
453+
454+
398455
### Unsubscribe
399456

400457
Stop receiving events for a subscription:
@@ -492,37 +549,55 @@ the declared subscription carries no query:
492549
- Evaluate query membership **on the client**: apply the predicate to each event and derive the
493550
enter/exit transitions yourself by comparing against the set you are already holding
494551

495-
**Example:** Task enters subscription:
552+
**Example:** a `task` the subscription covers is updated so that it now matches your predicate:
496553
```json
497554
{
555+
"messageId": "d2c74f38-9b15-4e06-a7f3-8c1d5b902e47",
498556
"type": "event",
499-
"subscription_id": "sub_3",
500-
"event": "updated",
557+
"timestamp": "2024-01-16T14:46:00Z",
558+
"subscriptionId": "6b3c9e07-2d54-4f18-9c8a-0e7d1b5a4632",
559+
"eventName": "data.record.updated",
501560
"object": "task",
502-
"data": {
503-
"id": "task_789",
504-
"status": "todo", // Changed from "in_progress" to "todo"
505-
"assignee_id": "user_123"
506-
},
507-
"reason": "entered_query"
561+
"payload": {
562+
"id": "7a1e4c92-0d63-4f85-b91a-2e6c8d357b04",
563+
"type": "data.record.updated",
564+
"object": "task",
565+
"recordId": "task_789",
566+
"changes": { "status": "todo" },
567+
"after": { "id": "task_789", "status": "todo", "assignee_id": "user_123" },
568+
"timestamp": "2024-01-16T14:46:00Z"
569+
}
508570
}
509571
```
510572

511-
**Example:** Task exits subscription:
573+
**Example:** the same subscription, a `task` updated so that it no longer matches:
512574
```json
513575
{
576+
"messageId": "e6f18a03-4d27-4c91-8b52-7a0e3d9c1465",
514577
"type": "event",
515-
"subscription_id": "sub_3",
516-
"event": "updated",
578+
"timestamp": "2024-01-16T14:47:00Z",
579+
"subscriptionId": "6b3c9e07-2d54-4f18-9c8a-0e7d1b5a4632",
580+
"eventName": "data.record.updated",
517581
"object": "task",
518-
"data": {
519-
"id": "task_456",
520-
"status": "done" // No longer matches "todo" filter
521-
},
522-
"reason": "exited_query"
582+
"payload": {
583+
"id": "9c05b7d4-1a68-4e30-97f2-5d8a1c60e3b9",
584+
"type": "data.record.updated",
585+
"object": "task",
586+
"recordId": "task_456",
587+
"changes": { "status": "done" },
588+
"after": { "id": "task_456", "status": "done" },
589+
"timestamp": "2024-01-16T14:47:00Z"
590+
}
523591
}
524592
```
525593

594+
⭐ **The two messages are identical in shape — nothing in the envelope says "entered" or "exited".**
595+
`EventMessageSchema` has no `reason` field, and no runtime tracks query membership to populate one,
596+
so the earlier `reason: "entered_query"` / `"exited_query"` examples described a signal the contract
597+
cannot send. The transition is yours to derive: apply your predicate to `payload.after` and compare
598+
against the set you are already holding.
599+
600+
526601
## Server-Sent Events (SSE)
527602

528603
### Connection Endpoint
@@ -731,20 +806,34 @@ Real-time subscriptions respect object-level and row-level permissions:
731806

732807
**Example:** User has rule "see only assigned tasks":
733808

734-
```javascript
735-
// User is assigned task_456
809+
```json
736810
{
811+
"messageId": "f3b90d67-5a24-4e18-9c07-1b8f2a6d4e35",
737812
"type": "event",
738-
"event": "updated",
813+
"timestamp": "2024-01-16T14:51:00Z",
814+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
815+
"eventName": "data.record.updated",
739816
"object": "task",
740-
"data": { "id": "task_456", "assignee_id": "user_123" },
741-
"reason": "entered_query"
817+
"userId": "user_123",
818+
"payload": {
819+
"id": "2d7c1f85-6b39-4a04-8e13-9f05c2b71a68",
820+
"type": "data.record.updated",
821+
"object": "task",
822+
"recordId": "task_456",
823+
"changes": { "assignee_id": "user_123" },
824+
"after": { "id": "task_456", "assignee_id": "user_123" },
825+
"userId": "user_123",
826+
"timestamp": "2024-01-16T14:51:00Z"
827+
}
742828
}
743-
744-
// Task reassigned to someone else
745-
// No event sent - task is now invisible to user
746829
```
747830

831+
Reassign `task_456` to someone else and, under the row-level behaviour described above, no further
832+
message arrives for it: the protocol has no "you lost access" signal to send, so absence of events
833+
is the whole signal. There is no `reason` field here either. What delivery actually enforces today
834+
is the implementation-status callout at the top of this page, not this section.
835+
836+
748837
## Scaling Considerations
749838

750839
### Connection Limits

0 commit comments

Comments
 (0)