diff --git a/.changeset/20233-actor-hot-external-query-delete-etl-storage-apimethod-dashboard-notification-record-runtime-rls-scim-migration-guidance-tracker-free.md b/.changeset/20233-actor-hot-external-query-delete-etl-storage-apimethod-dashboard-notification-record-runtime-rls-scim-migration-guidance-tracker-free.md new file mode 100644 index 00000000000..98067568a9a --- /dev/null +++ b/.changeset/20233-actor-hot-external-query-delete-etl-storage-apimethod-dashboard-notification-record-runtime-rls-scim-migration-guidance-tracker-free.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `os migrate meta` guidance for the `actor-*`, `hot-*`, `external-*`, `query-*`, `delete-*`, `etl-*`, `storage-*`, `apimethod-*`, `dashboard-*`, `notification-*`, `record-*`, `runtime-*`, `rls-*` and `scim-*` migration entries states each lesson in words instead of citing tracker numbers + +Clause-②: no + +The ADR-0087 semantic entries of the `actor-*` family (the retired `ctx.user.roles` alias), +the `hot-*` family (the inert `'disk'` / `'distributed'` state strategies and the file-watch +placeholder), the `external-*` family (the retired external-lookup and message-queue +schemas), the `query-*` family (the retired `QueryAST` request members and aggregation +functions), the `delete-*` family (the retired by-id repoint in a `beforeDelete` hook), the +`etl-*` family (the retired ETL pipeline layer), the `storage-*` family (the retired +single-argument `IStorageService.list`), the `apimethod-*` family (the `apiMethods` enum +shrunk to six primitives), the `dashboard-*` family (the `compareTo` offset, the page-only +modal target, the chart-config structure refusal, the single-measure metric tile and the +funnel-only `stageOrder`), the `notification-*` family (the retired inbox cursor), the +`record-*` family (the object-form detail sections and the converged chatter position), the +`runtime-*` family (the retired `HttpServer` wrapper), the `rls-*` family (the refused array +comparand, cross-class field comparison and stored-list ordering in row-level predicates) +and the `scim-*` family (the retired `sys_scim_provider` object) are printed by +`os migrate meta` as the header, `why:` and `verify:` lines of a manual change. Their text +sent the reader to issue-tracker, pull-request and decision-batch numbers — some of which no +longer resolve, and some in another repository — for what a ruling, measurement or fix had +decided; it now says what was decided, in the sentence being read. ADR ids are kept. One +entry of another family is corrected in the same way: `rest-api-endpoint-handler-status-retired` +now names the API skill, whose factual sweep corrected the `handlerStatus` sentence, instead +of the automation skill. + +Text only: no entry id, `from` / `to`, conversion or matching logic changes, and the chain +rewrites exactly what it rewrote before. No `surface` changes. The generated migration +registry, `spec-changes.json` and the protocol upgrade guide carry the same text. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 40042b74961..ec40cd6b949 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -219,7 +219,7 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 ("C skeleton + A semantics": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook session's `tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087. - Done when: No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` and observes the same array (the rename is a rename — the VALUE is `ExecutionContext.positions` on both sides, which the runtime pin `action-session-shape-contract.test.ts` asserts independently of the key name). Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Verify against a real dispatch, not a fixture: invoke an action as a caller holding positions and assert the body observed them under the canonical key. During the window both keys are present and equal, so a reader can be migrated and verified before the alias is removed; after it, `roles` is absent and a body still reading it sees `undefined` — which is why the read must be moved inside the window rather than at its close. - **`actor-user-roles-to-positions`** — `action body / AI route: ctx.user.roles (req.user.roles)` → ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions - - Why not automatic: The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was "kept for the REST/AI shapes", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048). + - Why not automatic: The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation window, no dual-emit, the alias simply gone in 17. ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit. Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema` once it had no producer and no consumer left) and unlike `ActionSessionSchema` (declared contract-first, as it stood, as the first stage of the session rename, precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (no caller) / `IStorageService.list` (no consumer) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was "kept for the REST/AI shapes", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins the removal flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087. - Done when: No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; every such read is `.positions` and observes the SAME array — the value was `ExecutionContext.positions` on both sides, so this is a pure key rename and no value has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed one silently sees `undefined` — move the read AS you upgrade, not after it. Verify against a real dispatch rather than a fixture: invoke an action (and an AI route) as a caller holding positions, assert the body observed them under the canonical key, and assert the old key is ABSENT by key existence (`'roles' in ctx.user === false`) rather than by `undefined`, which cannot tell a removed key from one left behind holding nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way. - **`aggregation-node-distinct-retired`** — `data.query.aggregations[].distinct` → the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data - Why not automatic: A DIVERGENCE, not an inert declaration — which is why it outlived the #4286 sweep that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the #6203 / #5907 divergences closed on the same axis, the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them then frozen under #5499, a freeze lifted 2026-08-11, after this ruling — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the #4286 disposition for `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049, #6815. @@ -234,8 +234,8 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot: `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, and its declared endpoints serve live). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` takes. Consequently `gateApiDraftsForPublish` is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if the Studio metadata-coverage work promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. The same refusal closes the direct-active write too, which had been a third path past the endpoint namespace and duplicate-path gates. ADR-0049 / ADR-0121. - Done when: No caller creates or updates an `api` item through the runtime metadata API. `PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` as well as direct-active, because the gate runs before the draft/publish branch and does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole point of the change: a stack declaring `apis:` still compiles, still passes `validateApiEndpointDeclarations` at publish (`publishPackage`) and at load (`buildEndpointIndex`), and its endpoints still SERVE — that route was always the only one that served. An operator who genuinely needs the runtime door back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and the endpoint still will not be served, which is why it is a diagnostic and not a workaround. Any `api` rows already sitting in `sys_metadata` from before this change were never served either; they can be deleted (`deleteMetaItem` is deliberately not gated by this refusal, so repair stays possible). - **`apimethod-enum-shrink`** — `data.object.enable.apiMethods (the eight legacy non-primitive values)` → the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six - - Why not automatic: The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired in #2377, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of #3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350). - - Done when: No authored `enable.apiMethods` array names a legacy value; `objectstack validate` passes. Run the reporter codemod first and read its widening flags before applying anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For every object where `history` became `get` or `search` became `list`, confirm the broader operation is one the API should genuinely expose; where it is not, the answer is not a different value in this enum but a permission set that withholds the operation. Where the six primitives are all present, prefer deleting the key: that is equivalent to default-open and it tracks future primitives, whereas a hand-listed six silently stops granting anything added later. `restore` / `purge` are deleted with no replacement — if trash-like behaviour was being relied on, that capability left in #2377 and this entry is not where it returns. + - Why not automatic: The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the enum shrink (phase 2 of the programme that made UI action buttons agree with the `apiMethods` allowlist) predates the gate that makes a breaking changeset state its ledger disposition. ADR-0087. + - Done when: No authored `enable.apiMethods` array names a legacy value; `objectstack validate` passes. Run the reporter codemod first and read its widening flags before applying anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For every object where `history` became `get` or `search` became `list`, confirm the broader operation is one the API should genuinely expose; where it is not, the answer is not a different value in this enum but a permission set that withholds the operation. Where the six primitives are all present, prefer deleting the key: that is equivalent to default-open and it tracks future primitives, whereas a hand-listed six silently stops granting anything added later. `restore` / `purge` are deleted with no replacement — if trash-like behaviour was being relied on, that capability left in the 11.0 dead-property removal and this entry is not where it returns. - **`approval-escalation-enabled-default-flip`** — `automation.ApprovalEscalation.enabled — an OMITTED value inside an approval node's escalation block` → nothing, for the common intent (escalate on timeout): an escalation block carrying timeoutHours is live by default. To declare an SLA OFF while keeping its configuration, write enabled: false explicitly — which is now the spelling the escalation sweep actually reads - Why not automatic: A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (#12278, maintainer ruling 2026-08-27) — the same category as protocol 17's `import-run-automations-declared-default-corrected`: the schema promised `enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positive `timeoutHours` escalated, and with `action: 'auto_approve'` that silently approved requests their author had declared off the clock. The flip moves the default to `true` and, in the same change, the sweep starts honouring an explicit `enabled: false`. The feature-level switch is whether an `escalation` block exists at all; within a block carrying `timeoutHours`, escalation is on unless explicitly turned off. Deployed metadata that OMITS `enabled` does not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializes `true`). Stored request snapshots written before the flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses config through the old schema before snapshotting), so the sweep keeps a read-side legacy window keyed on the snapshot's `created_at`: pre-flip snapshots keep escalating exactly as they do today, and the window retires itself as those pending requests drain. What DOES change is that an explicit `enabled: false` finally binds — a flow that authored it (e.g. the console toggle switched off after a timeout was set) stops escalating on requests opened after the upgrade, which is the declared intent being honoured. - Done when: A flow whose approval node omits `enabled` inside `escalation` still escalates on timeout (no metadata edit needed). A flow that writes `enabled: false` stops escalating for newly opened requests — verify one such request stays pending past its `timeoutHours` with no `escalate` audit row and no auto-decision. Requests opened BEFORE the upgrade keep their pre-upgrade behaviour (they escalate) regardless of the stored `enabled` bit. Clients that parse metadata through the published JSON Schema now materialize `enabled: true` where they materialized `false`; a client that needs the SLA off must write it explicitly. @@ -264,7 +264,7 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (#7990). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename. - Done when: Every authored connector entry parses through `DeclarativeConnectorEntrySchema`; no authored entry carries a non-`none` `authentication`; formerly inline credentials are reachable through `credentialRef` resolution and the connector still materializes. - **`dashboard-widget-compareto-offset`** — `dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')` → compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter` - - Why not automatic: The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform. + - Why not automatic: The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, taking the widget down; the arm ever only ran on the legacy inline chart path (measured when all three declared arms were found dead on the dataset path: two silently dropped, this one throwing). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform. - Done when: No dashboard widget declares `compareTo.offset`. Each former offset comparison states its window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` (or `'previousYear'`), and `dimension` is named wherever the selection dates more than one time dimension. `objectstack validate` passes, and each affected widget renders a `__compare` column over the window its author intended. - **`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. @@ -291,13 +291,13 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call. - Done when: You have READ every entry of every `apis:` block, not just the ones that fail to publish. Concretely: (1) each declared `path` is `/api/v1/apps//` and the stack declares that `manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is one you INTEND to be reachable without a session, and each carries `rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not intended to be anonymous have the key removed so the safe default (`true`) applies; (3) `objectstack validate` passes, which also proves no endpoint declares a shape 17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an `object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, `inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and (4) after publishing, each endpoint answers as you expect — an anonymous request to a session-only endpoint returns 401 rather than data. - **`delete-by-id-before-hook-repoint-retired`** — `a `beforeDelete` handler on a BY-ID `delete()` assigning `ctx.input.id` a DIFFERENT id, to move the delete onto that row` → delete the other row explicitly — `ctx.ql.delete(object, otherId)` / `ctx.api` — and let the addressed delete proceed or `throw` from the handler to stop it; to delete MANY rows, have the CALLER pass `{ multi: true, where: … }`. Writing the SAME id back is unaffected and stays legal. - - Why not automatic: The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table still answering differently: it HONOURED a repoint, re-resolving the new target by re-reading its pre-image and rebinding `previous` (#5272), so `afterDelete` and the roll-up recompute saw the row actually deleted. It now refuses with `HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, exactly as the `update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did. + - Why not automatic: The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table still answering differently: it HONOURED a repoint, re-resolving the new target by re-reading its pre-image and rebinding `previous` (the fix that first made a single-row delete bind `previous` at all), so `afterDelete` and the roll-up recompute saw the row actually deleted. It now refuses with `HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, exactly as the `update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did. -Read this as a RULING, not a defect report — that distinction is the reason the entry is worth its length. #5272's re-resolution was internally CORRECT and nothing stale ever leaked from it; the case that retires a rebind on `update()` (the write landing on a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) simply did not apply to it. #5574's engine half (PR #6697) therefore left the asymmetry standing on purpose rather than folding a behaviour removal into an ordering change, and filed it as #6752. The 2026-08-09 maintainer ruling on that card closed it on three measured axes instead: compatibility cost zero (a repository-wide grep for assignments into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL SIX are this family's own pins — no consumer anywhere repoints); one rule across both verbs beats two individually-correct rules an author has to memorize, since the justification for the split lived in an ADR rather than at the call site; and "a hook silently redirects which row gets deleted" is a top-grade footgun for authored — especially AI-authored — handlers however correctly the redirect is implemented. Correctness of a mechanism does not justify the surface it exposes. Aligning the other way, by building `update()` the same re-resolution, stays excluded by #5574's own recorded ruling ("do not silently pick re-resolution instead"). +Read this as a RULING, not a defect report — that distinction is the reason the entry is worth its length. That re-resolution was internally CORRECT and nothing stale ever leaked from it; the case that retires a rebind on `update()` (the write landing on a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) simply did not apply to it. The engine change that dispatches `before*` hooks per matched row on a bulk write therefore left the asymmetry standing on purpose rather than folding a behaviour removal into an ordering change, and filed it as a finding of its own. The 2026-08-09 maintainer ruling on that finding closed it on three measured axes instead: compatibility cost zero (a repository-wide grep for assignments into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL SIX are this family's own pins — no consumer anywhere repoints); one rule across both verbs beats two individually-correct rules an author has to memorize, since the justification for the split lived in an ADR rather than at the call site; and "a hook silently redirects which row gets deleted" is a top-grade footgun for authored — especially AI-authored — handlers however correctly the redirect is implemented. Correctness of a mechanism does not justify the surface it exposes. Aligning the other way, by building `update()` the same re-resolution, stays excluded by the recorded ruling that extended per-row hook semantics to `before*` hooks on bulk writes ("do not silently pick re-resolution instead"). Why this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as `hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at this step: FIRST, there is no source to convert — a `HookContext` is constructed per write and never persisted, so no `sys_metadata` row, example or template can carry the assignment. SECOND, the only place it is ever SPELLED is inside a handler body: author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative transform cannot safely rewrite an assignment inside free-form code, and the intent is not recoverable anyway — only the author knows whether the repoint meant "delete that row INSTEAD" or "delete that row TOO". -What makes this one cheaper to meet than its two siblings, and worth saying because it bounds the work: the removed capability has an ENFORCED channel at run time. The refusal throws before anything is written and its message NAMES the retired capability and the three replacement routes, so a handler that still repoints fails loudly and self-describingly on its first execution rather than going quiet. This ledger entry is the channel that reaches an upgrader BEFORE that first execution. #6752, #5272, #5574, PR #6697, ADR-0058 Amendment II.2. +What makes this one cheaper to meet than its two siblings, and worth saying because it bounds the work: the removed capability has an ENFORCED channel at run time. The refusal throws before anything is written and its message NAMES the retired capability and the three replacement routes, so a handler that still repoints fails loudly and self-describingly on its first execution rather than going quiet. This ledger entry is the channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2. - Done when: No `beforeDelete` handler assigns `ctx.input.id` anything but the id it arrived with — grep handler bodies for assignments into `input.id` and rewrite each into an explicit `ctx.ql.delete()` for the other row, a caller-side `{ multi: true, where: … }`, or a `throw`. A delete-heavy smoke run completes with no `HookTargetRebindError` (`ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, `event: 'beforeDelete'`) — and any that does raise names its `expectedId` and `observedId`, which identifies the handler that moved the target. - **`driver-aggregate-undeclared-key-aliases-removed`** — `driver aggregate() call argument — query.aggregate and aggregations[].func` → query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared - Why not automatic: `SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. "Never declared" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — the family of the org-axis red-line gate that read only rejected aliases while its own fixtures spelt them, so its tests stayed green and the rule stayed dead: a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (`IDataDriver.findStream`, removed with no tombstone because nothing parses a driver object), `storage-service-list-retired` (the zero-consumer `IStorageService.list`, whose two adapters answered differently and both incompletely) and `actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the maintainer's word rather than given a window). The removal ran in a fixed order — the fixtures re-spelt first, the two alias branches deleted second, the parameter narrowed to `DriverQuery` last — because the reverse order yields red nobody can explain. ADR-0049 / ADR-0087. @@ -343,8 +343,8 @@ ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely - **`enhanced-api-error-field-errors-renamed`** — `api.enhancedApiError.fieldErrors` → fields - Why not automatic: The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977. - Done when: No consumer reads `error.fieldErrors`; per-field validation detail is read from `error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse with the rename prescription instead of silently losing the array. -- **`etl-pipeline-layer-retired`** — `automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)` → (removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync. `AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the parsed definition; nothing reads `syncConfig` back off it, and the key has no reader outside `packages/spec` at all — the same measurement that retired `syncConfig.schedule` in 18 under ADR-0049 (#16320). What the platform DOES execute on a connector is its `actions`: a flow's `connector_action` node resolves the registered handler and awaits it, so an author who needs data actually moved drives it from there. Per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second) - - Why not automatic: The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the #4962 retry-vocabulary entry, absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement (#4738) and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the #4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078, #6414. +- **`etl-pipeline-layer-retired`** — `automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)` → (removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync. `AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the parsed definition; nothing reads `syncConfig` back off it, and the key has no reader outside `packages/spec` at all — the same measurement that retired `syncConfig.schedule` in 18 under ADR-0049, with the other cron-typed positions nothing reads. What the platform DOES execute on a connector is its `actions`: a flow's `connector_action` node resolves the registered handler and awaits it, so an author who needs data actually moved drives it from there. Per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second) + - Why not automatic: The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its automation copy deleted as dead), re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the retry convergence had not covered), absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the `activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an earlier tombstone go with the shape that carried it: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078. - Done when: No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, `ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, `ETLTransformation(Schema)`, `ETLEndpointType(Schema)`, `ETLTransformationType(Schema)`, `ETLSyncMode(Schema)`, `ETLRunStatus(Schema)` or the `ETL` factory from `@objectstack/spec/automation`; `tsc` reports TS2724/TS2305 on any that survives. Every author who was pointed at L2 has been re-pointed by name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommends `ETLPipeline` as L1's destination and no longer advertises a transformation-type table. The surviving layers still parse unchanged — a connector declaring `syncConfig` and an import declaring `mapping.transform` both behave exactly as they did in 16.x. - **`export-axis-opt-in`** — `security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key)` → an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every permission set whose holders are meant to keep exporting - Why not automatic: A secure-default FLIP, not a shape change — the same class as `rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and `action-descriptor-resume-authority-default-flip`, and it is registered for the same reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset inherited read; from 17 it denies. Reading a record and taking a bulk machine-readable copy of the whole table are different privileges — Salesforce "Export Reports", Dynamics "Export to Excel", NetSuite "Export Lists" and SAP `S_GUI` 61 all separate them — and the axis now says so. This cannot be a mechanical conversion in either direction: writing `allowExport: true` wherever the key is absent would preserve today's behaviour while silently defeating the entire point of the flip, and writing `false` would revoke a capability the deployment may legitimately want. Whether a given set's holders SHOULD be able to take a bulk copy is exactly the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Two details decide who is actually affected: package-shipped sets are re-seeded on upgrade, so the built-ins are handled — at 17.0.0 `admin_full_access` and `organization_admin` carried the grant explicitly, ON A `*` WILDCARD, and protocol 18 REMOVES it (see `admin-export-wildcard-removed`: the wildcard made the axis undeniable for an org admin, so from 18 an admin exports only what an app set grants — do not read this clause as a standing promise that the built-ins keep exporting) — while ENVIRONMENT-AUTHORED sets are not and must be edited by hand. `member_default` deliberately does NOT carry the grant, so ordinary authenticated users lose export until an admin grants it; that is the point of the flip, not an oversight. Merge semantics are unchanged and most-permissive, exactly like the CRUD bits: any set granting `true` grants export, and `false` is authoring intent rather than a veto, because permission sets are additive capability containers (ADR-0090). The super-user bits no longer confer it: `viewAllRecords` / `modifyAllRecords` are "may see all data", not "may take a bulk copy". Registered (backfilled) by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the export axis, and its extension to the CSV attachments scheduled reports mail out, both predate the gate that makes a breaking changeset state its ADR-0087 disposition. ADR-0087. @@ -353,7 +353,7 @@ ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely - Why not automatic: ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, added when the dry run was found skipping the field-level validation the real write ran) — and the maintainer's 2026-08-06 ruling D (a validate-only protocol operation, so the dry run's prediction is the engine's verdict by construction) retired that mirror: the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` / `IStorageService.list` / `actor-user-roles-to-positions` disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087; this is the removal the dry-run change deliberately deferred to a sweep of its own. - Done when: No code of yours reads any of the eight off a `buildFieldMetaMap` / `prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / `.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an `ExportFieldMeta`-typed value; each hit moves to the object schema you already passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, so an untyped or `any`-typed read compiles clean and silently becomes `undefined` — assert that the constraint your code acts on is still observed on a real import, not merely that the build is green. Note `hasDefault` has no one-to-one replacement key: it was the derived predicate `defaultValue != null`, mirroring the engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply that same `!= null` test yourself. - **`external-lookup-message-queue-families-retired`** — `data.externalLookup / data.externalDataSource / data.externalFieldMapping (the whole of data/external-lookup.zod.ts — 3 defs, 8 exported names) and system.messageQueue (the whole of system/message-queue.zod.ts — MessageQueueConfig, MessageQueueProvider, TopicConfig, ConsumerConfig, DeadLetterQueue — 5 defs, 14 exported names)` → (removed — there is no replacement key, because there was never a key: neither family was reachable from any metadata-type binding, stack collection or /meta door, so no document could carry either. For external data: `object.external` (`ObjectExternalBindingSchema`, ADR-0015/0062) names a datasource by reference and connection credentials live in the datasource config — never inline in object metadata; `data/external-catalog.zod.ts` is that federated path's catalog surface and is untouched. For message queues: the LIVE surface is `kernel/events/integrations.zod.ts`'s `EventMessageQueueConfig` (`EventBusConfig.messageQueue`), which deliberately carries NO credential field — broker connection and SASL credentials are runtime deployment configuration, not authorable metadata. Either capability returns via the ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin service first, the vocabulary second) - - Why not automatic: Both families are the #8075 census verdict (fork (b), accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `"clientSecret": "..."` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the #7990 class (cleartext-at-rest credential sinks), except that unlike #7990's two measured surfaces nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (#3950: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the #4834 / #4988 / #5055 / #6486 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The #5552 `data/ExternalFieldMapping:transform` tombstone (one of that retirement's three spellings) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with the #5552 prescription. ⚠️ The #7990 Option-B reopen trigger ("a third measured artefact-type surface") is NOT met by this census — that ruling's parked class-level write-boundary guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed. + - Why not automatic: Both families are the verdict of the 2026-08-12 census of spec schemas that permit inline credentials (fork (b): no `sys_metadata` door reaches them; accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `"clientSecret": "..."` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the class of the `sys_metadata` cleartext-sink finding (cleartext-at-rest credential sinks), except that unlike its two measured surfaces (driver config and connector `authentication`) nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (the lesson of the plugin sandboxing config that was never wired to anything: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the shape of the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs, the widget / i18n shapes and the sweep of five declared-but-inert surfaces — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The `data/ExternalFieldMapping:transform` tombstone of the field-mapping transform retirement (one of that retirement's three spellings; the whole transform union left because no runtime ever executed any of its five members) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with that retirement's prescription. ⚠️ The reopen trigger of the maintainer's 2026-08-12 ruling on the cleartext sink — it closed each artefact's contract (Option A) and parked the class-level `sys_metadata` write-boundary guard (Option B) until "a third measured artefact-type surface" — is NOT met by this census: that guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed. - Done when: No code imports `ExternalLookup(Schema|Parsed)`, `ExternalDataSource(Schema)`, `ExternalFieldMapping(Schema|Parsed)`, `MessageQueueConfig(Schema|Parsed)`, `MessageQueueProvider(Schema)`, `TopicConfig(Schema|Parsed)`, `ConsumerConfig(Schema|Parsed)` or `DeadLetterQueue(Schema|Parsed)` from `@objectstack/spec`, `@objectstack/spec/data` or `@objectstack/spec/system` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `data/external-lookup-retirement.test.ts` and `system/message-queue-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these shapes. `kernel/EventMessageQueueConfig` (with its inline provider enum and no credential key), `data/external-catalog.zod.ts`, `object.external` and `kernel/DeadLetterQueueEntry` survive unchanged. - **`field-runtime-create-withdrawn`** — `PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)` → Author the field inside its object and write the whole object — PUT /api/v1/meta/object/{object} with the new field in `fields` — or declare it in the object source (`**/*.object.ts`) and redeploy - Why not automatic: The `field` registry entry declared `allowRuntimeCreate: true` and the platform never built a read path for it. Measured end-to-end through the real HttpDispatcher -> ObjectStackProtocolImplementation -> SysMetadataRepository: `PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with {"success":true,"state":"active","message":"Saved field …"}, the row persisted, and `GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with zz_probe ABSENT — forever. The row is even self-readable by name (`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which makes it well-formed and universally inert rather than malformed. The seam is that `field` is the ONE declared type with no standalone existence: fields are authored inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ('field','.'), and nothing composes fragment rows into their parent — `applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` (`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 rather than build the read path, which is a feature spanning at least three packages (a composition step that does not exist, ~20 `gate.fields` call sites, physical schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a separate card — implementation first, declaration second. ⚠️ This is NOT the `api` withdrawal's rationale reused (`api-runtime-create-withdrawn`): that ruling rested on "zero business pull", and "add a field" is the opposite — a core Studio/CRM operation. The justification here is that the operation REMAINS AVAILABLE on the route that actually composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, broken SPELLING of adding a field, not the ability to add one. There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and no authored source changes — an `**/*.object.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `api` and `BatchOptions.validateOnly` take. ADR-0049 / ADR-0087. @@ -387,8 +387,8 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call. - Done when: Every job declaring `retryPolicy` parses: no `maxRetries` above 10 and no `backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a retry re-runs the handler with its writes and callouts. No job fails to register with the retry-policy bound prescription. - **`notification-list-cursor-retired`** — `api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)` → a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed - - Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (#3899 wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361. - - Done when: No caller sends `cursor` to `GET /api/v1/notifications` and no SDK call site passes it: `client.notifications.list({ cursor })` is a `tsc` error (TS2353, excess property), which is the enforced channel — the removal is loud at compile time for every TypeScript consumer. Reading `response.cursor` no longer type-checks either, and always answered `undefined` before. ⚠️ Behaviour on the wire is deliberately UNCHANGED and must be verified as such: a request still carrying `?cursor=…` is IGNORED, not refused — the domain reads three named query keys and no route validates this query against a schema, so an unknown key has never produced a 400 and does not start doing so here. The declaration stopped promising what the wire never did; the wire did not change. `unreadCount` is untouched (#6363) and still reports the total across the whole matching inbox rather than the window. A caller that omitted `limit` receives the same 50 rows it always received. + - Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with the repair that made `unreadCount` really count the whole inbox). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (the fix for request bodies never checked against their declared schemas wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (the silent strip measured when a field key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot where a default or constraint change on an authorable key was recorded by no gate), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078. + - Done when: No caller sends `cursor` to `GET /api/v1/notifications` and no SDK call site passes it: `client.notifications.list({ cursor })` is a `tsc` error (TS2353, excess property), which is the enforced channel — the removal is loud at compile time for every TypeScript consumer. Reading `response.cursor` no longer type-checks either, and always answered `undefined` before. ⚠️ Behaviour on the wire is deliberately UNCHANGED and must be verified as such: a request still carrying `?cursor=…` is IGNORED, not refused — the domain reads three named query keys and no route validates this query against a schema, so an unknown key has never produced a 400 and does not start doing so here. The declaration stopped promising what the wire never did; the wire did not change. `unreadCount` is untouched (it was the jointly ruled repair's business) and still reports the total across the whole matching inbox rather than the window. A caller that omitted `limit` receives the same 50 rows it always received. - **`package-uninstall-explicit-all-tenants`** — `protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)` → explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it - Why not automatic: An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's, while uninstall's orphaned-row defect was being repaired. That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant "this tenant" or "every tenant" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) takes for its own default flip. - Done when: Every caller of `deletePackage` states its tenant scope. A caller that intends an environment-wide uninstall passes `allTenants: true`; a caller that intends a scoped one passes `organizationId`; no caller passes both. An explicit `allTenants: false` is treated as undeclared and refused, since it is not an affirmative request for cross-tenant semantics. Verify the refusal is not merely absorbed: a 400 `TENANT_SCOPE_REQUIRED` reaching a deploy script that previously "succeeded" means that script was relying on the cross-tenant reading and must now say so on purpose. The org-scoped path is unchanged — an uninstall carrying an `organizationId` still removes that org's rows AND the environment-wide (`organization_id IS NULL`) rows, exactly as the orphaned-row repair left it. @@ -405,31 +405,31 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: Maintainer ruling 2026-08-20 (#9885), ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare. - Done when: No authored stack seeds `permissions` on a sys_position record, and no client write to that table carries the key. Concretely: (1) grep your stack sources for permissions next to sys_position — delete the key from any seed row; prose that was documenting intent belongs in `description`. (2) Boot and load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD naming the column — that refusal is the enforced channel, not a silent drop. (3) If you meant to grant capability, author it where it is enforced: bind permission sets to the position (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder) — the authz resolver then expands the bindings from the position name at request time. - **`query-array-string-agg-retired`** — `data.query.aggregations[].function ('array_agg' / 'string_agg')` → an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared - - Why not automatic: The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188. + - Why not automatic: The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and the maintainer's 2026-08-05 investment freeze on driver-memory and driver-mongodb had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049. - Done when: No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style roll-ups are assembled by the caller from an ordinary `fields` query, or materialised as a stored field. A query still carrying either value fails to parse with the removal prescription naming it, and authoring it is a `tsc` error at the call site; `count_distinct` continues to parse and is unaffected. - **`query-cursor-retired`** — `data.query.cursor` → a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern) - - Why not automatic: The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping "until hasMore is false" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286. + - Why not automatic: The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping "until hasMore is false" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078. - Done when: No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep pagination expresses the keyset as a `where` predicate on the sort key. A query still carrying `cursor` fails to parse with the removal prescription, and authoring it is a `tsc` error. - **`query-distinct-retired`** — `data.query.distinct` → `groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values - - Why not automatic: The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that "confirmed" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286. + - Why not automatic: The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that "confirmed" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078. - Done when: No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; deduplication goes through `groupBy` / `count_distinct` / the drivers' `distinct()` door. A query still carrying the key fails to parse with the removal prescription, and the REST list response reports a real `total` for queries that used to send it. -- **`query-field-node-object-form-retired`** — `data.query.fields` → expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924) - - Why not automatic: The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196. +- **`query-field-node-object-form-retired`** — `data.query.fields` → expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD` — refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by + - Why not automatic: The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078. - Done when: No caller puts an object in `fields[]`; related records AND single related columns are read through `expand`, with the foreign-key column retained in the projection so expansion has something to resolve. A `fields` entry that is not a string fails to parse with the removal prescription, and the list/query/export routes answer 400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`. -- **`query-joins-retired`** — `data.query.joins` → expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924) - - Why not automatic: The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286. +- **`query-joins-retired`** — `data.query.joins` → expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD` — refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by + - Why not automatic: The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078. - Done when: No caller sends `joins`; related records AND single related columns are read through `expand`, with the foreign-key column retained in the projection so expansion has something to resolve. A query that still carries `joins` fails to parse with the removal prescription (even as an empty array), and authoring it is a `tsc` error at the call site. - **`query-window-functions-retired`** — `data.query.windowFunctions` → `aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource - - Why not automatic: The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286. + - Why not automatic: The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078. - Done when: No caller sends `windowFunctions` in a query; request-level analytics use `aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL driver's `findWithWindowFunctions` door directly. A query that still carries the key fails to parse with the removal prescription naming that door. - **`record-details-sections-object-form`** — `ui.RecordDetailsProps.sections (the `record:details` page component)` → an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the heading translatable, and the new sibling `hideFields` omits named fields from the body - - Why not automatic: `record:details` declared `sections` as a list of section IDs — `["overview", "financials"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered by the #6350 stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired by #6350's neighbour — carries a tombstone while this face carried none. ADR-0087, #5611 (backfilled #6350). + - Why not automatic: `record:details` declared `sections` as a list of section IDs — `["overview", "financials"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the change that declared the object form predates the gate that makes a breaking changeset state its ledger disposition, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired in a neighbouring change — carries a tombstone while this face carried none. ADR-0087. - Done when: Every `record:details` component in authored metadata spells `sections` as an object array: each entry names the fields it renders (`fields: [...]`), optionally with `label` / `name` / `columns`. `objectstack validate` passes and each detail page renders the same sections, in the same order, with the same fields as before the upgrade — a page whose sections silently render EMPTY is the signature of an ID list left in place. A section that existed only as an ID, with no field list recoverable from the page it belonged to, is a judgement for the author: name the fields it was meant to show, or delete the entry. Fields previously hidden by a convention outside the schema move onto the declared `hideFields`. - **`rest-server-openapi31-block-removed`** — `restServer.openApi31` → (removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR) - Why not automatic: The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector webhook and event enums one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently. - Done when: No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` `restConfig`) carries `openApi31` — a config that includes it now fails the parse with the retirement prescription instead of being silently stripped. No code imports `OpenApi31Extensions(Schema)`, `Callback(Schema)` or `OpenApiWebhookEvent(Schema)` from `@objectstack/spec/api` (TS2305 after upgrade). The served /openapi.json is byte-identical before and after — the block never reached it. - **`runtime-httpserver-wrapper-retired`** — `runtime.HttpServer (the exported delegating wrapper class)` → register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — `HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one - - Why not automatic: `@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === "function"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). Registered by the #6350 stock reconciliation, not by the original change: #5122 landed before the #6148 completeness gate existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350). + - Why not automatic: `@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === "function"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — the ONLY entry path for declarative `apis:` endpoints since publish stopped refusing them and 17 began executing them — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` and `data-driver-find-stream-retired`. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger, not by the original change: the wrapper's removal landed before the gate that makes a breaking changeset state its ledger disposition existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087. - Done when: No code constructs `new HttpServer(...)` from `@objectstack/runtime`, and no import of the name resolves — the export is gone, so a typed caller fails to compile at the construction site. A host that was wrapping an adapter registers the ADAPTER INSTANCE as `http.server` instead, and then proves the capability came back: `getPort()` returns the real bound port after `listen(0)`, `getRawApp()` returns the framework-native app, and a declarative `apis:` endpoint declared in metadata answers its route rather than 404 — the last of which is the failure a wrapper produced silently. An adapter that genuinely needs to intercept calls implements `IHttpServer` in full, forwarding the optional members too, rather than declaring `implements` and dropping them. - **`sharing-execution-context-retired`** — `@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name` → `ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the sharing, approval and report contracts have declared since they converged onto it. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value - Why not automatic: ADR-0049 enforce-or-remove, completing the maintainer's ruling of 2026-08-07 on the share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin, which that same ruling moved onto the whole context: nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). One change converged the contracts, two more re-annotated the four implementations (sharing and audit, then approvals and reports), and this change removes the now-unreferenced declaration, the deletion that split had deferred. Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087. @@ -443,9 +443,9 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - **`spec-type-alias-input-suffix-retired`** — `type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)` → the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes. - Why not automatic: This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279). - Done when: No source imports a name ending `Input` from `@objectstack/spec` except the nine listed above: `rg "\b\w+Input\b" --type ts` over consumer code resolves only to those. A literal annotated with a bare spec type compiles while listing ONLY the keys the author means — `const c: Connector = { name, label, type }` type-checks, which it did not in 16.x — and a value read out of `XSchema.parse()` annotated with the bare name no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal that the annotation should be `XParsed`. `pnpm check:spec-parsed-alias` reports every bare alias as `z.input` and refuses both a bare `z.infer` alias and a reintroduced `XInput` synonym. -- **`storage-service-list-retired`** — `contracts.IStorageService.list` → track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781 - - Why not automatic: `list(prefix)` was an OPTIONAL contract method documented as "List files in a directory/prefix", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266). - - Done when: No code calls `storage.list(...)` on the `file-storage` service or on any `IStorageService` value. Code that needed "which files are under this prefix" reads the records it wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side: `storage.list(...)` no longer type-checks, and a PROXY typed against `IStorageService` that forwards to `inner.list` is exactly such a caller — the one in `@objectstack/service-storage` goes with the adapters (#5541). ⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the RESERVED route in the paragraph above was taken. `list` exists again on the contract, cursor-shaped — `list(prefix, { cursor, limit })` returning `{ items, nextCursor }` — because cloud had two first-party callers this repo could not see when the measurement said "nothing calls it" (tenant attachment reclamation, marketplace snapshot GC). This does NOT un-retire anything and the acceptance criterion above is unchanged for what it actually governs: the single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written against it still fails to compile, and the two dialects it had are now pinned against each other in `storage-adapter-list.conformance.test.ts` rather than left to diverge. What changed for an upgrader is only the destination: prefer the records you wrote, and reach for the restored member when there are none. +- **`storage-service-list-retired`** — `contracts.IStorageService.list` → track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored since, once cloud proved to be the first-party caller this repository could not see + - Why not automatic: `list(prefix)` was an OPTIONAL contract method documented as "List files in a directory/prefix", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. The email plugin's large-attachment storage work was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two dialects): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired`. ADR-0049 / ADR-0087. + - Done when: No code calls `storage.list(...)` on the `file-storage` service or on any `IStorageService` value. Code that needed "which files are under this prefix" reads the records it wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side: `storage.list(...)` no longer type-checks, and a PROXY typed against `IStorageService` that forwards to `inner.list` is exactly such a caller — the one in `@objectstack/service-storage` goes with the adapters' own `list` implementations, removed in the retirement's implementation half. ⚠️ AMENDED 2026-08-09, under the maintainer's 2026-08-08 ruling on cloud's storage-enumeration callers (option B: restore enumeration upstream, correctly shaped, rather than hand-roll S3 pagination one repository over): the RESERVED route in the paragraph above was taken. `list` exists again on the contract, cursor-shaped — `list(prefix, { cursor, limit })` returning `{ items, nextCursor }` — because cloud had two first-party callers this repo could not see when the measurement said "nothing calls it" (tenant attachment reclamation, marketplace snapshot GC). This does NOT un-retire anything and the acceptance criterion above is unchanged for what it actually governs: the single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written against it still fails to compile, and the two dialects it had are now pinned against each other in `storage-adapter-list.conformance.test.ts` rather than left to diverge. What changed for an upgrader is only the destination: prefer the records you wrote, and reach for the restored member when there are none. - **`tool-requires-confirmation-retired`** — `ai.tool.requiresConfirmation` → put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the flag the platform confirmation CONTRACT is written against, and that contract is ENFORCED. An AI-facing call on an action declaring the flag must carry the confirmation member `confirm: true` on the request and is REFUSED without it with `ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked, and a refused call did not run — no record was read and none was written. ⚠ Two bounds: the enforced set is the doors that enforce the author's `ai.exposed` opt-in, today the action door reached from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved - Why not automatic: `ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350). - Done when: No tool definition carries `requiresConfirmation`; the key now raises a located parse error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The load-bearing half is what happens NEXT, and no gate can check it for you: for every tool that carried the flag, decide whether that operation genuinely needs a human in the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: true`, which is what the confirmation contract (#16293) gates on — and that gate is PERFORMED: invoking the operation over an AI-exposed door without the confirmation member is REFUSED with `ACTION_CONFIRMATION_REQUIRED` (428) and nothing runs, so that call is a real check you can make rather than a destructive experiment. ⚠ Two bounds on what it proves: the enforced set is the doors that enforce the author's `ai.exposed` opt-in — today the action door reached from the MCP `run_action` tool — while REST `/actions` is not `ai.exposed`-gated and sits outside the gate, so an agent holding an API key on that route is still yours to put a human in front of; and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved. The decision above is still the one this criterion asks you to make. If the operation does not need a human, delete the key knowingly. Deleting it without that decision leaves exactly the state the retirement exists to end: a destructive tool nobody is approving, now without even the false flag to show that somebody once meant to. diff --git a/packages/cli/test/migrate-meta-engine-guidance.test.ts b/packages/cli/test/migrate-meta-engine-guidance.test.ts index 302be41171f..cdcab9bd198 100644 --- a/packages/cli/test/migrate-meta-engine-guidance.test.ts +++ b/packages/cli/test/migrate-meta-engine-guidance.test.ts @@ -6,8 +6,10 @@ * `kernel-*`, `system-*`, `datasource-*`, `filter-*`, `action-*`, `data-*`, * `element-*`, `field-*`, `export-*`, `api-*`, `dataset-*`, `hook-*`, * `metadata-*`, `rest-*`, `analytics-*`, `view-*`, `package-*`, `object-*`, - * `sharing-*`, `audit-*`, `flow-*`, `http-*`, `inline-*`) states each lesson in - * words and carries no tracker number. + * `sharing-*`, `audit-*`, `flow-*`, `http-*`, `inline-*`, `actor-*`, `hot-*`, + * `external-*`, `query-*`, `delete-*`, `etl-*`, `storage-*`, `apimethod-*`, + * `dashboard-*`, `notification-*`, `record-*`, `runtime-*`, `rls-*`, `scim-*`) + * states each lesson in words and carries no tracker number. * * ## What this pins * @@ -82,6 +84,9 @@ const COVERED_PREFIXES = [ 'field-', 'export-', 'api-', 'dataset-', 'hook-', 'metadata-', 'rest-', 'analytics-', 'view-', 'package-', 'object-', 'sharing-', 'audit-', 'flow-', 'http-', 'inline-', + 'actor-', 'hot-', 'external-', 'query-', 'delete-', 'etl-', 'storage-', + 'apimethod-', 'dashboard-', 'notification-', 'record-', 'runtime-', 'rls-', + 'scim-', ]; /** @@ -95,6 +100,7 @@ const REWRITTEN = [ 'action-descriptor-resume-authority-default-flip', 'action-engine-facade-find-query-envelope', 'action-session-roles-to-positions', + 'actor-user-roles-to-positions', 'analytics-authorable-unknown-keys-refused', 'analytics-date-range-array-two-bounds-required', 'analytics-query-request-envelope-retired', @@ -103,8 +109,14 @@ const REWRITTEN = [ 'api-error-retry-after-unit-in-key', 'api-runtime-config-durations-unit-in-key', 'api-runtime-create-withdrawn', + 'apimethod-enum-shrink', 'audit-log-action-enum-retired', 'audit-log-action-restore-retired', + 'dashboard-header-modal-target-page-only', + 'dashboard-widget-chart-config-structure-refused', + 'dashboard-widget-compareto-offset', + 'dashboard-widget-metric-family-multi-measure-refused', + 'dashboard-widget-stage-order-non-funnel-refused', 'data-driver-find-stream-retired', 'data-driver-query-omit-object', 'data-engine-batch-retired', @@ -122,6 +134,7 @@ const REWRITTEN = [ 'datasource-config-url-userinfo-refused', 'datasource-credentialsref-mongo-composed-no-username-refused', 'datasource-credentialsref-mongo-url-no-user-refused', + 'delete-by-id-before-hook-repoint-retired', 'driver-aggregate-undeclared-key-aliases-removed', 'driver-capabilities-inert-bits-removed', 'driver-options-timeout-to-timeout-ms', @@ -137,9 +150,11 @@ const REWRITTEN = [ 'engine-find-formula-filter-refused', 'engine-find-formula-order-by-refused', 'engine-update-upsert-retired', + 'etl-pipeline-layer-retired', 'export-axis-opt-in', 'export-field-meta-constraints-retired', 'export-job-family-retired', + 'external-lookup-message-queue-families-retired', 'field-currency-scale-refused', 'field-max-length-malformed-or-misplaced-refused', 'field-min-length-malformed-or-misplaced-refused', @@ -166,6 +181,8 @@ const REWRITTEN = [ 'hook-context-session-roles-retired', 'hook-register-empty-object-target-refused', 'hook-register-undispatched-lifecycle-event-refused', + 'hot-reload-inert-state-strategies-retired', + 'hot-reload-watch-placeholder-retired', 'http-request-errors-total-retired', 'http-server-runtime-vocabulary-retired', 'inline-grid-column-currency-scale-refused', @@ -183,6 +200,7 @@ const REWRITTEN = [ 'metadata-manager-config-cache-ttl-unit-in-key', 'metadata-manager-config-inert-cache-keys-retired', 'metadata-plugin-additional-types-retired', + 'notification-list-cursor-retired', 'object-block-sort-item-array', 'object-grid-data-view-data-converged', 'object-grid-default-filters-rule-array', @@ -201,12 +219,26 @@ const REWRITTEN = [ 'plugin-runtime-family-retired', 'plugin-security-scan-result-surface-retired', 'plugin-security-scanner-retired', + 'query-array-string-agg-retired', + 'query-cursor-retired', + 'query-distinct-retired', + 'query-field-node-object-form-retired', + 'query-joins-retired', + 'query-window-functions-retired', + 'record-chatter-position-vocabulary-converged', + 'record-details-sections-object-form', 'rest-api-endpoint-handler-status-retired', 'rest-api-plugin-durations-unit-in-key', 'rest-server-config-dead-keys-retired', 'rest-server-openapi31-block-removed', + 'rls-predicate-array-comparand-refused', + 'rls-predicate-cross-class-field-comparison-refused', + 'rls-predicate-stored-list-ordering-refused', + 'runtime-httpserver-wrapper-retired', + 'scim-provider-object-retired', 'sharing-execution-context-retired', 'sharing-rule-recipient-reconcile', + 'storage-service-list-retired', 'system-cache-durations-unit-in-key', 'system-collaboration-durations-unit-in-key', 'system-failover-health-check-interval-unit-in-key', diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index aebeedc76ff..66a2e59e82e 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -378,7 +378,7 @@ "replacement": "ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions", "migrationId": "actor-user-roles-to-positions", "toMajor": 17, - "rationale": "The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048)." + "rationale": "The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation window, no dual-emit, the alias simply gone in 17. ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit. Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema` once it had no producer and no consumer left) and unlike `ActionSessionSchema` (declared contract-first, as it stood, as the first stage of the session rename, precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (no caller) / `IStorageService.list` (no consumer) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins the removal flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087." }, { "surface": "data.query.aggregations[].distinct", @@ -413,7 +413,7 @@ "replacement": "the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six", "migrationId": "apimethod-enum-shrink", "toMajor": 17, - "rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired in #2377, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of #3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350)." + "rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the enum shrink (phase 2 of the programme that made UI action buttons agree with the `apiMethods` allowlist) predates the gate that makes a breaking changeset state its ledger disposition. ADR-0087." }, { "surface": "automation.ApprovalEscalation.enabled — an OMITTED value inside an approval node's escalation block", @@ -483,7 +483,7 @@ "replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`", "migrationId": "dashboard-widget-compareto-offset", "toMajor": 17, - "rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform." + "rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (measured when all three declared arms were found dead on the dataset path: two silently dropped, this one throwing). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform." }, { "surface": "contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream", @@ -546,7 +546,7 @@ "replacement": "delete the other row explicitly — `ctx.ql.delete(object, otherId)` / `ctx.api` — and let the addressed delete proceed or `throw` from the handler to stop it; to delete MANY rows, have the CALLER pass `{ multi: true, where: … }`. Writing the SAME id back is unaffected and stays legal.", "migrationId": "delete-by-id-before-hook-repoint-retired", "toMajor": 17, - "rationale": "The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table still answering differently: it HONOURED a repoint, re-resolving the new target by re-reading its pre-image and rebinding `previous` (#5272), so `afterDelete` and the roll-up recompute saw the row actually deleted. It now refuses with `HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, exactly as the `update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\nRead this as a RULING, not a defect report — that distinction is the reason the entry is worth its length. #5272's re-resolution was internally CORRECT and nothing stale ever leaked from it; the case that retires a rebind on `update()` (the write landing on a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) simply did not apply to it. #5574's engine half (PR #6697) therefore left the asymmetry standing on purpose rather than folding a behaviour removal into an ordering change, and filed it as #6752. The 2026-08-09 maintainer ruling on that card closed it on three measured axes instead: compatibility cost zero (a repository-wide grep for assignments into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL SIX are this family's own pins — no consumer anywhere repoints); one rule across both verbs beats two individually-correct rules an author has to memorize, since the justification for the split lived in an ADR rather than at the call site; and \"a hook silently redirects which row gets deleted\" is a top-grade footgun for authored — especially AI-authored — handlers however correctly the redirect is implemented. Correctness of a mechanism does not justify the surface it exposes. Aligning the other way, by building `update()` the same re-resolution, stays excluded by #5574's own recorded ruling (\"do not silently pick re-resolution instead\").\n\nWhy this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as `hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at this step: FIRST, there is no source to convert — a `HookContext` is constructed per write and never persisted, so no `sys_metadata` row, example or template can carry the assignment. SECOND, the only place it is ever SPELLED is inside a handler body: author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative transform cannot safely rewrite an assignment inside free-form code, and the intent is not recoverable anyway — only the author knows whether the repoint meant \"delete that row INSTEAD\" or \"delete that row TOO\".\n\nWhat makes this one cheaper to meet than its two siblings, and worth saying because it bounds the work: the removed capability has an ENFORCED channel at run time. The refusal throws before anything is written and its message NAMES the retired capability and the three replacement routes, so a handler that still repoints fails loudly and self-describingly on its first execution rather than going quiet. This ledger entry is the channel that reaches an upgrader BEFORE that first execution. #6752, #5272, #5574, PR #6697, ADR-0058 Amendment II.2." + "rationale": "The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table still answering differently: it HONOURED a repoint, re-resolving the new target by re-reading its pre-image and rebinding `previous` (the fix that first made a single-row delete bind `previous` at all), so `afterDelete` and the roll-up recompute saw the row actually deleted. It now refuses with `HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, exactly as the `update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\nRead this as a RULING, not a defect report — that distinction is the reason the entry is worth its length. That re-resolution was internally CORRECT and nothing stale ever leaked from it; the case that retires a rebind on `update()` (the write landing on a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) simply did not apply to it. The engine change that dispatches `before*` hooks per matched row on a bulk write therefore left the asymmetry standing on purpose rather than folding a behaviour removal into an ordering change, and filed it as a finding of its own. The 2026-08-09 maintainer ruling on that finding closed it on three measured axes instead: compatibility cost zero (a repository-wide grep for assignments into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL SIX are this family's own pins — no consumer anywhere repoints); one rule across both verbs beats two individually-correct rules an author has to memorize, since the justification for the split lived in an ADR rather than at the call site; and \"a hook silently redirects which row gets deleted\" is a top-grade footgun for authored — especially AI-authored — handlers however correctly the redirect is implemented. Correctness of a mechanism does not justify the surface it exposes. Aligning the other way, by building `update()` the same re-resolution, stays excluded by the recorded ruling that extended per-row hook semantics to `before*` hooks on bulk writes (\"do not silently pick re-resolution instead\").\n\nWhy this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as `hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at this step: FIRST, there is no source to convert — a `HookContext` is constructed per write and never persisted, so no `sys_metadata` row, example or template can carry the assignment. SECOND, the only place it is ever SPELLED is inside a handler body: author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative transform cannot safely rewrite an assignment inside free-form code, and the intent is not recoverable anyway — only the author knows whether the repoint meant \"delete that row INSTEAD\" or \"delete that row TOO\".\n\nWhat makes this one cheaper to meet than its two siblings, and worth saying because it bounds the work: the removed capability has an ENFORCED channel at run time. The refusal throws before anything is written and its message NAMES the retired capability and the three replacement routes, so a handler that still repoints fails loudly and self-describingly on its first execution rather than going quiet. This ledger entry is the channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2." }, { "surface": "driver aggregate() call argument — query.aggregate and aggregations[].func", @@ -606,10 +606,10 @@ }, { "surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)", - "replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync. `AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the parsed definition; nothing reads `syncConfig` back off it, and the key has no reader outside `packages/spec` at all — the same measurement that retired `syncConfig.schedule` in 18 under ADR-0049 (#16320). What the platform DOES execute on a connector is its `actions`: a flow's `connector_action` node resolves the registered handler and awaits it, so an author who needs data actually moved drives it from there. Per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)", + "replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync. `AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the parsed definition; nothing reads `syncConfig` back off it, and the key has no reader outside `packages/spec` at all — the same measurement that retired `syncConfig.schedule` in 18 under ADR-0049, with the other cron-typed positions nothing reads. What the platform DOES execute on a connector is its `actions`: a flow's `connector_action` node resolves the registered handler and awaits it, so an author who needs data actually moved drives it from there. Per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)", "migrationId": "etl-pipeline-layer-retired", "toMajor": 17, - "rationale": "The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the #4962 retry-vocabulary entry, absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement (#4738) and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the #4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078, #6414." + "rationale": "The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its automation copy deleted as dead), re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the retry convergence had not covered), absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the `activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an earlier tombstone go with the shape that carried it: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078." }, { "surface": "security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key)", @@ -630,7 +630,7 @@ "replacement": "(removed — there is no replacement key, because there was never a key: neither family was reachable from any metadata-type binding, stack collection or /meta door, so no document could carry either. For external data: `object.external` (`ObjectExternalBindingSchema`, ADR-0015/0062) names a datasource by reference and connection credentials live in the datasource config — never inline in object metadata; `data/external-catalog.zod.ts` is that federated path's catalog surface and is untouched. For message queues: the LIVE surface is `kernel/events/integrations.zod.ts`'s `EventMessageQueueConfig` (`EventBusConfig.messageQueue`), which deliberately carries NO credential field — broker connection and SASL credentials are runtime deployment configuration, not authorable metadata. Either capability returns via the ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin service first, the vocabulary second)", "migrationId": "external-lookup-message-queue-families-retired", "toMajor": 17, - "rationale": "Both families are the #8075 census verdict (fork (b), accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `\"clientSecret\": \"...\"` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the #7990 class (cleartext-at-rest credential sinks), except that unlike #7990's two measured surfaces nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (#3950: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the #4834 / #4988 / #5055 / #6486 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The #5552 `data/ExternalFieldMapping:transform` tombstone (one of that retirement's three spellings) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with the #5552 prescription. ⚠️ The #7990 Option-B reopen trigger (\"a third measured artefact-type surface\") is NOT met by this census — that ruling's parked class-level write-boundary guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed." + "rationale": "Both families are the verdict of the 2026-08-12 census of spec schemas that permit inline credentials (fork (b): no `sys_metadata` door reaches them; accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `\"clientSecret\": \"...\"` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the class of the `sys_metadata` cleartext-sink finding (cleartext-at-rest credential sinks), except that unlike its two measured surfaces (driver config and connector `authentication`) nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (the lesson of the plugin sandboxing config that was never wired to anything: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the shape of the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs, the widget / i18n shapes and the sweep of five declared-but-inert surfaces — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The `data/ExternalFieldMapping:transform` tombstone of the field-mapping transform retirement (one of that retirement's three spellings; the whole transform union left because no runtime ever executed any of its five members) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with that retirement's prescription. ⚠️ The reopen trigger of the maintainer's 2026-08-12 ruling on the cleartext sink — it closed each artefact's contract (Option A) and parked the class-level `sys_metadata` write-boundary guard (Option B) until \"a third measured artefact-type surface\" — is NOT met by this census: that guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed." }, { "surface": "PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)", @@ -700,7 +700,7 @@ "replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed", "migrationId": "notification-list-cursor-retired", "toMajor": 17, - "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (#3899 wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361." + "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with the repair that made `unreadCount` really count the whole inbox). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (the fix for request bodies never checked against their declared schemas wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (the silent strip measured when a field key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot where a default or constraint change on an authorable key was recorded by no gate), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078." }, { "surface": "protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)", @@ -742,49 +742,49 @@ "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared", "migrationId": "query-array-string-agg-retired", "toMajor": 17, - "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188." + "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and the maintainer's 2026-08-05 investment freeze on driver-memory and driver-mongodb had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049." }, { "surface": "data.query.cursor", "replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)", "migrationId": "query-cursor-retired", "toMajor": 17, - "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." + "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078." }, { "surface": "data.query.distinct", "replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values", "migrationId": "query-distinct-retired", "toMajor": 17, - "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." + "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078." }, { "surface": "data.query.fields", - "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924)", + "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD` — refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by", "migrationId": "query-field-node-object-form-retired", "toMajor": 17, - "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196." + "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078." }, { "surface": "data.query.joins", - "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924)", + "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD` — refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by", "migrationId": "query-joins-retired", "toMajor": 17, - "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286." + "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078." }, { "surface": "data.query.windowFunctions", "replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource", "migrationId": "query-window-functions-retired", "toMajor": 17, - "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286." + "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078." }, { "surface": "ui.RecordDetailsProps.sections (the `record:details` page component)", "replacement": "an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the heading translatable, and the new sibling `hideFields` omits named fields from the body", "migrationId": "record-details-sections-object-form", "toMajor": 17, - "rationale": "`record:details` declared `sections` as a list of section IDs — `[\"overview\", \"financials\"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered by the #6350 stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired by #6350's neighbour — carries a tombstone while this face carried none. ADR-0087, #5611 (backfilled #6350)." + "rationale": "`record:details` declared `sections` as a list of section IDs — `[\"overview\", \"financials\"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the change that declared the object form predates the gate that makes a breaking changeset state its ledger disposition, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired in a neighbouring change — carries a tombstone while this face carried none. ADR-0087." }, { "surface": "restServer.openApi31", @@ -798,7 +798,7 @@ "replacement": "register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — `HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one", "migrationId": "runtime-httpserver-wrapper-retired", "toMajor": 17, - "rationale": "`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === \"function\"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). Registered by the #6350 stock reconciliation, not by the original change: #5122 landed before the #6148 completeness gate existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350)." + "rationale": "`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === \"function\"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — the ONLY entry path for declarative `apis:` endpoints since publish stopped refusing them and 17 began executing them — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` and `data-driver-find-stream-retired`. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger, not by the original change: the wrapper's removal landed before the gate that makes a breaking changeset state its ledger disposition existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087." }, { "surface": "@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name", @@ -830,10 +830,10 @@ }, { "surface": "contracts.IStorageService.list", - "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781", + "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored since, once cloud proved to be the first-party caller this repository could not see", "migrationId": "storage-service-list-retired", "toMajor": 17, - "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266)." + "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. The email plugin's large-attachment storage work was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two dialects): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired`. ADR-0049 / ADR-0087." }, { "surface": "ai.tool.requiresConfirmation", @@ -1270,7 +1270,7 @@ "replacement": "ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions", "migrationId": "actor-user-roles-to-positions", "toMajor": 17, - "rationale": "The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048)." + "rationale": "The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation window, no dual-emit, the alias simply gone in 17. ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit. Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema` once it had no producer and no consumer left) and unlike `ActionSessionSchema` (declared contract-first, as it stood, as the first stage of the session rename, precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (no caller) / `IStorageService.list` (no consumer) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins the removal flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087." }, { "surface": "data.query.aggregations[].distinct", @@ -1305,7 +1305,7 @@ "replacement": "the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six", "migrationId": "apimethod-enum-shrink", "toMajor": 17, - "rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired in #2377, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of #3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350)." + "rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the enum shrink (phase 2 of the programme that made UI action buttons agree with the `apiMethods` allowlist) predates the gate that makes a breaking changeset state its ledger disposition. ADR-0087." }, { "surface": "automation.ApprovalEscalation.enabled — an OMITTED value inside an approval node's escalation block", @@ -1375,7 +1375,7 @@ "replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`", "migrationId": "dashboard-widget-compareto-offset", "toMajor": 17, - "rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform." + "rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (measured when all three declared arms were found dead on the dataset path: two silently dropped, this one throwing). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform." }, { "surface": "contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream", @@ -1438,7 +1438,7 @@ "replacement": "delete the other row explicitly — `ctx.ql.delete(object, otherId)` / `ctx.api` — and let the addressed delete proceed or `throw` from the handler to stop it; to delete MANY rows, have the CALLER pass `{ multi: true, where: … }`. Writing the SAME id back is unaffected and stays legal.", "migrationId": "delete-by-id-before-hook-repoint-retired", "toMajor": 17, - "rationale": "The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table still answering differently: it HONOURED a repoint, re-resolving the new target by re-reading its pre-image and rebinding `previous` (#5272), so `afterDelete` and the roll-up recompute saw the row actually deleted. It now refuses with `HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, exactly as the `update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\nRead this as a RULING, not a defect report — that distinction is the reason the entry is worth its length. #5272's re-resolution was internally CORRECT and nothing stale ever leaked from it; the case that retires a rebind on `update()` (the write landing on a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) simply did not apply to it. #5574's engine half (PR #6697) therefore left the asymmetry standing on purpose rather than folding a behaviour removal into an ordering change, and filed it as #6752. The 2026-08-09 maintainer ruling on that card closed it on three measured axes instead: compatibility cost zero (a repository-wide grep for assignments into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL SIX are this family's own pins — no consumer anywhere repoints); one rule across both verbs beats two individually-correct rules an author has to memorize, since the justification for the split lived in an ADR rather than at the call site; and \"a hook silently redirects which row gets deleted\" is a top-grade footgun for authored — especially AI-authored — handlers however correctly the redirect is implemented. Correctness of a mechanism does not justify the surface it exposes. Aligning the other way, by building `update()` the same re-resolution, stays excluded by #5574's own recorded ruling (\"do not silently pick re-resolution instead\").\n\nWhy this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as `hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at this step: FIRST, there is no source to convert — a `HookContext` is constructed per write and never persisted, so no `sys_metadata` row, example or template can carry the assignment. SECOND, the only place it is ever SPELLED is inside a handler body: author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative transform cannot safely rewrite an assignment inside free-form code, and the intent is not recoverable anyway — only the author knows whether the repoint meant \"delete that row INSTEAD\" or \"delete that row TOO\".\n\nWhat makes this one cheaper to meet than its two siblings, and worth saying because it bounds the work: the removed capability has an ENFORCED channel at run time. The refusal throws before anything is written and its message NAMES the retired capability and the three replacement routes, so a handler that still repoints fails loudly and self-describingly on its first execution rather than going quiet. This ledger entry is the channel that reaches an upgrader BEFORE that first execution. #6752, #5272, #5574, PR #6697, ADR-0058 Amendment II.2." + "rationale": "The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table still answering differently: it HONOURED a repoint, re-resolving the new target by re-reading its pre-image and rebinding `previous` (the fix that first made a single-row delete bind `previous` at all), so `afterDelete` and the roll-up recompute saw the row actually deleted. It now refuses with `HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, exactly as the `update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\nRead this as a RULING, not a defect report — that distinction is the reason the entry is worth its length. That re-resolution was internally CORRECT and nothing stale ever leaked from it; the case that retires a rebind on `update()` (the write landing on a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) simply did not apply to it. The engine change that dispatches `before*` hooks per matched row on a bulk write therefore left the asymmetry standing on purpose rather than folding a behaviour removal into an ordering change, and filed it as a finding of its own. The 2026-08-09 maintainer ruling on that finding closed it on three measured axes instead: compatibility cost zero (a repository-wide grep for assignments into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL SIX are this family's own pins — no consumer anywhere repoints); one rule across both verbs beats two individually-correct rules an author has to memorize, since the justification for the split lived in an ADR rather than at the call site; and \"a hook silently redirects which row gets deleted\" is a top-grade footgun for authored — especially AI-authored — handlers however correctly the redirect is implemented. Correctness of a mechanism does not justify the surface it exposes. Aligning the other way, by building `update()` the same re-resolution, stays excluded by the recorded ruling that extended per-row hook semantics to `before*` hooks on bulk writes (\"do not silently pick re-resolution instead\").\n\nWhy this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as `hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at this step: FIRST, there is no source to convert — a `HookContext` is constructed per write and never persisted, so no `sys_metadata` row, example or template can carry the assignment. SECOND, the only place it is ever SPELLED is inside a handler body: author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative transform cannot safely rewrite an assignment inside free-form code, and the intent is not recoverable anyway — only the author knows whether the repoint meant \"delete that row INSTEAD\" or \"delete that row TOO\".\n\nWhat makes this one cheaper to meet than its two siblings, and worth saying because it bounds the work: the removed capability has an ENFORCED channel at run time. The refusal throws before anything is written and its message NAMES the retired capability and the three replacement routes, so a handler that still repoints fails loudly and self-describingly on its first execution rather than going quiet. This ledger entry is the channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2." }, { "surface": "driver aggregate() call argument — query.aggregate and aggregations[].func", @@ -1498,10 +1498,10 @@ }, { "surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)", - "replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync. `AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the parsed definition; nothing reads `syncConfig` back off it, and the key has no reader outside `packages/spec` at all — the same measurement that retired `syncConfig.schedule` in 18 under ADR-0049 (#16320). What the platform DOES execute on a connector is its `actions`: a flow's `connector_action` node resolves the registered handler and awaits it, so an author who needs data actually moved drives it from there. Per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)", + "replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync. `AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the parsed definition; nothing reads `syncConfig` back off it, and the key has no reader outside `packages/spec` at all — the same measurement that retired `syncConfig.schedule` in 18 under ADR-0049, with the other cron-typed positions nothing reads. What the platform DOES execute on a connector is its `actions`: a flow's `connector_action` node resolves the registered handler and awaits it, so an author who needs data actually moved drives it from there. Per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)", "migrationId": "etl-pipeline-layer-retired", "toMajor": 17, - "rationale": "The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the #4962 retry-vocabulary entry, absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement (#4738) and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the #4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078, #6414." + "rationale": "The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its automation copy deleted as dead), re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the retry convergence had not covered), absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the `activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an earlier tombstone go with the shape that carried it: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078." }, { "surface": "security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key)", @@ -1522,7 +1522,7 @@ "replacement": "(removed — there is no replacement key, because there was never a key: neither family was reachable from any metadata-type binding, stack collection or /meta door, so no document could carry either. For external data: `object.external` (`ObjectExternalBindingSchema`, ADR-0015/0062) names a datasource by reference and connection credentials live in the datasource config — never inline in object metadata; `data/external-catalog.zod.ts` is that federated path's catalog surface and is untouched. For message queues: the LIVE surface is `kernel/events/integrations.zod.ts`'s `EventMessageQueueConfig` (`EventBusConfig.messageQueue`), which deliberately carries NO credential field — broker connection and SASL credentials are runtime deployment configuration, not authorable metadata. Either capability returns via the ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin service first, the vocabulary second)", "migrationId": "external-lookup-message-queue-families-retired", "toMajor": 17, - "rationale": "Both families are the #8075 census verdict (fork (b), accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `\"clientSecret\": \"...\"` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the #7990 class (cleartext-at-rest credential sinks), except that unlike #7990's two measured surfaces nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (#3950: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the #4834 / #4988 / #5055 / #6486 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The #5552 `data/ExternalFieldMapping:transform` tombstone (one of that retirement's three spellings) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with the #5552 prescription. ⚠️ The #7990 Option-B reopen trigger (\"a third measured artefact-type surface\") is NOT met by this census — that ruling's parked class-level write-boundary guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed." + "rationale": "Both families are the verdict of the 2026-08-12 census of spec schemas that permit inline credentials (fork (b): no `sys_metadata` door reaches them; accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `\"clientSecret\": \"...\"` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the class of the `sys_metadata` cleartext-sink finding (cleartext-at-rest credential sinks), except that unlike its two measured surfaces (driver config and connector `authentication`) nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (the lesson of the plugin sandboxing config that was never wired to anything: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the shape of the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs, the widget / i18n shapes and the sweep of five declared-but-inert surfaces — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The `data/ExternalFieldMapping:transform` tombstone of the field-mapping transform retirement (one of that retirement's three spellings; the whole transform union left because no runtime ever executed any of its five members) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with that retirement's prescription. ⚠️ The reopen trigger of the maintainer's 2026-08-12 ruling on the cleartext sink — it closed each artefact's contract (Option A) and parked the class-level `sys_metadata` write-boundary guard (Option B) until \"a third measured artefact-type surface\" — is NOT met by this census: that guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed." }, { "surface": "PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)", @@ -1592,7 +1592,7 @@ "replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed", "migrationId": "notification-list-cursor-retired", "toMajor": 17, - "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (#3899 wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361." + "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with the repair that made `unreadCount` really count the whole inbox). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (the fix for request bodies never checked against their declared schemas wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (the silent strip measured when a field key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot where a default or constraint change on an authorable key was recorded by no gate), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078." }, { "surface": "protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)", @@ -1634,49 +1634,49 @@ "replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared", "migrationId": "query-array-string-agg-retired", "toMajor": 17, - "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188." + "rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and the maintainer's 2026-08-05 investment freeze on driver-memory and driver-mongodb had both of those backends frozen at the time (that freeze was lifted on 2026-08-11). `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049." }, { "surface": "data.query.cursor", "replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)", "migrationId": "query-cursor-retired", "toMajor": 17, - "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." + "rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078." }, { "surface": "data.query.distinct", "replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values", "migrationId": "query-distinct-retired", "toMajor": 17, - "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286." + "rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078." }, { "surface": "data.query.fields", - "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924)", + "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD` — refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by", "migrationId": "query-field-node-object-form-retired", "toMajor": 17, - "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196." + "rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078." }, { "surface": "data.query.joins", - "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924)", + "replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD` — refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by", "migrationId": "query-joins-retired", "toMajor": 17, - "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286." + "rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078." }, { "surface": "data.query.windowFunctions", "replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource", "migrationId": "query-window-functions-retired", "toMajor": 17, - "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286." + "rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078." }, { "surface": "ui.RecordDetailsProps.sections (the `record:details` page component)", "replacement": "an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the heading translatable, and the new sibling `hideFields` omits named fields from the body", "migrationId": "record-details-sections-object-form", "toMajor": 17, - "rationale": "`record:details` declared `sections` as a list of section IDs — `[\"overview\", \"financials\"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered by the #6350 stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired by #6350's neighbour — carries a tombstone while this face carried none. ADR-0087, #5611 (backfilled #6350)." + "rationale": "`record:details` declared `sections` as a list of section IDs — `[\"overview\", \"financials\"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the change that declared the object form predates the gate that makes a breaking changeset state its ledger disposition, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired in a neighbouring change — carries a tombstone while this face carried none. ADR-0087." }, { "surface": "restServer.openApi31", @@ -1690,7 +1690,7 @@ "replacement": "register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — `HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one", "migrationId": "runtime-httpserver-wrapper-retired", "toMajor": 17, - "rationale": "`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === \"function\"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). Registered by the #6350 stock reconciliation, not by the original change: #5122 landed before the #6148 completeness gate existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350)." + "rationale": "`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === \"function\"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — the ONLY entry path for declarative `apis:` endpoints since publish stopped refusing them and 17 began executing them — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` and `data-driver-find-stream-retired`. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger, not by the original change: the wrapper's removal landed before the gate that makes a breaking changeset state its ledger disposition existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087." }, { "surface": "@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name", @@ -1722,10 +1722,10 @@ }, { "surface": "contracts.IStorageService.list", - "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781", + "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored since, once cloud proved to be the first-party caller this repository could not see", "migrationId": "storage-service-list-retired", "toMajor": 17, - "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266)." + "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. The email plugin's large-attachment storage work was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two dialects): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired`. ADR-0049 / ADR-0087." }, { "surface": "ai.tool.requiresConfirmation", diff --git a/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts index 8f96e5c67bd..9695095460b 100644 --- a/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts +++ b/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts @@ -17,33 +17,34 @@ export const entry: SemanticMigration = { + 'SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical ' + 'on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, ' + 'published straight into author-written code. The maintainer ruled it closed IMMEDIATELY ' - + '(2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone ' - + 'in 17 (PR #6048). ' + + '(2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation ' + + 'window, no dual-emit, the alias simply gone in 17. ' + '⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: ' + '`action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached ' - + 'through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, ' + + 'through the same `ctx`, and that one KEEPS its one-window dual-emit. Same word, ' + 'same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while ' + '`ctx.session.roles` still answers for the length of its window. ' + 'What makes this entry different in KIND from both session-side siblings: `ctx.user` has ' + 'no spec schema and never had one. It is a runtime TS interface, so unlike ' - + '`HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, ' - + '#5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so ' - + 'its key could be renamed), there is no schema key here to tombstone and no ' + + '`HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema` ' + + 'once it had no producer and no consumer left) and unlike `ActionSessionSchema` (declared ' + + 'contract-first, as it stood, as the first stage of the session rename, precisely so its ' + + 'key could be renamed), there is no schema key here to tombstone and no ' + '`retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` ' + 'through a `.parse()`, so a prescription there would have no one to reach. The enforced ' + 'channel is tsc, and it reports at the READ site inside the author\'s own body; for an ' + 'untyped or sandboxed body there is no enforced channel at all, which is exactly why ' + 'this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide ' - + 'are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / ' - + '`IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no ' + + 'are the ONLY way such a reader learns of the rename. It is the `findStream` (no caller) / ' + + '`IStorageService.list` (no consumer) disposition — a TS/API contract, no stored source, no ' + 'tombstone, tsc at the call site — applied to a surface that lives one layer further ' + 'out than either: those two are at least DECLARED in `packages/spec/src/contracts`, ' + 'this one only in `packages/runtime`. ' + 'Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent ' + 'grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` ' + 'is constructed per dispatch and never persisted, so no `sys_metadata` row, example or ' - + 'template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / ' - + '`hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ' + + 'template can carry the key (the `openApi31` / `activationEvents` / ' + + '`hook-context-session-roles-retired` shape). SECOND, the only place the key is ' + 'ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or ' + 'a sandboxed script. A declarative transform cannot safely rewrite an identifier inside ' + 'free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to ' @@ -53,12 +54,12 @@ export const entry: SemanticMigration = { + 'here because the ledger is where an upgrading consumer meets it: the declaration\'s own ' + 'comment claimed the alias was "kept for the REST/AI shapes", and that claim was ' + 'DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all ' - + 'of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build ' + + 'of them in the pins the removal flipped; the four `ActorUser` construction sites build ' + 'server-side envelopes that never enter a response body; objectui\'s `.roles` reads ' + 'belong to two unrelated producers (the better-auth session, and the ' + '`/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and ' + 'is the one consumer face left unverified — this entry, and the changeset\'s FROM/TO ' - + 'prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048).', + + 'prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087.', acceptanceCriteria: 'No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; ' + 'every such read is `.positions` and observes the SAME array — the value was ' diff --git a/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts b/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts index dba47176b36..4d0b9f60433 100644 --- a/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts +++ b/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts @@ -18,7 +18,8 @@ export const entry: SemanticMigration = { + 'fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; ' + '`import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; ' + '`history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, ' - + 'because `enable.trash` was retired in #2377, so the value is deleted outright. That ' + + 'because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ' + + 'ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That ' + 'last row is why this is a semantic entry and not a mechanical conversion, and the ' + 'reason is a security one: the mapping WIDENS. An allowlist naming `history` was ' + 'granting read of one record\'s audit trail; rewritten to `get` it grants ordinary ' @@ -30,8 +31,11 @@ export const entry: SemanticMigration = { + 'replacement per site, and FLAGS the allowlists the mapping would widen so the edit ' + 'stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing ' + '(permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what ' - + 'an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of ' - + '#3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350).', + + 'an author may newly write. Registered late, by the stock reconciliation that compared ' + + 'the breaking changesets already on the v17 release train against this ledger: the enum ' + + 'shrink (phase 2 of the programme that made UI action buttons agree with the ' + + '`apiMethods` allowlist) predates the gate that makes a breaking changeset state its ' + + 'ledger disposition. ADR-0087.', acceptanceCriteria: 'No authored `enable.apiMethods` array names a legacy value; `objectstack validate` ' + 'passes. Run the reporter codemod first and read its widening flags before applying ' @@ -42,6 +46,6 @@ export const entry: SemanticMigration = { + 'operation. Where the six primitives are all present, prefer deleting the key: that is ' + 'equivalent to default-open and it tracks future primitives, whereas a hand-listed six ' + 'silently stops granting anything added later. `restore` / `purge` are deleted with no ' - + 'replacement — if trash-like behaviour was being relied on, that capability left in ' - + '#2377 and this entry is not where it returns.', + + 'replacement — if trash-like behaviour was being relied on, that capability left in the ' + + '11.0 dead-property removal and this entry is not where it returns.', }; diff --git a/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts b/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts index 400099784d8..cf10039e96b 100644 --- a/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts +++ b/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts @@ -11,7 +11,9 @@ export const entry: SemanticMigration = { + '`{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset ' + 'path — the spec\'s single author-facing analytics shape — `{ offset }` was forwarded ' + 'verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, ' - + 'taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). ' + + 'taking the widget down; the arm ever only ran on the legacy inline chart path (measured ' + + 'when all three declared arms were found dead on the dataset path: two silently dropped, ' + + 'this one throwing). ' + "The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every " + 'other duration has NO faithful target: `previousPeriod` shifts by the length of whatever ' + "window the widget's filter resolves to, which equals `7d` only when that window happens " diff --git a/packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts b/packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts index aa06ffb1e0d..e9fd30754a6 100644 --- a/packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts @@ -16,17 +16,19 @@ export const entry: SemanticMigration = { 'The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` ' + 'handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table ' + 'still answering differently: it HONOURED a repoint, re-resolving the new target by ' - + "re-reading its pre-image and rebinding `previous` (#5272), so `afterDelete` and the " - + 'roll-up recompute saw the row actually deleted. It now refuses with ' + + "re-reading its pre-image and rebinding `previous` (the fix that first made a single-row " + + "delete bind `previous` at all), so `afterDelete` and the roll-up recompute saw the row " + + 'actually deleted. It now refuses with ' + '`HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: \'by-id\'`, exactly as the ' + '`update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\n' + 'Read this as a RULING, not a defect report — that distinction is the reason the entry ' - + 'is worth its length. #5272\'s re-resolution was internally CORRECT and nothing stale ' + + 'is worth its length. That re-resolution was internally CORRECT and nothing stale ' + 'ever leaked from it; the case that retires a rebind on `update()` (the write landing on ' + 'a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) ' - + 'simply did not apply to it. #5574\'s engine half (PR #6697) therefore left the asymmetry ' - + 'standing on purpose rather than folding a behaviour removal into an ordering change, and ' - + 'filed it as #6752. The 2026-08-09 maintainer ruling on that card closed it on three ' + + 'simply did not apply to it. The engine change that dispatches `before*` hooks per matched ' + + 'row on a bulk write therefore left the asymmetry standing on purpose rather than folding ' + + 'a behaviour removal into an ordering change, and filed it as a finding of its own. The ' + + '2026-08-09 maintainer ruling on that finding closed it on three ' + 'measured axes instead: compatibility cost zero (a repository-wide grep for assignments ' + "into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL " + 'SIX are this family\'s own pins — no consumer anywhere repoints); one rule across both ' @@ -35,8 +37,9 @@ export const entry: SemanticMigration = { + 'silently redirects which row gets deleted" is a top-grade footgun for authored — ' + 'especially AI-authored — handlers however correctly the redirect is implemented. ' + 'Correctness of a mechanism does not justify the surface it exposes. Aligning the other ' - + 'way, by building `update()` the same re-resolution, stays excluded by #5574\'s own ' - + 'recorded ruling ("do not silently pick re-resolution instead").\n\n' + + 'way, by building `update()` the same re-resolution, stays excluded by the recorded ' + + 'ruling that extended per-row hook semantics to `before*` hooks on bulk writes ("do not ' + + 'silently pick re-resolution instead").\n\n' + 'Why this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as ' + '`hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at ' + 'this step: FIRST, there is no source to convert — a `HookContext` is constructed per ' @@ -51,8 +54,7 @@ export const entry: SemanticMigration = { + 'throws before anything is written and its message NAMES the retired capability and the ' + 'three replacement routes, so a handler that still repoints fails loudly and self-' + 'describingly on its first execution rather than going quiet. This ledger entry is the ' - + 'channel that reaches an upgrader BEFORE that first execution. #6752, #5272, #5574, ' - + 'PR #6697, ADR-0058 Amendment II.2.', + + 'channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2.', acceptanceCriteria: 'No `beforeDelete` handler assigns `ctx.input.id` anything but the id it arrived with — ' + 'grep handler bodies for assignments into `input.id` and rewrite each into an explicit ' diff --git a/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts b/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts index 7365e43bdde..e1fbec25248 100644 --- a/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts @@ -17,7 +17,8 @@ export const entry: SemanticMigration = { + '`AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the ' + 'parsed definition; nothing reads `syncConfig` back off it, and the key has no ' + 'reader outside `packages/spec` at all — the same measurement that retired ' - + '`syncConfig.schedule` in 18 under ADR-0049 (#16320). What the platform DOES ' + + '`syncConfig.schedule` in 18 under ADR-0049, with the other cron-typed positions ' + + 'nothing reads. What the platform DOES ' + 'execute on a connector is its `actions`: a flow\'s `connector_action` node ' + 'resolves the registered handler and awaits it, so an author who needs data ' + 'actually moved drives it from there. Per-field value ' @@ -28,7 +29,8 @@ export const entry: SemanticMigration = { + 'because it never had an implementation either. It returns through the ENFORCE route: ' + 'the engine first, the vocabulary second)', reason: - 'The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and ' + 'The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its ' + + 'automation copy deleted as dead), re-measured one layer up and ' + 'identical: narrative-only. No engine ever parsed, scheduled or executed an ' + '`ETLPipeline`. Measured on origin/main immediately before the removal: the only ' + 'non-spec references in this repo are two fumadocs-generated documentation sources ' @@ -37,26 +39,29 @@ export const entry: SemanticMigration = { + 'on it — while the same file family\'s EXECUTED half does have one ' + '(`liveness/mapping.json`), which is the contrast that makes the absence meaningful ' + 'rather than an oversight. The `etl` string in this registry was the one untested ' - + 'link the finding named, and it is not a loader path: it was the id of the #4962 ' - + 'retry-vocabulary entry, absorbed here. ' + + 'link the finding named, and it is not a loader path: it was the id of the ' + + 'retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the ' + + 'retry convergence had not covered), absorbed here. ' + 'The layer was ADR-0078\'s asymmetry in its purest form — an author could write a ' + 'complete ten-stage pipeline, get no error, and get no execution. It was also ' + 'advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the ' - + 'recommended destination for authors displaced by the L1 retirement (#4738) and ' + + 'recommended destination for authors displaced by the L1 retirement and ' + 'listed ten transformation types with copyable examples down to ' + '`script | Custom JavaScript/Python`. That document is rewritten in the same change; ' + 'a retirement whose own doc still recommends the retired layer is self-contradictory, ' + 'and forwarding L1\'s authors to a second layer with no executor was the defect ' + 'compounding rather than closing. ' - + '⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the ' - + '#4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a ' + + '⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the ' + + '`activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an ' + + 'earlier tombstone go with the shape that carried it: both land in the unreleased ' + + 'protocol 17, so composed, a ' + 'rename of `retry.maxAttempts` on a shape that does not survive the major has no ' + 'observable effect — and keeping both would tell an upgrader to rewrite a key on a ' + 'schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes ' + 'with the shape that carried it, which is strictly stronger than the tombstone: there ' + 'is no longer a `retry` block to author the key into. Route 3 — no carrier key, no ' + 'parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this ' - + 'entry are the declaration. ADR-0049, ADR-0078, #6414.', + + 'entry are the declaration. ADR-0049, ADR-0078.', acceptanceCriteria: 'No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, ' + '`ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, ' diff --git a/packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts b/packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts index 58b0058a3be..02776520e91 100644 --- a/packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts @@ -25,13 +25,17 @@ export const entry: SemanticMigration = { + 'ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin ' + 'service first, the vocabulary second)', reason: - 'Both families are the #8075 census verdict (fork (b), accepted 2026-08-12): ' + 'Both families are the verdict of the 2026-08-12 census of spec schemas that ' + + 'permit inline credentials (fork (b): no `sys_metadata` door reaches them; ' + + 'accepted 2026-08-12): ' + 'security-shaped declared surface with inline-credential sinks and ZERO ' + 'consumers. `ExternalDataSourceSchema.authentication.config` is a record of ' + 'unknown whose own docblock example wrote `"clientSecret": "..."` inline, and ' + '`MessageQueueConfigSchema.sasl.password` was a required inline broker ' - + 'credential — the #7990 class (cleartext-at-rest credential sinks), except ' - + 'that unlike #7990\'s two measured surfaces nothing ever persisted these: no ' + + 'credential — the class of the `sys_metadata` cleartext-sink finding ' + + '(cleartext-at-rest credential sinks), except that unlike its two measured ' + + 'surfaces (driver config and connector `authentication`) nothing ever ' + + 'persisted these: no ' + 'metadata-type binding (kernel/metadata-type-schemas.ts imports neither ' + 'module), no stack collection, no object/field embedding (`object.external` ' + 'binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/' @@ -41,21 +45,28 @@ export const entry: SemanticMigration = { + '`kernel/EventMessageQueueConfig` deliberately has no credential key, so the ' + 'consumed shape had no credential and the credential-bearing shape had no ' + 'consumer. A dead schema minus one field is still a dead schema, so the whole ' - + 'declarations go, not just the credential faces (#3950: an exported schema ' + + 'declarations go, not just the credential faces (the lesson of the plugin ' + + 'sandboxing config that was never wired to anything: an exported schema ' + 'with no consumer reads as a capability to whoever finds it — here it read as ' + 'an invitation to author secrets in cleartext). With no carrier key there is ' + 'nothing to tombstone and no source or `sys_metadata` row for a D2 conversion ' - + 'to rewrite: route 3, the #4834 / #4988 / #5055 / #6486 shape — ' + + 'to rewrite: route 3, the shape of the earlier removals of the dynamic ' + + 'plugin-loading family, the `ui/` interaction configs, the widget / i18n ' + + 'shapes and the sweep of five declared-but-inert surfaces — ' + 'RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ' - + '⚠️ The #5552 `data/ExternalFieldMapping:transform` tombstone (one of that ' - + 'retirement\'s three spellings) is SUBSUMED by the def retirement, the ' + + '⚠️ The `data/ExternalFieldMapping:transform` tombstone of the field-mapping ' + + 'transform retirement (one of that retirement\'s three spellings; the whole ' + + 'transform union left because no runtime ever executed any of its five ' + + 'members) is SUBSUMED by the def retirement, the ' + 'WidgetManifest.performance way: it goes with the shape that carried it. The ' + 'base `shared/FieldMapping` tombstone and the `integration/' + 'ConnectorFieldMapping` spelling are untouched and still reject `transform` ' - + 'with the #5552 prescription. ' - + '⚠️ The #7990 Option-B reopen trigger ("a third measured artefact-type ' - + 'surface") is NOT met by this census — that ruling\'s parked class-level ' - + 'write-boundary guard stays parked; this is the ADR-0049 leg of the fork the ' + + 'with that retirement\'s prescription. ' + + '⚠️ The reopen trigger of the maintainer\'s 2026-08-12 ruling on the ' + + 'cleartext sink — it closed each artefact\'s contract (Option A) and parked ' + + 'the class-level `sys_metadata` write-boundary guard (Option B) until "a ' + + 'third measured artefact-type surface" — is NOT met by this census: that ' + + 'guard stays parked; this is the ADR-0049 leg of the fork the ' + 'triage pre-agreed.', acceptanceCriteria: 'No code imports `ExternalLookup(Schema|Parsed)`, `ExternalDataSource(Schema)`, ' diff --git a/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts index 440936d265a..408acc95e9d 100644 --- a/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts @@ -22,7 +22,8 @@ export const entry: SemanticMigration = { + 'before the declaration existed', reason: 'One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, ' - + 'Option A, ruled jointly with #6363). `cursor` was declared on the request and on ' + + 'Option A, ruled jointly with the repair that made `unreadCount` really count the whole ' + + 'inbox). `cursor` was declared on the request and on ' + 'the response and honoured on neither: the dispatcher domain reads `read` / `type` / ' + '`limit` and nothing else, and no emit site has ever written the response key. It ' + 'was worse than inert because it had a shipped PRODUCER — the SDK appended it to the ' @@ -30,13 +31,14 @@ export const entry: SemanticMigration = { + 'forever, with no error and no 400. Measured over a real boot with 60 unread before ' + 'the removal: page2 === page1, both parsing green against the response schema, which ' + 'is why no conformance gate could see it. ' - + 'This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the ' + + 'This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the ' + 'same verdict for the same reason, down to deleting the SDK producer alongside the ' + 'key. A first-class inbox cursor, if one is ever designed, will be a ' + 'response-minted opaque token — a different API — so keeping this one preserved a ' + 'wrong design rather than a roadmap. ' + 'The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the ' - + 'number: no request path parses a query string through this schema (#3899 wired the ' + + 'number: no request path parses a query string through this schema (the fix for ' + + 'request bodies never checked against their declared schemas wired the ' + "catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never " + 'stamped anything onto anything, and the server has always applied its own 50. ' + 'Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a ' @@ -50,17 +52,21 @@ export const entry: SemanticMigration = { + 'bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, ' + 'so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept ' + 'sending — a clean parse and a parameter that never takes effect, which is this ' - + "issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is " + + "issue's own defect re-created one layer down (the silent strip measured when a field " + + 'key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So ' + + '`cursor` is ' + '`retiredKey()` on both halves, typed `never` for tsc and raising the prescription ' + 'at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is ' + 'NO D2 conversion: a conversion rewrites an authored source or a stored ' + '`sys_metadata` row, and these two shapes are HTTP-only — nobody authors a ' + '`ListNotificationsRequest` and nothing persists one. Request AND response shapes: ' + 'two semantic TODOs for API callers, no stack conversion — the same disposition ' - + '`BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys ' + + '`BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the ' + + '`AnalyticsQueryRequest` envelope keys ' + 'already take in this major. The `limit` default is declared separately and ' - + 'mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` ' - + 'fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361.', + + 'mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot ' + + 'where a default or constraint change on an authorable key was recorded by no gate), ' + + 'whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller sends `cursor` to `GET /api/v1/notifications` and no SDK call site passes ' + 'it: `client.notifications.list({ cursor })` is a `tsc` error (TS2353, excess ' @@ -71,7 +77,8 @@ export const entry: SemanticMigration = { + 'IGNORED, not refused — the domain reads three named query keys and no route ' + 'validates this query against a schema, so an unknown key has never produced a 400 ' + 'and does not start doing so here. The declaration stopped promising what the wire ' - + 'never did; the wire did not change. `unreadCount` is untouched (#6363) and still ' + + 'never did; the wire did not change. `unreadCount` is untouched (it was the jointly ' + + 'ruled repair\'s business) and still ' + 'reports the total across the whole matching inbox rather than the window. A caller ' + 'that omitted `limit` receives the same 50 rows it always received.', }; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts index 9330a6224f9..025164529d7 100644 --- a/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts @@ -21,10 +21,11 @@ export const entry: SemanticMigration = { + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes ' + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation ' + 'that worked on one backend and failed on another is exactly the unpredictability the ' - + 'ruling ended, and #5499 had both of those backends frozen at the time (that freeze ' + + 'ruling ended, and the maintainer\'s 2026-08-05 investment freeze on driver-memory and ' + + 'driver-mongodb had both of those backends frozen at the time (that freeze ' + 'was lifted on 2026-08-11). `count_distinct` was ' + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s ' - + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188.', + + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049.', acceptanceCriteria: 'No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style ' + 'roll-ups are assembled by the caller from an ordinary `fields` query, or materialised ' diff --git a/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts index c65b58b6640..1ac0e9c7296 100644 --- a/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts @@ -17,7 +17,7 @@ export const entry: SemanticMigration = { + 'reserved REST parameter set; a first-class cursor, if ever designed, will be a ' + 'response-minted opaque token — a different API, so keeping this one preserved a ' + 'wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to ' - + 'rewrite. ADR-0049 / ADR-0078, #4286.', + + 'rewrite. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep ' + 'pagination expresses the keyset as a `where` predicate on the sort key. A query ' diff --git a/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts index 3e12ccdcca7..b2a05c80313 100644 --- a/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts @@ -17,7 +17,7 @@ export const entry: SemanticMigration = { + 'something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with ' + 'the key). The count suppression is deleted in the same change — `total` is truthful ' + 'for those queries again. A REQUEST surface, never stored; nothing to rewrite. ' - + 'ADR-0049 / ADR-0078, #4286.', + + 'ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; ' + 'deduplication goes through `groupBy` / `count_distinct` / the drivers\' `distinct()` ' diff --git a/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts index fbc658667c3..47122d33cfd 100644 --- a/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts @@ -11,9 +11,11 @@ export const entry: SemanticMigration = { + "projection (`fields: ['title', 'owner_id']`), because the relation is carried by that " + 'column and projecting it away leaves expansion nothing to resolve. A dotted `fields` ' + 'path is NOT a replacement: no driver ever resolved one, and the ingress refuses it ' - + '(`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, ' + + '(`400 INVALID_FIELD` — refused since a dotted projection was found silently widening ' + + 'the response to every field). Where the value is wanted on the queried object itself, ' + 'denormalise it onto that object (a stored field, written when the source changes) — the ' - + 'same remedy the sort axis prescribes (#6924)', + + 'same remedy the sort axis\'s refusal hint was corrected to prescribe, because a formula ' + + 'or rollup field materialises no column to sort or select by', reason: 'The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that ' + 'was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` ' @@ -23,7 +25,7 @@ export const entry: SemanticMigration = { + 'is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST ' + 'surface — `QueryAST` is never stored in stack metadata (no view, dataset or report ' + 'authors one), so there is no source for the chain to rewrite: the schema narrows to ' - + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196.', + + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller puts an object in `fields[]`; related records AND single related columns are ' + 'read through `expand`, with the foreign-key column retained in the projection so ' diff --git a/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts index 6f520b5a243..c9b6f2b79ee 100644 --- a/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts @@ -11,9 +11,11 @@ export const entry: SemanticMigration = { + "projection (`fields: ['title', 'owner_id']`), because the relation is carried by that " + 'column and projecting it away leaves expansion nothing to resolve. A dotted `fields` ' + 'path is NOT a replacement: no driver ever resolved one, and the ingress refuses it ' - + '(`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, ' + + '(`400 INVALID_FIELD` — refused since a dotted projection was found silently widening ' + + 'the response to every field). Where the value is wanted on the queried object itself, ' + 'denormalise it onto that object (a stored field, written when the source changes) — the ' - + 'same remedy the sort axis prescribes (#6924)', + + 'same remedy the sort axis\'s refusal hint was corrected to prescribe, because a formula ' + + 'or rollup field materialises no column to sort or select by', reason: 'The `joins` array was declared-but-inert: no engine or driver read `query.joins` ' + 'anywhere on the query path, so a query carrying it behaved exactly as if the key were ' @@ -23,7 +25,7 @@ export const entry: SemanticMigration = { + 'capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with ' + 'the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there ' + 'is no source for the chain to rewrite; callers move their own queries. ' - + 'ADR-0049 / ADR-0078, #4286.', + + 'ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller sends `joins`; related records AND single related columns are read through ' + '`expand`, with the foreign-key column retained in the projection so expansion has ' diff --git a/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts index 02de089e656..96678778769 100644 --- a/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts @@ -17,7 +17,7 @@ export const entry: SemanticMigration = { + 'matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door ' + 'never read, so that cluster is removed with the key rather than left as a false ' + 'affordance. A REQUEST surface, never stored; no source to rewrite. ' - + 'ADR-0049 / ADR-0078, #4286.', + + 'ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller sends `windowFunctions` in a query; request-level analytics use ' + '`aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL ' diff --git a/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts b/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts index 768c90d8602..8029d810579 100644 --- a/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts +++ b/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts @@ -27,11 +27,13 @@ export const entry: SemanticMigration = { + '`sys_user` platform page — authors the object form. So the break lands only on stored ' + 'metadata written against a declaration nothing ever honoured, and it lands at publish ' + 'time rather than rewriting data at rest. The same change DECLARED `hideFields`, which ' - + 'the `sys_user` platform page had been authoring undeclared. Registered by the #6350 ' - + 'stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it ' + + 'the `sys_user` platform page had been authoring undeclared. Registered late, by the ' + + 'stock reconciliation that compared the breaking changesets already on the v17 release ' + + 'train against this ledger: the change that declared the object form predates the gate ' + + 'that makes a breaking changeset state its ledger disposition, so nothing asked it ' + 'what it had done about the ledger, and the sibling key on the same def — ' - + '`ui/RecordDetailsProps:layout`, retired by #6350\'s neighbour — carries a tombstone ' - + 'while this face carried none. ADR-0087, #5611 (backfilled #6350).', + + '`ui/RecordDetailsProps:layout`, retired in a neighbouring change — carries a tombstone ' + + 'while this face carried none. ADR-0087.', acceptanceCriteria: 'Every `record:details` component in authored metadata spells `sections` as an object ' + 'array: each entry names the fields it renders (`fields: [...]`), optionally with ' diff --git a/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts b/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts index 75bea16ae8b..181209b6372 100644 --- a/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts @@ -20,17 +20,20 @@ export const entry: SemanticMigration = { + 'the whole time. The sharpest consequence is worth writing down before anyone reaches ' + 'for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered ' + 'the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, ' - + 'because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` ' - + 'endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server ' + + 'because `setFallbackHandler` — the ONLY entry path for declarative `apis:` endpoints ' + + 'since publish stopped refusing them and 17 began executing them — was never forwarded. ' + + 'This is a TS/API contract surface: an HTTP server ' + 'adapter is CODE, never stack metadata, so there is no authored source for the chain to ' + 'rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a ' + '`.parse()`. That is precisely why this entry must exist: for an untyped JS host the ' + 'ledger is the only notification channel there is, and for a typed one tsc reports at ' + 'the construction site. Same disposition, and the same reason, as ' - + '`storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). ' - + 'Registered by the #6350 stock reconciliation, not by the original change: #5122 landed ' - + 'before the #6148 completeness gate existed, so nothing ever asked it what it had done ' - + 'about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350).', + + '`storage-service-list-retired` and `data-driver-find-stream-retired`. ' + + 'Registered by the stock reconciliation that compared the breaking changesets already on ' + + 'the v17 release train against this ledger, not by the original change: the wrapper\'s ' + + 'removal landed before the gate that makes a breaking changeset state its ledger ' + + 'disposition existed, so nothing ever asked it what it had done about the ledger. ' + + 'ADR-0049 / ADR-0087.', acceptanceCriteria: 'No code constructs `new HttpServer(...)` from `@objectstack/runtime`, and no import of ' + 'the name resolves — the export is gone, so a typed caller fails to compile at the ' diff --git a/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts b/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts index 30d37d2cd8b..cbc057a0258 100644 --- a/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts @@ -9,7 +9,8 @@ export const entry: SemanticMigration = { 'track the keys you wrote (sys_file / file-reference records, queryable through ' + 'ObjectQL with real pagination) instead of enumerating the bucket — and where no ' + 'such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this ' - + 'entry reserved, restored in #6781', + + 'entry reserved, restored since, once cloud proved to be the first-party caller this ' + + 'repository could not see', reason: '`list(prefix)` was an OPTIONAL contract method documented as "List files in a ' + 'directory/prefix", and the two shipped adapters answered the same call with two ' @@ -23,14 +24,16 @@ export const entry: SemanticMigration = { + 'caller received was the first page, with no signal. One contract method, two ' + 'dialects, both quietly incomplete — and the first feature that genuinely needed to ' + 'enumerate a prefix (backup, orphan sweep, migration audit) would have got two ' - + 'different answers on two deployments without an error on either. #5172 was nearly ' - + 'that feature: it planned to drive attachment reclamation off ' + + 'different answers on two deployments without an error on either. The email plugin\'s ' + + 'large-attachment storage work was nearly that feature: it planned to drive attachment ' + + 'reclamation off ' + '`list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one ' + 'level down, and switched to queue-driven deferred work instead. Nothing consumed ' + 'it afterwards: the only in-repo call site was the `SwappableStorageService` ' + 'pass-through (which itself rejects when the active adapter has no `list`), and ' + 'REST, CLI and the storage routes never called it. Remove was chosen over ' - + 'align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a ' + + 'align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two ' + + 'dialects): aligning would grow a ' + 'conformance surface nobody walks, while a prefix listing that cannot paginate is ' + 'the wrong signature to inherit — when a real caller needs enumeration it returns ' + 'cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases ' @@ -40,8 +43,7 @@ export const entry: SemanticMigration = { + 'tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription ' + 'there would reach no one. The enforced channel is tsc, and it reports at the call ' + 'site. Same disposition, and the same reason, as ' - + '`data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 ' - + '(analysis #5266).', + + '`data-driver-find-stream-retired`. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No code calls `storage.list(...)` on the `file-storage` service or on any ' + '`IStorageService` value. Code that needed "which files are under this prefix" ' @@ -53,8 +55,11 @@ export const entry: SemanticMigration = { + 'contract, so deleting it is cleanup that can follow. The break is on the CALLER ' + 'side: `storage.list(...)` no longer type-checks, and a PROXY typed against ' + '`IStorageService` that forwards to `inner.list` is exactly such a caller — the ' - + 'one in `@objectstack/service-storage` goes with the adapters (#5541). ' - + '⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the ' + + 'one in `@objectstack/service-storage` goes with the adapters\' own `list` ' + + 'implementations, removed in the retirement\'s implementation half. ' + + '⚠️ AMENDED 2026-08-09, under the maintainer\'s 2026-08-08 ruling on cloud\'s ' + + 'storage-enumeration callers (option B: restore enumeration upstream, correctly shaped, ' + + 'rather than hand-roll S3 pagination one repository over): the ' + 'RESERVED route in the paragraph above was taken. `list` exists again on the ' + 'contract, cursor-shaped — `list(prefix, { cursor, limit })` returning ' + '`{ items, nextCursor }` — because cloud had two first-party callers this repo ' diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-header-modal-target-page-only.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-header-modal-target-page-only.ts index 698e6e87f39..f2f4824a251 100644 --- a/packages/spec/src/migrations/entries/semantic/18.dashboard-header-modal-target-page-only.ts +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-header-modal-target-page-only.ts @@ -15,11 +15,11 @@ export const entry: SemanticMigration = { + 'with an `.` form-view target (`actionType` accepts the full action-type ' + 'enum, so that shape reaches this surface too)', reason: - 'Maintainer ruling objectstack#6739-A (2026-08-09): a `type: \'modal\'` string target names ' + 'Maintainer ruling A on modal targets (2026-08-09): a `type: \'modal\'` string target names ' + 'a PAGE, only — the spec TSDoc, the published docs and `defineStack`\'s cross-reference ' + 'walk already agreed, and the renderer\'s page-then-object leniency (self-labelled ' - + 'Back-compat) was retired rather than codified. objectui#4764 deleted the object fallback ' - + 'in the shared `useActionModal`; objectui#4782 deleted `DashboardView`\'s own second copy ' + + 'Back-compat) was retired rather than codified. One objectui change deleted the object ' + + 'fallback in the shared `useActionModal`; a second deleted `DashboardView`\'s own second copy ' + 'of the prefix convention (which had no page resolution at all), after enumerating both ' + 'repos\' corpora and finding zero producers of the prefix form. The `os validate` lint ' + 'rule (`validateDashboardActionRefs`) then still pointed the other way: it accepted the ' diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-chart-config-structure-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-chart-config-structure-refused.ts index 331975ac8ff..a71c1c4fca0 100644 --- a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-chart-config-structure-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-chart-config-structure-refused.ts @@ -33,7 +33,7 @@ export const entry: SemanticMigration = { + 'and a per-series mark type (the combo chart a widget could author through ' + '`series[].type`) has no authoring channel on this face at all.', reason: - 'Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options ' + 'Maintainer ruling of 2026-09-12 on the dataset-bound chart config, taking options ' + 'C+D together: the protocol states the ownership split AND refuses the structural keys by ' + 'name, because stating it without refusing them leaves the declared-but-inert shape ' + 'ADR-0049 exists to end, and refusing them without stating it leaves an author with no ' diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts index 8e12a7c227e..8b68454b3b6 100644 --- a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts @@ -20,22 +20,24 @@ export const entry: SemanticMigration = { + '`combo`) render one mark per measure — all of them keep the unbounded `values` ' + 'they have always had.', reason: - 'objectui#8894 ruling D (decision batch #119 item 4, 2026-09-12 「同意」) on the ' - + 'maintainer\'s standing rule 「协议不正确的应该先修改协议。」 — judge the protocol ' - + 'wrong rather than invent display semantics for `values[1..]`. Measured on ' - + 'objectui#7293 defect 1: `values` was `z.array(z.string()).min(1)` with NO upper ' + 'Maintainer ruling D of 2026-09-12, on objectui\'s finding that a metric tile silently ' + + 'drops every measure after the first, applying the maintainer\'s standing rule ' + + '「协议不正确的应该先修改协议。」 — judge the protocol ' + + 'wrong rather than invent display semantics for `values[1..]`. Measured in ' + + 'objectui\'s first report of the defect: `values` was `z.array(z.string()).min(1)` with NO upper ' + 'bound on every widget type, so a `metric` tile could declare three measures; the ' + 'dataset query selected and computed all three, and the tile rendered `values[0]`. ' + 'The other two were queried and dropped on the floor — the declared≠delivered shape ' + 'ADR-0049 exists to end, kept alive by a runtime warning rather than closed. ' - + 'objectui PR #8887 (merged) added the sub-caption, and the seat\'s second half made ' + + 'An objectui fix (merged) added the declared sub-caption, and objectui\'s interim half made ' + 'the tile SAY that the extra measures are not rendered: that makes the tile honest ' + 'about dropping them, it does not make the document legal. A metric tile answers ONE ' + 'number — that is what the family means on every mainstream dashboard product, and ' + '`ChartTypeSchema` groups these five under "Performance (single value)" in its own ' + 'words. Several numbers is a DIFFERENT visual, not a variant of this one, so the ' + 'repair is an accept-set narrowing and not a renderer feature. ⛔ NOT the other arm ' - + '(`objectstack-ai/duly#109`\'s wish for several numbers on one tile): under this ' + + '(the wish, from a downstream application\'s manager dashboard, for several numbers on ' + + 'one tile): under this ' + 'ruling that is a request for a different widget type, and it stays reachable ' + 'through `table` / the chart families, which this narrowing does not touch. ' + 'Ships at once, no deprecation window: there is no window in which a queried-and-' diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-stage-order-non-funnel-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-stage-order-non-funnel-refused.ts index eb6ed89a24e..3b88081fe70 100644 --- a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-stage-order-non-funnel-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-stage-order-non-funnel-refused.ts @@ -17,7 +17,8 @@ export const entry: SemanticMigration = { + 'lands at `options.stageOrder` and names the type the widget carries, the one type ' + 'that reads the key, and the two keys to reach for instead.', reason: - '#17344 finding 1, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose ' + 'The first finding of the report that `options.stageOrder` is honoured by the funnel ' + + 'branch only, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose ' + 'whole content was SILENCE. `options` is the open renderer-extras bag, so ' + '`stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, ' + '`table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and ' diff --git a/packages/spec/src/migrations/entries/semantic/18.hot-reload-inert-state-strategies-retired.ts b/packages/spec/src/migrations/entries/semantic/18.hot-reload-inert-state-strategies-retired.ts index 2cc5792752f..be399d7e1c9 100644 --- a/packages/spec/src/migrations/entries/semantic/18.hot-reload-inert-state-strategies-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.hot-reload-inert-state-strategies-retired.ts @@ -19,7 +19,8 @@ export const entry: SemanticMigration = { + 'it.', reason: 'ADR-0049 enforce-or-remove, applied one level INSIDE the library the ' - + '2026-08-25 #11825 ruling kept. That ruling retired the authorable ' + + 'maintainer\'s 2026-08-25 ruling on the advanced plugin-lifecycle config ' + + 'kept. That ruling retired the authorable ' + 'lifecycle-config container and deliberately kept `HotReloadConfigSchema` as ' + 'a host-driven library parameter type; this card measured the kept ' + "vocabulary's own remainder and found the same defect in it. Measured at " @@ -33,8 +34,10 @@ export const entry: SemanticMigration = { + 'configured to survive. `distributedConfig` had ZERO readers anywhere ' + '(every reference inside `packages/spec` itself plus the generated reference ' + 'page; nothing in objectui), so an author could name a Redis endpoint, a TTL ' - + 'and a replication factor and nothing ever opened a connection — the #3950 ' - + 'shape, sharpened by cluster-persistence vocabulary an AI author (ADR-0033) ' + + 'and a replication factor and nothing ever opened a connection — the shape ' + + 'of the plugin sandboxing / integrity / approval config that was never wired ' + + 'to anything (an exported schema no runtime reads is read as a capability), ' + + 'sharpened by cluster-persistence vocabulary an AI author (ADR-0033) ' + 'reads as proof the capability exists. The key left with the enum value its ' + 'own doc comment named it "required" for, and `DistributedStateConfig` was ' + 'its orphan value schema. Two routes in one card because the surface has two ' @@ -47,7 +50,8 @@ export const entry: SemanticMigration = { + 'manifest embed ever carried it, and nothing in the tree parses ' + '`HotReloadConfigSchema` outside its own unit test — so there is no authored ' + 'document to rewrite and no one who could receive a parse-time ' - + 'prescription. Route 3, the #4834 / #11825 shape: this entry IS the ' + + 'prescription. Route 3, the shape of the dynamic plugin-loading family\'s ' + + 'removal and of that lifecycle-config ruling: this entry IS the ' + 'declaration.', acceptanceCriteria: "No host passes `stateStrategy: 'disk'` or `'distributed'` to " @@ -67,7 +71,7 @@ export const entry: SemanticMigration = { + "'distributed' already stored to memory, so a host that migrates either to " + "'memory' keeps byte-identical behaviour — what changes is that the two " + 'spellings which never described what happened are now refused instead of ' - + 'silently honoured. The #11825 keep itself stands: `HotReloadConfigSchema`, ' + + 'silently honoured. That ruling\'s keep itself stands: `HotReloadConfigSchema`, ' + '`PluginStateSnapshotSchema` and the health vocabularies still export from ' + '`./kernel`, and `HotReloadManager` / `PluginHealthMonitor` still export ' + 'from `@objectstack/core` with their tests green.', diff --git a/packages/spec/src/migrations/entries/semantic/18.hot-reload-watch-placeholder-retired.ts b/packages/spec/src/migrations/entries/semantic/18.hot-reload-watch-placeholder-retired.ts index c840c6f7739..3b2ab216565 100644 --- a/packages/spec/src/migrations/entries/semantic/18.hot-reload-watch-placeholder-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.hot-reload-watch-placeholder-retired.ts @@ -18,8 +18,9 @@ export const entry: SemanticMigration = { + '`@objectstack/metadata-fs` and `@objectstack/cli` — never in ' + '`@objectstack/core` — so a host has a working model to copy.', reason: - 'ADR-0049 enforce-or-remove, applied one symbol over from #12340 in the ' - + 'same file and on the same per-key test. `HotReloadManager.startWatching` ' + 'ADR-0049 enforce-or-remove, applied one symbol over from the inert ' + + "'disk' / 'distributed' state strategies retired in the same file, and on " + + 'the same per-key test. `HotReloadManager.startWatching` ' + 'contained NO watcher: its whole body was a guard plus ' + "`logger.info('File watching started', { patterns })` above an in-source " + 'note saying real watching "would require chokidar or similar / This is a ' @@ -31,15 +32,17 @@ export const entry: SemanticMigration = { + 'the same scan; `watchHandles.set` resolves nothing anywhere). So ' + '`watchPatterns` had no reader that ACTED on it — its only two uses were ' + 'log lines — and an author could declare a glob while no file change ' - + 'could ever trigger a reload. This is the #3950 shape with the volume ' - + 'turned up: #12340\'s inert fallback at least announced itself at DEBUG, ' + + 'could ever trigger a reload. This is the shape of the plugin sandboxing ' + + 'config that was never wired to anything, with the volume turned up: the ' + + 'inert state-strategy fallback at least announced itself at DEBUG, ' + 'whereas this said "File watching started" at INFO — positive ' + 'confirmation of a capability that did not exist, which an operator, or ' + 'an AI author (ADR-0033), reads as proof and stops looking. Neither of ' + 'the other two ADR-0049 states was available: ENFORCE would build for a ' + 'caller that does not exist (no runtime composes `HotReloadManager` — ' + 'only its own unit test and `core/examples/phase2-integration.ts` ' - + 'construct it, the same fact that decided #12340\'s route), and ' + + 'construct it, the same fact that decided the state-strategy retirement\'s ' + + 'route), and ' + 'EXPERIMENTAL requires a roadmap, where a scan of every planning doc ' + 'returned ZERO mentions of hot-reload file watching against 145 control ' + 'hits in the same files. Route 3 again: `HotReloadConfig` is not an ' @@ -52,8 +55,10 @@ export const entry: SemanticMigration = { + 'than deleted, and the BUILD is what decided that: the plain deletion ' + 'was tried first and `gen:schema` gate (a) refused it, because ' + '`HotReloadConfigSchema` is not `.strict()` and a bare deletion would ' - + 'be a silent strip (#3733, ADR-0104) — the very defect being retired, ' - + 'one layer down. #12340 could take route 3 because what left there was ' + + 'be a silent strip (the failure measured when a field key pruned from a ' + + 'non-strict schema still parsed successfully and simply vanished, ' + + 'ADR-0104) — the very defect being retired, one layer down. The ' + + 'state-strategy retirement could take route 3 because what left there was ' + 'a whole DEF; a key leaving a SURVIVING def has no such exit. This ' + 'entry IS the declaration.', acceptanceCriteria: @@ -74,7 +79,8 @@ export const entry: SemanticMigration = { + '`reloadPlugin` and state preservation are untouched, and ' + '`stopWatching` keeps the half that always did something (it cancels a ' + 'pending debounced reload; its unreachable `watchHandles` branch left ' - + 'with the placeholder). The #11825 keep still stands: ' + + 'with the placeholder). What the maintainer\'s 2026-08-25 ruling on the ' + + 'advanced plugin-lifecycle config kept still stands: ' + '`HotReloadConfigSchema` and `PluginStateSnapshotSchema` still export ' + 'from `./kernel`, and `HotReloadManager` / `PluginHealthMonitor` still ' + 'export from `@objectstack/core` with their tests green.', diff --git a/packages/spec/src/migrations/entries/semantic/18.record-chatter-position-vocabulary-converged.ts b/packages/spec/src/migrations/entries/semantic/18.record-chatter-position-vocabulary-converged.ts index 60a40d6a280..424a31362d1 100644 --- a/packages/spec/src/migrations/entries/semantic/18.record-chatter-position-vocabulary-converged.ts +++ b/packages/spec/src/migrations/entries/semantic/18.record-chatter-position-vocabulary-converged.ts @@ -22,7 +22,7 @@ export const entry: SemanticMigration = { + '(the schema\'s own DEFAULT, materialized onto every parsed node that said nothing) ' + 'silently fell through to the in-flow render, and the value that actually docks the ' + 'panel (`right`) was refused at publish — declared ≠ enforced in both directions on the ' - + 'same key. The maintainer ruling (2026-08-15, #8762) converged the row on the ' + + 'same key. The maintainer ruling of 2026-08-15 on this row converged it on the ' + 'renderer\'s vocabulary with no mapping layer, and dropped all three schema defaults per ' + 'the `maxVisible` principle (renderer fallbacks stay the renderer\'s facts): the old ' + '`collapsible` default (`true`) additionally INVERTED the renderer merge\'s own fallback ' @@ -34,7 +34,7 @@ export const entry: SemanticMigration = { + 'chain cannot make: whether `drawer` → `right` (a docked panel standing in for a ' + 'never-implemented overlay) is the presentation the author wants, and whether a page ' + 'that relied on the old materialized `collapsible: true` default should now author it ' - + 'explicitly. ADR-0087, maintainer ruling 2026-08-15, #8762.', + + 'explicitly. ADR-0087, maintainer ruling 2026-08-15.', acceptanceCriteria: 'No authored `record:chatter` / `record:discussion` component carries `position: ' + "'sidebar' | 'inline' | 'drawer'`; `objectstack validate` passes. Review the rewritten " diff --git a/packages/spec/src/migrations/entries/semantic/18.rest-api-endpoint-handler-status-retired.ts b/packages/spec/src/migrations/entries/semantic/18.rest-api-endpoint-handler-status-retired.ts index 25b16c95156..2014457b4ef 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rest-api-endpoint-handler-status-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rest-api-endpoint-handler-status-retired.ts @@ -46,7 +46,7 @@ export const entry: SemanticMigration = { + 'been closed strictly once `api` became a registered metadata type, and ' + 'the surface a published skill had been teaching as working machinery ' + '(this finding came out of correcting that skill sentence, in a factual ' - + 'sweep of the automation skill). Bookkeeping: the KEY is tombstoned with ' + + 'sweep of the API skill). Bookkeeping: the KEY is tombstoned with ' + 'retiredKey() on the ' + 'non-strict RestApiEndpointSchema (api/RestApiEndpoint:handlerStatus in ' + 'RETIRED_KEYS_BY_MAJOR[18]); the DEFS leave whole — api/HandlerStatus ' diff --git a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts index fa3207a898b..32054144a20 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-array-comparand-refused.ts @@ -23,8 +23,9 @@ export const entry: SemanticMigration = { + '!(record.status in ["closed", "archived"]). Scalar != and ==, null, Date comparands, and ' + '{ $field } references between single-valued columns evaluate exactly as before', reason: - 'Ruling A on #19886 refuses an array comparand under $ne, and the equality slot is ruling ' - + '乙 on #19757; stage 2a of #19886 lands both on the formula face, the evaluator ' + 'Ruling A of 2026-09-24 refuses an array comparand under $ne, and ruling 乙 of 2026-09-23 ' + + 'refuses one in the implicit-equality slot, each for every driver at once; this change ' + + 'lands both on the formula face, the evaluator ' + 'plugin-security runs against the post-image of an insert or update to enforce a ' + 'row-level check. It compared strictly, and no stored value ever equals an array, so a ' + 'check written record.status != ["closed", "archived"], or != against a current_user ' diff --git a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-cross-class-field-comparison-refused.ts b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-cross-class-field-comparison-refused.ts index 1b75f4b0110..7490df57c70 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-cross-class-field-comparison-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-cross-class-field-comparison-refused.ts @@ -39,7 +39,9 @@ export const entry: SemanticMigration = { + 'file family is refused by name, whatever the deployment stores: during the ADR-0104 ' + 'dual-encoding window one media column can hold a bare id and another the JSON-quoted form of ' + 'the same id, so no comparison against the family is provably one answer on every path. ' - + 'driver-sql has refused such a comparison on the read since #5222, so a policy written ' + + 'driver-sql has refused such a comparison on the read since it first compiled a { $field } ' + + 'reference to a column-to-column comparison (a text column ordered against a number answered ' + + 'differently on SQLite than in memory, so the pushdown refused it), so a policy written ' + 'record.status != record.amount (text and a number), record.status != record.photo (text and ' + 'an image) or record.status != record.is_open (text and a formula field) got three answers, ' + 'measured through the real plugin-security on driver-sql, on SQLite and PostgreSQL: ' @@ -47,16 +49,17 @@ export const entry: SemanticMigration = { + 'by-id update or delete it scoped 403, and an insert or update its check judged, or its using ' + 'standing in as the check, was admitted and stored, because the write check compared the two ' + 'raw values. The classification is now exported once from @objectstack/spec/data ' - + '(crossFieldComparisonVerdict) and read by every judge. The authoring arm (#20347): the ' + + '(crossFieldComparisonVerdict) and read by every judge. The authoring arm: the ' + 'rls-predicate-unenforceable rule refuses the comparison in using and check, on every ' + 'operation, at os validate, build and lint and at the metadata save door for a permission ' + 'set, and the sharing-rule-unlowerable-condition rule refuses it in a sharing-rule condition ' - + 'at os validate, build and lint. The write-check arm (#20355): the row-level write gate hands ' + + 'at os validate, build and lint. The write-check arm: the row-level write gate hands ' + 'matchesFilterCondition the object\'s declared columns, and a comparison the classification ' + 'does not define is refused INVALID_FILTER / 400 for every insert and update the check judges, ' + 'before any record is read, with nothing stored; the message withholds the columns and the ' + 'server log names the policy and both. A comparison against a json or multiple field is now ' - + 'refused by its declared type on the write too, where #19886 judged it by the value each ' + + 'refused by its declared type on the write too, where the earlier refusal of an array ' + + 'comparand under $ne judged it by the value each ' + 'record held. driver-memory, a test driver with no field-reference arm, still reads such a ' + 'comparison as a literal. Shipped producers were counted before the change: no shipped ' + 'row-level policy or sharing-rule condition compares two fields of different classes. ' diff --git a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts index 96fd4f706a7..36837d94652 100644 --- a/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.rls-predicate-stored-list-ordering-refused.ts @@ -26,7 +26,9 @@ export const entry: SemanticMigration = { + 'before, and so are null and Date values, and every equality (==, !=, in) against a stored ' + 'list', reason: - 'Stage 2e of #19886, the mirror of stage 2d with the list on the record\'s side, measured ' + 'The mirror, with the list on the record\'s side, of the earlier refusal of an ordering ' + + 'operator against an array comparand (one of the same-class leaks that followed the ' + + '2026-09-24 ruling refusing an array under $ne), measured ' + 'through the real plugin-security on driver-sql and driver-memory. record.tags > "a", with ' + 'tags a json column holding ["m"], lowered to { tags: { $gt: "a" } }, and the write-check ' + 'evaluator compared the list\'s JavaScript string form ("m" > "a"), so the check admitted and ' diff --git a/packages/spec/src/migrations/entries/semantic/18.scim-provider-object-retired.ts b/packages/spec/src/migrations/entries/semantic/18.scim-provider-object-retired.ts index bf89a5bedda..7ca255ece4f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.scim-provider-object-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.scim-provider-object-retired.ts @@ -13,7 +13,8 @@ export const entry: SemanticMigration = { + 'retired `/scim/generate-token` endpoint.', replacement: '(removed — no direct replacement row. The stable `@better-auth/scim` ' - + '1.7.x line (#3653, PR #12726) derives no `scimProvider` model: SCIM ' + + '1.7.x line, which the platform adopted as one whole-model migration, ' + + 'derives no `scimProvider` model: SCIM ' + 'state lives in the seven stable platform objects ' + '(`sys_scim_connection_binding`, `sys_scim_group`, ' + '`sys_scim_group_member`, `sys_scim_identity_tombstone`, ' @@ -26,16 +27,18 @@ export const entry: SemanticMigration = { + 'on any path, so the IdP reissues its token — a migration-day operator ' + 'action, not a code rewrite.)', reason: - 'Maintainer ruling 2026-08-24 on #11693 (verbatim: 「11700 11693 不需要考虑' - + '历史数据,其他按照你的建议继续」) — disposition A: retire, with no ' + 'Maintainer ruling 2026-08-24 on the disposition of `sys_scim_provider` ' + + '(verbatim, in part: 「不需要考虑历史数据」) — disposition A: retire, with no ' + 'data-migration path owed for existing rows (reaffirmed 2026-08-25: SCIM ' + 'has no real customers; the binding constraint is a smooth upgrade). ' - + 'Executed as #11757 after the stable-1.7.1 migration landed (#3653 / ' - + 'PR #12726): the installed library derives no `scimProvider` model, so ' + + 'Executed as a retirement of its own after the stable-1.7.1 migration ' + + 'landed: the installed library derives no `scimProvider` model, so ' + 'the object backed nothing — nothing could write a row to it any more. ' + 'Retiring it also removes its `provider_id` unique index, whose ' - + 'stricter-than-upstream uniqueness was flagged on #3653 and parked ' - + 'pending exactly this retirement.', + + 'stricter-than-upstream uniqueness (one `provider_id` across every ' + + 'organization, where upstream scopes it per organization) was flagged ' + + 'while the SCIM upgrade was parked, and left pending exactly this ' + + 'retirement.', acceptanceCriteria: 'No code imports `SysScimProvider` from `@objectstack/platform-objects` ' + '(TS2305 after upgrade); `isPlatformProvidedObjectName(\'sys_scim_provider\')` ' @@ -45,7 +48,7 @@ export const entry: SemanticMigration = { + 'spec registry conformance test (`platform-object-names.test.ts`) pins ' + 'the absence bidirectionally — re-adding either the object file or the ' + 'registry name alone reds `registry group "platform-objects" is out of ' - + 'date` (measured both ways on #11757). Existing `sys_scim_provider` ' + + 'date` (measured both ways when the object was retired). Existing `sys_scim_provider` ' + 'tables in deployed databases are left in place untouched, by ruling — ' + 'no backfill, no reaper, no migrate command.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 06c06741fe8..25c7ac40a42 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1322,33 +1322,34 @@ const step17: MigrationStep = { + 'SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical ' + 'on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, ' + 'published straight into author-written code. The maintainer ruled it closed IMMEDIATELY ' - + '(2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone ' - + 'in 17 (PR #6048). ' + + '(2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation ' + + 'window, no dual-emit, the alias simply gone in 17. ' + '⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: ' + '`action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached ' - + 'through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, ' + + 'through the same `ctx`, and that one KEEPS its one-window dual-emit. Same word, ' + 'same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while ' + '`ctx.session.roles` still answers for the length of its window. ' + 'What makes this entry different in KIND from both session-side siblings: `ctx.user` has ' + 'no spec schema and never had one. It is a runtime TS interface, so unlike ' - + '`HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, ' - + '#5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so ' - + 'its key could be renamed), there is no schema key here to tombstone and no ' + + '`HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema` ' + + 'once it had no producer and no consumer left) and unlike `ActionSessionSchema` (declared ' + + 'contract-first, as it stood, as the first stage of the session rename, precisely so its ' + + 'key could be renamed), there is no schema key here to tombstone and no ' + '`retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` ' + 'through a `.parse()`, so a prescription there would have no one to reach. The enforced ' + 'channel is tsc, and it reports at the READ site inside the author\'s own body; for an ' + 'untyped or sandboxed body there is no enforced channel at all, which is exactly why ' + 'this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide ' - + 'are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / ' - + '`IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no ' + + 'are the ONLY way such a reader learns of the rename. It is the `findStream` (no caller) / ' + + '`IStorageService.list` (no consumer) disposition — a TS/API contract, no stored source, no ' + 'tombstone, tsc at the call site — applied to a surface that lives one layer further ' + 'out than either: those two are at least DECLARED in `packages/spec/src/contracts`, ' + 'this one only in `packages/runtime`. ' + 'Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent ' + 'grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` ' + 'is constructed per dispatch and never persisted, so no `sys_metadata` row, example or ' - + 'template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / ' - + '`hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ' + + 'template can carry the key (the `openApi31` / `activationEvents` / ' + + '`hook-context-session-roles-retired` shape). SECOND, the only place the key is ' + 'ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or ' + 'a sandboxed script. A declarative transform cannot safely rewrite an identifier inside ' + 'free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to ' @@ -1358,12 +1359,12 @@ const step17: MigrationStep = { + 'here because the ledger is where an upgrading consumer meets it: the declaration\'s own ' + 'comment claimed the alias was "kept for the REST/AI shapes", and that claim was ' + 'DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all ' - + 'of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build ' + + 'of them in the pins the removal flipped; the four `ActorUser` construction sites build ' + 'server-side envelopes that never enter a response body; objectui\'s `.roles` reads ' + 'belong to two unrelated producers (the better-auth session, and the ' + '`/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and ' + 'is the one consumer face left unverified — this entry, and the changeset\'s FROM/TO ' - + 'prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048).', + + 'prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087.', acceptanceCriteria: 'No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; ' + 'every such read is `.positions` and observes the SAME array — the value was ' @@ -1529,7 +1530,8 @@ const step17: MigrationStep = { + 'fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; ' + '`import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; ' + '`history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, ' - + 'because `enable.trash` was retired in #2377, so the value is deleted outright. That ' + + 'because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ' + + 'ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That ' + 'last row is why this is a semantic entry and not a mechanical conversion, and the ' + 'reason is a security one: the mapping WIDENS. An allowlist naming `history` was ' + 'granting read of one record\'s audit trail; rewritten to `get` it grants ordinary ' @@ -1541,8 +1543,11 @@ const step17: MigrationStep = { + 'replacement per site, and FLAGS the allowlists the mapping would widen so the edit ' + 'stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing ' + '(permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what ' - + 'an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of ' - + '#3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350).', + + 'an author may newly write. Registered late, by the stock reconciliation that compared ' + + 'the breaking changesets already on the v17 release train against this ledger: the enum ' + + 'shrink (phase 2 of the programme that made UI action buttons agree with the ' + + '`apiMethods` allowlist) predates the gate that makes a breaking changeset state its ' + + 'ledger disposition. ADR-0087.', acceptanceCriteria: 'No authored `enable.apiMethods` array names a legacy value; `objectstack validate` ' + 'passes. Run the reporter codemod first and read its widening flags before applying ' @@ -1553,8 +1558,8 @@ const step17: MigrationStep = { + 'operation. Where the six primitives are all present, prefer deleting the key: that is ' + 'equivalent to default-open and it tracks future primitives, whereas a hand-listed six ' + 'silently stops granting anything added later. `restore` / `purge` are deleted with no ' - + 'replacement — if trash-like behaviour was being relied on, that capability left in ' - + '#2377 and this entry is not where it returns.', + + 'replacement — if trash-like behaviour was being relied on, that capability left in the ' + + '11.0 dead-property removal and this entry is not where it returns.', }, { id: 'approval-escalation-enabled-default-flip', @@ -1993,7 +1998,9 @@ const step17: MigrationStep = { + '`{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset ' + 'path — the spec\'s single author-facing analytics shape — `{ offset }` was forwarded ' + 'verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, ' - + 'taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). ' + + 'taking the widget down; the arm ever only ran on the legacy inline chart path (measured ' + + 'when all three declared arms were found dead on the dataset path: two silently dropped, ' + + 'this one throwing). ' + "The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every " + 'other duration has NO faithful target: `previousPeriod` shifts by the length of whatever ' + "window the widget's filter resolves to, which equals `7d` only when that window happens " @@ -2332,17 +2339,19 @@ const step17: MigrationStep = { 'The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` ' + 'handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table ' + 'still answering differently: it HONOURED a repoint, re-resolving the new target by ' - + "re-reading its pre-image and rebinding `previous` (#5272), so `afterDelete` and the " - + 'roll-up recompute saw the row actually deleted. It now refuses with ' + + "re-reading its pre-image and rebinding `previous` (the fix that first made a single-row " + + "delete bind `previous` at all), so `afterDelete` and the roll-up recompute saw the row " + + 'actually deleted. It now refuses with ' + '`HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: \'by-id\'`, exactly as the ' + '`update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\n' + 'Read this as a RULING, not a defect report — that distinction is the reason the entry ' - + 'is worth its length. #5272\'s re-resolution was internally CORRECT and nothing stale ' + + 'is worth its length. That re-resolution was internally CORRECT and nothing stale ' + 'ever leaked from it; the case that retires a rebind on `update()` (the write landing on ' + 'a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) ' - + 'simply did not apply to it. #5574\'s engine half (PR #6697) therefore left the asymmetry ' - + 'standing on purpose rather than folding a behaviour removal into an ordering change, and ' - + 'filed it as #6752. The 2026-08-09 maintainer ruling on that card closed it on three ' + + 'simply did not apply to it. The engine change that dispatches `before*` hooks per matched ' + + 'row on a bulk write therefore left the asymmetry standing on purpose rather than folding ' + + 'a behaviour removal into an ordering change, and filed it as a finding of its own. The ' + + '2026-08-09 maintainer ruling on that finding closed it on three ' + 'measured axes instead: compatibility cost zero (a repository-wide grep for assignments ' + "into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL " + 'SIX are this family\'s own pins — no consumer anywhere repoints); one rule across both ' @@ -2351,8 +2360,9 @@ const step17: MigrationStep = { + 'silently redirects which row gets deleted" is a top-grade footgun for authored — ' + 'especially AI-authored — handlers however correctly the redirect is implemented. ' + 'Correctness of a mechanism does not justify the surface it exposes. Aligning the other ' - + 'way, by building `update()` the same re-resolution, stays excluded by #5574\'s own ' - + 'recorded ruling ("do not silently pick re-resolution instead").\n\n' + + 'way, by building `update()` the same re-resolution, stays excluded by the recorded ' + + 'ruling that extended per-row hook semantics to `before*` hooks on bulk writes ("do not ' + + 'silently pick re-resolution instead").\n\n' + 'Why this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as ' + '`hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at ' + 'this step: FIRST, there is no source to convert — a `HookContext` is constructed per ' @@ -2367,8 +2377,7 @@ const step17: MigrationStep = { + 'throws before anything is written and its message NAMES the retired capability and the ' + 'three replacement routes, so a handler that still repoints fails loudly and self-' + 'describingly on its first execution rather than going quiet. This ledger entry is the ' - + 'channel that reaches an upgrader BEFORE that first execution. #6752, #5272, #5574, ' - + 'PR #6697, ADR-0058 Amendment II.2.', + + 'channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2.', acceptanceCriteria: 'No `beforeDelete` handler assigns `ctx.input.id` anything but the id it arrived with — ' + 'grep handler bodies for assignments into `input.id` and rewrite each into an explicit ' @@ -2812,7 +2821,8 @@ const step17: MigrationStep = { + '`AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the ' + 'parsed definition; nothing reads `syncConfig` back off it, and the key has no ' + 'reader outside `packages/spec` at all — the same measurement that retired ' - + '`syncConfig.schedule` in 18 under ADR-0049 (#16320). What the platform DOES ' + + '`syncConfig.schedule` in 18 under ADR-0049, with the other cron-typed positions ' + + 'nothing reads. What the platform DOES ' + 'execute on a connector is its `actions`: a flow\'s `connector_action` node ' + 'resolves the registered handler and awaits it, so an author who needs data ' + 'actually moved drives it from there. Per-field value ' @@ -2823,7 +2833,8 @@ const step17: MigrationStep = { + 'because it never had an implementation either. It returns through the ENFORCE route: ' + 'the engine first, the vocabulary second)', reason: - 'The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and ' + 'The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its ' + + 'automation copy deleted as dead), re-measured one layer up and ' + 'identical: narrative-only. No engine ever parsed, scheduled or executed an ' + '`ETLPipeline`. Measured on origin/main immediately before the removal: the only ' + 'non-spec references in this repo are two fumadocs-generated documentation sources ' @@ -2832,26 +2843,29 @@ const step17: MigrationStep = { + 'on it — while the same file family\'s EXECUTED half does have one ' + '(`liveness/mapping.json`), which is the contrast that makes the absence meaningful ' + 'rather than an oversight. The `etl` string in this registry was the one untested ' - + 'link the finding named, and it is not a loader path: it was the id of the #4962 ' - + 'retry-vocabulary entry, absorbed here. ' + + 'link the finding named, and it is not a loader path: it was the id of the ' + + 'retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the ' + + 'retry convergence had not covered), absorbed here. ' + 'The layer was ADR-0078\'s asymmetry in its purest form — an author could write a ' + 'complete ten-stage pipeline, get no error, and get no execution. It was also ' + 'advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the ' - + 'recommended destination for authors displaced by the L1 retirement (#4738) and ' + + 'recommended destination for authors displaced by the L1 retirement and ' + 'listed ten transformation types with copyable examples down to ' + '`script | Custom JavaScript/Python`. That document is rewritten in the same change; ' + 'a retirement whose own doc still recommends the retired layer is self-contradictory, ' + 'and forwarding L1\'s authors to a second layer with no executor was the defect ' + 'compounding rather than closing. ' - + '⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the ' - + '#4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a ' + + '⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the ' + + '`activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an ' + + 'earlier tombstone go with the shape that carried it: both land in the unreleased ' + + 'protocol 17, so composed, a ' + 'rename of `retry.maxAttempts` on a shape that does not survive the major has no ' + 'observable effect — and keeping both would tell an upgrader to rewrite a key on a ' + 'schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes ' + 'with the shape that carried it, which is strictly stronger than the tombstone: there ' + 'is no longer a `retry` block to author the key into. Route 3 — no carrier key, no ' + 'parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this ' - + 'entry are the declaration. ADR-0049, ADR-0078, #6414.', + + 'entry are the declaration. ADR-0049, ADR-0078.', acceptanceCriteria: 'No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, ' + '`ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, ' @@ -3001,13 +3015,17 @@ const step17: MigrationStep = { + 'ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin ' + 'service first, the vocabulary second)', reason: - 'Both families are the #8075 census verdict (fork (b), accepted 2026-08-12): ' + 'Both families are the verdict of the 2026-08-12 census of spec schemas that ' + + 'permit inline credentials (fork (b): no `sys_metadata` door reaches them; ' + + 'accepted 2026-08-12): ' + 'security-shaped declared surface with inline-credential sinks and ZERO ' + 'consumers. `ExternalDataSourceSchema.authentication.config` is a record of ' + 'unknown whose own docblock example wrote `"clientSecret": "..."` inline, and ' + '`MessageQueueConfigSchema.sasl.password` was a required inline broker ' - + 'credential — the #7990 class (cleartext-at-rest credential sinks), except ' - + 'that unlike #7990\'s two measured surfaces nothing ever persisted these: no ' + + 'credential — the class of the `sys_metadata` cleartext-sink finding ' + + '(cleartext-at-rest credential sinks), except that unlike its two measured ' + + 'surfaces (driver config and connector `authentication`) nothing ever ' + + 'persisted these: no ' + 'metadata-type binding (kernel/metadata-type-schemas.ts imports neither ' + 'module), no stack collection, no object/field embedding (`object.external` ' + 'binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/' @@ -3017,21 +3035,28 @@ const step17: MigrationStep = { + '`kernel/EventMessageQueueConfig` deliberately has no credential key, so the ' + 'consumed shape had no credential and the credential-bearing shape had no ' + 'consumer. A dead schema minus one field is still a dead schema, so the whole ' - + 'declarations go, not just the credential faces (#3950: an exported schema ' + + 'declarations go, not just the credential faces (the lesson of the plugin ' + + 'sandboxing config that was never wired to anything: an exported schema ' + 'with no consumer reads as a capability to whoever finds it — here it read as ' + 'an invitation to author secrets in cleartext). With no carrier key there is ' + 'nothing to tombstone and no source or `sys_metadata` row for a D2 conversion ' - + 'to rewrite: route 3, the #4834 / #4988 / #5055 / #6486 shape — ' + + 'to rewrite: route 3, the shape of the earlier removals of the dynamic ' + + 'plugin-loading family, the `ui/` interaction configs, the widget / i18n ' + + 'shapes and the sweep of five declared-but-inert surfaces — ' + 'RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ' - + '⚠️ The #5552 `data/ExternalFieldMapping:transform` tombstone (one of that ' - + 'retirement\'s three spellings) is SUBSUMED by the def retirement, the ' + + '⚠️ The `data/ExternalFieldMapping:transform` tombstone of the field-mapping ' + + 'transform retirement (one of that retirement\'s three spellings; the whole ' + + 'transform union left because no runtime ever executed any of its five ' + + 'members) is SUBSUMED by the def retirement, the ' + 'WidgetManifest.performance way: it goes with the shape that carried it. The ' + 'base `shared/FieldMapping` tombstone and the `integration/' + 'ConnectorFieldMapping` spelling are untouched and still reject `transform` ' - + 'with the #5552 prescription. ' - + '⚠️ The #7990 Option-B reopen trigger ("a third measured artefact-type ' - + 'surface") is NOT met by this census — that ruling\'s parked class-level ' - + 'write-boundary guard stays parked; this is the ADR-0049 leg of the fork the ' + + 'with that retirement\'s prescription. ' + + '⚠️ The reopen trigger of the maintainer\'s 2026-08-12 ruling on the ' + + 'cleartext sink — it closed each artefact\'s contract (Option A) and parked ' + + 'the class-level `sys_metadata` write-boundary guard (Option B) until "a ' + + 'third measured artefact-type surface" — is NOT met by this census: that ' + + 'guard stays parked; this is the ADR-0049 leg of the fork the ' + 'triage pre-agreed.', acceptanceCriteria: 'No code imports `ExternalLookup(Schema|Parsed)`, `ExternalDataSource(Schema)`, ' @@ -3508,7 +3533,8 @@ const step17: MigrationStep = { + 'before the declaration existed', reason: 'One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, ' - + 'Option A, ruled jointly with #6363). `cursor` was declared on the request and on ' + + 'Option A, ruled jointly with the repair that made `unreadCount` really count the whole ' + + 'inbox). `cursor` was declared on the request and on ' + 'the response and honoured on neither: the dispatcher domain reads `read` / `type` / ' + '`limit` and nothing else, and no emit site has ever written the response key. It ' + 'was worse than inert because it had a shipped PRODUCER — the SDK appended it to the ' @@ -3516,13 +3542,14 @@ const step17: MigrationStep = { + 'forever, with no error and no 400. Measured over a real boot with 60 unread before ' + 'the removal: page2 === page1, both parsing green against the response schema, which ' + 'is why no conformance gate could see it. ' - + 'This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the ' + + 'This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the ' + 'same verdict for the same reason, down to deleting the SDK producer alongside the ' + 'key. A first-class inbox cursor, if one is ever designed, will be a ' + 'response-minted opaque token — a different API — so keeping this one preserved a ' + 'wrong design rather than a roadmap. ' + 'The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the ' - + 'number: no request path parses a query string through this schema (#3899 wired the ' + + 'number: no request path parses a query string through this schema (the fix for ' + + 'request bodies never checked against their declared schemas wired the ' + "catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never " + 'stamped anything onto anything, and the server has always applied its own 50. ' + 'Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a ' @@ -3536,17 +3563,21 @@ const step17: MigrationStep = { + 'bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, ' + 'so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept ' + 'sending — a clean parse and a parameter that never takes effect, which is this ' - + "issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is " + + "issue's own defect re-created one layer down (the silent strip measured when a field " + + 'key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So ' + + '`cursor` is ' + '`retiredKey()` on both halves, typed `never` for tsc and raising the prescription ' + 'at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is ' + 'NO D2 conversion: a conversion rewrites an authored source or a stored ' + '`sys_metadata` row, and these two shapes are HTTP-only — nobody authors a ' + '`ListNotificationsRequest` and nothing persists one. Request AND response shapes: ' + 'two semantic TODOs for API callers, no stack conversion — the same disposition ' - + '`BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys ' + + '`BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the ' + + '`AnalyticsQueryRequest` envelope keys ' + 'already take in this major. The `limit` default is declared separately and ' - + 'mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` ' - + 'fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361.', + + 'mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot ' + + 'where a default or constraint change on an authorable key was recorded by no gate), ' + + 'whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller sends `cursor` to `GET /api/v1/notifications` and no SDK call site passes ' + 'it: `client.notifications.list({ cursor })` is a `tsc` error (TS2353, excess ' @@ -3557,7 +3588,8 @@ const step17: MigrationStep = { + 'IGNORED, not refused — the domain reads three named query keys and no route ' + 'validates this query against a schema, so an unknown key has never produced a 400 ' + 'and does not start doing so here. The declaration stopped promising what the wire ' - + 'never did; the wire did not change. `unreadCount` is untouched (#6363) and still ' + + 'never did; the wire did not change. `unreadCount` is untouched (it was the jointly ' + + 'ruled repair\'s business) and still ' + 'reports the total across the whole matching inbox rather than the window. A caller ' + 'that omitted `limit` receives the same 50 rows it always received.', }, @@ -3844,10 +3876,11 @@ const step17: MigrationStep = { + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes ' + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation ' + 'that worked on one backend and failed on another is exactly the unpredictability the ' - + 'ruling ended, and #5499 had both of those backends frozen at the time (that freeze ' + + 'ruling ended, and the maintainer\'s 2026-08-05 investment freeze on driver-memory and ' + + 'driver-mongodb had both of those backends frozen at the time (that freeze ' + 'was lifted on 2026-08-11). `count_distinct` was ' + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s ' - + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188.', + + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049.', acceptanceCriteria: 'No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style ' + 'roll-ups are assembled by the caller from an ordinary `fields` query, or materialised ' @@ -3870,7 +3903,7 @@ const step17: MigrationStep = { + 'reserved REST parameter set; a first-class cursor, if ever designed, will be a ' + 'response-minted opaque token — a different API, so keeping this one preserved a ' + 'wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to ' - + 'rewrite. ADR-0049 / ADR-0078, #4286.', + + 'rewrite. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep ' + 'pagination expresses the keyset as a `where` predicate on the sort key. A query ' @@ -3892,7 +3925,7 @@ const step17: MigrationStep = { + 'something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with ' + 'the key). The count suppression is deleted in the same change — `total` is truthful ' + 'for those queries again. A REQUEST surface, never stored; nothing to rewrite. ' - + 'ADR-0049 / ADR-0078, #4286.', + + 'ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; ' + 'deduplication goes through `groupBy` / `count_distinct` / the drivers\' `distinct()` ' @@ -3908,9 +3941,11 @@ const step17: MigrationStep = { + "projection (`fields: ['title', 'owner_id']`), because the relation is carried by that " + 'column and projecting it away leaves expansion nothing to resolve. A dotted `fields` ' + 'path is NOT a replacement: no driver ever resolved one, and the ingress refuses it ' - + '(`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, ' + + '(`400 INVALID_FIELD` — refused since a dotted projection was found silently widening ' + + 'the response to every field). Where the value is wanted on the queried object itself, ' + 'denormalise it onto that object (a stored field, written when the source changes) — the ' - + 'same remedy the sort axis prescribes (#6924)', + + 'same remedy the sort axis\'s refusal hint was corrected to prescribe, because a formula ' + + 'or rollup field materialises no column to sort or select by', reason: 'The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that ' + 'was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` ' @@ -3920,7 +3955,7 @@ const step17: MigrationStep = { + 'is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST ' + 'surface — `QueryAST` is never stored in stack metadata (no view, dataset or report ' + 'authors one), so there is no source for the chain to rewrite: the schema narrows to ' - + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196.', + + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller puts an object in `fields[]`; related records AND single related columns are ' + 'read through `expand`, with the foreign-key column retained in the projection so ' @@ -3937,9 +3972,11 @@ const step17: MigrationStep = { + "projection (`fields: ['title', 'owner_id']`), because the relation is carried by that " + 'column and projecting it away leaves expansion nothing to resolve. A dotted `fields` ' + 'path is NOT a replacement: no driver ever resolved one, and the ingress refuses it ' - + '(`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, ' + + '(`400 INVALID_FIELD` — refused since a dotted projection was found silently widening ' + + 'the response to every field). Where the value is wanted on the queried object itself, ' + 'denormalise it onto that object (a stored field, written when the source changes) — the ' - + 'same remedy the sort axis prescribes (#6924)', + + 'same remedy the sort axis\'s refusal hint was corrected to prescribe, because a formula ' + + 'or rollup field materialises no column to sort or select by', reason: 'The `joins` array was declared-but-inert: no engine or driver read `query.joins` ' + 'anywhere on the query path, so a query carrying it behaved exactly as if the key were ' @@ -3949,7 +3986,7 @@ const step17: MigrationStep = { + 'capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with ' + 'the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there ' + 'is no source for the chain to rewrite; callers move their own queries. ' - + 'ADR-0049 / ADR-0078, #4286.', + + 'ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller sends `joins`; related records AND single related columns are read through ' + '`expand`, with the foreign-key column retained in the projection so expansion has ' @@ -3971,7 +4008,7 @@ const step17: MigrationStep = { + 'matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door ' + 'never read, so that cluster is removed with the key rather than left as a false ' + 'affordance. A REQUEST surface, never stored; no source to rewrite. ' - + 'ADR-0049 / ADR-0078, #4286.', + + 'ADR-0049 / ADR-0078.', acceptanceCriteria: 'No caller sends `windowFunctions` in a query; request-level analytics use ' + '`aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL ' @@ -4003,11 +4040,13 @@ const step17: MigrationStep = { + '`sys_user` platform page — authors the object form. So the break lands only on stored ' + 'metadata written against a declaration nothing ever honoured, and it lands at publish ' + 'time rather than rewriting data at rest. The same change DECLARED `hideFields`, which ' - + 'the `sys_user` platform page had been authoring undeclared. Registered by the #6350 ' - + 'stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it ' + + 'the `sys_user` platform page had been authoring undeclared. Registered late, by the ' + + 'stock reconciliation that compared the breaking changesets already on the v17 release ' + + 'train against this ledger: the change that declared the object form predates the gate ' + + 'that makes a breaking changeset state its ledger disposition, so nothing asked it ' + 'what it had done about the ledger, and the sibling key on the same def — ' - + '`ui/RecordDetailsProps:layout`, retired by #6350\'s neighbour — carries a tombstone ' - + 'while this face carried none. ADR-0087, #5611 (backfilled #6350).', + + '`ui/RecordDetailsProps:layout`, retired in a neighbouring change — carries a tombstone ' + + 'while this face carried none. ADR-0087.', acceptanceCriteria: 'Every `record:details` component in authored metadata spells `sections` as an object ' + 'array: each entry names the fields it renders (`fields: [...]`), optionally with ' @@ -4070,17 +4109,20 @@ const step17: MigrationStep = { + 'the whole time. The sharpest consequence is worth writing down before anyone reaches ' + 'for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered ' + 'the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, ' - + 'because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` ' - + 'endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server ' + + 'because `setFallbackHandler` — the ONLY entry path for declarative `apis:` endpoints ' + + 'since publish stopped refusing them and 17 began executing them — was never forwarded. ' + + 'This is a TS/API contract surface: an HTTP server ' + 'adapter is CODE, never stack metadata, so there is no authored source for the chain to ' + 'rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a ' + '`.parse()`. That is precisely why this entry must exist: for an untyped JS host the ' + 'ledger is the only notification channel there is, and for a typed one tsc reports at ' + 'the construction site. Same disposition, and the same reason, as ' - + '`storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). ' - + 'Registered by the #6350 stock reconciliation, not by the original change: #5122 landed ' - + 'before the #6148 completeness gate existed, so nothing ever asked it what it had done ' - + 'about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350).', + + '`storage-service-list-retired` and `data-driver-find-stream-retired`. ' + + 'Registered by the stock reconciliation that compared the breaking changesets already on ' + + 'the v17 release train against this ledger, not by the original change: the wrapper\'s ' + + 'removal landed before the gate that makes a breaking changeset state its ledger ' + + 'disposition existed, so nothing ever asked it what it had done about the ledger. ' + + 'ADR-0049 / ADR-0087.', acceptanceCriteria: 'No code constructs `new HttpServer(...)` from `@objectstack/runtime`, and no import of ' + 'the name resolves — the export is gone, so a typed caller fails to compile at the ' @@ -4304,7 +4346,8 @@ const step17: MigrationStep = { 'track the keys you wrote (sys_file / file-reference records, queryable through ' + 'ObjectQL with real pagination) instead of enumerating the bucket — and where no ' + 'such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this ' - + 'entry reserved, restored in #6781', + + 'entry reserved, restored since, once cloud proved to be the first-party caller this ' + + 'repository could not see', reason: '`list(prefix)` was an OPTIONAL contract method documented as "List files in a ' + 'directory/prefix", and the two shipped adapters answered the same call with two ' @@ -4318,14 +4361,16 @@ const step17: MigrationStep = { + 'caller received was the first page, with no signal. One contract method, two ' + 'dialects, both quietly incomplete — and the first feature that genuinely needed to ' + 'enumerate a prefix (backup, orphan sweep, migration audit) would have got two ' - + 'different answers on two deployments without an error on either. #5172 was nearly ' - + 'that feature: it planned to drive attachment reclamation off ' + + 'different answers on two deployments without an error on either. The email plugin\'s ' + + 'large-attachment storage work was nearly that feature: it planned to drive attachment ' + + 'reclamation off ' + '`list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one ' + 'level down, and switched to queue-driven deferred work instead. Nothing consumed ' + 'it afterwards: the only in-repo call site was the `SwappableStorageService` ' + 'pass-through (which itself rejects when the active adapter has no `list`), and ' + 'REST, CLI and the storage routes never called it. Remove was chosen over ' - + 'align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a ' + + 'align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two ' + + 'dialects): aligning would grow a ' + 'conformance surface nobody walks, while a prefix listing that cannot paginate is ' + 'the wrong signature to inherit — when a real caller needs enumeration it returns ' + 'cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases ' @@ -4335,8 +4380,7 @@ const step17: MigrationStep = { + 'tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription ' + 'there would reach no one. The enforced channel is tsc, and it reports at the call ' + 'site. Same disposition, and the same reason, as ' - + '`data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 ' - + '(analysis #5266).', + + '`data-driver-find-stream-retired`. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No code calls `storage.list(...)` on the `file-storage` service or on any ' + '`IStorageService` value. Code that needed "which files are under this prefix" ' @@ -4348,8 +4392,11 @@ const step17: MigrationStep = { + 'contract, so deleting it is cleanup that can follow. The break is on the CALLER ' + 'side: `storage.list(...)` no longer type-checks, and a PROXY typed against ' + '`IStorageService` that forwards to `inner.list` is exactly such a caller — the ' - + 'one in `@objectstack/service-storage` goes with the adapters (#5541). ' - + '⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the ' + + 'one in `@objectstack/service-storage` goes with the adapters\' own `list` ' + + 'implementations, removed in the retirement\'s implementation half. ' + + '⚠️ AMENDED 2026-08-09, under the maintainer\'s 2026-08-08 ruling on cloud\'s ' + + 'storage-enumeration callers (option B: restore enumeration upstream, correctly shaped, ' + + 'rather than hand-roll S3 pagination one repository over): the ' + 'RESERVED route in the paragraph above was taken. `list` exists again on the ' + 'contract, cursor-shaped — `list(prefix, { cursor, limit })` returning ' + '`{ items, nextCursor }` — because cloud had two first-party callers this repo ' @@ -7688,11 +7735,11 @@ const step18: MigrationStep = { + 'with an `.` form-view target (`actionType` accepts the full action-type ' + 'enum, so that shape reaches this surface too)', reason: - 'Maintainer ruling objectstack#6739-A (2026-08-09): a `type: \'modal\'` string target names ' + 'Maintainer ruling A on modal targets (2026-08-09): a `type: \'modal\'` string target names ' + 'a PAGE, only — the spec TSDoc, the published docs and `defineStack`\'s cross-reference ' + 'walk already agreed, and the renderer\'s page-then-object leniency (self-labelled ' - + 'Back-compat) was retired rather than codified. objectui#4764 deleted the object fallback ' - + 'in the shared `useActionModal`; objectui#4782 deleted `DashboardView`\'s own second copy ' + + 'Back-compat) was retired rather than codified. One objectui change deleted the object ' + + 'fallback in the shared `useActionModal`; a second deleted `DashboardView`\'s own second copy ' + 'of the prefix convention (which had no page resolution at all), after enumerating both ' + 'repos\' corpora and finding zero producers of the prefix form. The `os validate` lint ' + 'rule (`validateDashboardActionRefs`) then still pointed the other way: it accepted the ' @@ -7769,7 +7816,7 @@ const step18: MigrationStep = { + 'and a per-series mark type (the combo chart a widget could author through ' + '`series[].type`) has no authoring channel on this face at all.', reason: - 'Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options ' + 'Maintainer ruling of 2026-09-12 on the dataset-bound chart config, taking options ' + 'C+D together: the protocol states the ownership split AND refuses the structural keys by ' + 'name, because stating it without refusing them leaves the declared-but-inert shape ' + 'ADR-0049 exists to end, and refusing them without stating it leaves an author with no ' @@ -7828,22 +7875,24 @@ const step18: MigrationStep = { + '`combo`) render one mark per measure — all of them keep the unbounded `values` ' + 'they have always had.', reason: - 'objectui#8894 ruling D (decision batch #119 item 4, 2026-09-12 「同意」) on the ' - + 'maintainer\'s standing rule 「协议不正确的应该先修改协议。」 — judge the protocol ' - + 'wrong rather than invent display semantics for `values[1..]`. Measured on ' - + 'objectui#7293 defect 1: `values` was `z.array(z.string()).min(1)` with NO upper ' + 'Maintainer ruling D of 2026-09-12, on objectui\'s finding that a metric tile silently ' + + 'drops every measure after the first, applying the maintainer\'s standing rule ' + + '「协议不正确的应该先修改协议。」 — judge the protocol ' + + 'wrong rather than invent display semantics for `values[1..]`. Measured in ' + + 'objectui\'s first report of the defect: `values` was `z.array(z.string()).min(1)` with NO upper ' + 'bound on every widget type, so a `metric` tile could declare three measures; the ' + 'dataset query selected and computed all three, and the tile rendered `values[0]`. ' + 'The other two were queried and dropped on the floor — the declared≠delivered shape ' + 'ADR-0049 exists to end, kept alive by a runtime warning rather than closed. ' - + 'objectui PR #8887 (merged) added the sub-caption, and the seat\'s second half made ' + + 'An objectui fix (merged) added the declared sub-caption, and objectui\'s interim half made ' + 'the tile SAY that the extra measures are not rendered: that makes the tile honest ' + 'about dropping them, it does not make the document legal. A metric tile answers ONE ' + 'number — that is what the family means on every mainstream dashboard product, and ' + '`ChartTypeSchema` groups these five under "Performance (single value)" in its own ' + 'words. Several numbers is a DIFFERENT visual, not a variant of this one, so the ' + 'repair is an accept-set narrowing and not a renderer feature. ⛔ NOT the other arm ' - + '(`objectstack-ai/duly#109`\'s wish for several numbers on one tile): under this ' + + '(the wish, from a downstream application\'s manager dashboard, for several numbers on ' + + 'one tile): under this ' + 'ruling that is a request for a different widget type, and it stays reachable ' + 'through `table` / the chart families, which this narrowing does not touch. ' + 'Ships at once, no deprecation window: there is no window in which a queried-and-' @@ -7914,7 +7963,8 @@ const step18: MigrationStep = { + 'lands at `options.stageOrder` and names the type the widget carries, the one type ' + 'that reads the key, and the two keys to reach for instead.', reason: - '#17344 finding 1, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose ' + 'The first finding of the report that `options.stageOrder` is honoured by the funnel ' + + 'branch only, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose ' + 'whole content was SILENCE. `options` is the open renderer-extras bag, so ' + '`stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, ' + '`table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and ' @@ -11232,7 +11282,8 @@ const step18: MigrationStep = { + 'it.', reason: 'ADR-0049 enforce-or-remove, applied one level INSIDE the library the ' - + '2026-08-25 #11825 ruling kept. That ruling retired the authorable ' + + 'maintainer\'s 2026-08-25 ruling on the advanced plugin-lifecycle config ' + + 'kept. That ruling retired the authorable ' + 'lifecycle-config container and deliberately kept `HotReloadConfigSchema` as ' + 'a host-driven library parameter type; this card measured the kept ' + "vocabulary's own remainder and found the same defect in it. Measured at " @@ -11246,8 +11297,10 @@ const step18: MigrationStep = { + 'configured to survive. `distributedConfig` had ZERO readers anywhere ' + '(every reference inside `packages/spec` itself plus the generated reference ' + 'page; nothing in objectui), so an author could name a Redis endpoint, a TTL ' - + 'and a replication factor and nothing ever opened a connection — the #3950 ' - + 'shape, sharpened by cluster-persistence vocabulary an AI author (ADR-0033) ' + + 'and a replication factor and nothing ever opened a connection — the shape ' + + 'of the plugin sandboxing / integrity / approval config that was never wired ' + + 'to anything (an exported schema no runtime reads is read as a capability), ' + + 'sharpened by cluster-persistence vocabulary an AI author (ADR-0033) ' + 'reads as proof the capability exists. The key left with the enum value its ' + 'own doc comment named it "required" for, and `DistributedStateConfig` was ' + 'its orphan value schema. Two routes in one card because the surface has two ' @@ -11260,7 +11313,8 @@ const step18: MigrationStep = { + 'manifest embed ever carried it, and nothing in the tree parses ' + '`HotReloadConfigSchema` outside its own unit test — so there is no authored ' + 'document to rewrite and no one who could receive a parse-time ' - + 'prescription. Route 3, the #4834 / #11825 shape: this entry IS the ' + + 'prescription. Route 3, the shape of the dynamic plugin-loading family\'s ' + + 'removal and of that lifecycle-config ruling: this entry IS the ' + 'declaration.', acceptanceCriteria: "No host passes `stateStrategy: 'disk'` or `'distributed'` to " @@ -11280,7 +11334,7 @@ const step18: MigrationStep = { + "'distributed' already stored to memory, so a host that migrates either to " + "'memory' keeps byte-identical behaviour — what changes is that the two " + 'spellings which never described what happened are now refused instead of ' - + 'silently honoured. The #11825 keep itself stands: `HotReloadConfigSchema`, ' + + 'silently honoured. That ruling\'s keep itself stands: `HotReloadConfigSchema`, ' + '`PluginStateSnapshotSchema` and the health vocabularies still export from ' + '`./kernel`, and `HotReloadManager` / `PluginHealthMonitor` still export ' + 'from `@objectstack/core` with their tests green.', @@ -11301,8 +11355,9 @@ const step18: MigrationStep = { + '`@objectstack/metadata-fs` and `@objectstack/cli` — never in ' + '`@objectstack/core` — so a host has a working model to copy.', reason: - 'ADR-0049 enforce-or-remove, applied one symbol over from #12340 in the ' - + 'same file and on the same per-key test. `HotReloadManager.startWatching` ' + 'ADR-0049 enforce-or-remove, applied one symbol over from the inert ' + + "'disk' / 'distributed' state strategies retired in the same file, and on " + + 'the same per-key test. `HotReloadManager.startWatching` ' + 'contained NO watcher: its whole body was a guard plus ' + "`logger.info('File watching started', { patterns })` above an in-source " + 'note saying real watching "would require chokidar or similar / This is a ' @@ -11314,15 +11369,17 @@ const step18: MigrationStep = { + 'the same scan; `watchHandles.set` resolves nothing anywhere). So ' + '`watchPatterns` had no reader that ACTED on it — its only two uses were ' + 'log lines — and an author could declare a glob while no file change ' - + 'could ever trigger a reload. This is the #3950 shape with the volume ' - + 'turned up: #12340\'s inert fallback at least announced itself at DEBUG, ' + + 'could ever trigger a reload. This is the shape of the plugin sandboxing ' + + 'config that was never wired to anything, with the volume turned up: the ' + + 'inert state-strategy fallback at least announced itself at DEBUG, ' + 'whereas this said "File watching started" at INFO — positive ' + 'confirmation of a capability that did not exist, which an operator, or ' + 'an AI author (ADR-0033), reads as proof and stops looking. Neither of ' + 'the other two ADR-0049 states was available: ENFORCE would build for a ' + 'caller that does not exist (no runtime composes `HotReloadManager` — ' + 'only its own unit test and `core/examples/phase2-integration.ts` ' - + 'construct it, the same fact that decided #12340\'s route), and ' + + 'construct it, the same fact that decided the state-strategy retirement\'s ' + + 'route), and ' + 'EXPERIMENTAL requires a roadmap, where a scan of every planning doc ' + 'returned ZERO mentions of hot-reload file watching against 145 control ' + 'hits in the same files. Route 3 again: `HotReloadConfig` is not an ' @@ -11335,8 +11392,10 @@ const step18: MigrationStep = { + 'than deleted, and the BUILD is what decided that: the plain deletion ' + 'was tried first and `gen:schema` gate (a) refused it, because ' + '`HotReloadConfigSchema` is not `.strict()` and a bare deletion would ' - + 'be a silent strip (#3733, ADR-0104) — the very defect being retired, ' - + 'one layer down. #12340 could take route 3 because what left there was ' + + 'be a silent strip (the failure measured when a field key pruned from a ' + + 'non-strict schema still parsed successfully and simply vanished, ' + + 'ADR-0104) — the very defect being retired, one layer down. The ' + + 'state-strategy retirement could take route 3 because what left there was ' + 'a whole DEF; a key leaving a SURVIVING def has no such exit. This ' + 'entry IS the declaration.', acceptanceCriteria: @@ -11357,7 +11416,8 @@ const step18: MigrationStep = { + '`reloadPlugin` and state preservation are untouched, and ' + '`stopWatching` keeps the half that always did something (it cancels a ' + 'pending debounced reload; its unreachable `watchHandles` branch left ' - + 'with the placeholder). The #11825 keep still stands: ' + + 'with the placeholder). What the maintainer\'s 2026-08-25 ruling on the ' + + 'advanced plugin-lifecycle config kept still stands: ' + '`HotReloadConfigSchema` and `PluginStateSnapshotSchema` still export ' + 'from `./kernel`, and `HotReloadManager` / `PluginHealthMonitor` still ' + 'export from `@objectstack/core` with their tests green.', @@ -14166,7 +14226,7 @@ const step18: MigrationStep = { + '(the schema\'s own DEFAULT, materialized onto every parsed node that said nothing) ' + 'silently fell through to the in-flow render, and the value that actually docks the ' + 'panel (`right`) was refused at publish — declared ≠ enforced in both directions on the ' - + 'same key. The maintainer ruling (2026-08-15, #8762) converged the row on the ' + + 'same key. The maintainer ruling of 2026-08-15 on this row converged it on the ' + 'renderer\'s vocabulary with no mapping layer, and dropped all three schema defaults per ' + 'the `maxVisible` principle (renderer fallbacks stay the renderer\'s facts): the old ' + '`collapsible` default (`true`) additionally INVERTED the renderer merge\'s own fallback ' @@ -14178,7 +14238,7 @@ const step18: MigrationStep = { + 'chain cannot make: whether `drawer` → `right` (a docked panel standing in for a ' + 'never-implemented overlay) is the presentation the author wants, and whether a page ' + 'that relied on the old materialized `collapsible: true` default should now author it ' - + 'explicitly. ADR-0087, maintainer ruling 2026-08-15, #8762.', + + 'explicitly. ADR-0087, maintainer ruling 2026-08-15.', acceptanceCriteria: 'No authored `record:chatter` / `record:discussion` component carries `position: ' + "'sidebar' | 'inline' | 'drawer'`; `objectstack validate` passes. Review the rewritten " @@ -14349,7 +14409,7 @@ const step18: MigrationStep = { + 'been closed strictly once `api` became a registered metadata type, and ' + 'the surface a published skill had been teaching as working machinery ' + '(this finding came out of correcting that skill sentence, in a factual ' - + 'sweep of the automation skill). Bookkeeping: the KEY is tombstoned with ' + + 'sweep of the API skill). Bookkeeping: the KEY is tombstoned with ' + 'retiredKey() on the ' + 'non-strict RestApiEndpointSchema (api/RestApiEndpoint:handlerStatus in ' + 'RETIRED_KEYS_BY_MAJOR[18]); the DEFS leave whole — api/HandlerStatus ' @@ -14545,8 +14605,9 @@ const step18: MigrationStep = { + '!(record.status in ["closed", "archived"]). Scalar != and ==, null, Date comparands, and ' + '{ $field } references between single-valued columns evaluate exactly as before', reason: - 'Ruling A on #19886 refuses an array comparand under $ne, and the equality slot is ruling ' - + '乙 on #19757; stage 2a of #19886 lands both on the formula face, the evaluator ' + 'Ruling A of 2026-09-24 refuses an array comparand under $ne, and ruling 乙 of 2026-09-23 ' + + 'refuses one in the implicit-equality slot, each for every driver at once; this change ' + + 'lands both on the formula face, the evaluator ' + 'plugin-security runs against the post-image of an insert or update to enforce a ' + 'row-level check. It compared strictly, and no stored value ever equals an array, so a ' + 'check written record.status != ["closed", "archived"], or != against a current_user ' @@ -14606,7 +14667,9 @@ const step18: MigrationStep = { + 'file family is refused by name, whatever the deployment stores: during the ADR-0104 ' + 'dual-encoding window one media column can hold a bare id and another the JSON-quoted form of ' + 'the same id, so no comparison against the family is provably one answer on every path. ' - + 'driver-sql has refused such a comparison on the read since #5222, so a policy written ' + + 'driver-sql has refused such a comparison on the read since it first compiled a { $field } ' + + 'reference to a column-to-column comparison (a text column ordered against a number answered ' + + 'differently on SQLite than in memory, so the pushdown refused it), so a policy written ' + 'record.status != record.amount (text and a number), record.status != record.photo (text and ' + 'an image) or record.status != record.is_open (text and a formula field) got three answers, ' + 'measured through the real plugin-security on driver-sql, on SQLite and PostgreSQL: ' @@ -14614,16 +14677,17 @@ const step18: MigrationStep = { + 'by-id update or delete it scoped 403, and an insert or update its check judged, or its using ' + 'standing in as the check, was admitted and stored, because the write check compared the two ' + 'raw values. The classification is now exported once from @objectstack/spec/data ' - + '(crossFieldComparisonVerdict) and read by every judge. The authoring arm (#20347): the ' + + '(crossFieldComparisonVerdict) and read by every judge. The authoring arm: the ' + 'rls-predicate-unenforceable rule refuses the comparison in using and check, on every ' + 'operation, at os validate, build and lint and at the metadata save door for a permission ' + 'set, and the sharing-rule-unlowerable-condition rule refuses it in a sharing-rule condition ' - + 'at os validate, build and lint. The write-check arm (#20355): the row-level write gate hands ' + + 'at os validate, build and lint. The write-check arm: the row-level write gate hands ' + 'matchesFilterCondition the object\'s declared columns, and a comparison the classification ' + 'does not define is refused INVALID_FILTER / 400 for every insert and update the check judges, ' + 'before any record is read, with nothing stored; the message withholds the columns and the ' + 'server log names the policy and both. A comparison against a json or multiple field is now ' - + 'refused by its declared type on the write too, where #19886 judged it by the value each ' + + 'refused by its declared type on the write too, where the earlier refusal of an array ' + + 'comparand under $ne judged it by the value each ' + 'record held. driver-memory, a test driver with no field-reference arm, still reads such a ' + 'comparison as a literal. Shipped producers were counted before the change: no shipped ' + 'row-level policy or sharing-rule condition compares two fields of different classes. ' @@ -14664,7 +14728,9 @@ const step18: MigrationStep = { + 'before, and so are null and Date values, and every equality (==, !=, in) against a stored ' + 'list', reason: - 'Stage 2e of #19886, the mirror of stage 2d with the list on the record\'s side, measured ' + 'The mirror, with the list on the record\'s side, of the earlier refusal of an ordering ' + + 'operator against an array comparand (one of the same-class leaks that followed the ' + + '2026-09-24 ruling refusing an array under $ne), measured ' + 'through the real plugin-security on driver-sql and driver-memory. record.tags > "a", with ' + 'tags a json column holding ["m"], lowered to { tags: { $gt: "a" } }, and the write-check ' + 'evaluator compared the list\'s JavaScript string form ("m" > "a"), so the check admitted and ' @@ -14885,7 +14951,8 @@ const step18: MigrationStep = { + 'retired `/scim/generate-token` endpoint.', replacement: '(removed — no direct replacement row. The stable `@better-auth/scim` ' - + '1.7.x line (#3653, PR #12726) derives no `scimProvider` model: SCIM ' + + '1.7.x line, which the platform adopted as one whole-model migration, ' + + 'derives no `scimProvider` model: SCIM ' + 'state lives in the seven stable platform objects ' + '(`sys_scim_connection_binding`, `sys_scim_group`, ' + '`sys_scim_group_member`, `sys_scim_identity_tombstone`, ' @@ -14898,16 +14965,18 @@ const step18: MigrationStep = { + 'on any path, so the IdP reissues its token — a migration-day operator ' + 'action, not a code rewrite.)', reason: - 'Maintainer ruling 2026-08-24 on #11693 (verbatim: 「11700 11693 不需要考虑' - + '历史数据,其他按照你的建议继续」) — disposition A: retire, with no ' + 'Maintainer ruling 2026-08-24 on the disposition of `sys_scim_provider` ' + + '(verbatim, in part: 「不需要考虑历史数据」) — disposition A: retire, with no ' + 'data-migration path owed for existing rows (reaffirmed 2026-08-25: SCIM ' + 'has no real customers; the binding constraint is a smooth upgrade). ' - + 'Executed as #11757 after the stable-1.7.1 migration landed (#3653 / ' - + 'PR #12726): the installed library derives no `scimProvider` model, so ' + + 'Executed as a retirement of its own after the stable-1.7.1 migration ' + + 'landed: the installed library derives no `scimProvider` model, so ' + 'the object backed nothing — nothing could write a row to it any more. ' + 'Retiring it also removes its `provider_id` unique index, whose ' - + 'stricter-than-upstream uniqueness was flagged on #3653 and parked ' - + 'pending exactly this retirement.', + + 'stricter-than-upstream uniqueness (one `provider_id` across every ' + + 'organization, where upstream scopes it per organization) was flagged ' + + 'while the SCIM upgrade was parked, and left pending exactly this ' + + 'retirement.', acceptanceCriteria: 'No code imports `SysScimProvider` from `@objectstack/platform-objects` ' + '(TS2305 after upgrade); `isPlatformProvidedObjectName(\'sys_scim_provider\')` ' @@ -14917,7 +14986,7 @@ const step18: MigrationStep = { + 'spec registry conformance test (`platform-object-names.test.ts`) pins ' + 'the absence bidirectionally — re-adding either the object file or the ' + 'registry name alone reds `registry group "platform-objects" is out of ' - + 'date` (measured both ways on #11757). Existing `sys_scim_provider` ' + + 'date` (measured both ways when the object was retired). Existing `sys_scim_provider` ' + 'tables in deployed databases are left in place untouched, by ruling — ' + 'no backfill, no reaper, no migrate command.', },