Skip to content

docs(protocol): drop the five WebSocket message types WebSocketMessageType does not declare - #17700

Merged
os-bill merged 1 commit into
mainfrom
claude/issue-17184-realtime-protocol-message-types
Sep 11, 2026
Merged

os-bill merged 1 commit into
mainfrom
claude/issue-17184-realtime-protocol-message-types

Conversation

@os-bill

@os-bill os-bill commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Part of #17184 — the message-type half of that card.

  • Clause-②: no

Prose only, one file: content/docs/protocol/kernel/realtime-protocol.mdx. No schema, no enum, no behaviour; packages/spec/src/api/websocket.zod.ts is byte-unchanged and was read-only for this round.

The fork, and the branch this PR takes

The card offers two readings: (a) the page documents a protocol the platform does not implement, or (b) the four types are real and the declaration is missing them. Every measurement below points at (a), for all of them, individually. Nothing here adds a member to a published enum.

Prerequisite readings — taken on origin/main at 88a933088e, anchored by content

  1. WebSocketMessageType still declares exactly ten members. Read live off the built package rather than inherited from the card: WebSocketMessageType.options over packages/spec/dist/api/index.mjs prints ["subscribe","unsubscribe","event","ping","pong","ack","error","presence","cursor","edit"].
  2. BaseWebSocketMessage still types type against that enum — the reading the whole argument rests on. packages/spec/src/api/websocket.zod.ts: const BaseWebSocketMessage = z.object({ … type: WebSocketMessageType.describe('Message type'), … }), and WebSocketMessageSchema is a z.discriminatedUnion('type', …) over exactly those ten branches. The premise stands.
  3. The page still taught all four — anchored by content, not by line, with a census of every type-value site on the page rather than a lookup of the card's list. That census turned up five, not four: the card's auth / auth_success / auth_error / subscribed, plus unsubscribed in the Unsubscribe section, which is the same defect in the same file and is corrected here too.
  4. Producer/consumer search, per type, with a lit control — below.

Per-type verdict — each judged on its own evidence

The probe is one regex applied uniformly to all fifteen values (ten declared, five undeclared) over the tracked files of packages, apps, examples in a worktree with no build output in it: (type\s*[:=]+\s*|case\s+)['"]X['"].

type declared? probe hits verdict
auth no 0 documentation only ⇒ fork (a)
auth_success no 0 documentation only ⇒ fork (a)
auth_error no 0 documentation only ⇒ fork (a)
subscribed no 0 documentation only ⇒ fork (a)
unsubscribed no 0 documentation only ⇒ fork (a)

The lit control, and it lights for all ten: under the same probe every declared member returns a non-zero count — subscribe 2, unsubscribe 1, event 2, ping 3, pong 2, ack 2, error 9, presence 1, cursor 1, edit 1. An instrument that returns zero for everything proves nothing; this one separates the two sets cleanly, ten from five.

Two supporting readings, neither load-bearing on its own:

  • A broader whole-repo probe outside content/docs returns one line for auth_success|auth_error, and it is not a hit: packages/spec/src/identity/protocol.ts:136 is OAUTH_ERROR: 'oauth_error', an OAuth error-code constant matched as a substring. Its control, ErrorMessageSchema on the same corpus, returns 13 occurrences across 6 files.
  • The sibling objectui checkout returns 0 for all five — but its control does not light there (subscribe, ping, pong, ack are all 0 too), so that repo carries no WebSocket message producer at all and those zeros carry no weight about the five specifically. Reported rather than counted. cloud is not checked out in this container, so it is not measured.

The declared contract agrees with the verdict independently of any grep: IRealtimeService.handleUpgrade is optional and unimplemented across the open framework (#2462, #3197), the module note on websocket.zod.ts says "no WebSocket server is mounted", and WebSocketConfigSchema.headers is described as "Custom headers for WebSocket handshake" — the declared seam for a credential is the HTTP upgrade, not an in-band message. So auth is not an omission from the enum; authentication is declared somewhere else. Likewise subscribed / unsubscribed are not omissions: AckMessageSchema (type: 'ack', ackMessageId, success, optional error) is the acknowledgement the protocol declares.

The corrected prose

  • A new Declared message vocabulary section names the ten members, states that BaseWebSocketMessage pins type to them and that a message outside the enum matches no branch of the discriminated union, and carries a callout naming the five types the page used to teach and why they were not protocol.
  • Authentication now says the declared protocol has no authentication message, puts the credential on the upgrade request via WebSocketConfig.headers into IRealtimeService.handleUpgrade(request), and notes that a refused upgrade never becomes a WebSocket — so there is no success or failure message to wait for. The three handshake fences are gone.
  • The Subscribe and Unsubscribe acknowledgements are now the declared ack envelope, with ackMessageId echoing the acknowledged message's messageId.
  • The three JavaScript snippets that sent { type: 'auth' } — in Establishing Connection, in the reconnection client, and under Security Considerations — no longer do. The reconnection client's authenticate() method and its token constructor argument are gone with it; that example is about backoff.
  • ⛔ Nothing was written implying the five types are coming. The page states the declared position only.

Verification

Instrument: re-run the same content census over the edited page and safeParse each distinct value against the built enum.

ack  DECLARED   error DECLARED   event DECLARED   ping DECLARED
pong DECLARED   subscribe DECLARED   unsubscribe DECLARED
sites=22 distinct=7 undeclared=0
control (the five removed types, same call): auth=NOT DECLARED auth_success=NOT DECLARED
auth_error=NOT DECLARED subscribed=NOT DECLARED unsubscribed=NOT DECLARED

The control is the point: the same call that reports every remaining site as declared still reports the five removed types as undeclared, so the zero above is a reading and not a dead instrument. Before the edit the same census printed five undeclared values across eight sites.

packages/spec was built under the shared verify lock (VERDICT command-exit 0) so the parse reads a fresh dist/, not a stale one. grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over the edited file returns nothing.

A repo-wide lint run is CI's; this diff touches one .mdx file and no package, so the affected-package build/test closure is empty.

Changeset

skip-changeset. The rule: a changeset is owed when the PR moves something published, and what a package publishes is what its files[] actually ships. The measurement: of 81 package.json files on this tree, 11 are private: true and 70 are publishable; every one of the 70 declares an explicit files[] (none ships its whole directory), none of those arrays mentions content, and none of the 70 lives at the repo root — so no published tarball can contain content/docs/** by any route. Positive control for the reader: @objectstack/spec reads back ["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"], so it is reading real arrays. content/ is consumed only by apps/docs, which is private: true.

验收备注

  • What is deliberately left standing. The card also names top-level fields no schema declares on the subscribe pair — subscription_id, object, events, filter — and those spellings survive on the remaining subscribe fences (Subscribe to Object Events, Subscribe to Specific Record, Subscribe to Query Results, Permission Enforcement). They use a declared type, so they are the field-shape half of [finding] realtime-protocol.mdx documents four WebSocket message types that WebSocketMessageType does not declare (auth, auth_success, auth_error, subscribed) #17184 rather than the message-type half, and rewriting all of them onto SubscribeMessageSchema ({ messageId, type, timestamp, subscription: { subscriptionId, events, objects?, filters?, channels? } }) is a materially larger edit on a surface this round was not dispatched to. Naming it here so the seat can decide whether it is a follow-up round or a second card.
  • A fifth type, corrected in place. unsubscribed was not in the card's list of four. It is the same defect class, in the same file, inside the declared file face of this round's claim, and it is a mechanical correction of a form already pinned by the other four — so it is corrected here rather than filed. Evidence: "type": "unsubscribed" in the Unsubscribe section; 0 probe hits; WebSocketMessageType.safeParse('unsubscribed') returns success: false.
  • No new card was opened by this round.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH


Generated by Claude Code

…geType` does not declare

`WebSocketMessageType` is an exhaustive ten-member enum and `BaseWebSocketMessage`
types every message's `type` against it, so a message whose type is outside the
enum parses against no schema in the file. The realtime-protocol page taught
five types that are not members: an in-band handshake (auth, auth_success,
auth_error) and two acknowledgements (subscribed, unsubscribed).

Measured on this tree. All ten declared members show at least one producer or
consumer site under a message-type-context probe over packages/apps/examples;
all five of the above return zero there, and zero in objectui. The contract
agrees: IRealtimeService.handleUpgrade is optional and unimplemented, and
WebSocketConfig.headers is the declared seam for a credential ("Custom headers
for WebSocket handshake"). The five were documentation, not protocol.

Prose only. The page now states the declared vocabulary, puts authentication on
the upgrade request where the declared shapes put it, and shows the declared
`ack` envelope for subscribe and unsubscribe acknowledgements. No schema, no
enum and no behaviour changed; packages/spec/src/api/websocket.zod.ts is
byte-unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@os-bill os-bill added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 11, 2026 — with Claude
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 11, 2026
@os-bill
os-bill marked this pull request as ready for review September 11, 2026 16:24
@os-bill
os-bill added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 4273a97 Sep 11, 2026
37 checks passed
@os-bill
os-bill deleted the claude/issue-17184-realtime-protocol-message-types branch September 11, 2026 16:54
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ibeMessageSchema declares (objectstack-ai#17747)

Part of objectstack-ai#17184 — the **second half**. The first half (the four
undeclared message types) landed as objectstack-ai#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** (objectstack-ai#2) and the **JS client snippet** (objectstack-ai#6) were not on the
card's list. objectstack-ai#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**: objectstack-ai#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 objectstack-ai#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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…geSchema declares (objectstack-ai#18183)

Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#17700 and objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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
objectstack-ai#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants