Skip to content

Commit ec739e6

Browse files
os-billclaude
andauthored
docs(protocol): put the realtime subscribe fences on the shape SubscribeMessageSchema declares (#17747)
Part of #17184 — the **second half**. The first half (the four undeclared message types) landed as #17700; this PR does not revisit it. - **Clause-②: no** — this PR puts no new key on any published payload. `packages/spec/src/api/websocket.zod.ts` is **byte-unchanged**: blob `3348dd76745b61e83801e2e8bae48c6756c5a69c` on both sides (`git rev-parse HEAD:<path>` vs `git hash-object <path>`, and an empty `git 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 exactly `type: 'subscribe'` plus a `subscription` object, on top of the three `BaseWebSocketMessage` fields; `EventSubscriptionSchema` (`:102`–`:117`) declares `subscriptionId` / `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: | page (top level) | declared | axes | |:--|:--|:--| | `subscription_id` | `subscription.subscriptionId` | nesting + snake/camel + **`z.string().uuid()`, so `"sub_1"` does not parse** | | `object`, a string | `subscription.objects`, an array | nesting + singular/plural + scalar/array | | `filter` | `subscription.filters` | nesting + singular/plural | | `events: ["created", …]` | `z.array(EventPatternSchema)` | bare verbs vs patterns — `DataEventType` publishes `data.record.created` | | *absent* | `messageId`, `timestamp` | **both required by `BaseWebSocketMessage`** — an axis not in the card | ### Two page keys map to nothing at all `record_id` and `query` are 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 `filter` as *"Optional filter (same syntax as HTTP API filters)"*. The schema's docblock (`:106`–`:115`) 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 … a subscription carrying `filters` receives 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. | # | line (pre) | section | type | top-level keys | |--:|--:|:--|:--|:--| | 1 | 256–266 | Subscribe to Object Events | `subscribe` | `type`, `subscription_id`, `object`, `events`, `filter` | | 2 | 379–384 | **Unsubscribe** | `unsubscribe` | `type`, `subscription_id` | | 3 | 404–412 | Subscribe to Specific Record | `subscribe` | `type`, `subscription_id`, `object`, `record_id`, `events` | | 4 | 421–435 | Subscribe to Query Results | `subscribe` | `type`, `subscription_id`, `object`, `query`, `events` | | 5 | 655–662 | Permission Enforcement | `subscribe` | `type`, `subscription_id`, `object`, `events` | | 6 | 679–689 | **Client Reconnection (JS)** | `subscribe` | `type`, `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` (or `request` for unsubscribe). ## Fork verdict: **(a) the page is wrong** — every fence Producer evidence on this tree, all six fences landing on the same verdict: - `SubscribeMessageSchema`, `UnsubscribeMessageSchema` and `EventSubscriptionSchema` have **zero** consumers outside `packages/spec`'s own declaration, its unit test and the type-alias pin. - `IRealtimeService.handleUpgrade` is deliberately unimplemented platform-wide — `service-realtime/src/no-channel-route.pin.test.ts:49` asserts it **is not a function**. - `subscription_id` → **0** across **6120** tracked `packages/**` source files (no `dist/`), with four lit controls on the same corpus and the same call: `subscriptionId` **40**, `eventTypes` **27**, `handleUpgrade` **18**, `EventSubscriptionSchema` **13**. - The 660 `record_id` hits are the generic DB-column convention: **zero** files carry both `record_id` and `WebSocket`. 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 (`subscriptionId` 0, `EventSubscription` 0) — only the generic `useState` (2262) lights, which proves the corpus is real source and nothing more. A zero whose control does not light is not a reading. `cloud` is not checked out here: **NOT MEASURED**. ## Verification - **40/40** derived gates green, on a clean full re-run after the builds. Gate family derived mechanically via `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (it reads the change set from the merge base itself), not hand-listed. - Five gates first returned `PREREQUISITE NOT MET` / "build first" (exit 3 and exit 1) — **NOT MEASURED, not red**. After building `@objectstack/lint...`, `@objectstack/spec` and `@objectstack/client-react...` all five are green. Exit codes captured **before** any pipe. - MDX compiles: `pnpm exec fumadocs-mdx` exit 0. - No generated or tracked artifact moved — `authorable-surface.base.json` included. `git status --porcelain` shows exactly one file. - No control characters: `check:nul-bytes` exit 0, plus a direct `grep -naP` sweep of the edited file. ## `skip-changeset`, and a conflict with the dispatch to flag ⚠️ **The dispatch asked for a patch changeset; this PR carries none, and the label instead.** Reporting rather than silently choosing: - `content/docs/**` ships in **no** published package's `files[]` — 0 of 70 publishable packages mention `content/` or `docs/` there, with all 70 shipping `dist` as the positive control. - `@objectstack/docs`, which owns the page, is `private: 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. - **Precedent on the identical surface**: #17700, the first half of this very card, merged with no changeset. By the standing criterion — nothing published moves — this is the textbook `skip-changeset` case, which `lint.yml` states 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, ⛔ not `content/docs/releases/`, ⛔ no other page. One finding deliberately left out and filed separately: the `type: "event"` fences on this same page carry top-level `subscription_id` / `event` / `data` / `changes` / `reason` where `EventMessageSchema` declares `subscriptionId` / `eventName` / `payload` / `userId` and no `changes` or `reason`. 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.com/claude-code) https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- _Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent af98a04 commit ec739e6

1 file changed

Lines changed: 105 additions & 43 deletions

File tree

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

Lines changed: 105 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -255,21 +255,45 @@ Subscribe to changes on a specific object:
255255
**Request:**
256256
```json
257257
{
258+
"messageId": "550e8400-e29b-41d4-a716-446655440000",
258259
"type": "subscribe",
259-
"subscription_id": "sub_1",
260-
"object": "task",
261-
"events": ["created", "updated", "deleted"],
262-
"filter": {
263-
"assignee_id": "user_123"
260+
"timestamp": "2024-01-16T14:30:00Z",
261+
"subscription": {
262+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
263+
"events": ["data.record.created", "data.record.updated", "data.record.deleted"],
264+
"objects": ["task"]
264265
}
265266
}
266267
```
267268

269+
**`SubscribeMessageSchema` declares exactly two things of its own**`type: "subscribe"` and a
270+
`subscription` object — on top of the three `BaseWebSocketMessage` fields every message carries.
271+
Nothing else sits at the top level: the subscription's own fields live **inside** `subscription`
272+
(`EventSubscriptionSchema`), and they are camelCase.
273+
268274
**Parameters:**
269-
- `subscription_id`: Client-generated unique ID for this subscription
270-
- `object`: Object name to subscribe to
271-
- `events`: Array of events to listen for (default: all)
272-
- `filter`: Optional filter (same syntax as HTTP API filters)
275+
- `messageId`: UUID for this message (`BaseWebSocketMessage`)
276+
- `timestamp`: ISO 8601 datetime the message was sent (`BaseWebSocketMessage`)
277+
- `subscription.subscriptionId`: **UUID** identifying the subscription — the schema declares
278+
`z.string().uuid()`, so an opaque handle like `"sub_1"` does not parse
279+
- `subscription.events`: array of event **patterns**, not bare verbs — lowercase dot notation with
280+
wildcards (`"data.record.*"`, `"*.created"`). The platform-checked vocabulary is the closed
281+
`DataEventType` / `BulkDataEventType` enums (`packages/spec/src/api/events.zod.ts`), whose members
282+
are `data.record.created` / `data.record.updated` / `data.record.deleted` and their bulk siblings
283+
- `subscription.objects`: optional **array** of object names to filter events by (`["account",
284+
"contact"]`) — plural, and an array even for a single object
285+
- `subscription.channels`: optional array of channel names for scoped subscriptions
286+
- `subscription.filters`: optional, and **declared `unknown`** — see the callout below
287+
288+
<Callout type="warn">
289+
**`filters` is declared but not enforced — it is not "the same syntax as HTTP API filters".**
290+
`EventSubscriptionSchema.filters` is `z.unknown()`, and no runtime evaluates a payload filter:
291+
`matchesSubscription` (`@objectstack/service-realtime`) matches on object name and event type
292+
only. A subscription carrying `filters` therefore **receives every event its patterns match**
293+
the extra conditions are silently ignored, in the permissive direction. The key is typed
294+
`unknown` deliberately, because validating a shape nothing reads would imply an enforcement that
295+
does not exist (objectui#2945). Narrow with `objects` and `events`, and re-check on the client.
296+
</Callout>
273297

274298
**Success Response:**
275299

@@ -378,11 +402,19 @@ Stop receiving events for a subscription:
378402
**Request:**
379403
```json
380404
{
405+
"messageId": "b3d1a77e-4c62-4f0b-9a55-1d8e2f6c0b34",
381406
"type": "unsubscribe",
382-
"subscription_id": "sub_1"
407+
"timestamp": "2024-01-16T14:35:00Z",
408+
"request": {
409+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7"
410+
}
383411
}
384412
```
385413

414+
`UnsubscribeMessageSchema` nests its payload under `request` (`UnsubscribeRequestSchema`), whose one
415+
field is `subscriptionId` — a UUID, and the same UUID the subscribe message declared. Note the key
416+
is **not** the `subscription` of a subscribe message: the two envelopes use different wrappers.
417+
386418
**Response:** the same `ack` envelope as a subscribe acknowledgement — there is no `unsubscribed`
387419
type either.
388420

@@ -398,47 +430,67 @@ type either.
398430

399431
### Subscribe to Specific Record
400432

401-
Watch a single record for changes:
433+
Watch a single record for changes — **narrowed on the client**, because the declared subscription
434+
has no record-level field:
402435

403436
**Request:**
404437
```json
405438
{
439+
"messageId": "c1f4b9a2-6e78-4a31-bb05-9d3e7c2a1f80",
406440
"type": "subscribe",
407-
"subscription_id": "sub_2",
408-
"object": "task",
409-
"record_id": "task_456",
410-
"events": ["updated", "deleted"]
441+
"timestamp": "2024-01-16T14:40:00Z",
442+
"subscription": {
443+
"subscriptionId": "2a6d5f14-8b90-4c27-a3e1-7f05b8d29c46",
444+
"events": ["data.record.updated", "data.record.deleted"],
445+
"objects": ["task"]
446+
}
411447
}
412448
```
413449

450+
<Callout type="warn">
451+
**There is no `recordId` on a subscription, and no `record_id` anywhere in the protocol.**
452+
`EventSubscriptionSchema` declares exactly `subscriptionId`, `events`, `objects`, `filters` and
453+
`channels` — the narrowest scope the contract can express is **object + event pattern**. This
454+
page used to show a top-level `record_id`, which no schema declares and no runtime reads.
455+
Subscribe at object scope as above and compare the incoming event's own record identity on the
456+
client; `filters` cannot do it for you (see the callout under **Subscribe to Object Events**).
457+
</Callout>
458+
414459
**Use case:** Detail pages that need to reflect live changes to the currently viewed record.
415460

416461
### Subscribe to Query Results
417462

418-
Subscribe to a dynamic set of records matching a query:
463+
Subscribe to a dynamic set of records matching a query — again **narrowed on the client**, because
464+
the declared subscription carries no query:
419465

420466
**Request:**
421467
```json
422468
{
469+
"messageId": "d8e0c3b5-1f47-4d92-8a6c-5b4e9f210a73",
423470
"type": "subscribe",
424-
"subscription_id": "sub_3",
425-
"object": "task",
426-
"query": {
427-
"filter": {
428-
"status": "todo",
429-
"assignee_id": "user_123"
430-
},
431-
"sort": "-priority"
432-
},
433-
"events": ["created", "updated", "deleted"]
471+
"timestamp": "2024-01-16T14:45:00Z",
472+
"subscription": {
473+
"subscriptionId": "6b3c9e07-2d54-4f18-9c8a-0e7d1b5a4632",
474+
"events": ["data.record.created", "data.record.updated", "data.record.deleted"],
475+
"objects": ["task"]
476+
}
434477
}
435478
```
436479

437-
**Behavior:**
438-
- Receive `created` events when records matching query are created
439-
- Receive `updated` events when subscribed records change
440-
- Receive `deleted` events when subscribed records are deleted
441-
- Automatically receive events when records enter/exit the query filter
480+
<Callout type="warn">
481+
**There is no `query` on a subscription either, and no server-side query membership tracking.**
482+
A top-level `query` object appeared on this page but is declared nowhere: `EventSubscriptionSchema`
483+
has no such field, and the only payload-shaped key it does have — `filters` — is `unknown` and
484+
unenforced. Nothing computes whether a record entered or left a result set, so a server cannot
485+
send you an "entered/exited" signal it does not track.
486+
</Callout>
487+
488+
**Behavior at object scope** — what the declared subscription above actually delivers:
489+
- Receive `data.record.created` events for every `task` the subscription's patterns match
490+
- Receive `data.record.updated` events for every `task` that changes
491+
- Receive `data.record.deleted` events for every `task` that is deleted
492+
- Evaluate query membership **on the client**: apply the predicate to each event and derive the
493+
enter/exit transitions yourself by comparing against the set you are already holding
442494

443495
**Example:** Task enters subscription:
444496
```json
@@ -625,20 +677,26 @@ class ObjectStackClient {
625677
}
626678

627679
resubscribe() {
628-
this.subscriptions.forEach((config, id) => {
629-
this.send({ ...config, subscription_id: id });
680+
this.subscriptions.forEach((subscription, id) => {
681+
this.send({ type: 'subscribe', subscription: { ...subscription, subscriptionId: id } });
630682
});
631683
}
632-
633-
subscribe(config) {
634-
const id = `sub_${Date.now()}`;
635-
this.subscriptions.set(id, config);
636-
this.send({ ...config, type: 'subscribe', subscription_id: id });
684+
685+
subscribe(subscription) {
686+
// `subscriptionId` is declared `z.string().uuid()` — not an opaque handle.
687+
const id = crypto.randomUUID();
688+
this.subscriptions.set(id, subscription);
689+
this.send({ type: 'subscribe', subscription: { ...subscription, subscriptionId: id } });
637690
return id;
638691
}
639-
692+
640693
send(message) {
641-
this.ws.send(JSON.stringify(message));
694+
// Every message carries the three `BaseWebSocketMessage` fields.
695+
this.ws.send(JSON.stringify({
696+
messageId: crypto.randomUUID(),
697+
timestamp: new Date().toISOString(),
698+
...message,
699+
}));
642700
}
643701
}
644702

@@ -654,10 +712,14 @@ Real-time subscriptions respect object-level and row-level permissions:
654712
**Scenario:** User subscribes to all tasks:
655713
```json
656714
{
715+
"messageId": "e5a7d210-3c86-4b4f-9017-8f2c6d3b5a19",
657716
"type": "subscribe",
658-
"subscription_id": "sub_1",
659-
"object": "task",
660-
"events": ["created", "updated"]
717+
"timestamp": "2024-01-16T14:50:00Z",
718+
"subscription": {
719+
"subscriptionId": "9f8c1e42-5b7a-4d33-9e10-6c2f8a91b4d7",
720+
"events": ["data.record.created", "data.record.updated"],
721+
"objects": ["task"]
722+
}
661723
}
662724
```
663725

0 commit comments

Comments
 (0)