diff --git a/.changeset/data-field-changed-event-removed.md b/.changeset/data-field-changed-event-removed.md new file mode 100644 index 0000000000..b8ced3f0d6 --- /dev/null +++ b/.changeset/data-field-changed-event-removed.md @@ -0,0 +1,75 @@ +--- +'@objectstack/spec': major +--- + +**BREAKING**: `DataEventType` drops `data.field.changed` — it had no producer (ADR-0049 enforce-or-remove, #4673) + +`data.field.changed` was declared in the `DataEventType` enum and emitted by +nothing. The engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` +and (since #4639) `data.records.{updated,deleted}`; no other producer exists in +either repository. A subscriber that switched on `data.field.changed` held a +branch that could never run — and because the surrounding `switch` still +compiled, nothing ever reported the gap. That is ADR-0078's silently-inert +declaration, on the event vocabulary. + +It also could not have been implemented against this contract as written: +`DataEventSchema` is record-shaped (`recordId`, `changes`, `before`, `after`) +with no `field` / `oldValue` / `newValue` slot, so the member advertised a +granularity the payload has no room for. + +**FROM → TO** + +| FROM | TO | +| :--- | :--- | +| `type: 'data.field.changed'` | `type: 'data.record.updated'`, reading the per-field detail from the payload's `changes` map (with `before` / `after` for surrounding state) | + +**The one-line fix** — delete the dead branch and read `changes` off the update +event: + +```ts +// BEFORE — never ran; no producer ever sent this event +if (event.type === 'data.field.changed') { onFieldChange(event); } + +// AFTER — the changed fields have always ridden on the record event +if (event.type === 'data.record.updated') { + for (const [field, value] of Object.entries(event.changes ?? {})) onFieldChange(field, value); +} +``` + +Removing that branch changes no observable behaviour — it never executed — so +this is deleting code that could not run, not rebuilding a capability. Note the +replacement is one event per write rather than N events on a wide table. + +**The retirement kit:** + +- **Schema** — the member is gone from `DataEventType` (`api/events.zod.ts`), + with an in-schema comment recording what was removed and what the live + mechanism is. Deliberately **no `retiredKey()` tombstone**: a removed enum + VALUE cannot carry a fix-it prescription the way an authorable object key + can (the same limit the sharing-rule `full` retirement hit). The enforced + channels are `tsc`, which fails any consumer still naming the value in a + `DataEventType` position, and the enum parse, which now rejects the name + instead of accepting an event that never arrives. +- **ADR-0087 D3 semantic migration** — `data-field-changed-event-retired` in + `migrations/registry.ts` (step 17), carrying the reason and acceptance + criteria. Registered as a **semantic TODO rather than a D2 conversion** + because this is a runtime EVENT surface: no stack, example or template + authors an event name, so there is no source for `os migrate meta` to + rewrite. (Webhooks subscribe through the separate authorable + `WebhookTriggerType`, whose vocabulary was already trimmed to producers that + exist, #3196.) +- **No liveness-ledger entry** — the ledger governs authorable metadata types + (`object`, `field`, `flow`, …); `DataEvent` is a runtime payload contract and + has no ledger file. `check:liveness` and `check:empty-state` pass unchanged. +- **No `authorable-surface.json` movement** — that ratchet tracks authorable + *keys* (`api/DataEvent:type` and friends), not enum members, so the key list + is unchanged and gates (a)/(b) correctly stay silent. +- **Tests** — `api/events.test.ts` pins the narrowed `.options`, asserts the + retired name no longer parses, and pins the FROM → TO replacement (that + `data.record.updated` really does carry `changes` / `before` / `after`). +- **Docs** — `content/docs/references/api/events.mdx` and + `docs/protocol-upgrade-guide.md` regenerated. + +If a genuine per-field change stream is ever wanted, it earns its own honest +contract — the precedent #4639 set for bulk writes — rather than reclaiming +this slot. diff --git a/content/docs/references/api/events.mdx b/content/docs/references/api/events.mdx index 6b27f61acc..b5a92b6576 100644 --- a/content/docs/references/api/events.mdx +++ b/content/docs/references/api/events.mdx @@ -42,7 +42,7 @@ const result = DataEventSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique event identifier | -| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| 'data.field.changed'>` | ✅ | Event type | +| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted'>` | ✅ | Event type | | **object** | `string` | ✅ | Object name | | **recordId** | `string` | ✅ | Record ID | | **changes** | `Record` | optional | Changed fields | @@ -61,7 +61,6 @@ const result = DataEventSchema.parse(data); * `data.record.created` * `data.record.updated` * `data.record.deleted` -* `data.field.changed` --- diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 88aace74c8..320e720f90 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -172,6 +172,8 @@ The same window converges the retry policy (#4661). `@objectstack/spec/automatio The subtle half is the defaults, and it is worth stating because no gate can see it: `job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` while the automation shape defaulted 0 / 1, and the authorable-surface gate compares KEY SETS — a changed default is invisible to it, to the tombstone mechanism and to `spec_changes` alike. The merged declaration takes 0 / 1 (retry replays side effects, so it is opt-in — the same reading already recorded in `flow-retry-max-retries-required`), and the conversion writes the pre-17 numbers into every existing `job.retryPolicy` that omitted them. Deployed stacks therefore keep their exact behaviour; what changes is only what a NEWLY authored omission means. +The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` drops `data.field.changed` (#4673). It had no producer anywhere — the engine emits `data.record.{created,updated,deleted}` and, since #4639, `data.records.{updated,deleted}` — so a subscriber switching on it held a branch that could never run, and the `switch` still compiled, which is why an empty member could sit in a public enum this long. It could not have been implemented against this contract as written: `DataEventSchema` is record-shaped and has no `field` / `oldValue` / `newValue` slot, so the member advertised a granularity the payload has no room for. Nothing is lost — per-field detail already rides on `data.record.updated` as `changes` (with `before` / `after`), one event per write instead of N on a wide table. Like the driver contract above it is a runtime surface, never stored in stack metadata, so it is one semantic TODO for event consumers rather than a source rewrite, and it carries no tombstone: a removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` retirement noted. Should a real per-field stream ever be wanted, it earns its own contract on the #4639 precedent rather than reclaiming this slot. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -249,6 +251,9 @@ The subtle half is the defaults, and it is worth stating because no gate can see - **`data-driver-find-stream-retired`** — `contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream` → find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts) - Why not automatic: `findStream` was a REQUIRED contract method documented as "optimized for large datasets to avoid memory overflow", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484. - Done when: No code calls `driver.findStream(...)`; large reads page through `find()` with `limit`/`offset` (which guarantees a total order across the whole walk) or go through the export surface. Drivers and test doubles no longer implement the method — one left behind still compiles and is simply never reached, so removing it is cleanup rather than a break, while a CALLER of it no longer type-checks. +- **`data-field-changed-event-retired`** — `api.DataEventType 'data.field.changed'` → the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after` + - Why not automatic: `data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement hit above). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673. + - Done when: No consumer subscribes to or switches on `data.field.changed`; per-field change detail is read from a `data.record.updated` event's `changes` map (with `before` / `after` for the surrounding state). Deleting the dead branch changes no observable behaviour — it never executed — so the migration is removing code that could not run, not rebuilding a capability. --- diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index f1704f966d..d50039d0f5 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -416,6 +416,13 @@ "migrationId": "data-driver-find-stream-retired", "toMajor": 17, "rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484." + }, + { + "surface": "api.DataEventType 'data.field.changed'", + "replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`", + "migrationId": "data-field-changed-event-retired", + "toMajor": 17, + "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement hit above). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673." } ], "removed": [] @@ -891,6 +898,13 @@ "migrationId": "data-driver-find-stream-retired", "toMajor": 17, "rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484." + }, + { + "surface": "api.DataEventType 'data.field.changed'", + "replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`", + "migrationId": "data-field-changed-event-retired", + "toMajor": 17, + "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement hit above). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673." } ], "removed": [] diff --git a/packages/spec/src/api/events.test.ts b/packages/spec/src/api/events.test.ts index 76624c65b0..e4d6c02d02 100644 --- a/packages/spec/src/api/events.test.ts +++ b/packages/spec/src/api/events.test.ts @@ -111,7 +111,6 @@ describe('DataEventSchema', () => { 'data.record.created', 'data.record.updated', 'data.record.deleted', - 'data.field.changed', ]); expect(() => DataEventSchema.parse({ id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a3', @@ -121,4 +120,38 @@ describe('DataEventSchema', () => { timestamp: new Date().toISOString(), })).toThrow(); }); + + // #4673 (ADR-0049 enforce-or-remove): `data.field.changed` was declared here + // with no producer anywhere in the repo. It is gone, and the enum is the only + // channel that can say so — an enum member cannot carry a retiredKey() + // fix-it prescription the way an authorable object key can. + it('no longer accepts the retired data.field.changed event name', () => { + expect(DataEventType.options).not.toContain('data.field.changed'); + expect(() => DataEventSchema.parse({ + id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a4', + type: 'data.field.changed', + object: 'account', + recordId: 'rec_1', + timestamp: new Date().toISOString(), + })).toThrow(); + }); + + // The replacement is not a different event name — it is the payload the live + // record event already carries. This pins that FROM → TO so the changeset's + // one-line fix stays true. + it('carries per-field detail on data.record.updated via changes/before/after', () => { + const event = DataEventSchema.parse({ + id: '4b4720e8-97c3-4a12-9b70-b70a3d2314a5', + type: 'data.record.updated', + object: 'account', + recordId: 'rec_1', + changes: { name: 'New Name' }, + before: { name: 'Old Name' }, + after: { name: 'New Name' }, + timestamp: new Date().toISOString(), + }); + expect(event.changes).toEqual({ name: 'New Name' }); + expect(event.before).toEqual({ name: 'Old Name' }); + expect(event.after).toEqual({ name: 'New Name' }); + }); }); diff --git a/packages/spec/src/api/events.zod.ts b/packages/spec/src/api/events.zod.ts index c01b9df4ae..78655506b7 100644 --- a/packages/spec/src/api/events.zod.ts +++ b/packages/spec/src/api/events.zod.ts @@ -63,12 +63,26 @@ export type MetadataEventType = z.infer; * * Triggered when data records are created, updated, or deleted. * Follows the pattern: `data.record.{action}` + * + * Only event names with a REAL producer are declared here — a subscriber must + * not be able to wait on something that never fires (the same rule + * `WebhookTriggerType` states for its own vocabulary). The engine's + * `publishDataEvent` emits exactly these three. + * + * REMOVED in 17.0.0 (#4673, ADR-0049 enforce-or-remove): `data.field.changed`. + * Nothing in either repository ever emitted it, and `DataEventSchema` has no + * `field` / `oldValue` / `newValue` slot to carry per-field semantics anyway — + * it is record-shaped. Per-field detail is already on the record event: a + * `data.record.updated` payload carries `changes` (the changed fields), + * `before` and `after`. A consumer that keyed on `data.field.changed` was + * keying on an event no producer sent (ADR-0078's silently-inert declaration). + * If a genuine per-field stream is ever needed it gets its own honest contract + * — the precedent #4639 set for bulk writes — rather than an empty member here. */ export const DataEventType = z.enum([ 'data.record.created', 'data.record.updated', 'data.record.deleted', - 'data.field.changed', ]); export type DataEventType = z.infer; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 341522a933..642ca68d19 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -657,7 +657,22 @@ const step17: MigrationStep = { + 'reading already recorded in `flow-retry-max-retries-required`), and the conversion writes ' + 'the pre-17 numbers into every existing `job.retryPolicy` that omitted them. Deployed ' + 'stacks therefore keep their exact behaviour; what changes is only what a NEWLY authored ' - + 'omission means.', + + 'omission means.\n\n' + + 'The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` drops ' + + '`data.field.changed` (#4673). It had no producer anywhere — the engine emits ' + + '`data.record.{created,updated,deleted}` and, since #4639, `data.records.{updated,' + + 'deleted}` — so a subscriber switching on it held a branch that could never run, and ' + + 'the `switch` still compiled, which is why an empty member could sit in a public enum ' + + 'this long. It could not have been implemented against this contract as written: ' + + '`DataEventSchema` is record-shaped and has no `field` / `oldValue` / `newValue` slot, ' + + 'so the member advertised a granularity the payload has no room for. Nothing is lost — ' + + 'per-field detail already rides on `data.record.updated` as `changes` (with `before` / ' + + '`after`), one event per write instead of N on a wide table. Like the driver contract ' + + 'above it is a runtime surface, never stored in stack metadata, so it is one semantic ' + + 'TODO for event consumers rather than a source rewrite, and it carries no tombstone: a ' + + 'removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` ' + + 'retirement noted. Should a real per-field stream ever be wanted, it earns its own ' + + 'contract on the #4639 precedent rather than reclaiming this slot.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -960,6 +975,42 @@ const step17: MigrationStep = { + 'method — one left behind still compiles and is simply never reached, so removing ' + 'it is cleanup rather than a break, while a CALLER of it no longer type-checks.', }, + { + id: 'data-field-changed-event-retired', + surface: "api.DataEventType 'data.field.changed'", + replacement: + "the `data.record.updated` event, whose payload already carries the per-field " + + 'detail: `changes` (the changed fields), plus `before` / `after`', + reason: + '`data.field.changed` was declared in `DataEventType` and emitted by nothing — the ' + + 'engine\'s `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since ' + + '#4639) `data.records.{updated,deleted}`, and no other producer exists in either ' + + 'repository. A subscriber that switched on it was waiting on an event no producer ' + + 'sends: the branch never ran, and because the surrounding `switch` still compiled, ' + + 'nothing anywhere reported the gap (ADR-0078\'s silently-inert declaration, on the ' + + 'event vocabulary). `DataEventSchema` could not have carried the semantics even if ' + + 'something had emitted it — the payload is record-shaped (`recordId`, `changes`, ' + + '`before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member ' + + 'promised a granularity the contract has no room for. Per-field detail is therefore ' + + 'not lost: it has always ridden on `data.record.updated` as `changes`, which is one ' + + 'event per write rather than N events on a wide table. This is a runtime EVENT ' + + 'surface — no stack, example or template authors an event name (webhooks subscribe ' + + 'through the separate authorable `WebhookTriggerType`, whose vocabulary was already ' + + 'trimmed to producers that exist, #3196) — so there is no source for the chain to ' + + 'rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a ' + + 'retiredKey() fix-it error the way an authorable object key can (the same limit the ' + + 'sharing-rule `full` retirement hit above). The enforced channels are tsc, which ' + + 'fails any consumer still naming the value in a `DataEventType` position, and the ' + + 'enum parse, which now rejects the name instead of accepting an event that never ' + + 'arrives. A genuine per-field stream, if one is ever wanted, gets its own honest ' + + 'contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673.', + acceptanceCriteria: + 'No consumer subscribes to or switches on `data.field.changed`; per-field change ' + + 'detail is read from a `data.record.updated` event\'s `changes` map (with `before` / ' + + '`after` for the surrounding state). Deleting the dead branch changes no observable ' + + 'behaviour — it never executed — so the migration is removing code that could not ' + + 'run, not rebuilding a capability.', + }, ], };