diff --git a/.changeset/20288-realtime-event-type-emitted-vocabulary.md b/.changeset/20288-realtime-event-type-emitted-vocabulary.md new file mode 100644 index 00000000000..eb57bd5631b --- /dev/null +++ b/.changeset/20288-realtime-event-type-emitted-vocabulary.md @@ -0,0 +1,75 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: `RealtimeEventType` names the realtime events the runtime emits — `data.record.*` and `data.records.*`; `record.created` / `record.updated` / `record.deleted` / `field.changed` retired (#20288) + +Clause-②: yes (narrowing) + +**BREAKING** — shipped as `minor` under the launch-window convention +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by +this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, +never by the level). The enum both gains and loses values: it gains the five +names the runtime emits and loses the four it never emitted. + +`RealtimeEventType` (`@objectstack/spec/api`) types `SubscriptionEvent.type`, so +it is the event vocabulary of `Subscription.events[]` and +`RealtimeConfig.subscriptions[].events[]`, and the generated API reference +published it as such. None of its four values was ever emitted by anything. The +ObjectQL engine publishes `data.record.created` / `data.record.updated` / +`data.record.deleted` for each written record and `data.records.updated` / +`data.records.deleted` for a predicate write (`multi: true`), and it parses every +event through `DataEventSchema` / `BulkDataEventSchema` before publishing. A +subscription written with the names the reference showed could never fire. +The runtime's published event names do not change; the enum moves to them. + +- **Accepted now:** exactly `DataEventType` + `BulkDataEventType` — + `data.record.created`, `data.record.updated`, `data.record.deleted`, + `data.records.updated`, `data.records.deleted`. Metadata change events + (`metadata.{type}.{action}`) are not members: a subscription event is + record-shaped, and metadata events keep their own `MetadataEventType` contract + and `subscribeMetadata` client primitive. +- **Refused now:** `record.created`, `record.updated`, `record.deleted` and + `field.changed`, each with its own prescription. Any other unknown value keeps + zod's own message, which lists the legal names. + +``` +FROM SubscriptionSchema.parse({ id, transport: 'websocket', events: [{ type: 'record.created', object: 'account' }] }) + -> parsed; nothing ever published 'record.created', so the subscription never fired +TO -> ZodError at events[0].type (invalid_value): `record.created` was removed from + `RealtimeEventType` in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Write + `data.record.created` — the same event, spelled the way the engine publishes it. + +FROM { type: 'record.updated' } -> TO { type: 'data.record.updated' } + (add { type: 'data.records.updated' } to also hear predicate writes, which carry a count, not records) +FROM { type: 'record.deleted' } -> TO { type: 'data.record.deleted' } + (add { type: 'data.records.deleted' } to also hear predicate writes) +FROM { type: 'field.changed' } -> TO { type: 'data.record.updated' } + (read the field from the DataEvent payload's `changes`; no event is published per field) +``` + +**Fix.** Rename each value as mapped above. `tsc` refuses the old values at a +`RealtimeEventType` / `SubscriptionEvent` position (the type no longer contains +them), and a `SubscriptionSchema`, `SubscriptionEventSchema` or +`RealtimeConfigSchema` parse refuses them with the prescription. A handler keyed +on an old name never ran, so the rename changes behaviour only by making it fire. +Nothing in the open framework parses a subscription today — it mounts no realtime +transport — so no running deployment changes behaviour. + +### The kit + +- **Schema.** `RealtimeEventType` lists the five emitted names; its error map + gives each retired value its own prescription (the `HookBodyCapability` + precedent for a removed enum value). `realtime.test.ts` pins the enum equal to + `DataEventType` + `BulkDataEventType`, and pins each refusal's code, path and + first sentence through `SubscriptionSchema`. +- **ADR-0087.** The D3 entry `realtime-event-type-unemitted-values-retired` + carries the prescription to `os migrate meta` and the upgrade guide. No D2 + conversion and no `RETIRED_KEYS_BY_MAJOR` row: an enum value is not a key, and + a subscription is neither a stack collection member nor a stored row, so no + conversion seam would ever see one. +- **Docs.** The `realtime` reference page is regenerated; the + `RealtimeEventPayload.type` and `RealtimeEvent.type` examples name emitted + events. + + diff --git a/.changeset/20288-service-realtime-emitted-event-examples.md b/.changeset/20288-service-realtime-emitted-event-examples.md new file mode 100644 index 00000000000..7c7a1519221 --- /dev/null +++ b/.changeset/20288-service-realtime-emitted-event-examples.md @@ -0,0 +1,14 @@ +--- +'@objectstack/service-realtime': patch +--- + +docs(service-realtime): the README and `InMemoryRealtimeAdapter` examples name the events the ObjectQL engine actually publishes (#20288) + +The usage example subscribed to and published `record.created`, and the security +posture section said the engine publishes `record.created` / `record.updated`. +The engine publishes `data.record.created` / `data.record.updated` / +`data.record.deleted` (and `data.records.updated` / `data.records.deleted` for a +predicate write), with the spec's `DataEvent` as the envelope's `payload` — the row +is `payload.after`, not the payload itself. Both examples now subscribe to +`data.record.created` and publish that shape. No code changes: the adapter matches +whatever event type a subscription names, exactly as before. diff --git a/content/docs/references/api/realtime.mdx b/content/docs/references/api/realtime.mdx index f97a57ba518..36abee6568f 100644 --- a/content/docs/references/api/realtime.mdx +++ b/content/docs/references/api/realtime.mdx @@ -37,7 +37,7 @@ const result = RealtimeConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique subscription identifier | -| **events** | `{ type: Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'field.changed'>; object?: string; filters?: any }[]` | ✅ | Array of events to subscribe to | +| **events** | `{ type: Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| …>; object?: string; filters?: any }[]` | ✅ | Array of events to subscribe to | | **transport** | `Enum<'websocket' \| 'sse' \| 'polling'>` | ✅ | Transport protocol to use | | **channel** | `string` | optional | Optional channel name for grouping subscriptions | @@ -51,7 +51,7 @@ const result = RealtimeConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique event identifier | -| **type** | `string` | ✅ | Event type (e.g., record.created, record.updated) | +| **type** | `string` | ✅ | Event type (e.g., data.record.created, data.records.updated) | | **object** | `string` | optional | Object name the event relates to | | **action** | `Enum<'created' \| 'updated' \| 'deleted'>` | optional | Action performed | | **payload** | `Record` | ✅ | Event payload data | @@ -64,14 +64,15 @@ const result = RealtimeConfigSchema.parse(data); ## RealtimeEventType -Realtime event type +Realtime event type: a record-change event name the runtime emits ### Allowed Values -* `record.created` -* `record.updated` -* `record.deleted` -* `field.changed` +* `data.record.created` +* `data.record.updated` +* `data.record.deleted` +* `data.records.updated` +* `data.records.deleted` --- @@ -97,7 +98,7 @@ Realtime event type | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique subscription identifier | -| **events** | `{ type: Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'field.changed'>; object?: string; filters?: any }[]` | ✅ | Array of events to subscribe to | +| **events** | `{ type: Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| …>; object?: string; filters?: any }[]` | ✅ | Array of events to subscribe to | | **transport** | `Enum<'websocket' \| 'sse' \| 'polling'>` | ✅ | Transport protocol to use | | **channel** | `string` | optional | Optional channel name for grouping subscriptions | @@ -105,7 +106,7 @@ Realtime event type | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'field.changed'>` | ✅ | Type of event to subscribe to | +| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| …>` | ✅ | Type of event to subscribe to | | **object** | `string` | optional | Object name to subscribe to | | **filters** | `any` | optional | Filter conditions | @@ -118,7 +119,7 @@ Realtime event type | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'field.changed'>` | ✅ | Type of event to subscribe to | +| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| 'data.records.updated' \| 'data.records.deleted'>` | ✅ | Type of event to subscribe to | | **object** | `string` | optional | Object name to subscribe to | | **filters** | `any` | optional | Filter conditions | diff --git a/packages/services/service-realtime/README.md b/packages/services/service-realtime/README.md index 9a40eb6ecef..a29e65677be 100644 --- a/packages/services/service-realtime/README.md +++ b/packages/services/service-realtime/README.md @@ -43,13 +43,19 @@ const realtime = kernel.getService('realtime'); const subId = await realtime.subscribe('records', (event) => { console.log(event.type, event.payload); -}, { object: 'account', eventTypes: ['record.created'] }); +}, { object: 'account', eventTypes: ['data.record.created'] }); +// The shape the ObjectQL engine publishes for an insert: the envelope's +// `payload` is the spec's `DataEvent`, and the row itself is `payload.after`. +const timestamp = new Date().toISOString(); await realtime.publish({ - type: 'record.created', + type: 'data.record.created', object: 'account', - payload: { id: 'acc-1', name: 'Acme' }, - timestamp: new Date().toISOString(), + payload: { + id: crypto.randomUUID(), type: 'data.record.created', object: 'account', + recordId: 'acc-1', after: { id: 'acc-1', name: 'Acme' }, timestamp, + }, + timestamp, }); await realtime.unsubscribe(subId); @@ -70,7 +76,7 @@ Delivery is a **pure fan-out with no per-recipient authorization seam**: - subscriptions carry **no principal** — there is nothing to check a row against; - `matchesSubscription` filters only by object name + event type; -- the ObjectQL engine publishes `record.created` / `record.updated` events with the +- the ObjectQL engine publishes `data.record.created` / `data.record.updated` events with the **full record body** (the `after` row) — rows and fields a subscriber's own `find` would hide under RLS/FLS/tenant scoping. diff --git a/packages/services/service-realtime/src/in-memory-realtime-adapter.ts b/packages/services/service-realtime/src/in-memory-realtime-adapter.ts index 02c22cf22c7..e20f368fb33 100644 --- a/packages/services/service-realtime/src/in-memory-realtime-adapter.ts +++ b/packages/services/service-realtime/src/in-memory-realtime-adapter.ts @@ -53,13 +53,19 @@ export interface InMemoryRealtimeAdapterOptions { * * const subId = await realtime.subscribe('records', (event) => { * console.log('Received:', event.type, event.payload); - * }, { object: 'account', eventTypes: ['record.created'] }); + * }, { object: 'account', eventTypes: ['data.record.created'] }); * + * // The shape the ObjectQL engine publishes for an insert: the envelope's + * // `payload` is the spec's `DataEvent`, and the row itself is `payload.after`. + * const timestamp = new Date().toISOString(); * await realtime.publish({ - * type: 'record.created', + * type: 'data.record.created', * object: 'account', - * payload: { id: 'acc-1', name: 'Acme' }, - * timestamp: new Date().toISOString(), + * payload: { + * id: crypto.randomUUID(), type: 'data.record.created', object: 'account', + * recordId: 'acc-1', after: { id: 'acc-1', name: 'Acme' }, timestamp, + * }, + * timestamp, * }); * * await realtime.unsubscribe(subId); diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 8482295f2c9..c04481c6fd6 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -942,7 +942,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | batch_endpoints | seeded 2026-09-02 (#14369) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 2 = `operations.upsertMany` and `defaultAtomic`. `upsertMany` is `endpoints.schema`'s twin — a switch declared for a route that was never built (`this.protocol` carries `createManyData` / `updateManyData` / `deleteManyData` and no upsert counterpart), so `false` disables nothing. `defaultAtomic` promises a transaction default that no batch handler consults. Live 5 = `maxBatchSize` (load-bearing since #11984 gave it a real parse — before that a configured `0` was the live cap, because `0` is not nullish), `enableBatchEndpoint`, and the three `operations.*` switches that do gate a mount **#14691 RETIRED both (2026-09-03, ADR-0049)**: `operations.upsertMany` and `defaultAtomic` are `retiredKey()` tombstones, rows kept `dead` with a REMOVED note. `defaultAtomic` is the family's worked enforce-or-remove call: the per-request `options.atomic` (ADR-0119 D4, opt-in) IS the contract, and a server default that flipped it silently is the move that ADR refused, so the key was removed rather than wired; upsert lives on as an operation type of the generic batch endpoint. `evidenceScope` widened to `cross-repo` (#14796) | | route_generation | seeded 2026-09-02 (#14369) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 6 = every key it has, and that is the finding: `routes` is parsed, defaulted and normalized into `this.config.routes`, and nothing ever reads it back. `excludeObjects: ['sys_log']` excludes nothing, `nameTransform: 'plural'` still mounts every route under the raw object name, and the per-object `overrides` record (drilled to `enabled` / `basePath` / `operations`) turns nothing on or off. ⚠️ The `overrides` hits in `packages/rest/src` are a REQUEST BODY and a test builder — different keys with the same name. This is the one member of the family with a customer-visible limb: `RestServerConfigSchema`'s own `@example` advertises `routes: { excludeObjects: ['system_log'] }`, so the published prose promises a capability the runtime does not deliver (Prime Directive #10). Fixing that example belongs to whichever enforce-or-remove limb the key lands on — `routes.*` reads as designed-but-never-wired, so enforcing it is real work in route generation that changes the mounted surface, and no dev agent decides that **#14691 RETIRED all six (2026-09-03, ADR-0049)**: every key is now a `retiredKey()` tombstone and the sub-object is tombstones-only; the rows stay `dead` with a REMOVED note (non-strict schema) and the three `overrides.*` child rows collapse into the one `overrides` row. Triage held `overrides` open as an ENFORCE candidate; the measurement closed it as REMOVE because the capability already exists at its proper seat — per-object exposure is the object's own `enable.apiEnabled` / `enable.apiMethods`, enforced by rest-server.ts#enforceApiAccess (404 / 405) — and `basePath` / `nameTransform` would contradict the one deployment-wide data base and Prime Directive #6 (the object name IS the REST path segment). The `@example` limb is corrected in the same change. `evidenceScope` widened to `cross-repo` (#14796) | | rest_api | seeded 2026-09-21 (#14640) — the FIFTH `RestServerConfig` sub-object, enrolled a round after the four above and deliberately so. #14369 left `RestApiConfigSchema` out because the `api` block's consumption seam was then still VALIDATE-ONLY (#11637 ran the declared contract and discarded its output), so a census would have recorded a half that was about to move; the gate source and four rows of this table said as much. That fence was re-tested before a line of this ledger was written and it has EXPIRED: `RestServer.normalizeConfig` now BUILDS the `api` block from `parseDeclaredApiConfig`'s output — “the asymmetry is gone and all five now build from their parsed output” — and the change is RELEASED, not in flight, with `packages/rest/CHANGELOG.md` re-stating the same zero this file records. ⛔ **The ledger is `rest_api.json`, NOT `api.json`**: that name was already taken by `ApiEndpointSchema`, the registered `api` metadata type with real consumers in the matcher, executor, policy chain and mapping layer — one spelling, two unrelated meanings inside `packages/spec`, and filing here would have published one file's measurement under the other's name. Live 20 = `version` / `basePath` / `apiPath`, which `getApiBasePath` splices into the prefix of EVERY mounted route (read through a whole-block destructure, which is why the dead-key census below had to sweep destructuring shapes and not a property-access pattern alone), the eight `enable*` switches, each gating a mount and most of them also the discovery document's capability block, and `projectResolution` — plus, since #20294 (ENFORCED 2026-09-28, ruling B on #20359), the eight identity members of `documentation` (`title`, `description`, `termsOfService`, `contact.name` / `url` / `email`, `license.name` / `url`), which `RestServer.overlayDocumentationInfo` lays over the served OpenAPI `info` on both `/openapi.json` doors (`contact` / `license` replaced whole; nothing authored serves the artifact's `info` unchanged). Dead 4 = the `requireAuth` tombstone (#3963, still `.omit()`ed by this seam because #3963 chose warn-and-ignore and converting that to a boot failure is that decision's to make), the `responseFormat` and `documentation.enabled` tombstones (RETIRED 2026-09-27, #20295, ADR-0049 enforce-or-remove — refused at `RestServer` construction with their prescription; `responseFormat` retired whole, so its three child rows collapsed into one), and the `documentation.version` tombstone (RETIRED 2026-09-28, #20294 — the served `info.version` is the protocol version, #11646; an app's own release number goes into `description`). Until #20294 the nine non-`enabled` members of `documentation` (drilled, including its nested `contact` / `license`) were all dead too — normalized into `this.config.api` and read back by nothing, so `documentation.title` retitled no served document. Every zero carries a lit control on the same instrument (twelve sibling keys on the same block return 1-2 reads), each of the three shapes a spelling sweep is blind to was swept with its own control, and the backstop is structural rather than textual: `NormalizedRestServerConfig` is module-local with no `export` and `RestServer.config` is `private`, so the normalized block cannot be reached from outside that one class. ⛔ **The two dead containers do NOT share one verdict**: `documentation`'s members are OpenAPI `info` fields whose enforce route collides with a recorded ownership decision (`info` is written by `build-openapi.ts` and was passed through untouched by #11646 — settled by ruling B on #20359, which split the block by field owner: identity overlaid, `version` retired), while `responseFormat`'s enforce route means making the response envelope configurable — a larger claim. This file records status; the enforce-or-remove call per key is a follow-up on the human floor — made for `responseFormat` and `documentation.enabled` (retired, #20295), and for `documentation`'s other members (#20294: the identity members enforced, `version` retired). `evidenceScope` stays `in-repo`: objectui was measured clean at the pinned sha and at head against a lit control, but the closed cloud runtime was not reachable from the measuring container, so #14796's structural reading is cited as a standing reading rather than re-claimed as a sweep | -| realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`) are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | +| realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`), as measured at `5f5511f0` before #20288 repointed the enum at the emitted `DataEventType` + `BulkDataEventType` names, are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | | connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/30 split (live/planned/dead; counts read from the generated `state-counts/connector.md` shard, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 30 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `triggers` (6, and the schema's own docblock says so: #3197), `metadata`, `actions.description`/`.outputSchema`, and the six top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status` and `webhooks`. That sums to 30, the dead count the generated `state-counts/connector.md` shard carries. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `fieldMappings.transform` and `triggers.interval` — but ⛔ that eight is NOT a separate addend: the first six ARE the top-level tombstones counted above and the last two are already inside the `fieldMappings` and `triggers` counts, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | | analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That is `dimensions.granularities` (read by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead). The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 9 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the three `description`s, `measures.format`, `dimensions.granularities`, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity. **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | diff --git a/packages/spec/liveness/realtime_subscription.json b/packages/spec/liveness/realtime_subscription.json index 64803aaac8a..83bec78fca5 100644 --- a/packages/spec/liveness/realtime_subscription.json +++ b/packages/spec/liveness/realtime_subscription.json @@ -1,6 +1,6 @@ { "type": "realtime_subscription", - "_note": "SubscriptionSchema — packages/spec/src/api/realtime.zod.ts#SubscriptionSchema, the item type of RealtimeConfigSchema.subscriptions and the `Subscription` the generated API reference publishes (content/docs/references/api/realtime.mdx). It is not a metadata type, not stored metadata, not a request body and not a manifest: it is a TRANSPORT-PROTOCOL surface — what a client declares to open a realtime subscription — so no registry has ever held it and no ratchet rooted in one could ask who reads it. The ledger governs it through the gate's SPEC_ONLY_SCHEMAS override, the same route `query` / `qa` / `manifest` and the four RestServerConfig sub-objects take; check-liveness.mts carries the rationale, including why SubscriptionSchema is the root rather than RealtimeConfigSchema (the walk drills one level, so rooting on the config would leave `events[].type` and `events[].filters` with no row of their own — #4956's shape). Seeded 2026-09-04 from the census filed with #14446. WHERE THE CARD'S TWO KEYS ACTUALLY LIVE, because the card and its triage both name them on the wrong schema: `type` and `filters` are declared on SubscriptionEventSchema (realtime.zod.ts:46-50), not on SubscriptionSchema (:57-62, whose own keys are id / events / transport / channel). The lines the card cites (:47, :49) are exact and the shapes it quotes are exact; only the owning symbol is misattributed. The platform's own authorable-surface census already spells them apart — packages/spec/authorable-surface/api.json lists `api/SubscriptionEvent:type` and `api/SubscriptionEvent:filters` beside `api/Subscription:id` / `:events` / `:transport` / `:channel`. Reached from this root they are the `events` container's children, which is why this file roots here and drills `events`. EVERY KEY IN THIS FILE IS `dead`, and the container is why: nothing outside packages/spec imports SubscriptionSchema, SubscriptionEventSchema or RealtimeConfigSchema at all, so no key beneath them can be read (the `manifest.contributes` reasoning — a container with zero reads of the container itself). THREE SPELLINGS OF A REALTIME SUBSCRIPTION EXIST AND ONLY THE THIRD IS EXECUTED, which is the trap for the next reader: (1) this schema, published in the API reference and parsed by nothing; (2) EventSubscriptionSchema (packages/spec/src/api/websocket.zod.ts#EventSubscriptionSchema), the WebSocket protocol's own subscription, whose `filters` carries a NOT-YET-ENFORCED describe() for the same reason as this one's; (3) RealtimeSubscriptionOptions (packages/spec/src/contracts/realtime-service.ts#RealtimeSubscriptionOptions), a plain TS interface with `object` + `eventTypes: string[]`, which is what the shipped adapter actually reads (packages/services/service-realtime/src/in-memory-realtime-adapter.ts#matchesSubscription). ⚠️ Do not read the `Subscription` identifiers in packages/services/service-realtime/src/in-memory-realtime-adapter.ts or packages/services/service-cluster/src/memory/pubsub.ts as consumers of this schema: both are file-local interfaces with the same name and a different shape. ⚠️ Nor the prose in websocket.zod.ts:62-63 saying \"the subscription shape the transports actually carry is … SubscriptionEventSchema\" — measured at 5f5511f0 the transports carry (3), not this schema; that sentence is a comment, not a consumer. This file RECORDS status; it decides nothing. No key is enforced, removed, deprecated, re-described or repointed by the change that seeds it, and realtime.zod.ts is not edited. The enforce-or-remove call per dead key (ADR-0049) is a follow-up on the human floor. CENSUS METHOD AND SCOPE, run at 5f5511f0 (2026-09-04): `git grep -nE 'SubscriptionEventSchema|\\bSubscriptionSchema\\b|RealtimeConfigSchema'` over the whole repo excluding CHANGELOGs returns hits in exactly eleven files, and every one is spec's own tree, a generated artifact or published prose — realtime.zod.ts (the declaration), realtime.test.ts (its own unit test), type-alias-convention.pin.test.ts (the alias pin), websocket.zod.ts (the comment above), check-liveness.mts (this enrolment), api-surface/ + declaration-map/ + export-origins/ (generated), content/docs/references/api/realtime.mdx (generated reference) and skills/objectstack-api (published prose). Zero runtime readers. The `Subscription` / `SubscriptionEvent` / `RealtimeConfig` TYPE names were swept separately across *.ts / *.tsx outside packages/spec and produced only the file-local interfaces named above. objectui @24e027e: one import of RealtimeConfigSchema, in packages/collaboration/src/__tests__/spec-symbol-batch7.test.ts, which is a DIVERGENCE PIN — it asserts that objectui's own RealtimeSubscriptionConfig is not a dialect of the spec's RealtimeConfig and reads the schema's key NAMES to do it; zero hits for SubscriptionSchema, SubscriptionEventSchema or RealtimeEventType. The closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo` rather than claiming a sweep that was not run. AUTHOR-WARN CHANNEL: none exists for this type, and no entry here is marked `authorWarn` for that reason (`_authorWarnSkipped`). The CLI lint (packages/lint/src/lint-liveness-properties.ts) walks stack COLLECTIONS — `stack.flows`, `stack.views`, … — and a realtime subscription is not part of a stack at all: `stack.zod.ts` has no `realtime` key and TYPE_COLLECTIONS has no subscription entry. Marking an entry `authorWarn` here would produce a warning nothing can emit, which is the same silent no-op this ledger exists to catch, so the dead entries below carry their correction in `note`.", + "_note": "SubscriptionSchema — packages/spec/src/api/realtime.zod.ts#SubscriptionSchema, the item type of RealtimeConfigSchema.subscriptions and the `Subscription` the generated API reference publishes (content/docs/references/api/realtime.mdx). It is not a metadata type, not stored metadata, not a request body and not a manifest: it is a TRANSPORT-PROTOCOL surface — what a client declares to open a realtime subscription — so no registry has ever held it and no ratchet rooted in one could ask who reads it. The ledger governs it through the gate's SPEC_ONLY_SCHEMAS override, the same route `query` / `qa` / `manifest` and the four RestServerConfig sub-objects take; check-liveness.mts carries the rationale, including why SubscriptionSchema is the root rather than RealtimeConfigSchema (the walk drills one level, so rooting on the config would leave `events[].type` and `events[].filters` with no row of their own — #4956's shape). Seeded 2026-09-04 from the census filed with #14446. WHERE THE CARD'S TWO KEYS ACTUALLY LIVE, because the card and its triage both name them on the wrong schema: `type` and `filters` are declared on SubscriptionEventSchema (realtime.zod.ts:46-50), not on SubscriptionSchema (:57-62, whose own keys are id / events / transport / channel). The lines the card cites (:47, :49) are exact and the shapes it quotes are exact; only the owning symbol is misattributed. The platform's own authorable-surface census already spells them apart — packages/spec/authorable-surface/api.json lists `api/SubscriptionEvent:type` and `api/SubscriptionEvent:filters` beside `api/Subscription:id` / `:events` / `:transport` / `:channel`. Reached from this root they are the `events` container's children, which is why this file roots here and drills `events`. EVERY KEY IN THIS FILE IS `dead`, and the container is why: nothing outside packages/spec imports SubscriptionSchema, SubscriptionEventSchema or RealtimeConfigSchema at all, so no key beneath them can be read (the `manifest.contributes` reasoning — a container with zero reads of the container itself). THREE SPELLINGS OF A REALTIME SUBSCRIPTION EXIST AND ONLY THE THIRD IS EXECUTED, which is the trap for the next reader: (1) this schema, published in the API reference and parsed by nothing; (2) EventSubscriptionSchema (packages/spec/src/api/websocket.zod.ts#EventSubscriptionSchema), the WebSocket protocol's own subscription, whose `filters` carries a NOT-YET-ENFORCED describe() for the same reason as this one's; (3) RealtimeSubscriptionOptions (packages/spec/src/contracts/realtime-service.ts#RealtimeSubscriptionOptions), a plain TS interface with `object` + `eventTypes: string[]`, which is what the shipped adapter actually reads (packages/services/service-realtime/src/in-memory-realtime-adapter.ts#matchesSubscription). ⚠️ Do not read the `Subscription` identifiers in packages/services/service-realtime/src/in-memory-realtime-adapter.ts or packages/services/service-cluster/src/memory/pubsub.ts as consumers of this schema: both are file-local interfaces with the same name and a different shape. ⚠️ Nor the prose in websocket.zod.ts:62-63 saying \"the subscription shape the transports actually carry is … SubscriptionEventSchema\" — measured at 5f5511f0 the transports carry (3), not this schema; that sentence is a comment, not a consumer. This file RECORDS status; it decides nothing. No key is enforced, removed, deprecated, re-described or repointed by the change that seeds it, and realtime.zod.ts is not edited. The enforce-or-remove call per dead key (ADR-0049) is a follow-up on the human floor. CENSUS METHOD AND SCOPE, run at 5f5511f0 (2026-09-04): `git grep -nE 'SubscriptionEventSchema|\\bSubscriptionSchema\\b|RealtimeConfigSchema'` over the whole repo excluding CHANGELOGs returns hits in exactly eleven files, and every one is spec's own tree, a generated artifact or published prose — realtime.zod.ts (the declaration), realtime.test.ts (its own unit test), type-alias-convention.pin.test.ts (the alias pin), websocket.zod.ts (the comment above), check-liveness.mts (this enrolment), api-surface/ + declaration-map/ + export-origins/ (generated), content/docs/references/api/realtime.mdx (generated reference) and skills/objectstack-api (published prose). Zero runtime readers. The `Subscription` / `SubscriptionEvent` / `RealtimeConfig` TYPE names were swept separately across *.ts / *.tsx outside packages/spec and produced only the file-local interfaces named above. objectui @24e027e: one import of RealtimeConfigSchema, in packages/collaboration/src/__tests__/spec-symbol-batch7.test.ts, which is a DIVERGENCE PIN — it asserts that objectui's own RealtimeSubscriptionConfig is not a dialect of the spec's RealtimeConfig and reads the schema's key NAMES to do it; zero hits for SubscriptionSchema, SubscriptionEventSchema or RealtimeEventType. The closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo` rather than claiming a sweep that was not run. AUTHOR-WARN CHANNEL: none exists for this type, and no entry here is marked `authorWarn` for that reason (`_authorWarnSkipped`). The CLI lint (packages/lint/src/lint-liveness-properties.ts) walks stack COLLECTIONS — `stack.flows`, `stack.views`, … — and a realtime subscription is not part of a stack at all: `stack.zod.ts` has no `realtime` key and TYPE_COLLECTIONS has no subscription entry. Marking an entry `authorWarn` here would produce a warning nothing can emit, which is the same silent no-op this ledger exists to catch, so the dead entries below carry their correction in `note`. POINTERS (2026-09-29, #20288): the transport these six rows wait on stays out of open core — maintainer ruling A on #14646 (5542632689, 2026-09-04) and ruling 甲 on #8347 (5791499872, 2026-09-23); they land live-elsewhere once a channel-serving occupant parses SubscriptionSchema. RealtimeEventType itself was repointed at the emitted DataEventType + BulkDataEventType names by #20288, so the `events.type` row's member list is as measured at 5f5511f0; that row stays dead because nothing parses the container.", "props": { "id": { "status": "dead", diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index bbd88137c52..03447962b60 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -450,6 +450,8 @@ const PENDING_GOVERNANCE: Record = {}; // // The census filed with #14446 found the two members that make governing it // worth the row: `events[].type` accepts `RealtimeEventType`, whose four members +// (as measured at `5f5511f0`, before #20288 repointed the enum at the emitted +// `DataEventType` + `BulkDataEventType` names) // are spelled `record.created` / `record.updated` / `record.deleted` / // `field.changed` while the engine publishes `data.record.*` (DataEventType, // src/api/events.zod.ts) — DISJOINT vocabularies, so every member of the diff --git a/packages/spec/src/api/realtime.test.ts b/packages/spec/src/api/realtime.test.ts index fdb13a733ba..09131f7aeff 100644 --- a/packages/spec/src/api/realtime.test.ts +++ b/packages/spec/src/api/realtime.test.ts @@ -11,6 +11,7 @@ import { type RealtimeEvent, } from './realtime.zod'; import { PresenceStatus, RealtimeRecordAction } from './realtime-shared.zod'; +import { BulkDataEventType, DataEventType } from './events.zod'; describe('TransportProtocol', () => { it('should accept valid transport protocols', () => { @@ -27,11 +28,16 @@ describe('TransportProtocol', () => { }); describe('RealtimeEventType', () => { - it('should accept valid event types', () => { - expect(() => RealtimeEventType.parse('record.created')).not.toThrow(); - expect(() => RealtimeEventType.parse('record.updated')).not.toThrow(); - expect(() => RealtimeEventType.parse('record.deleted')).not.toThrow(); - expect(() => RealtimeEventType.parse('field.changed')).not.toThrow(); + it('names exactly the record-change events the runtime emits (DataEventType + BulkDataEventType)', () => { + // The ObjectQL engine parses every data event it publishes through + // DataEventSchema / BulkDataEventSchema, so these two enums ARE the emitted + // set. A name added to or dropped from either one turns this red here. + expect([...RealtimeEventType.options].sort()).toEqual( + [...DataEventType.options, ...BulkDataEventType.options].sort(), + ); + for (const emitted of [...DataEventType.options, ...BulkDataEventType.options]) { + expect(RealtimeEventType.safeParse(emitted).success, emitted).toBe(true); + } }); it('should reject invalid event types', () => { @@ -39,12 +45,46 @@ describe('RealtimeEventType', () => { expect(() => RealtimeEventType.parse('object.modified')).toThrow(); expect(() => RealtimeEventType.parse('')).toThrow(); }); + + // The four values the enum used to accept. None was ever emitted, so each is + // refused with a prescription naming the emitted name to write instead. + const RETIRED: ReadonlyArray = [ + ['record.created', 'data.record.created'], + ['record.updated', 'data.record.updated'], + ['record.deleted', 'data.record.deleted'], + ['field.changed', 'data.record.updated'], + ]; + + it.each(RETIRED)('refuses the retired value %s at the authoring door with its prescription', (retired, write) => { + const result = SubscriptionSchema.safeParse({ + id: '550e8400-e29b-41d4-a716-446655440000', + events: [{ type: retired, object: 'account' }], + transport: 'websocket', + }); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('invalid_value'); + expect(issues[0].path).toEqual(['events', 0, 'type']); + const firstSentence = issues[0].message.split(' — ')[0]; + expect(firstSentence).toBe( + `\`${retired}\` was removed from \`RealtimeEventType\` in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove)`, + ); + expect(issues[0].message).toContain(`Write \`${write}\``); + }); + + it('keeps zod\'s own message for a value that was never legal', () => { + const result = RealtimeEventType.safeParse('data.record.create'); + expect(result.success).toBe(false); + expect(result.error!.issues[0].code).toBe('invalid_value'); + expect(result.error!.issues[0].message).not.toContain('was removed'); + }); }); describe('SubscriptionEventSchema', () => { it('should accept valid subscription event', () => { const event = { - type: 'record.created', + type: 'data.record.created', object: 'account', filters: { status: 'active' }, }; @@ -54,7 +94,7 @@ describe('SubscriptionEventSchema', () => { it('should accept event without object', () => { const event = { - type: 'record.created', + type: 'data.record.created', }; const parsed = SubscriptionEventSchema.parse(event); @@ -63,7 +103,7 @@ describe('SubscriptionEventSchema', () => { it('should accept event without filters', () => { const event = { - type: 'record.updated', + type: 'data.record.updated', object: 'contact', }; @@ -73,9 +113,9 @@ describe('SubscriptionEventSchema', () => { it('should accept various filter types', () => { const events = [ - { type: 'record.created', filters: { status: 'active' } }, - { type: 'record.updated', filters: ['field1', 'field2'] }, - { type: 'field.changed', filters: 'name' }, + { type: 'data.record.created', filters: { status: 'active' } }, + { type: 'data.record.updated', filters: ['field1', 'field2'] }, + { type: 'data.records.deleted', filters: 'name' }, ]; events.forEach(event => { @@ -89,7 +129,7 @@ describe('SubscriptionSchema', () => { const subscription: Subscription = { id: '550e8400-e29b-41d4-a716-446655440000', events: [ - { type: 'record.created', object: 'account' }, + { type: 'data.record.created', object: 'account' }, ], transport: 'websocket', }; @@ -101,7 +141,7 @@ describe('SubscriptionSchema', () => { const subscription = { id: '550e8400-e29b-41d4-a716-446655440000', events: [ - { type: 'record.updated', object: 'contact' }, + { type: 'data.record.updated', object: 'contact' }, ], transport: 'sse', channel: 'user-notifications', @@ -115,9 +155,9 @@ describe('SubscriptionSchema', () => { const subscription = { id: '550e8400-e29b-41d4-a716-446655440000', events: [ - { type: 'record.created', object: 'account' }, - { type: 'record.updated', object: 'account' }, - { type: 'record.deleted', object: 'account' }, + { type: 'data.record.created', object: 'account' }, + { type: 'data.record.updated', object: 'account' }, + { type: 'data.record.deleted', object: 'account' }, ], transport: 'websocket', }; @@ -132,7 +172,7 @@ describe('SubscriptionSchema', () => { transports.forEach(transport => { const subscription = { id: '550e8400-e29b-41d4-a716-446655440000', - events: [{ type: 'record.created' }], + events: [{ type: 'data.record.created' }], transport, }; @@ -144,13 +184,13 @@ describe('SubscriptionSchema', () => { it('should validate UUID format', () => { expect(() => SubscriptionSchema.parse({ id: 'not-a-uuid', - events: [{ type: 'record.created' }], + events: [{ type: 'data.record.created' }], transport: 'websocket', })).toThrow(); expect(() => SubscriptionSchema.parse({ id: '550e8400-e29b-41d4-a716-446655440000', - events: [{ type: 'record.created' }], + events: [{ type: 'data.record.created' }], transport: 'websocket', })).not.toThrow(); }); @@ -160,7 +200,7 @@ describe('SubscriptionSchema', () => { id: '550e8400-e29b-41d4-a716-446655440000', events: [ { - type: 'record.updated', + type: 'data.record.updated', object: 'opportunity', filters: { stage: 'closed_won', amount: { $gt: 10000 } }, }, @@ -173,7 +213,7 @@ describe('SubscriptionSchema', () => { it('should reject subscription without required fields', () => { expect(() => SubscriptionSchema.parse({ - events: [{ type: 'record.created' }], + events: [{ type: 'data.record.created' }], transport: 'websocket', })).toThrow(); @@ -184,7 +224,7 @@ describe('SubscriptionSchema', () => { expect(() => SubscriptionSchema.parse({ id: '550e8400-e29b-41d4-a716-446655440000', - events: [{ type: 'record.created' }], + events: [{ type: 'data.record.created' }], })).toThrow(); }); }); diff --git a/packages/spec/src/api/realtime.zod.ts b/packages/spec/src/api/realtime.zod.ts index a4129934978..0101aeff612 100644 --- a/packages/spec/src/api/realtime.zod.ts +++ b/packages/spec/src/api/realtime.zod.ts @@ -20,22 +20,90 @@ export const TransportProtocol = z.enum([ export type TransportProtocol = z.input; +// Retired event-name prescriptions, one per value `RealtimeEventType` used to +// accept. Declared with `//` (never `/** */`): build-docs takes a file's first +// JSDoc as the reference page's blurb, and this file deliberately renders none. +// No `os migrate meta` sentence: a subscription is not a stack collection +// member and never a stored row, so the command has nothing to list for it. +const REALTIME_EVENT_TYPE_RETIRED: ReadonlyMap = new Map([ + ['record.created', + '`record.created` was removed from `RealtimeEventType` in @objectstack/spec 17.5.0 ' + + '(ADR-0049 enforce-or-remove) — no producer ever emitted it, so a subscription naming it ' + + 'could never fire: the ObjectQL engine publishes each inserted record as ' + + '`data.record.created`. Write `data.record.created` — the same event, spelled the way the ' + + 'engine publishes it.'], + ['record.updated', + '`record.updated` was removed from `RealtimeEventType` in @objectstack/spec 17.5.0 ' + + '(ADR-0049 enforce-or-remove) — no producer ever emitted it, so a subscription naming it ' + + 'could never fire: the ObjectQL engine publishes a single-record update as ' + + '`data.record.updated`, and a predicate write (`multi: true`) as the aggregate ' + + '`data.records.updated`, which carries a count and no record. Write `data.record.updated`; ' + + 'add a second event on `data.records.updated` if the subscriber must also hear predicate ' + + 'writes.'], + ['record.deleted', + '`record.deleted` was removed from `RealtimeEventType` in @objectstack/spec 17.5.0 ' + + '(ADR-0049 enforce-or-remove) — no producer ever emitted it, so a subscription naming it ' + + 'could never fire: the ObjectQL engine publishes a single-record delete as ' + + '`data.record.deleted`, and a predicate write (`multi: true`) as the aggregate ' + + '`data.records.deleted`, which carries a count and no record. Write `data.record.deleted`; ' + + 'add a second event on `data.records.deleted` if the subscriber must also hear predicate ' + + 'writes.'], + ['field.changed', + '`field.changed` was removed from `RealtimeEventType` in @objectstack/spec 17.5.0 ' + + '(ADR-0049 enforce-or-remove) — no producer ever emitted it, and no event is published per ' + + 'field: an update is published once per write as `data.record.updated`, whose `DataEvent` ' + + 'payload lists the changed fields in `changes`. Write `data.record.updated` and read the ' + + 'field from `changes`.'], +]); + /** * Event Type Enum - * Types of realtime events that can be subscribed to + * The realtime event names a subscription can listen for: exactly the + * record-change events the runtime emits, and nothing else. + * + * - `data.record.created` / `data.record.updated` / `data.record.deleted` — one + * event per written record (`DataEventType`, `events.zod.ts`); + * - `data.records.updated` / `data.records.deleted` — the aggregate event a + * predicate write (`multi: true`) publishes instead, carrying a count and no + * record (`BulkDataEventType`). + * + * The ObjectQL engine parses every one of these events through + * `DataEventSchema` / `BulkDataEventSchema` before it publishes, so those two + * enums bound what can arrive. `realtime.test.ts` pins this enum equal to their + * union: a name the engine gains or loses there turns that pin red rather than + * drifting apart from this list. + * + * Metadata change events (`metadata.{type}.{action}`, `MetadataEventType`) are + * deliberately NOT members. A subscription event is record-shaped — `object` + * names a data object and `filters` is meant to narrow records — while a metadata event is + * keyed by metadata type and has its own contract and its own client primitive + * (`subscribeMetadata`). + * + * REMOVED in 17.5.0 (#20288, ADR-0049 enforce-or-remove): `record.created`, + * `record.updated`, `record.deleted` and `field.changed`. No producer ever + * emitted any of them, so a subscription written with the only names the API + * reference showed could never fire. The runtime's published names did not + * change; this enum moved to them. Each removed value is refused with its own + * prescription ({@link REALTIME_EVENT_TYPE_RETIRED}); any other unknown value + * keeps zod's own message, which lists the legal names. * - * ⚠️ NOT YET ENFORCED — declared but has no runtime consumer (#3197). The - * engine publishes realtime events under the `data.record.created/updated/deleted` - * names from `DataEventType` (events.zod.ts), which this enum does not match, - * and nothing ever emits `field.changed`. Subscriptions authored against these - * values are not validated or filtered by any runtime. + * Nothing in the open framework parses a subscription: it mounts no realtime + * transport (#14646 ruling A, #8347 ruling 甲). The vocabulary is fixed here so + * that whatever does parse `SubscriptionSchema` receives names that fire. */ export const RealtimeEventType = z.enum([ - 'record.created', - 'record.updated', - 'record.deleted', - 'field.changed', -]).describe('Realtime event type '); + 'data.record.created', + 'data.record.updated', + 'data.record.deleted', + 'data.records.updated', + 'data.records.deleted', +], { + // Only a value that USED to be legal gets the retirement prescription — + // telling the author of a typo that their value "was removed" would + // misinform. (The `HookBodyCapability` / `RuntimeMode` precedent.) + error: (issue) => + typeof issue.input === 'string' ? REALTIME_EVENT_TYPE_RETIRED.get(issue.input) : undefined, +}).describe('Realtime event type: a record-change event name the runtime emits'); export type RealtimeEventType = z.input; @@ -78,7 +146,7 @@ export type RealtimePresence = z.input; */ export const RealtimeEventSchema = lazySchema(() => z.object({ id: z.string().uuid().describe('Unique event identifier'), - type: z.string().describe('Event type (e.g., record.created, record.updated)'), + type: z.string().describe('Event type (e.g., data.record.created, data.records.updated)'), object: z.string().optional().describe('Object name the event relates to'), action: RealtimeRecordAction.optional().describe('Action performed'), payload: z.record(z.string(), z.unknown()).describe('Event payload data'), diff --git a/packages/spec/src/contracts/realtime-service.ts b/packages/spec/src/contracts/realtime-service.ts index aa2df48bde2..36f145cbd28 100644 --- a/packages/spec/src/contracts/realtime-service.ts +++ b/packages/spec/src/contracts/realtime-service.ts @@ -17,7 +17,7 @@ * A realtime event payload */ export interface RealtimeEventPayload { - /** Event type (e.g. 'record.created', 'record.updated') */ + /** Event type (e.g. 'data.record.created', 'data.records.updated', 'metadata.object.updated') */ type: string; /** Object name the event relates to */ object?: string; diff --git a/packages/spec/src/migrations/entries/semantic/18.realtime-event-type-unemitted-values-retired.ts b/packages/spec/src/migrations/entries/semantic/18.realtime-event-type-unemitted-values-retired.ts new file mode 100644 index 00000000000..a696bddc6f4 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.realtime-event-type-unemitted-values-retired.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The realtime subscription vocabulary moves to the names the runtime emits. +// Semantic only, with no D2 conversion: a subscription is not a stack +// collection member and never a stored row, so no conversion seam would ever +// see one — the refusal on the enum's own error map carries the prescription. +export const entry: SemanticMigration = { + id: 'realtime-event-type-unemitted-values-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + "api.RealtimeEventType — the values 'record.created', 'record.updated', " + + "'record.deleted' and 'field.changed' left the enum. It types " + + 'SubscriptionEvent.type, so it reaches Subscription.events[].type and ' + + 'RealtimeConfig.subscriptions[].events[].type', + replacement: + 'the names the runtime emits, which are now the whole enum: ' + + "'data.record.created' / 'data.record.updated' / 'data.record.deleted' for " + + "a single-record write, and 'data.records.updated' / 'data.records.deleted' " + + 'for a predicate write (multi: true), which carries a count and no record. ' + + "'record.created' becomes 'data.record.created'; 'record.updated' and " + + "'record.deleted' become their data.record twins, plus the data.records " + + 'twin where a predicate write must be heard too; ' + + "'field.changed' becomes 'data.record.updated', whose DataEvent payload " + + 'lists the changed fields in changes', + reason: + 'ADR-0049 enforce-or-remove. RealtimeEventType was published in the ' + + 'generated API reference as the vocabulary of a realtime subscription, and ' + + 'no producer anywhere emitted any of its four values. What the runtime ' + + 'publishes is the DataEventType / BulkDataEventType vocabulary: the ObjectQL ' + + 'engine sends data.record.created, data.record.updated and ' + + 'data.record.deleted for each written record and data.records.updated / ' + + 'data.records.deleted for a predicate write, and it parses every event ' + + 'through DataEventSchema / BulkDataEventSchema before publishing. A ' + + 'subscription written with the only names the reference showed could ' + + 'therefore never fire, and nothing said so. The direction was settled ' + + 'before this change: the enum moves to the emitted names, and the runtime ' + + "keeps publishing exactly what it published — changing the runtime's live " + + 'event names to match an enum nothing had ever used would break every ' + + 'real subscriber. field.changed is the same dead spelling that ' + + 'DataEventType already dropped in protocol 17 (the entry ' + + 'data-field-changed-event-retired): no per-field event exists, because an ' + + "update's per-field detail rides on data.record.updated as changes. Metadata " + + 'change events (metadata.{type}.{action}) were not added: a subscription ' + + 'event is record-shaped (object names a data object, filters narrows ' + + 'records), and metadata events have their own MetadataEventType contract ' + + 'and client primitive. Bookkeeping: an enum VALUE puts nothing in ' + + 'RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched; its ' + + "prescription hangs on the enum's own error map (the HookBodyCapability " + + 'precedent). It is a SEMANTIC entry rather than a D2 conversion because ' + + 'there is no source to rewrite: stack.zod.ts has no realtime key, no ' + + 'metadata type holds a subscription, and the open framework mounts no ' + + 'realtime transport that would parse one (maintainer ruling of 2026-09-04: ' + + 'realtime stays out of open core) — so the conversion chain has no seam ' + + 'that would ever see a subscription. ADR-0049 / ADR-0087.', + acceptanceCriteria: + "No code or document names 'record.created', 'record.updated', " + + "'record.deleted' or 'field.changed' as a RealtimeEventType value. " + + 'TypeScript rejects each one at a RealtimeEventType or SubscriptionEvent ' + + 'position, because the type no longer contains it, and a SubscriptionSchema, ' + + 'SubscriptionEventSchema or RealtimeConfigSchema parse refuses it with its ' + + 'per-value prescription (pinned in api/realtime.test.ts). A subscriber that ' + + 'meant field.changed listens on data.record.updated and reads the field ' + + "from the DataEvent payload's changes map. A handler keyed on an old name " + + 'never ran, since nothing emitted it, so renaming it changes behaviour only ' + + 'by making it fire.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 5252470f021..4ff46dda9ad 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -14146,6 +14146,71 @@ const step18: MigrationStep = { + 'that does not it prints the scenario as skipped with the unmet service and the services the target declares ' + 'available. A suite without `requires` runs exactly as before.', }, + // The realtime subscription vocabulary moves to the names the runtime emits. + // Semantic only, with no D2 conversion: a subscription is not a stack + // collection member and never a stored row, so no conversion seam would ever + // see one — the refusal on the enum's own error map carries the prescription. + { + id: 'realtime-event-type-unemitted-values-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + "api.RealtimeEventType — the values 'record.created', 'record.updated', " + + "'record.deleted' and 'field.changed' left the enum. It types " + + 'SubscriptionEvent.type, so it reaches Subscription.events[].type and ' + + 'RealtimeConfig.subscriptions[].events[].type', + replacement: + 'the names the runtime emits, which are now the whole enum: ' + + "'data.record.created' / 'data.record.updated' / 'data.record.deleted' for " + + "a single-record write, and 'data.records.updated' / 'data.records.deleted' " + + 'for a predicate write (multi: true), which carries a count and no record. ' + + "'record.created' becomes 'data.record.created'; 'record.updated' and " + + "'record.deleted' become their data.record twins, plus the data.records " + + 'twin where a predicate write must be heard too; ' + + "'field.changed' becomes 'data.record.updated', whose DataEvent payload " + + 'lists the changed fields in changes', + reason: + 'ADR-0049 enforce-or-remove. RealtimeEventType was published in the ' + + 'generated API reference as the vocabulary of a realtime subscription, and ' + + 'no producer anywhere emitted any of its four values. What the runtime ' + + 'publishes is the DataEventType / BulkDataEventType vocabulary: the ObjectQL ' + + 'engine sends data.record.created, data.record.updated and ' + + 'data.record.deleted for each written record and data.records.updated / ' + + 'data.records.deleted for a predicate write, and it parses every event ' + + 'through DataEventSchema / BulkDataEventSchema before publishing. A ' + + 'subscription written with the only names the reference showed could ' + + 'therefore never fire, and nothing said so. The direction was settled ' + + 'before this change: the enum moves to the emitted names, and the runtime ' + + "keeps publishing exactly what it published — changing the runtime's live " + + 'event names to match an enum nothing had ever used would break every ' + + 'real subscriber. field.changed is the same dead spelling that ' + + 'DataEventType already dropped in protocol 17 (the entry ' + + 'data-field-changed-event-retired): no per-field event exists, because an ' + + "update's per-field detail rides on data.record.updated as changes. Metadata " + + 'change events (metadata.{type}.{action}) were not added: a subscription ' + + 'event is record-shaped (object names a data object, filters narrows ' + + 'records), and metadata events have their own MetadataEventType contract ' + + 'and client primitive. Bookkeeping: an enum VALUE puts nothing in ' + + 'RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched; its ' + + "prescription hangs on the enum's own error map (the HookBodyCapability " + + 'precedent). It is a SEMANTIC entry rather than a D2 conversion because ' + + 'there is no source to rewrite: stack.zod.ts has no realtime key, no ' + + 'metadata type holds a subscription, and the open framework mounts no ' + + 'realtime transport that would parse one (maintainer ruling of 2026-09-04: ' + + 'realtime stays out of open core) — so the conversion chain has no seam ' + + 'that would ever see a subscription. ADR-0049 / ADR-0087.', + acceptanceCriteria: + "No code or document names 'record.created', 'record.updated', " + + "'record.deleted' or 'field.changed' as a RealtimeEventType value. " + + 'TypeScript rejects each one at a RealtimeEventType or SubscriptionEvent ' + + 'position, because the type no longer contains it, and a SubscriptionSchema, ' + + 'SubscriptionEventSchema or RealtimeConfigSchema parse refuses it with its ' + + 'per-value prescription (pinned in api/realtime.test.ts). A subscriber that ' + + 'meant field.changed listens on data.record.updated and reads the field ' + + "from the DataEvent payload's changes map. A handler keyed on an old name " + + 'never ran, since nothing emitted it, so renaming it changes behaviour only ' + + 'by making it fire.', + }, { id: 'record-chatter-position-vocabulary-converged', surface: