diff --git a/.changeset/20233-stage-8-migration-guidance-tracker-free.md b/.changeset/20233-stage-8-migration-guidance-tracker-free.md new file mode 100644 index 00000000000..4ad41c993bc --- /dev/null +++ b/.changeset/20233-stage-8-migration-guidance-tracker-free.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `os migrate meta` guidance for twenty-four more migration-entry families — `stack-*`, `evaluated-*`, `aggregation-*`, `authoring-*`, `automation-*`, `cache-*`, `tenant-*`, `client-*`, `spec-*`, `cli-*`, `identity-*`, `import-*`, `tool-*`, `advanced-*`, `cloud-*`, `startup-*`, `sys-*`, `declarative-*`, `sort-*`, `address-*`, `packages-*`, `platform-*`, `session-*` and `strategy-*` — states each lesson in words instead of citing tracker numbers + +Clause-②: no + +The ADR-0087 semantic entries of these twenty-four families 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, decision-batch and summon numbers — some of which no longer +resolve, and some in another repository or a vendor's tracker — for what a ruling, +measurement or fix had decided; it now says what was decided, in the sentence being read. +ADR ids are kept, and so are the rule numbers of this repository's own contributor guide. + +Two entries also carried a tracker number in `surface`, the header line itself: +`authoring-schemas-strict-unknown-keys` now names the unknown-key strictness wave, and +`evaluated-expression-slots-source-required` names the census of engine-evaluated slots. +One sentence is corrected while being rewritten: `cli-command-contribution-retired` said the +`manifest.contributes.commands` tombstone was protocol 17; it is registered under protocol 18. + +Text only: no entry id, `from` / `to`, conversion or matching logic changes, and the chain +rewrites exactly what it rewrote before. 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 ec40cd6b949..48fdc46a2d1 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -221,8 +221,8 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - **`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, 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. +- **`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 it took the enforce leg of the 2026-08-07 ruling that kept it declared and retired `array_agg` / `string_agg`. `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 sweep of the `QueryAST` members no executor runs, the one 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 divergences closed earlier on the same axis (an aggregate function name the remote Turso face compiled and the local face refused, and an unsupported function thrown as a bare error with no code on both SQL faces), 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, driver-memory and driver-mongodb, then under the maintainer's 2026-08-05 investment freeze, 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 disposition that sweep gave `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049. - Done when: No caller sends `distinct` inside an `aggregations[]` entry, on the wire or through the SDK; a deduplicated count is written as `{ function: 'count_distinct', field }` and reads the same number on every backend. A query still carrying the key fails to parse with the removal prescription — including through `EngineAggregateOptionsSchema`, which reuses `AggregationNodeSchema` by reference — and `POST /api/v1/data/:object/query` answers `400 VALIDATION_FAILED` with a `fields[]` entry at `aggregations..distinct` instead of serving a number. Authoring it is a `tsc` error at the call site. ⚠️ The observable NUMBERS change on exactly one path and that is the point of the change: a `sum`/`avg` that used to be deduplicated by the in-memory fallback now answers what every SQL face has always answered for the same query. Verify against the SQL answer, not against the pre-upgrade fallback answer — the two disagreed, which is why the key is gone. - **`analytics-query-request-envelope-retired`** — `api.analyticsQueryRequest.query` → bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...) - Why not automatic: The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (the fallback that answered /analytics/query when no analytics service was installed, and dropped the caller's identity and its `where` filter at the door), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves. @@ -248,8 +248,8 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - **`auth-config-unadvertised-reserved-features`** — `api.authConfig.features.passkeys / api.authConfig.features.magicLink` → (removed — no replacement flag; the capabilities are not advertised) - Why not automatic: Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481. - Done when: No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; a client that gated UI on either now treats the capability as absent rather than reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` with either key fails to parse with its own prescription instead of being silently stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts `/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call directly. -- **`authoring-schemas-strict-unknown-keys`** — `the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them` → declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`) - - Why not automatic: zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, #4001, #5073, #5599 (registered #7630, backfilling #6350). +- **`authoring-schemas-strict-unknown-keys`** — `the protocol-17 authoring schemas closed against undeclared keys by the unknown-key strictness wave — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them` → declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`) + - Why not automatic: zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the maintainer's 2026-08-12 ruling that the wave is registered one entry per major, not one per batch, mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before closing its block: `userFilters.allowAddTab` was already read by objectui, so the maintainer's 2026-08-04 ruling declared it in the spec rather than let a correct-looking refusal tell authors to delete a working capability. The entry was registered in the backfill of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0049 / ADR-0078 / ADR-0087. - Done when: `objectstack validate` passes with no unknown-key parse errors on any authoring surface — the sweep is "fix until nothing raises", and every rejection carries its own fix. ⚠️ Parsing clean is the weaker half on three of these faces, because the key was being dropped rather than refused and a config that silently did nothing looked exactly like one that worked: re-check that responsive/theme/chart styling actually renders as authored, that every `aria` block still names the element it was written for, and that each state machine and loop config still carries the transitions and caps you declared. For stored `view` bodies, `GET /api/v1/meta/diagnostics?type=view` lists every overlay the identity precondition now rejects, one row per view with the reason; each is fixed by giving the body a real view shape or deleting an overlay that was never a view. - **`batch-options-validate-only-retired`** — `api.batchOptions.validateOnly` → (removed — no dry-run today; open an issue to design a no-commit batch preview) - Why not automatic: The `validateOnly` key promised a dry-run ("validate records without persisting") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it. @@ -258,7 +258,7 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119/#4620 rollback marking is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so "attempted and undone" vs "never ran" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. #4793. - Done when: No consumer reads `row.error` or `row.record` on a batch result row; failures are read from `row.errors` (message via `errors[0].message`, rollback state via `errors[0].code` — ROLLED_BACK / NOT_ATTEMPTED), records from `row.data`, and rows correlate to the request via `row.index`. Every row the three endpoints emit parses under `BatchOperationResultSchema` with those keys present. - **`client-delete-result-success`** — `client.DeleteDataResult.deleted (the return of `client.data.delete()`)` → `success` — `r.deleted` → `r.success`. Same call, same wire body, declared name - - Why not automatic: `DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same ruling #5581 applied on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the #6350 stock reconciliation. ADR-0087, #5638 (backfilled #6350). + - Why not automatic: `DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same rule already moved the runtime's ObjectQL fallback from answering `deleted: true` to the declared `success`, on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the stock reconciliation of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0087. - Done when: No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller must be swept by hand because nothing will report it. Nothing about the request, the route, the status codes or the error shapes changes, and no server needs upgrading — the value you may now read is the one that was already arriving. ⚠️ The real work is behavioural: every `if (r.deleted)` has been false since it was written, so re-read what each of those branches was supposed to do. Post-delete cleanup, cache invalidation, audit writes and UI refreshes guarded that way have never run, and switching to `r.success` turns them ON for the first time — verify that is what you want rather than assuming it restores prior behaviour. Any test that passed while asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming. - **`connector-inline-authentication-publish-refused`** — `connector.authentication on AUTHORED entries (defineStack `connectors:`, `PUT /meta/connector/:name`) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too` → a catalog descriptor drops `authentication` (or sets `{ type: "none" }`) and documents the auth scheme in `description`; a dispatchable instance declares `provider` and references its credential with `auth: { type, credentialRef }` (ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape still carries resolved secrets inline. - 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. @@ -288,7 +288,7 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: The inline-credential closure refused the credential KEYS, and building it measured that `config.url` still accepted the identical secret one syntax over — `postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as `config.password` did, and the key refusal itself steered authors there. The maintainer ruled on 2026-08-12 (Option A) to refuse the URL userinfo password at publish, through one value-level parse the driver schemas share. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entry `datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the userinfo alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (measured when the inline-credential refusal was built). - Done when: Every datasource parses with a credential-free `config.url` / `config.syncUrl` (no userinfo password segment); each affected datasource carries `external.credentialsRef` (or has its secret bound through the connection form) and still connects; no URL-embedded credential remains in any stored `sys_metadata` row or authored source. - **`declarative-apis-endpoints-live`** — `stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)` → the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }` - - 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. + - 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 (which is why the maintainer's 2026-08-04 ruling refused a non-empty `apis:` outright until an executor existed). Protocol 17 ships that executor and narrows the 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 source older than that refusal, 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. 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` (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. @@ -381,8 +381,8 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so "zero consumers in this repo" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep: `plugin-hono-server`, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as the config half's removal in this very file and the earlier removals of the dynamic plugin-loading family, the `ui/` interaction configs and the widget / i18n shapes. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049. - Done when: No source imports `ServerEvent`, `ServerEventType`, `ServerEventSchema`, `ServerCapabilities`, `ServerCapabilitiesSchema`, `ServerCapabilitiesParsed`, `ServerStatus` or `ServerStatusSchema` from `@objectstack/spec/system` — a grep over consumer code resolves none of them, and `tsc` reports TS2724/TS2305 on any that survives. The route-registration half of the same module still resolves (`RouteHandlerMetadataSchema`, `MiddlewareType`, `MiddlewareConfigSchema`, `MiddlewareConfig`), and `StackServerConfigSchema` — the one authorable server surface — is untouched: a stack declaring `server: { trustProxy, security }` parses exactly as it did in 16.x. - **`import-run-automations-declared-default-corrected`** — `api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as "off by default for bulk"; it is now default(true), which is what the server has always done` → an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing - - Why not automatic: A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since #2922 — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` (#6361) takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09 (#6704, disposition A: the spec follows the runtime). ADR-0049 / ADR-0078. - - Done when: Every import request of yours that must NOT fire triggers sends `runAutomations: false` explicitly, rather than omitting the key and trusting the old declared default. The check is worth doing precisely where it looks unnecessary: if you build the body by parsing it through `ImportRequestSchema` (or the published JSON Schema) and then send the PARSED object, your bulk loads were running with automations OFF and will now run with them ON — that is the only class whose behaviour changes, and it changes toward what an unvalidated caller always got. ⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as such: a body that omits `runAutomations` fired triggers before this change and fires them after, and `runAutomations: false` turns them off before and after. Nothing starts being refused — the route never validated this body against the schema and does not begin to. `dryRun` is unaffected and still runs NO automations whatever the flag says (#6037). + - Why not automatic: A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since the flag was first honoured — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped default was found invisible to every gate — whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09, disposition A: the spec follows the runtime. ADR-0049 / ADR-0078. + - Done when: Every import request of yours that must NOT fire triggers sends `runAutomations: false` explicitly, rather than omitting the key and trusting the old declared default. The check is worth doing precisely where it looks unnecessary: if you build the body by parsing it through `ImportRequestSchema` (or the published JSON Schema) and then send the PARSED object, your bulk loads were running with automations OFF and will now run with them ON — that is the only class whose behaviour changes, and it changes toward what an unvalidated caller always got. ⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as such: a body that omits `runAutomations` fired triggers before this change and fires them after, and `runAutomations: false` turns them off before and after. Nothing starts being refused — the route never validated this body against the schema and does not begin to. `dryRun` is unaffected and still runs NO automations whatever the flag says: it asks the engine's validate-only write path for its verdict, and that path deliberately fires no hooks. - **`job-retry-policy-constraints-tightened`** — `job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)` → maxRetries <= 10, and backoffMultiplier >= 1 - 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. @@ -438,17 +438,17 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-conte - Why not automatic: The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came out of the metadata property liveness audit, which found security properties parsed but never enforced, and was registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. ADR-0078 / ADR-0090 D3 / ADR-0087. - Done when: No sharing rule names `group` or `guest`, and none carries `type: owner`; stale definitions now FAIL parse with the valid options listed, so the sweep is "fix until nothing raises". ⚠️ Parsing clean is the weaker half — verify the SHARES, because a rule that was silently materialising nothing looked exactly like one that worked. For every rule that named `group`, confirm the `sys_team` it now resolves to has the membership you expected, and that records reach the people the rule was written for. Each former `guest` rule needs an explicit decision about anonymous access — a public form grant or a share link, or knowingly no access at all — and each former owner-type rule needs a `criteria` predicate that names the same population, checked against a representative record. Where a single business unit was meant, use `business_unit`; `unit_and_subordinates` is the subtree and grants strictly more. - **`sort-node-direction-rejected`** — `data.query.orderBy[].direction (SortNode)` → `order` — `orderBy: [{ field: "updated_at", order: "desc" }]`. One word, same values (`asc` / `desc`) - - Why not automatic: `SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: "updated_at", direction: "desc" })` returned `{ field: "updated_at", order: "asc" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for "the latest N", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: "order" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare "unrecognized key" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the #6350 stock reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350). + - Why not automatic: `SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: "updated_at", direction: "desc" })` returned `{ field: "updated_at", order: "asc" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for "the latest N", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: "order" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare "unrecognized key" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the stock reconciliation of the v17 train's breaking changesets: the in-code alias tombstone shipped with the change that closed both doors (maintainer ruling 2026-08-03), but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087. - Done when: No authored `orderBy` entry — in metadata, in a saved view's `sort[]`, or in a REST / RPC request body — spells the key `direction`. The upgrade's own verify loop is that the failure is now LOUD: a stale `direction` raises a named parse error (or `400 INVALID_SORT` at ingress) quoting `order`, so a sweep is "fix until nothing raises" rather than a search. ⚠️ Check the RESULTS, not just the parse: every list, report and paged query that carried `direction: "desc"` has been silently serving ASCENDING order and, wherever it was paired with `limit`, a different set of rows. After the rename those pages change what they return — that is the defect being corrected, not a regression, and any downstream expectation baked against the old output has to be re-read rather than restored. - **`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). + - Why not automatic: This entry exists for the reason `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` 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 gap ADR-0087 registration exists to close — the `ctx.user` `roles` alias was removed with no ledger entry, and that entry had to land separately. ⚠️ 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 (its phase 2, which moved every bare name to the author state). - 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 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. + - 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 stock reconciliation of the v17 train's breaking changesets: the `retiredKey()` tombstone shipped with the change that executed ADR-0033's deletion of this unenforced key, 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. + - 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 platform confirmation contract 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. - **`ui-interaction-config-family-retired`** — `ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)` → (removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor — the way inbound rate limiting came back, as a new key carrying only what its executor consumes — not by un-retiring a declaration) - Why not automatic: Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (its types package deliberately dropped the spec/ui zod-validator re-exports and keeps type-only ones). The 2026-08-04 ruling retired the family — touch, drag-and-drop, keyboard and motion are renderer built-in behaviour and offline belongs to a sync engine, none of it per-page metadata — and weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (the lesson of the datasource capability flags: `readOnly` was precisely validated and read by nothing, while a shipped example called a datasource a read replica and wrote through it). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as `plugin-runtime-family-retired` (the kernel plugin-runtime family) and the `HttpServerConfig` retirement (seven keys no runtime read and no authoring door reached, retired with their container). ⚠️ Not to be confused with the theme-token retirement (theme-driven typography is not a near-term capability, so nine token groups nothing consumed were retired), which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049. - Done when: No code imports any of the 64 retired names from `@objectstack/spec` or `@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, `SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, `LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, `TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, `AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, `MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, `DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, `FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, `KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, `ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, `EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these blocks: a stack that parsed before parses byte-for-byte the same after. If you consumed the bare `ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, declare that union locally — it is your client's policy, not the platform's. `@objectstack/spec/integration`'s `ConnectorConflictResolution` (connector sync) and `@objectstack/spec/api`'s `ConflictResolutionStrategy` (route merge policy) are different concepts and are untouched. diff --git a/packages/cli/test/migrate-meta-engine-guidance.test.ts b/packages/cli/test/migrate-meta-engine-guidance.test.ts index cdcab9bd198..af734bc40c0 100644 --- a/packages/cli/test/migrate-meta-engine-guidance.test.ts +++ b/packages/cli/test/migrate-meta-engine-guidance.test.ts @@ -8,8 +8,13 @@ * `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-*`) - * states each lesson in words and carries no tracker number. + * `dashboard-*`, `notification-*`, `record-*`, `runtime-*`, `rls-*`, `scim-*`, + * `stack-*`, `evaluated-*`, `aggregation-*`, `authoring-*`, `automation-*`, + * `cache-*`, `tenant-*`, `client-*`, `spec-*`, `cli-*`, `identity-*`, + * `import-*`, `tool-*`, `advanced-*`, `cloud-*`, `startup-*`, `sys-*`, + * `declarative-*`, `sort-*`, `address-*`, `packages-*`, `platform-*`, + * `session-*`, `strategy-*`) states each lesson in words and carries no + * tracker number. * * ## What this pins * @@ -87,6 +92,10 @@ const COVERED_PREFIXES = [ 'actor-', 'hot-', 'external-', 'query-', 'delete-', 'etl-', 'storage-', 'apimethod-', 'dashboard-', 'notification-', 'record-', 'runtime-', 'rls-', 'scim-', + 'stack-', 'evaluated-', 'aggregation-', 'authoring-', 'automation-', 'cache-', + 'tenant-', 'client-', 'spec-', 'cli-', 'identity-', 'import-', 'tool-', + 'advanced-', 'cloud-', 'startup-', 'sys-', 'declarative-', 'sort-', 'address-', + 'packages-', 'platform-', 'session-', 'strategy-', ]; /** @@ -101,6 +110,9 @@ const REWRITTEN = [ 'action-engine-facade-find-query-envelope', 'action-session-roles-to-positions', 'actor-user-roles-to-positions', + 'address-location-value-unknown-keys-refused', + 'advanced-plugin-lifecycle-config-retired', + 'aggregation-node-distinct-retired', 'analytics-authorable-unknown-keys-refused', 'analytics-date-range-array-two-bounds-required', 'analytics-query-request-envelope-retired', @@ -112,6 +124,14 @@ const REWRITTEN = [ 'apimethod-enum-shrink', 'audit-log-action-enum-retired', 'audit-log-action-restore-retired', + 'authoring-schemas-strict-unknown-keys', + 'automation-flow-list-route-retired', + 'automation-runs-cursor-retired', + 'cache-warmup-scheduled-strategy-retired', + 'cli-command-contribution-retired', + 'client-delete-result-success', + 'client-meta-reset-result-reset', + 'cloud-subpath-retired', 'dashboard-header-modal-target-page-only', 'dashboard-widget-chart-config-structure-refused', 'dashboard-widget-compareto-offset', @@ -134,6 +154,7 @@ const REWRITTEN = [ 'datasource-config-url-userinfo-refused', 'datasource-credentialsref-mongo-composed-no-username-refused', 'datasource-credentialsref-mongo-url-no-user-refused', + 'declarative-apis-endpoints-live', 'delete-by-id-before-hook-repoint-retired', 'driver-aggregate-undeclared-key-aliases-removed', 'driver-capabilities-inert-bits-removed', @@ -151,6 +172,7 @@ const REWRITTEN = [ 'engine-find-formula-order-by-refused', 'engine-update-upsert-retired', 'etl-pipeline-layer-retired', + 'evaluated-expression-slots-source-required', 'export-axis-opt-in', 'export-field-meta-constraints-retired', 'export-job-family-retired', @@ -185,6 +207,8 @@ const REWRITTEN = [ 'hot-reload-watch-placeholder-retired', 'http-request-errors-total-retired', 'http-server-runtime-vocabulary-retired', + 'identity-api-key-schema-retired', + 'import-run-automations-declared-default-corrected', 'inline-grid-column-currency-scale-refused', 'kernel-compatibility-matrix-estimated-migration-time-unit-in-key', 'kernel-context-preview-mode-retired', @@ -209,6 +233,8 @@ const REWRITTEN = [ 'package-install-request-unknown-keys-refused', 'package-rollback-response-retired', 'package-uninstall-explicit-all-tenants', + 'packages-list-pagination-retired', + 'platform-timezone-columns-iana-domain-refused', 'plugin-activation-events-retired', 'plugin-auto-restart-never-reinitialised', 'plugin-manifest-contributes-dead-members-retired', @@ -236,9 +262,18 @@ const REWRITTEN = [ 'rls-predicate-stored-list-ordering-refused', 'runtime-httpserver-wrapper-retired', 'scim-provider-object-retired', + 'session-payload-positions-security-axis', + 'session-user-language-retired', 'sharing-execution-context-retired', 'sharing-rule-recipient-reconcile', + 'sort-node-direction-rejected', + 'spec-type-alias-input-suffix-retired', + 'stack-themes-carrier-retired', + 'stack-top-level-unknown-keys-refused', + 'startup-orchestrator-retired', 'storage-service-list-retired', + 'strategy-context-aggregation-method-narrowed', + 'sys-account-issuer-retired', 'system-cache-durations-unit-in-key', 'system-collaboration-durations-unit-in-key', 'system-failover-health-check-interval-unit-in-key', @@ -249,6 +284,9 @@ const REWRITTEN = [ 'system-tracing-otel-exporter-durations-unit-in-key', 'system-tracing-span-duration-unit-in-key', 'system-worker-queue-rate-limit-duration-unit-in-key', + 'tenant-schema-cache-ttl-unit-in-key', + 'tenant-timeouts-unit-in-key', + 'tool-requires-confirmation-retired', 'ui-cloud-connection-widgets-unknown-keys-refused', 'ui-form-field-length-malformed-refused', 'ui-form-field-precision-scale-integer-refused', diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 66a2e59e82e..5b1494fa671 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -382,10 +382,10 @@ }, { "surface": "data.query.aggregations[].distinct", - "replacement": "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", + "replacement": "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 it took the enforce leg of the 2026-08-07 ruling that kept it declared and retired `array_agg` / `string_agg`. `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", "migrationId": "aggregation-node-distinct-retired", "toMajor": 17, - "rationale": "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." + "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the `QueryAST` members no executor runs, the one 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 divergences closed earlier on the same axis (an aggregate function name the remote Turso face compiled and the local face refused, and an unsupported function thrown as a bare error with no code on both SQL faces), 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, driver-memory and driver-mongodb, then under the maintainer's 2026-08-05 investment freeze, 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 disposition that sweep gave `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049." }, { "surface": "api.analyticsQueryRequest.query", @@ -444,11 +444,11 @@ "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481." }, { - "surface": "the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them", + "surface": "the protocol-17 authoring schemas closed against undeclared keys by the unknown-key strictness wave — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them", "replacement": "declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)", "migrationId": "authoring-schemas-strict-unknown-keys", "toMajor": 17, - "rationale": "zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, #4001, #5073, #5599 (registered #7630, backfilling #6350)." + "rationale": "zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the maintainer's 2026-08-12 ruling that the wave is registered one entry per major, not one per batch, mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before closing its block: `userFilters.allowAddTab` was already read by objectui, so the maintainer's 2026-08-04 ruling declared it in the spec rather than let a correct-looking refusal tell authors to delete a working capability. The entry was registered in the backfill of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0049 / ADR-0078 / ADR-0087." }, { "surface": "api.batchOptions.validateOnly", @@ -469,7 +469,7 @@ "replacement": "`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name", "migrationId": "client-delete-result-success", "toMajor": 17, - "rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same ruling #5581 applied on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the #6350 stock reconciliation. ADR-0087, #5638 (backfilled #6350)." + "rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same rule already moved the runtime's ObjectQL fallback from answering `deleted: true` to the declared `success`, on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the stock reconciliation of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0087." }, { "surface": "connector.authentication on AUTHORED entries (defineStack `connectors:`, `PUT /meta/connector/:name`) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too", @@ -539,7 +539,7 @@ "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", "migrationId": "declarative-apis-endpoints-live", "toMajor": 17, - "rationale": "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." + "rationale": "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 (which is why the maintainer's 2026-08-04 ruling refused a non-empty `apis:` outright until an executor existed). Protocol 17 ships that executor and narrows the 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 source older than that refusal, 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. 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." }, { "surface": "a `beforeDelete` handler on a BY-ID `delete()` assigning `ctx.input.id` a DIFFERENT id, to move the delete onto that row", @@ -686,7 +686,7 @@ "replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing", "migrationId": "import-run-automations-declared-default-corrected", "toMajor": 17, - "rationale": "A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since #2922 — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` (#6361) takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09 (#6704, disposition A: the spec follows the runtime). ADR-0049 / ADR-0078." + "rationale": "A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since the flag was first honoured — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped default was found invisible to every gate — whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09, disposition A: the spec follows the runtime. ADR-0049 / ADR-0078." }, { "surface": "job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)", @@ -819,14 +819,14 @@ "replacement": "`order` — `orderBy: [{ field: \"updated_at\", order: \"desc\" }]`. One word, same values (`asc` / `desc`)", "migrationId": "sort-node-direction-rejected", "toMajor": 17, - "rationale": "`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: \"updated_at\", direction: \"desc\" })` returned `{ field: \"updated_at\", order: \"asc\" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for \"the latest N\", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: \"order\" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare \"unrecognized key\" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: \"asc\"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the #6350 stock reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350)." + "rationale": "`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: \"updated_at\", direction: \"desc\" })` returned `{ field: \"updated_at\", order: \"asc\" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for \"the latest N\", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: \"order\" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare \"unrecognized key\" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: \"asc\"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the stock reconciliation of the v17 train's breaking changesets: the in-code alias tombstone shipped with the change that closed both doors (maintainer ruling 2026-08-03), but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087." }, { "surface": "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/)", "replacement": "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.", "migrationId": "spec-type-alias-input-suffix-retired", "toMajor": 17, - "rationale": "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)." + "rationale": "This entry exists for the reason `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` 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 gap ADR-0087 registration exists to close — the `ctx.user` `roles` alias was removed with no ledger entry, and that entry had to land separately. ⚠️ 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 (its phase 2, which moved every bare name to the author state)." }, { "surface": "contracts.IStorageService.list", @@ -840,7 +840,7 @@ "replacement": "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", "migrationId": "tool-requires-confirmation-retired", "toMajor": 17, - "rationale": "`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)." + "rationale": "`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 stock reconciliation of the v17 train's breaking changesets: the `retiredKey()` tombstone shipped with the change that executed ADR-0033's deletion of this unenforced key, 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." }, { "surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)", @@ -1274,10 +1274,10 @@ }, { "surface": "data.query.aggregations[].distinct", - "replacement": "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", + "replacement": "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 it took the enforce leg of the 2026-08-07 ruling that kept it declared and retired `array_agg` / `string_agg`. `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", "migrationId": "aggregation-node-distinct-retired", "toMajor": 17, - "rationale": "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." + "rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the `QueryAST` members no executor runs, the one 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 divergences closed earlier on the same axis (an aggregate function name the remote Turso face compiled and the local face refused, and an unsupported function thrown as a bare error with no code on both SQL faces), 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, driver-memory and driver-mongodb, then under the maintainer's 2026-08-05 investment freeze, 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 disposition that sweep gave `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049." }, { "surface": "api.analyticsQueryRequest.query", @@ -1336,11 +1336,11 @@ "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481." }, { - "surface": "the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them", + "surface": "the protocol-17 authoring schemas closed against undeclared keys by the unknown-key strictness wave — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them", "replacement": "declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)", "migrationId": "authoring-schemas-strict-unknown-keys", "toMajor": 17, - "rationale": "zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, #4001, #5073, #5599 (registered #7630, backfilling #6350)." + "rationale": "zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the maintainer's 2026-08-12 ruling that the wave is registered one entry per major, not one per batch, mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before closing its block: `userFilters.allowAddTab` was already read by objectui, so the maintainer's 2026-08-04 ruling declared it in the spec rather than let a correct-looking refusal tell authors to delete a working capability. The entry was registered in the backfill of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0049 / ADR-0078 / ADR-0087." }, { "surface": "api.batchOptions.validateOnly", @@ -1361,7 +1361,7 @@ "replacement": "`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name", "migrationId": "client-delete-result-success", "toMajor": 17, - "rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same ruling #5581 applied on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the #6350 stock reconciliation. ADR-0087, #5638 (backfilled #6350)." + "rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same rule already moved the runtime's ObjectQL fallback from answering `deleted: true` to the declared `success`, on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the stock reconciliation of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0087." }, { "surface": "connector.authentication on AUTHORED entries (defineStack `connectors:`, `PUT /meta/connector/:name`) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too", @@ -1431,7 +1431,7 @@ "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", "migrationId": "declarative-apis-endpoints-live", "toMajor": 17, - "rationale": "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." + "rationale": "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 (which is why the maintainer's 2026-08-04 ruling refused a non-empty `apis:` outright until an executor existed). Protocol 17 ships that executor and narrows the 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 source older than that refusal, 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. 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." }, { "surface": "a `beforeDelete` handler on a BY-ID `delete()` assigning `ctx.input.id` a DIFFERENT id, to move the delete onto that row", @@ -1578,7 +1578,7 @@ "replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing", "migrationId": "import-run-automations-declared-default-corrected", "toMajor": 17, - "rationale": "A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since #2922 — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` (#6361) takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09 (#6704, disposition A: the spec follows the runtime). ADR-0049 / ADR-0078." + "rationale": "A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since the flag was first honoured — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped default was found invisible to every gate — whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09, disposition A: the spec follows the runtime. ADR-0049 / ADR-0078." }, { "surface": "job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)", @@ -1711,14 +1711,14 @@ "replacement": "`order` — `orderBy: [{ field: \"updated_at\", order: \"desc\" }]`. One word, same values (`asc` / `desc`)", "migrationId": "sort-node-direction-rejected", "toMajor": 17, - "rationale": "`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: \"updated_at\", direction: \"desc\" })` returned `{ field: \"updated_at\", order: \"asc\" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for \"the latest N\", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: \"order\" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare \"unrecognized key\" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: \"asc\"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the #6350 stock reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350)." + "rationale": "`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: \"updated_at\", direction: \"desc\" })` returned `{ field: \"updated_at\", order: \"asc\" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for \"the latest N\", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: \"order\" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare \"unrecognized key\" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: \"asc\"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the stock reconciliation of the v17 train's breaking changesets: the in-code alias tombstone shipped with the change that closed both doors (maintainer ruling 2026-08-03), but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087." }, { "surface": "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/)", "replacement": "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.", "migrationId": "spec-type-alias-input-suffix-retired", "toMajor": 17, - "rationale": "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)." + "rationale": "This entry exists for the reason `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` 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 gap ADR-0087 registration exists to close — the `ctx.user` `roles` alias was removed with no ledger entry, and that entry had to land separately. ⚠️ 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 (its phase 2, which moved every bare name to the author state)." }, { "surface": "contracts.IStorageService.list", @@ -1732,7 +1732,7 @@ "replacement": "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", "migrationId": "tool-requires-confirmation-retired", "toMajor": 17, - "rationale": "`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)." + "rationale": "`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 stock reconciliation of the v17 train's breaking changesets: the `retiredKey()` tombstone shipped with the change that executed ADR-0033's deletion of this unenforced key, 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." }, { "surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)", diff --git a/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts b/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts index 63fa0506726..9588f665237 100644 --- a/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts @@ -8,12 +8,15 @@ export const entry: SemanticMigration = { replacement: '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 ' + + 'both SQL faces since it took the enforce leg of the 2026-08-07 ruling that kept it ' + + 'declared and retired `array_agg` / `string_agg`. ' + + '`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', reason: - '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 ' + 'A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the ' + + '`QueryAST` members no executor runs, the one 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 ' @@ -22,21 +25,24 @@ export const entry: SemanticMigration = { + '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 ' + + 'to serve it — and unlike the divergences closed earlier on the same axis (an ' + + 'aggregate function name the remote Turso face compiled and the local face refused, and ' + + 'an unsupported function thrown as a bare error with no code on both SQL faces), 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 — ' + + 'and lowering `SUM(DISTINCT …)` across five faces — two of them, driver-memory and ' + + 'driver-mongodb, then under the maintainer\'s 2026-08-05 investment freeze, 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 ' + + 'rewrite and callers move their own queries: the disposition that sweep gave ' + '`joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ' - + 'ADR-0049, #6815.', + + 'ADR-0049.', acceptanceCriteria: 'No caller sends `distinct` inside an `aggregations[]` entry, on the wire or through ' + 'the SDK; a deduplicated count is written as `{ function: \'count_distinct\', field }` ' diff --git a/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts b/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts index 87ea962f180..94b2cc96ff3 100644 --- a/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts +++ b/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts @@ -45,7 +45,8 @@ import type { SemanticMigration } from '../../types.js'; export const entry: SemanticMigration = { id: 'authoring-schemas-strict-unknown-keys', surface: - 'the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` ' + 'the protocol-17 authoring schemas closed against undeclared keys by the unknown-key ' + + 'strictness wave — `automation/` ' + '(flow and its six nested blocks, control-flow, state-machine, webhook, time-relative ' + 'trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and ' + '`identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` ' @@ -77,14 +78,19 @@ export const entry: SemanticMigration = { + 'is not an unknown-key close but the same defect one level up — the `view` union had an ' + 'arm that both stripped and required nothing, so it matched every object and `saveMetaItem` ' + 'persisted garbage as an ACTIVE view overlay that read back badged valid. ' - + 'This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the ' + + 'This is ONE entry for the whole major by the maintainer\'s 2026-08-12 ruling that the ' + + 'wave is registered one entry per major, not one per batch, mirroring the ' + "registry's only two precedents of this shape; the eleven batches it folds are the " + 'changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, ' + '`-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, ' + '`view-subblock-strictness-batch18`, `rare-jars-shave`, ' + '`user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, ' - + 'each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, ' - + '#4001, #5073, #5599 (registered #7630, backfilling #6350).', + + 'each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before ' + + 'closing its block: `userFilters.allowAddTab` was already read by objectui, so the ' + + 'maintainer\'s 2026-08-04 ruling declared it in the spec rather than let a correct-looking ' + + 'refusal tell authors to delete a working capability. The entry was registered in the ' + + 'backfill of the v17 train\'s breaking changesets, which had never been compared against ' + + 'the ledger. ADR-0049 / ADR-0078 / ADR-0087.', acceptanceCriteria: '`objectstack validate` passes with no unknown-key parse errors on any authoring surface — ' + 'the sweep is "fix until nothing raises", and every rejection carries its own fix. ' diff --git a/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts b/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts index 53051b34b57..a5306301aff 100644 --- a/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts +++ b/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts @@ -29,10 +29,12 @@ export const entry: SemanticMigration = { + 'there is no constrained channel at all, which is why the ledger entry is the only ' + 'notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one ' + 'producer shape, and a consumer accepting two spellings is what contract-first exists ' - + 'to prevent (the same ruling #5581 applied on the producer side). No deprecated ' + + 'to prevent (the same rule already moved the runtime\'s ObjectQL fallback from answering ' + + '`deleted: true` to the declared `success`, on the producer side). No deprecated ' + '`deleted?: boolean` transition key ships, for the same reason — a transition period is ' - + 'for keys that WORKED, and this one never did. Registered by the #6350 stock ' - + 'reconciliation. ADR-0087, #5638 (backfilled #6350).', + + 'for keys that WORKED, and this one never did. Registered by the stock reconciliation of ' + + 'the v17 train\'s breaking changesets, which had never been compared against the ledger. ' + + 'ADR-0087.', acceptanceCriteria: 'No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.' + 'delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller ' diff --git a/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts b/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts index 9d1da3c90dc..856a74deb02 100644 --- a/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts +++ b/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts @@ -15,11 +15,13 @@ export const entry: SemanticMigration = { + '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 ' + + '(which is why the maintainer\'s 2026-08-04 ruling refused a non-empty `apis:` outright ' + + 'until an executor existed). Protocol 17 ships that ' + + 'executor and narrows the 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 ' + + 'or one restored from a source older than that ' + + 'refusal, 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 ' @@ -36,7 +38,7 @@ export const entry: SemanticMigration = { + '(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 ' + + 'to avoid. 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: ' diff --git a/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts b/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts index 101551552ab..a443ce5620d 100644 --- a/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts +++ b/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts @@ -25,8 +25,9 @@ export const entry: SemanticMigration = { + 'meant to fire triggers is a judgment no transform can make, so the prescription is ' + 'a TODO rather than a rewrite. The server decides in import-prepare.ts with ' + '`body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has ' - + 'since #2922 — automations always ran on import historically (the engine ignored ' - + 'the flag entirely before then), so opt-out was made the explicit act, matching ' + + 'since the flag was first honoured — automations always ran on import historically ' + + '(the engine ignored the flag entirely before then), ' + + 'so opt-out was made the explicit act, matching ' + 'platform convention. The schema said the opposite in both machine-readable and ' + "human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s " + 'JSON Schema, and the describe prose in the published reference tables for both ' @@ -45,12 +46,13 @@ export const entry: SemanticMigration = { + 'warned, and the reference page told an author the wrong thing in the other ' + 'direction. There is deliberately NO schema tombstone and no D2 conversion: no key ' + 'is removed, and an HTTP request body is neither authored nor persisted — the same ' - + 'disposition `notification-list-cursor-retired` (#6361) takes for the sibling ' + + 'disposition `notification-list-cursor-retired` takes for the sibling ' + 'default on this major, and `batch-options-validate-only-retired` before it. The ' + 'declared move itself is recorded mechanically, per key, in ' - + 'DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are ' - + 're-derived on every build. Maintainer ruling 2026-08-09 (#6704, disposition A: ' - + 'the spec follows the runtime). ADR-0049 / ADR-0078.', + + 'DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped ' + + 'default was found invisible to every gate — whose `from`/`to` fingerprints are ' + + 're-derived on every build. Maintainer ruling 2026-08-09, disposition A: ' + + 'the spec follows the runtime. ADR-0049 / ADR-0078.', acceptanceCriteria: 'Every import request of yours that must NOT fire triggers sends `runAutomations: ' + 'false` explicitly, rather than omitting the key and trusting the old declared ' @@ -64,5 +66,6 @@ export const entry: SemanticMigration = { + 'fires them after, and `runAutomations: false` turns them off before and after. ' + 'Nothing starts being refused — the route never validated this body against the ' + 'schema and does not begin to. `dryRun` is unaffected and still runs NO automations ' - + 'whatever the flag says (#6037).', + + 'whatever the flag says: it asks the engine\'s validate-only write path for its verdict, ' + + 'and that path deliberately fires no hooks.', }; diff --git a/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts b/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts index 4542d063a06..e5e9dbdad83 100644 --- a/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts +++ b/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts @@ -30,11 +30,13 @@ export const entry: SemanticMigration = { + 'is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the ' + 'author may have written it meaning ascending and been silently GIVEN ascending, so ' + 'the visible behaviour never contradicted them, and only they can say whether the ' - + 'sort they have been reading was the sort they asked for. Registered by the #6350 stock ' - + 'reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half ' + + 'sort they have been reading was the sort they asked for. Registered by the stock ' + + 'reconciliation of the v17 train\'s breaking changesets: the in-code alias tombstone ' + + 'shipped with the change that closed both doors ' + + '(maintainer ruling 2026-08-03), but the ledger half ' + 'never did, and a retirement needs both — the tombstone is the proof the removal was ' + 'declared, the ledger entry is what `spec-changes.json`, the upgrade guide and ' - + '`os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350).', + + '`os migrate meta` project to consumers. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No authored `orderBy` entry — in metadata, in a saved view\'s `sort[]`, or in a REST / ' + 'RPC request body — spells the key `direction`. The upgrade\'s own verify loop is that ' diff --git a/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts b/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts index e9f098cc6fc..65c347b7906 100644 --- a/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts @@ -23,8 +23,8 @@ export const entry: SemanticMigration = { + '`ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or ' + '`Partial`-shaped) types no bare alias denotes.', reason: - 'This entry exists for the reason `data-driver-find-stream-retired` (#4484), ' - + '`storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) ' + 'This entry exists for the reason `data-driver-find-stream-retired`, ' + + '`storage-service-list-retired` and `actor-user-roles-to-positions` ' + '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, ' @@ -37,16 +37,18 @@ export const entry: SemanticMigration = { + '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 ' + + 'that carries the second half, which is precisely the gap ADR-0087 registration ' + + 'exists to close — the `ctx.user` `roles` alias was removed with no ledger entry, and ' + + 'that entry had to land separately. ⚠️ 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).', + + 'retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9 (its ' + + 'phase 2, which moved every bare name to the author state).', acceptanceCriteria: '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 ' diff --git a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts index 76b6f3cf137..30db587880a 100644 --- a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts @@ -37,19 +37,20 @@ export const entry: SemanticMigration = { + '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 ' + + '`@objectstack/spec` is guaranteed to hit. Registered by the stock reconciliation of the ' + + 'v17 train\'s breaking changesets: the `retiredKey()` tombstone shipped with the change ' + + 'that executed ADR-0033\'s deletion of this unenforced key, 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).', + + 'ADR-0049 / ADR-0087.', acceptanceCriteria: '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 ' + + 'true`, which is what the platform confirmation contract 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 ' diff --git a/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts index ba7ebabfd21..aab53d301d0 100644 --- a/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.address-location-value-unknown-keys-refused.ts @@ -15,11 +15,15 @@ export const entry: SemanticMigration = { + '`latitude` → `lat`, `longitude` → `lng`). A key that names no declared member is removed ' + 'at the producer — never tolerated at a consumer (AGENTS.md #0.1)', reason: - 'Maintainer ruling 2026-09-01 on #13802 (option A). Both value classes were all-optional ' + 'Maintainer ruling 2026-09-01, option A: both value classes refuse undeclared keys. Both ' + + 'value classes were all-optional ' + 'STRIPPING `z.object`s, so a value with a completely wrong key set parsed green and the ' + 'wrong keys vanished from the parse output: the showcase seed wrote `postal_code`, the ' - + 'platform accepted it, dropped it, and rendered an empty ZIP box (#13388, objectui#6812; ' - + '#5143 named the same stripping on the widget round-trip), while a stored-value scan over ' + + 'platform accepted it, dropped it, and rendered an empty ZIP box (found while counting ' + + 'stored address values for objectui\'s survey of which structured values its field ' + + 'validator checks; an earlier report had named the same stripping on the address ' + + 'widget\'s round-trip, whose ZIP input bound `zipCode` against a stored `postalCode`), ' + + 'while a stored-value scan over ' + 'the class could only ever report a clean count it had no way to earn. Closing the two ' + 'shapes restores declared = enforced and pulls "loose" back to the one deliberate ' + 'exception (`FileValueSchema`, untouched). Where the refusal BITES is the ADR-0104 write ' diff --git a/packages/spec/src/migrations/entries/semantic/18.advanced-plugin-lifecycle-config-retired.ts b/packages/spec/src/migrations/entries/semantic/18.advanced-plugin-lifecycle-config-retired.ts index 02b93948118..f6493913e49 100644 --- a/packages/spec/src/migrations/entries/semantic/18.advanced-plugin-lifecycle-config-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.advanced-plugin-lifecycle-config-retired.ts @@ -18,7 +18,8 @@ export const entry: SemanticMigration = { + 'library in `@objectstack/core`: construct `PluginHealthMonitor` and ' + 'pass a `PluginHealthCheck` per plugin, construct `HotReloadManager` and ' + 'pass a `HotReloadConfig` — the `content/docs/protocol/kernel/' - + 'lifecycle.mdx` examples (#11811) are the supported usage, and those ' + + 'lifecycle.mdx` examples — rewritten to show the plugin exposing a method and ' + + 'the host registering it, never a declarative field — are the supported usage, and those ' + 'input vocabularies (`PluginHealthStatus` / `PluginHealthCheck` / ' + '`PluginHealthReport`, `HotReloadConfig` with its embedded ' + '`DistributedStateConfig`, `PluginStateSnapshot`) SURVIVE in the same ' @@ -26,8 +27,9 @@ export const entry: SemanticMigration = { + 'vocabularies return only via the ENFORCE route of ADR-0049 through a ' + 'new ADR — the executor first, the vocabulary second)', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25 on #11825 ' - + '(route 2). The container aggregated six config groups — `health`, ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, route 2: ' + + 'retire the config container and keep the classes as a host-driven ' + + 'library. The container aggregated six config groups — `health`, ' + '`hotReload`, `degradation`, `updates`, `resources`, `observability` — ' + 'and NO group had a runtime reader, re-measured per group at the ' + 'retirement\'s base commit (8cdd696) with positive controls: the kernel ' @@ -45,12 +47,14 @@ export const entry: SemanticMigration = { + 'metadata-type binding ever embedded the container, so no authored ' + 'document could carry it: an author declaring `health: {...}` or ' + '`rollback: { automatic: true }` got a clean parse and NOTHING — the ' - + '#3950 shape at container scale, sharpened by production-safety ' + + 'shape of the plugin sandboxing / integrity / approval config that was ' + + 'never wired to anything, at container scale, sharpened by production-safety ' + 'vocabulary (auto-restart, zero-downtime rolling updates, automatic ' + 'rollback) an AI author (ADR-0033) reads as proof the capability ' + 'exists. With no carrier key and no authored document there is nothing ' - + 'to tombstone and no seam for a D2 conversion: route 3, the #4834 / ' - + '#8715 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the ' + + 'to tombstone and no seam for a D2 conversion: route 3, the shape of ' + + 'the dynamic plugin-loading family\'s removal and of the retired ' + + '`ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the ' + 'declaration.', acceptanceCriteria: 'No code imports any of the 9 retired names from `@objectstack/spec` or ' diff --git a/packages/spec/src/migrations/entries/semantic/18.automation-flow-list-route-retired.ts b/packages/spec/src/migrations/entries/semantic/18.automation-flow-list-route-retired.ts index b63a91ab13a..6913139dde5 100644 --- a/packages/spec/src/migrations/entries/semantic/18.automation-flow-list-route-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.automation-flow-list-route-retired.ts @@ -23,8 +23,10 @@ export const entry: SemanticMigration = { + '`GET /api/v1/automation/_status` (`client.automation.getRuntimeStatus`), which is ' + 'unchanged', reason: - 'Maintainer ruling on #19543 (door ④, verbatim 「退役,统一走 /meta/flow」, recorded in ' - + 'that card\'s re-derivation comment of 2026-09-25), under ADR-0049 enforce-or-remove. The ' + 'Maintainer ruling of 2026-09-25 on the list doors found declaring `limit` / `cursor` and ' + + 'never reading them (this route is door ④; verbatim 「退役,统一走 /meta/flow」, given when ' + + 'asked why the flow list does not use the standard ' + + 'API), under ADR-0049 enforce-or-remove. The ' + 'route\'s contract described a capability nobody built: ListFlowsRequestSchema declared ' + '`status`, `type`, `limit` (default 50) and `cursor`, and the handler read none of them — ' + 'it asked the automation service for its flow names with no arguments at all. ' @@ -42,8 +44,7 @@ export const entry: SemanticMigration = { + 'There is no alias and no transition window: GET simply stops being mounted there. There is ' + 'no D2 conversion and no tombstone, because the shape is HTTP-only — nobody authors a ' + 'ListFlowsRequest and nothing persists one — so the three schemas are whole-def removals in ' - + 'RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106, ' - + '#19543.', + + 'RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106.', acceptanceCriteria: 'On the composition `objectstack serve` builds, GET is no longer mounted at ' + '/api/v1/automation (nor at its environment-scoped twin), so the host gives its standard ' diff --git a/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts index 4a90fec3f84..6369b79b3f9 100644 --- a/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts @@ -22,8 +22,9 @@ export const entry: SemanticMigration = { + 'rather than the constant `false` it used to be, so for the first time it answers the ' + 'question a caller reaching for a cursor was actually asking', reason: - 'ADR-0049 enforce-or-remove (director seat, decision batch #204 item 2, maintainer ' - + '「204 同意」 2026-09-21, letter C of three for this door; letter A — build a cursor ' + 'ADR-0049 enforce-or-remove (maintainer ruling 2026-09-21 on the list doors found ' + + 'declaring `limit` / `cursor` and never reading them — this door is door ①, and the ' + + 'ruling took letter C of three for it; letter A — build a cursor ' + 'protocol for a 100-row window — and letter B — retire the key and leave the ' + '`hasMore` lie standing — were both considered and refused). `cursor` was declared on ' + 'the request, VALIDATED at the boundary, forwarded into a `cursor?: string` slot on ' @@ -33,7 +34,8 @@ export const entry: SemanticMigration = { + 'error. ' + '⭐ The `limit` half of this door was NOT retired, and the distinction is the ruling, ' + 'not an oversight. The sibling `/packages` door retired its `limit` with its `cursor` ' - + '(#17667, decision batch #126 item 1) because nothing read it; the parent ruling ' + + '(the 2026-09-13 ruling aligning that door\'s declaration with its reads: pagination is ' + + 'no part of a small bounded list) because nothing read it; the parent ruling ' + 'explicitly does not transfer here. On this door `limit` is read end to end — the ' + 'boundary enforces the declared 1..100 range off the schema itself, the service takes ' + 'it as an option, and the engine spends it as `RunStore.listHistory`\'s window — and ' @@ -66,8 +68,8 @@ export const entry: SemanticMigration = { + 'in the schema alone would have left the one generated client this repo ships typing it ' + '`string` and sending it into a route that silently drops it — the ADR-0104 shape the ' + 'tombstone exists to prevent, re-created one layer down. The same call was made when ' - + '#6361 retired the notifications `cursor`: the client dropped the option and recorded ' - + 'the removal in its docblock. ADR-0049 / ADR-0087, #19543.', + + 'the notifications `cursor` was retired: the client dropped the option and recorded ' + + 'the removal in its docblock. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No caller sends `cursor` to `GET /api/v1/automation/:name/runs`, and that is true of every ' + 'channel this repo ships rather than of the schema alone. Writing it on a ' @@ -83,9 +85,11 @@ export const entry: SemanticMigration = { + 'the schema typing the key `never` while the shipped client typed it `string` and sent it, ' + 'silently dropped by a route that no longer reads it (ADR-0104). ' + '⚠️ ONE wire behaviour CHANGES and must be verified as such, because it reverses a ' - + 'decision recorded under #7300: a repeated `?cursor=a&cursor=b` used to answer ' + + 'decision recorded when this door\'s query parameters were first validated where they are ' + + 'read (the fix for `?limit=abc` reaching the engine as `NaN`): a repeated ' + + '`?cursor=a&cursor=b` used to answer ' + '`400 VALIDATION_FAILED` with a `details.fields[]` entry naming `cursor`, and now ' - + 'answers `200` with the key ignored like any other unrecognised query name. #7300 ' + + 'answers `200` with the key ignored like any other unrecognised query name. That fix ' + 'validated the key rather than deciding it, so that a future cursor implementation ' + 'would not be the one to discover the type was unenforced; this ruling decides it ' + 'instead — there will be no cursor implementation on this door — so the refusal would ' diff --git a/packages/spec/src/migrations/entries/semantic/18.cache-warmup-scheduled-strategy-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cache-warmup-scheduled-strategy-retired.ts index a828ca52281..2ecda0d516f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cache-warmup-scheduled-strategy-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cache-warmup-scheduled-strategy-retired.ts @@ -16,11 +16,12 @@ export const entry: SemanticMigration = { + 'the vocabulary ever described without pointing outside itself. There is no ' + 'replacement for the cadence: a warmup on a schedule is a job. Declare a `job` with ' + 'schedule.expression (system/job.zod.ts) whose handler does the warming — that is ' - + 'the one cron slot this platform evaluates, and it is the slot #16320 deliberately ' - + 'kept when it deleted the other seven', + + 'the one cron slot this platform evaluates, and it is the slot deliberately kept ' + + 'when the seven cron-typed positions nothing read were deleted', reason: - 'ADR-0049 enforce-or-remove, closing the residue #16320 left inside the schema it had ' - + 'just edited. That card deleted CacheWarmup.schedule — the cron key this enum member ' + 'ADR-0049 enforce-or-remove, closing the residue the retirement of the seven cron-typed ' + + 'positions nothing reads left inside the schema it had just edited. That retirement ' + + 'deleted CacheWarmup.schedule — the cron key this enum member ' + 'selected — and declined the member itself on the reading that it is "a value, not a ' + "position this ruling names\". That is a statement about the ruling's SCOPE, not a " + 'finding that the value was sound: after the deletion the member declared a warmup ' @@ -42,14 +43,16 @@ export const entry: SemanticMigration = { + 'conversion because there is no source to rewrite: CacheWarmup is bound to no ' + 'metadata type and embedded in no stack collection, so no authored document and no ' + 'stored row has ever carried this value, and os migrate meta has nothing to list. ' - + 'Route 3 of the retirement playbook, the #4834 / #11825 shape: this entry IS the ' - + 'declaration. ADR-0049, ADR-0087, #17157, #16320.', + + 'Route 3 of the retirement playbook, the shape of the dynamic plugin-loading family\'s ' + + 'removal and the advanced plugin-lifecycle config\'s retirement: this entry IS the ' + + 'declaration. ADR-0049, ADR-0087.', acceptanceCriteria: "No configuration passes strategy: 'scheduled' to CacheWarmupSchema or to " + 'DistributedCacheConfigSchema.warmup. TypeScript callers cannot: ' + "CacheWarmup['strategy'] is now 'eager' | 'lazy', so the literal is a compile error " + 'at the authoring site. Callers that arrive as JSON get a parse REFUSAL — not the ' - + 'silent strip #16320 left for the schedule key beside it, because a narrowed enum ' + + 'silent strip the cron-position retirement left for ' + + 'the schedule key beside it, because a narrowed enum ' + 'rejects rather than drops — carrying the prescription, which names the job route. ' + 'Concretely, check two places. (1) Any host or deployment config embedding a ' + 'DistributedCacheConfig: a warmup block selecting the retired strategy now fails to ' diff --git a/packages/spec/src/migrations/entries/semantic/18.cli-command-contribution-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cli-command-contribution-retired.ts index 2739f846594..9606e97464f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cli-command-contribution-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cli-command-contribution-retired.ts @@ -17,12 +17,14 @@ export const entry: SemanticMigration = { + 'module docblock\'s Commander.js migration record, which the ' + '`manifest.contributes.commands` tombstone cites)', reason: - 'ADR-0049 enforce-or-remove; #12007, the exported orphan-value-schema ' - + 'class (#3950: an exported schema with no consumer reads as a ' - + 'capability). The schema described a "CLI Command Contribution ' + 'ADR-0049 enforce-or-remove, applied to the exported orphan-value-schema ' + + 'class: an exported schema with no consumer reads as a capability, the ' + + 'lesson of the plugin sandboxing / integrity / approval config that was ' + + 'never wired to anything. The schema described a "CLI Command Contribution ' + 'declaration in the manifest" and claimed retention "for describing ' - + 'command metadata in plugin manifests" — but after #10724 tombstoned ' - + '`manifest.contributes.commands` (protocol 17), no manifest surface ' + + 'command metadata in plugin manifests" — but after the retirement of the ' + + 'plugin manifest\'s nine dead `contributes` members tombstoned ' + + '`manifest.contributes.commands` (protocol 18), no manifest surface ' + 'could legally carry these entries: the export advertised a shape whose ' + 'only declared carrier rejects it. The manifest never referenced this ' + 'schema even before the tombstone — its inline `commands` item schema ' @@ -31,7 +33,8 @@ export const entry: SemanticMigration = { + '(146f448a5) with positive controls in objectstack, objectui (pinned ' + 'sha) and cloud. With no carrier key and no authored document there is ' + 'nothing to tombstone and no seam for a D2 conversion: route 3, the ' - + '#11825 / #8715 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the ' + + 'shape of the advanced plugin-lifecycle config\'s retirement and of the ' + + 'retired `ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the ' + 'declaration.', acceptanceCriteria: 'No code imports `CLICommandContributionSchema` or ' @@ -42,8 +45,8 @@ export const entry: SemanticMigration = { + 'document needs editing: the def was reachable from no metadata-type ' + 'binding, stack collection or manifest embed — the only surface that ' + 'ever claimed to carry command contributions ' - + '(`manifest.contributes.commands`) already rejects the key with the ' - + '#10724 prescription, which is unchanged by this retirement. ' + + '(`manifest.contributes.commands`) already rejects the key with its ' + + 'own tombstone prescription, which is unchanged by this retirement. ' + '`OclifPluginConfigSchema` / `OclifPluginConfig` survive on `./kernel` ' + '(same pin). ⚠️ Runtime behaviour is deliberately UNCHANGED: the CLI ' + 'never resolved commands from this declaration — commands are ' diff --git a/packages/spec/src/migrations/entries/semantic/18.client-meta-reset-result-reset.ts b/packages/spec/src/migrations/entries/semantic/18.client-meta-reset-result-reset.ts index f7626ce99ed..79e93474c77 100644 --- a/packages/spec/src/migrations/entries/semantic/18.client-meta-reset-result-reset.ts +++ b/packages/spec/src/migrations/entries/semantic/18.client-meta-reset-result-reset.ts @@ -42,9 +42,9 @@ export const entry: SemanticMigration = { + 'and a consumer accepting two spellings is what contract-first exists to prevent. No ' + 'deprecated `deleted?: boolean` transition key ships, for the same reason — a ' + 'transition period is for keys that WORKED, and this one never did. The identical ' - + 'correction one door over is `client-delete-result-success` (#5638); the wire is ' + + 'correction one door over is `client-delete-result-success`; the wire is ' + 'deliberately untouched here, per the 2026-08-29 ruling that reality is the ' - + 'contract. ADR-0087, #13023.', + + 'contract. ADR-0087.', acceptanceCriteria: 'No code reads `.deleted`, `.type` or `.name` off a `client.meta.deleteItem()` / ' + '`client.environment(id).meta.deleteItem()` result; `tsc` names every site for a ' diff --git a/packages/spec/src/migrations/entries/semantic/18.cloud-subpath-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cloud-subpath-retired.ts index a4cae6ccd0c..d461b373188 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cloud-subpath-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cloud-subpath-retired.ts @@ -22,19 +22,24 @@ export const entry: SemanticMigration = { + 'environment-artifact envelope was only ever a re-export and is imported from ' + '`@objectstack/spec/system`. (2) The cloud control plane\'s contracts have NO ' + 'open-source replacement: `environment.zod` and `tenant.zod` are re-declared in the ' - + 'cloud repo beside their producer (objectstack-ai/cloud#2037), and `developer-portal.zod`, ' + + 'cloud repo beside their producer, and `developer-portal.zod`, ' + '`marketplace-admin.zod`, `app-store.zod`, `environment-package.zod` are deleted outright — ' - + 'zero consumers in any repo (maintainer ruling on #16526, option A). Recoverable from git ' + + 'zero consumers in any repo (maintainer ruling 2026-09-07, option A: cloud does not host ' + + 'the four consumer-less files, the move deletes them). Recoverable from git ' + 'history at `d5d8d50db` if a declaration is ever wanted again; that is a new card in the ' + 'cloud repo, not a re-import.', reason: 'Maintainer direction (2026-09-06, verbatim, untranslated): 「我一直觉得 cloud 的协议应该放在云端,' - + '没必要开源」; ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). ' + + '没必要开源」; ruled option B "cut by owner" on 2026-09-07: the control-plane half leaves ' + + 'the open-source spec, the package & marketplace format half stays. ' + 'The control-plane schemas\' producer and every consumer live in the closed cloud repo — the ' + 'open-source tree read exactly one type from them (`EnvironmentType`, for the discovery fold ' + 'table). Leaving them published made the obvious-looking binding of `client.environments.*` ' + 'to a camelCase `Environment` row compile and read `undefined` at runtime against a ' - + 'snake_case wire (#11925 / #12036); with the declarations gone the mis-binding is ' + + 'snake_case wire (the client SDK\'s cloud methods carried no return annotation and were ' + + 'typed from `any`, ' + + 'and `@objectstack/spec/cloud` declared camelCase rows for a control plane that speaks ' + + 'snake_case); with the declarations gone the mis-binding is ' + 'structurally impossible rather than warned about in a docblock. No alias and no ' + 'deprecation window, per the standing 2026-08-27 ruling 「项目在创业阶段,用户也很少,短期不考虑渐进。」. ' + 'Not losslessly convertible: an import path is TypeScript source, not a metadata document ' diff --git a/packages/spec/src/migrations/entries/semantic/18.evaluated-expression-slots-source-required.ts b/packages/spec/src/migrations/entries/semantic/18.evaluated-expression-slots-source-required.ts index 2a0d6c2d4dc..8e551da1814 100644 --- a/packages/spec/src/migrations/entries/semantic/18.evaluated-expression-slots-source-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.evaluated-expression-slots-source-required.ts @@ -7,8 +7,9 @@ import type { SemanticMigration } from '../../types.js'; export const entry: SemanticMigration = { id: 'evaluated-expression-slots-source-required', surface: - 'every EVALUATED expression slot in the spec — the 34 declaring positions of the #15811 census ' - + 'that survive into this major, enumerated by identity and not by a name scan: the formula ' + 'every EVALUATED expression slot in the spec — the 34 declaring positions of the census of ' + + 'engine-evaluated slots outside the flow ledger that survive into this ' + + 'major, enumerated by identity and not by a name scan: the formula ' + 'Field.expression; the predicate ' + 'keys visibleWhen / visibleOn / readonlyWhen / requiredWhen / visibility / disabledWhen / ' + 'visible / disabled / condition / when, on Field, SelectOption, InlineGridColumn, ' @@ -28,7 +29,7 @@ export const entry: SemanticMigration = { + 'upgrader never meets those two slots under THIS rule — the composite of the two changes is ' + 'the retirement alone, and stating the narrowing for a slot that no longer accepts an ' + 'expression at all would send the upgrader to author one. That absorption is the only reason ' - + 'the count here is not the census figure the #15811 card records. The published ' + + 'the count here is not the census figure of 36. The published ' + 'TypeScript interface RowCrudPredicates narrows with the two slots it mirrors. Reachable ' + 'wherever metadata is authored or stored: defineStack sources, an exported stack passed to ' + 'objectstack validate, a POST body on any of these metadata types, and a row already sitting ' @@ -36,7 +37,8 @@ export const entry: SemanticMigration = { replacement: 'a non-blank `source`. ⭐ For an `ast`-only envelope the recovery is MECHANICAL and lossless ' + 'for `cel`, which is the one dialect in this population that has an AST at all: ' - + '`printCelAst(ast)` from `@objectstack/formula` (#15811, the inverse of `parseCelToAst`) ' + + '`printCelAst(ast)` from `@objectstack/formula` (shipped with this narrowing, the inverse ' + + 'of `parseCelToAst`) ' + 'prints the AST back to surface syntax, and the recovered string is the new `source` — keep ' + 'the `ast` beside it if you want, an `ast` BESIDE a string `source` is untouched and stays ' + 'admitted everywhere. ⚠️ Lossless is about MEANING, not bytes: the printer re-renders from ' @@ -45,7 +47,8 @@ export const entry: SemanticMigration = { + 'back. It answers `null` — never a guess — for an `ast` it cannot round-trip through the ' + 'platform\'s own bounded parser; that `null` is the hand-migration case. ' + 'For a BLANK `source` there is nothing to print from, so this entry delegates the judgment, ' - + 'and it is the same fork #15807 named: author the predicate the slot was meant to carry, or ' + + 'and it is the same fork the flow-edge `condition` narrowing named: author the predicate the ' + + 'slot was meant to carry, or ' + 'REMOVE the key entirely. ⚠️ Those two are not interchangeable and the choice is per slot, ' + 'not per file. A refused predicate reached its evaluator and faulted, and what the fault DID ' + 'differs by slot: on the fail-closed ones (`ObjectFieldGroup.visibleWhen`, ' @@ -55,9 +58,11 @@ export const entry: SemanticMigration = { + 'happening. Removing to clear the refusal is therefore safe on one half of the population ' + 'and a silent disclosure on the other', reason: - 'Card #15811, ruled 2026-09-12 (director seat, decision batch #122 item 2): the rule #15430 ' - + 'set for the flow-node ledger and #15807 carried to `FlowEdgeSchema.condition` generalises ' - + 'to every other slot an engine evaluates. Each of those slots now composes ' + 'Maintainer ruling 2026-09-12, option A: every engine-evaluated expression slot requires a ' + + 'non-blank `source`, with an ADR-0087 migration path, ' + + 'while the persistence contract stays wide. The ' + + 'rule first set for the flow-node ledger, and then carried to `FlowEdgeSchema.condition`, ' + + 'generalises to every other slot an engine evaluates. Each of those slots now composes ' + '`EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot ' + 'is held to what the engine can actually run. The engine reads `source` alone ' + '(`cel-engine.ts` `evaluate`: "AST-only evaluation not yet supported; persist `source`"), so ' @@ -96,7 +101,7 @@ export const entry: SemanticMigration = { + 'alerts and settings manifests, and is NOT an expression slot elsewhere; and ONE of the ' + 'positions is a union member, `RecordAlertProps.visible`, whose sibling arm is untouched: it ' + 'still takes a boolean literal, so a boolean there is not a hit. ⚠️ The two OTHER union ' - + 'members the #15811 census listed — `ServiceLevelIndicator.successCriteria` and ' + + 'members the census listed — `ServiceLevelIndicator.successCriteria` and ' + '`TraceSamplingConfig.composite[].condition` — are deliberately NOT on this sweep, because ' + 'their expression arms were retired outright in this same major (see `surface`). Sweep those ' + 'two under `observability-cel-predicates-retired` instead, whose instruction is the opposite ' diff --git a/packages/spec/src/migrations/entries/semantic/18.identity-api-key-schema-retired.ts b/packages/spec/src/migrations/entries/semantic/18.identity-api-key-schema-retired.ts index c67f59068a7..09c3a1c8f8e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.identity-api-key-schema-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.identity-api-key-schema-retired.ts @@ -20,8 +20,8 @@ export const entry: SemanticMigration = { + 'limiting returns only via the ENFORCE route of ADR-0049 through a new ' + 'ADR — the executor first, the vocabulary second)', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15 on #8715 ' - + '(disposition B: delete). `ApiKeySchema` documented better-auth\'s `apiKey` ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15, ' + + 'disposition B: delete the schema. `ApiKeySchema` documented better-auth\'s `apiKey` ' + 'PLUGIN schema — a plugin this platform does not load ' + '(`plugin-auth/src/managed-extension-fields.ts` states the table is ' + 'hand-rolled ObjectStack): `start` and `lastRefetchAt` name columns that do ' @@ -39,8 +39,11 @@ export const entry: SemanticMigration = { + 'declarations and the published one was fiction; the generated reference ' + 'page rendered it faithfully, which is how the defect surfaced as a docs ' + 'card. With no carrier key and no authored document there is nothing to ' - + 'tombstone and no seam for a D2 conversion: route 3, the #4834 / #4988 / ' - + '#5055 / #6486 / #8075 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE ' + + 'tombstone and no seam for a D2 conversion: route 3, the shape of the ' + + 'earlier removals of the dynamic plugin-loading family, the `ui/` ' + + 'interaction configs, the widget / i18n shapes, five declared-but-inert ' + + 'surfaces and two credential-bearing schemas no `sys_metadata` door ' + + 'reached — RETIRED_DEFS_BY_MAJOR plus this entry ARE ' + 'the declaration.', acceptanceCriteria: 'No code imports `ApiKeySchema`, `ApiKey` or `ApiKeyParsed` from ' diff --git a/packages/spec/src/migrations/entries/semantic/18.packages-list-pagination-retired.ts b/packages/spec/src/migrations/entries/semantic/18.packages-list-pagination-retired.ts index 31f21453e5c..67c2959c902 100644 --- a/packages/spec/src/migrations/entries/semantic/18.packages-list-pagination-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.packages-list-pagination-retired.ts @@ -21,8 +21,8 @@ export const entry: SemanticMigration = { + 'every installed row after it. A client that sized a buffer to the declared 50 should ' + 'size it to the installed set instead', reason: - 'One capability, both halves, never half-deleted (director seat, decision batch #126 ' - + 'item 1, maintainer 「同意」 2026-09-13, route 2 of three; routes 1 — build paging — ' + 'One capability, both halves, never half-deleted (maintainer ruling 2026-09-13, ' + + 'route 2 of three; routes 1 — build paging — ' + 'and 3 — refuse unknown names — were considered and refused). `limit` and `cursor` ' + 'were declared on the request and honoured on neither: the serving door filters on ' + '`status`, `type` and `enabled` and then returns every remaining row, and no emit site has ever ' @@ -42,9 +42,10 @@ export const entry: SemanticMigration = { + 'have joined by reuse. It does not — no REST list door in the tree paginates, the one ' + 'encode/decode cursor pair in the repo belongs to the storage-adapter list contract ' + 'and is imported by no door, and the travel of this platform is the other way: ' - + '`data.query.cursor` (#4286) and `api/ListNotificationsRequest:cursor` (#6361) were ' + + '`data.query.cursor` and `api/ListNotificationsRequest:cursor` were ' + 'both retired before this one, for the same reason. ' - + 'Route 2, and the bookkeeping splits exactly as #6361 did. There IS a tombstone: the ' + + 'Route 2, and the bookkeeping splits exactly as the notifications `cursor` retirement ' + + 'did. There IS a tombstone: the ' + 'schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever ' + "a generated client kept sending — a clean parse and a parameter that never takes " + "effect, which is this issue's own defect re-created one layer down (ADR-0104). So " @@ -60,7 +61,7 @@ export const entry: SemanticMigration = { + '`version` (by-id) and `keepData` (uninstall) are query parameters the doors already ' + 'executed and no request schema declared, and they are now declared where they are ' + 'executed. No accept set moves — the doors served them before and serve them ' - + 'identically now. ADR-0049 / ADR-0087, #17667.', + + 'identically now. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No caller sends `limit` or `cursor` to `GET /api/v1/packages`: writing either on a ' + '`ListInstalledPackagesRequest` is a `tsc` error (the input type is `never`), which ' diff --git a/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts b/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts index 04bced465db..74eff11766b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.platform-timezone-columns-iana-domain-refused.ts @@ -18,8 +18,10 @@ export const entry: SemanticMigration = { + 'the deployment holds, which is what makes this entry semantic rather than a D2 ' + 'conversion.', reason: - '#16296 gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared ' - + 'on `sys_business_unit.timezone` / `sys_organization.timezone` since #14238. It is a ' + 'The change validating `sys_job.timezone` and `sys_report_schedule.timezone` against the ' + + 'IANA domain gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared on ' + + '`sys_business_unit.timezone` / `sys_organization.timezone` since those two objects ' + + 'first gained a timezone column. It is a ' + 'WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value ' + 'already stored outside the domain is never re-read against it, no DDL is planned, ' + 'and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped ' @@ -33,12 +35,14 @@ export const entry: SemanticMigration = { + 'minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, ' + 'permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo\'s own ' + 'breaking-change detector classified it non-breaking and asked for no ADR-0087 ' - + 'disposition at all — measured on the shipped changeset. #16421 closed that hole ' + + 'disposition at all — measured on the shipped changeset. A ruling closed that hole ' + '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose ' + 'banner) and this row is the other half of the same ruling: the narrowing that ' + 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. ' - + 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B ' - + '(objectstack#16421 comment 5572145955, 2026-09-07), verbatim and untranslated: 「同意」. The direct precedents for registering a change ' + + 'Maintainer ruling 2026-09-07, option B: the clause-② declaration gains a widen / narrow ' + + 'arm that the ADR-0087 classifier reads, and each narrowing that already shipped ' + + 'without a banner is recorded as one ledger row. ' + + 'The direct precedents for registering a change ' + 'no transform can apply are `schedule-flow-acting-organization-required` (protocol ' + '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a ' + 'deployment judgement, registered anyway because the prescription is real.', diff --git a/packages/spec/src/migrations/entries/semantic/18.session-payload-positions-security-axis.ts b/packages/spec/src/migrations/entries/semantic/18.session-payload-positions-security-axis.ts index 6c5b29cac29..904a6b82104 100644 --- a/packages/spec/src/migrations/entries/semantic/18.session-payload-positions-security-axis.ts +++ b/packages/spec/src/migrations/entries/semantic/18.session-payload-positions-security-axis.ts @@ -40,8 +40,8 @@ export const entry: SemanticMigration = { + 'published unchanged as `user.role` — the single exception ADR-0090 D3\'s "role" word ' + 'ban carves out for third-party schema. Minting a `roles` array would revive the exact ' + 'banned identifier `check:role-word` ratchets against, to publish information the ' - + 'payload already carries. Maintainer ruling 2026-09-05 (#15136, director decision ' - + 'batch #39 item 2, verbatim 「同意」): option A, one name, one meaning. ADR-0068 D1/D2, ' + + 'payload already carries. Maintainer ruling 2026-09-05: option A, one name, one meaning ' + + '— `current_user.positions` means the security positions everywhere. ADR-0068 D1/D2, ' + 'ADR-0090 D3/D5, ADR-0057 D4.', acceptanceCriteria: 'No predicate and no client reader treats `current_user.positions` / ' diff --git a/packages/spec/src/migrations/entries/semantic/18.session-user-language-retired.ts b/packages/spec/src/migrations/entries/semantic/18.session-user-language-retired.ts index d19e86338b3..716226bb6a8 100644 --- a/packages/spec/src/migrations/entries/semantic/18.session-user-language-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.session-user-language-retired.ts @@ -14,16 +14,17 @@ export const entry: SemanticMigration = { + 'session endpoint wrote it, no client read it (objectui measured zero readers at its ' + 'pinned sha), so a reader trusting the published contract received a constant that was ' + 'not the user\'s language. Meanwhile the user\'s real preference landed as the ' - + 'first-class column `sys_user.locale` (#13881), which the session type could not see — ' + + 'first-class column `sys_user.locale` (ruled 2026-09-01 once measured demand for a ' + + 'per-user notification locale arrived), which the session type could not see — ' + 'three spellings of one concept on the published surface, none of them right. The ' - + 'maintainer ruled option D (2026-09-03, #14788): retire the dead key under ADR-0049 ' + + 'maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 ' + 'enforce-or-remove and make `GET /auth/me/localization` the ONE read face, with its ' + '`locale` projecting the user column first. This is a RESPONSE surface — the server ' + 'mints a `SessionUser` and nobody authors or persists one — so there is no source for ' + 'the chain to rewrite; the schema tombstones the key via retiredKey() and consumers ' + 'move their read to the endpoint. No replacement field joins the session contract ' + 'until a session endpoint really produces one (no dual-spelling window, 不渐进). ' - + 'ADR-0049, ADR-0087, #14788.', + + 'ADR-0049, ADR-0087.', acceptanceCriteria: 'No client reads `user.language` off a `SessionResponse` / `UserProfileResponse`; a ' + 'client that seeded its UI language from it now reads `locale` off ' diff --git a/packages/spec/src/migrations/entries/semantic/18.stack-themes-carrier-retired.ts b/packages/spec/src/migrations/entries/semantic/18.stack-themes-carrier-retired.ts index 5d4a640b23c..6c1656631ff 100644 --- a/packages/spec/src/migrations/entries/semantic/18.stack-themes-carrier-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.stack-themes-carrier-retired.ts @@ -24,7 +24,7 @@ export const entry: SemanticMigration = { + 'objectui, driving `--primary`, `--accent` and their derived CSS variables). A palette ' + 'value your own stylesheet consumed has no spec slot any more: move it into your own CSS.', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 on #10485 (disposition B: ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 (disposition B: ' + '退役授权面 — objectui engine code and its unit tests are retained). The pipeline was ' + 'live from the authoring gate (`ObjectStackDefinitionSchema.themes`, `defineTheme`) ' + 'through artifact ingest (`ARTIFACT_FIELD_TO_TYPE.themes`) and stopped there, measured: ' @@ -38,9 +38,11 @@ export const entry: SemanticMigration = { + 'is `app.branding`, and that path is live and untouched.', acceptanceCriteria: 'No stack source authors `themes:`; a stack that still does is refused at parse with the ' - + 'prescription (unrecognized_keys carrying the #10485 guidance — pinned in ' - + '`stack-top-level-strict.test.ts`). `PUT /meta/theme/:name` gets the #8421 ' - + 'unrecognised-type refusal instead of the pre-#10194 store-anything branch (pinned in ' + + 'prescription (unrecognized_keys carrying the retirement\'s guidance — pinned in ' + + '`stack-top-level-strict.test.ts`). `PUT /meta/theme/:name` gets the unrecognised-type ' + + 'refusal (a `/meta` type name the platform does not have is refused, never minted as a ' + + 'namespace) instead of the store-anything branch ' + + 'it had before `theme` was validated at the `/meta` write door (pinned in ' + '`protocol.unrecognised-meta-type.test.ts`). Legacy stored `theme` rows are untouched: ' + '`applyConversionsToStoredItem` passes them through, reads still answer, and DELETE ' + 'still works, so the residue is removable. ⚠️ On-screen behaviour is deliberately ' diff --git a/packages/spec/src/migrations/entries/semantic/18.stack-top-level-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.stack-top-level-unknown-keys-refused.ts index 9f7b2ac4560..a0b26eae1c7 100644 --- a/packages/spec/src/migrations/entries/semantic/18.stack-top-level-unknown-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.stack-top-level-unknown-keys-refused.ts @@ -10,18 +10,24 @@ export const entry: SemanticMigration = { + 'a near miss (`objectz` → `objects`, `flow` → `flows`), and carrying a curated ' + 'prescription for the known retirements (`approvals`/`approvalProcesses` → Approval-node ' + 'flows per ADR-0019; `workflows` → `state_machine` validation rules per ADR-0020; ' - + '`portals` removed in #3464; `storage` is deployment config, OS_STORAGE_*; `onDisable` ' - + 'was never invoked, #4212). `onEnable` is now DECLARED rather than silently stripped — ' - + 'the runtime has always executed it off the authored bundle (#4095)', + + '`portals` removed with the dead `PortalSchema`; `storage` is deployment config, ' + + 'OS_STORAGE_*; `onDisable` was never invoked, and left with the lifecycle-hook family ' + + 'the kernel never implemented). `onEnable` is now DECLARED rather than silently stripped — ' + + 'the runtime has always executed it off the authored bundle (a config-booted app keeps ' + + 'it too, since the fix that stopped the loader dropping it and every script action ' + + 'handler it registered)', reason: - 'The outermost authoring door was the last strip-mode surface of the #4001 campaign: an ' + 'The outermost authoring door was the last strip-mode surface of the unknown-key ' + + 'strictness campaign: an ' + 'unknown top-level stack key parsed green and its value was silently dropped. Measured on ' - + '17.0.0 GA (#8687): three injected bogus top-level keys added ZERO warnings to ' + + '17.0.0 GA: three injected bogus top-level keys added ZERO warnings to ' + '`os validate` and exited 0 — even `--strict` could not catch them, because the ' + '`defineStack:` naming diagnostic printed at load, outside the warning tally. The failure ' + 'population is a typo or stale key (`flow` for `flows`, `approvalProcesses` after the 7.4 ' + 'removal) shipping an artifact with a whole metadata family absent at runtime, debugged ' - + 'from the far end — the root of hotcrm#1141. Unknown top-level keys are now refused at ' + + 'from the far end — the root of a downstream application\'s report of a top-level typo ' + + 'that shipped an artifact minus a whole family with `validate` and `build` both green. ' + + 'Unknown top-level keys are now refused at ' + 'parse time, which fails `validate` (and every other path through this one parse) ' + 'outright; the near-miss guidance that used to arrive as a load-time warning now rides ' + 'the refusal itself.', diff --git a/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts b/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts index 114661e4c56..f4d81dac9f6 100644 --- a/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.startup-orchestrator-retired.ts @@ -33,8 +33,9 @@ export const entry: SemanticMigration = { + 'StartupOptions.context likewise: the kernel starts plugins sequentially ' + 'and passes its own PluginContext)', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling on #16059 (director seat, ' - + 'decision batch #60, 2026-09-06). The module declared an orchestration ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a ' + + 'startup-result contract re-declared as the shape the kernel ships, and ' + + 'retire the rest. The module declared an orchestration ' + 'design that never landed, and the spec and the kernel had already drifted ' + 'into disagreement about the one shape that did: PluginStartupResultSchema ' + 'described a plugin object, a required durationMs and a health member, ' @@ -46,10 +47,12 @@ export const entry: SemanticMigration = { + 'controls (defineStack, ManifestSchema); every remaining reference was a ' + 'generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus ' + 'are the sharpest of the four: they name a per-plugin health probe the ' - + 'runtime has never had, which is the #3950 shape an AI author (ADR-0033) ' + + 'runtime has never had, the shape of the plugin sandboxing / integrity / ' + + 'approval config that was never wired to anything, which an AI author (ADR-0033) ' + 'reads as proof the capability exists. With no authored document carrying ' + 'any of the three defs there is no seam for a D2 conversion and no author to ' - + 'tombstone for: route 3, the #4834 / #11825 shape — RETIRED_DEFS_BY_MAJOR ' + + 'tombstone for: route 3, the shape of the dynamic plugin-loading family\'s ' + + 'removal and the advanced plugin-lifecycle config\'s retirement — RETIRED_DEFS_BY_MAJOR ' + 'plus this entry ARE the declaration. The two keys of the SURVIVING result ' + 'schema that leave (plugin, health) are tombstoned instead, and registered ' + 'in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is ' @@ -57,7 +60,8 @@ export const entry: SemanticMigration = { + 'to leave it: core deprecated startTime alias, which held the same elapsed ' + 'milliseconds as durationMs under a name that promises an instant. The ' + 're-declaration had to either mirror it or tombstone it, and mirroring is ' - + 'refused by check:duration-unit-keys (ruling B on #14478) since it is an ' + + 'refused by check:duration-unit-keys (ruling B on duration-shaped number keys: ' + + 'the unit lives in the key name) since it is an ' + 'elapsed number whose key name carries no unit and matches neither of that ' + 'rule two schema-declared exemptions. So the L1 window closes here and the ' + 'kernel stops populating it in the same change.', diff --git a/packages/spec/src/migrations/entries/semantic/18.strategy-context-aggregation-method-narrowed.ts b/packages/spec/src/migrations/entries/semantic/18.strategy-context-aggregation-method-narrowed.ts index 74c2dc87618..58cb0ca01b2 100644 --- a/packages/spec/src/migrations/entries/semantic/18.strategy-context-aggregation-method-narrowed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.strategy-context-aggregation-method-narrowed.ts @@ -14,10 +14,13 @@ export const entry: SemanticMigration = { + 'string-typed value narrows the value to the enum - typing it ' + 'AggregationFunction, or parsing with the spec\'s own AggregationFunction zod ' + 'enum where the value enters from data. Values outside the six were never ' - + 'served: the bridge has parsed-and-refused them at runtime since #11833, and ' + + 'served: the bridge has parsed-and-refused them at runtime since it stopped ' + + 'declaring its own engine type and began parsing the method with the spec ' + + 'enum, and ' + 'that refusal stays as defence in depth', reason: - '#12776, maintainer ruling 2026-08-28 (option A, census-first). Two spec-declared ' + 'Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. ' + + 'Two spec-declared ' + 'surfaces described the same value and disagreed about its type: ' + 'IDataEngine.aggregate\'s aggregations[].function is the closed six-value ' + 'AggregationFunction enum while StrategyContext.executeAggregate declared the ' @@ -35,8 +38,9 @@ export const entry: SemanticMigration = { + 'is the channel that reaches them. In-repo census at the ruling (hard ' + 'precondition, measured before the narrowing landed): every implementor and ' + 'every call site filling method is legal under the enum - ' - + 'ObjectQLStrategy.resolveMeasureAggregation emits only the six post-#12209 ' - + 'refusal, the two literal producers write count, and every test fixture is ' + + 'ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a ' + + 'custom-SQL measure up front, the two literal ' + + 'producers write count, and every test fixture is ' + 'implementor-side and stays assignable by contravariance.', acceptanceCriteria: 'External implementors of StrategyContext stay source-compatible: a handler ' @@ -45,7 +49,7 @@ export const entry: SemanticMigration = { + 'out-of-vocabulary value fail tsc at the executeAggregate call site on upgrade; ' + 'the fix is narrowing the value\'s type to AggregationFunction (parsing with ' + 'the spec enum where it enters from data), never widening a local mirror of ' - + 'the contract. Runtime behaviour is unchanged: the bridge\'s #11833 ' + + 'the contract. Runtime behaviour is unchanged: the bridge\'s ' + 'parse-and-refuse accepts and rejects exactly the same sets before and after, ' + 'and no stored metadata or document needs editing.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts index 206b7b012a7..39abe4e20db 100644 --- a/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts @@ -17,14 +17,15 @@ export const entry: SemanticMigration = { + '`backfillAccountIssuer` on its own schedule deletes the call; there is no successor pass. ' + 'Existing deployments run the ceremony below before the column is dropped.', reason: - 'better-auth 1.7.3 removed the issuer-scoped account identity outright ' - + '(better-auth/better-auth#10909): `createLocalAccountIssuer` is deleted, `accountSchema.issuer` ' + 'better-auth 1.7.3 removed the issuer-scoped account identity outright: ' + + '`createLocalAccountIssuer` is deleted, `accountSchema.issuer` ' + 'is gone, `AccountKey` is `(providerId, accountId)` again, and the `account.issuer` column ' + 'and its unique index are gone from `get-tables`. There is no drop-in replacement. ' - + 'Maintainer ruling 2026-09-10 on #16629: adopt the rollback rather than own a fork of an ' + + 'Maintainer ruling 2026-09-10: adopt the rollback rather than own a fork of an ' + 'identity model the vendor abandoned — a permanent fork on the authentication library was ' - + 'refused, and staying pinned was refused as the durable answer (#16186 was the stopgap and ' - + 'has done its job). The column was a net liability in its own right: a credential row whose ' + + 'refused, and staying pinned was refused as the durable answer (the exact pin to 1.7.2, ' + + 'taken after a floating 1.7.3 broke a fresh seeded boot, was the stopgap and has done ' + + 'its job). The column was a net liability in its own right: a credential row whose ' + '`issuer` was not the local credential issuer was invisible to `findAccountByKey`, so ' + 'sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the ' + '`sys_user` row rather than at the account — four checklist items rediscovered that ' @@ -35,7 +36,8 @@ export const entry: SemanticMigration = { 'BEFORE the column is dropped, `os migrate account-issuer` reads zero on the deployment: no ' + '`(provider_id, account_id)` key is held by more than one row. That pre-flight reads ROWS, ' + 'never the index declaration, because `syncDeclaredIndexes` logs a plain UNIQUE whose CREATE ' - + 'failed on existing duplicates and lets the boot continue (#14902 / #15479) — so a database ' + + 'failed on existing duplicates and lets the boot continue (a plain unique over duplicate ' + + 'rows was made loud and non-fatal, the MySQL hash-shadow arm included) — so a database ' + 'can carry the declaration without the constraint, and on such a database the drop degrades ' + 'SILENTLY rather than failing. A dirty read refuses; so does a read that throws or a scan ' + 'that truncates. `os migrate apply --allow-destructive` re-runs the same pre-flight and ' diff --git a/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts index 1bec9b2fd41..28fc081b2f4 100644 --- a/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts @@ -8,13 +8,16 @@ export const entry: SemanticMigration = { replacement: '`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value ' + '(seconds) is unchanged', reason: - 'Director-seat ruling A on #15939, 2026-09-11, carrying the maintainer\'s 「同意」 (decision ' - + 'batch #115), executing the #14478 rule per file. The key carried its unit (seconds) in a ' + 'Maintainer ruling A, 2026-09-11: the gate that reads a duration key\'s JSDoc lands last, ' + + 'after its offenders are fixed file by file — so this entry executes, per file, the rule ' + + 'that a duration number key carries its unit in ' + + 'its name. The key carried its unit (seconds) in a ' + 'source JSDoc only — "Schema cache TTL in seconds" — while `.describe()`, the text ' + '`content/docs/references/**` publishes, said "Schema cache TTL" and named no unit at all. ' + 'So the reader who most needs the unit, the reader of the published reference page, was the ' + 'only reader who never saw it: 3600 is a plausible number of seconds and a plausible number ' - + 'of milliseconds, and nothing on the page decided it. Under the #14478 gate, moving the unit ' + + 'of milliseconds, and nothing on the page decided ' + + 'it. Under that rule\'s gate, moving the unit ' + 'into the describe alone is itself a violation (unit in prose, none in the name), so the key ' + 'is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: ' + 'counted on this tree, the suffixed family already spells it that way in every member ' diff --git a/packages/spec/src/migrations/entries/semantic/18.tenant-timeouts-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.tenant-timeouts-unit-in-key.ts index 68df3649f07..d9325ec987c 100644 --- a/packages/spec/src/migrations/entries/semantic/18.tenant-timeouts-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.tenant-timeouts-unit-in-key.ts @@ -9,13 +9,16 @@ export const entry: SemanticMigration = { replacement: '`connectionPool.idleTimeoutSeconds` (default 300) and `accessControl.sessionTimeoutSeconds` ' + '(default 3600) — rename each key; the values (seconds) are unchanged', reason: - 'Maintainer ruling 2026-09-02 on #14478 (ruled B), folding in #14519. Both keys carried their ' + 'Maintainer ruling 2026-09-02, B: a duration number key carries its unit in its name, ' + + 'enforced by a gate with no grandfathered baseline — folding in the finding that these ' + + 'two descriptions named no unit. Both keys carried their ' + 'unit (seconds) in a source JSDoc only; `.describe()` — the text `content/docs/references/**` ' + 'publishes — said "Idle pool timeout" and "Session timeout" with no unit at all. So the one ' + 'reader who most needs the unit, the reader of the published reference page, was the only ' + 'reader who never saw it: 300 is a plausible number of seconds and a plausible number of ' - + 'milliseconds, and nothing on the page decided it. #14519 proposed adding the unit to the two ' - + 'descriptions; under the #14478 gate that exact fix is a violation (unit in prose, none in ' + + 'milliseconds, and nothing on the page decided it. ' + + 'That finding proposed adding the unit to the two ' + + 'descriptions; under the ruled gate that exact fix is a violation (unit in prose, none in ' + 'the name), so the keys are renamed instead — one breaking change per key, and the tree ' + 'never passes through a state the gate refuses. Both are retiredKey tombstones (the nested ' + 'objects are not strict). Why a semantic entry and not a D2 conversion: neither schema is a ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 48ef8972759..b106020a80b 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1388,12 +1388,15 @@ const step17: MigrationStep = { replacement: '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 ' + + 'both SQL faces since it took the enforce leg of the 2026-08-07 ruling that kept it ' + + 'declared and retired `array_agg` / `string_agg`. ' + + '`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', reason: - '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 ' + 'A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the ' + + '`QueryAST` members no executor runs, the one 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 ' @@ -1402,21 +1405,24 @@ const step17: MigrationStep = { + '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 ' + + 'to serve it — and unlike the divergences closed earlier on the same axis (an ' + + 'aggregate function name the remote Turso face compiled and the local face refused, and ' + + 'an unsupported function thrown as a bare error with no code on both SQL faces), 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 — ' + + 'and lowering `SUM(DISTINCT …)` across five faces — two of them, driver-memory and ' + + 'driver-mongodb, then under the maintainer\'s 2026-08-05 investment freeze, 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 ' + + 'rewrite and callers move their own queries: the disposition that sweep gave ' + '`joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ' - + 'ADR-0049, #6815.', + + 'ADR-0049.', acceptanceCriteria: 'No caller sends `distinct` inside an `aggregations[]` entry, on the wire or through ' + 'the SDK; a deduplicated count is written as `{ function: \'count_distinct\', field }` ' @@ -1826,7 +1832,8 @@ const step17: MigrationStep = { { id: 'authoring-schemas-strict-unknown-keys', surface: - 'the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` ' + 'the protocol-17 authoring schemas closed against undeclared keys by the unknown-key ' + + 'strictness wave — `automation/` ' + '(flow and its six nested blocks, control-flow, state-machine, webhook, time-relative ' + 'trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and ' + '`identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` ' @@ -1858,14 +1865,19 @@ const step17: MigrationStep = { + 'is not an unknown-key close but the same defect one level up — the `view` union had an ' + 'arm that both stripped and required nothing, so it matched every object and `saveMetaItem` ' + 'persisted garbage as an ACTIVE view overlay that read back badged valid. ' - + 'This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the ' + + 'This is ONE entry for the whole major by the maintainer\'s 2026-08-12 ruling that the ' + + 'wave is registered one entry per major, not one per batch, mirroring the ' + "registry's only two precedents of this shape; the eleven batches it folds are the " + 'changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, ' + '`-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, ' + '`view-subblock-strictness-batch18`, `rare-jars-shave`, ' + '`user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, ' - + 'each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, ' - + '#4001, #5073, #5599 (registered #7630, backfilling #6350).', + + 'each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before ' + + 'closing its block: `userFilters.allowAddTab` was already read by objectui, so the ' + + 'maintainer\'s 2026-08-04 ruling declared it in the spec rather than let a correct-looking ' + + 'refusal tell authors to delete a working capability. The entry was registered in the ' + + 'backfill of the v17 train\'s breaking changesets, which had never been compared against ' + + 'the ledger. ADR-0049 / ADR-0078 / ADR-0087.', acceptanceCriteria: '`objectstack validate` passes with no unknown-key parse errors on any authoring surface — ' + 'the sweep is "fix until nothing raises", and every rejection carries its own fix. ' @@ -1951,10 +1963,12 @@ const step17: MigrationStep = { + 'there is no constrained channel at all, which is why the ledger entry is the only ' + 'notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one ' + 'producer shape, and a consumer accepting two spellings is what contract-first exists ' - + 'to prevent (the same ruling #5581 applied on the producer side). No deprecated ' + + 'to prevent (the same rule already moved the runtime\'s ObjectQL fallback from answering ' + + '`deleted: true` to the declared `success`, on the producer side). No deprecated ' + '`deleted?: boolean` transition key ships, for the same reason — a transition period is ' - + 'for keys that WORKED, and this one never did. Registered by the #6350 stock ' - + 'reconciliation. ADR-0087, #5638 (backfilled #6350).', + + 'for keys that WORKED, and this one never did. Registered by the stock reconciliation of ' + + 'the v17 train\'s breaking changesets, which had never been compared against the ledger. ' + + 'ADR-0087.', acceptanceCriteria: 'No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.' + 'delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller ' @@ -2281,11 +2295,13 @@ const step17: MigrationStep = { + '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 ' + + '(which is why the maintainer\'s 2026-08-04 ruling refused a non-empty `apis:` outright ' + + 'until an executor existed). Protocol 17 ships that ' + + 'executor and narrows the 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 ' + + 'or one restored from a source older than that ' + + 'refusal, 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 ' @@ -2302,7 +2318,7 @@ const step17: MigrationStep = { + '(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 ' + + 'to avoid. 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: ' @@ -3453,8 +3469,9 @@ const step17: MigrationStep = { + 'meant to fire triggers is a judgment no transform can make, so the prescription is ' + 'a TODO rather than a rewrite. The server decides in import-prepare.ts with ' + '`body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has ' - + 'since #2922 — automations always ran on import historically (the engine ignored ' - + 'the flag entirely before then), so opt-out was made the explicit act, matching ' + + 'since the flag was first honoured — automations always ran on import historically ' + + '(the engine ignored the flag entirely before then), ' + + 'so opt-out was made the explicit act, matching ' + 'platform convention. The schema said the opposite in both machine-readable and ' + "human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s " + 'JSON Schema, and the describe prose in the published reference tables for both ' @@ -3473,12 +3490,13 @@ const step17: MigrationStep = { + 'warned, and the reference page told an author the wrong thing in the other ' + 'direction. There is deliberately NO schema tombstone and no D2 conversion: no key ' + 'is removed, and an HTTP request body is neither authored nor persisted — the same ' - + 'disposition `notification-list-cursor-retired` (#6361) takes for the sibling ' + + 'disposition `notification-list-cursor-retired` takes for the sibling ' + 'default on this major, and `batch-options-validate-only-retired` before it. The ' + 'declared move itself is recorded mechanically, per key, in ' - + 'DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are ' - + 're-derived on every build. Maintainer ruling 2026-08-09 (#6704, disposition A: ' - + 'the spec follows the runtime). ADR-0049 / ADR-0078.', + + 'DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped ' + + 'default was found invisible to every gate — whose `from`/`to` fingerprints are ' + + 're-derived on every build. Maintainer ruling 2026-08-09, disposition A: ' + + 'the spec follows the runtime. ADR-0049 / ADR-0078.', acceptanceCriteria: 'Every import request of yours that must NOT fire triggers sends `runAutomations: ' + 'false` explicitly, rather than omitting the key and trusting the old declared ' @@ -3492,7 +3510,8 @@ const step17: MigrationStep = { + 'fires them after, and `runAutomations: false` turns them off before and after. ' + 'Nothing starts being refused — the route never validated this body against the ' + 'schema and does not begin to. `dryRun` is unaffected and still runs NO automations ' - + 'whatever the flag says (#6037).', + + 'whatever the flag says: it asks the engine\'s validate-only write path for its verdict, ' + + 'and that path deliberately fires no hooks.', }, { id: 'job-retry-policy-constraints-tightened', @@ -4266,11 +4285,13 @@ const step17: MigrationStep = { + 'is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the ' + 'author may have written it meaning ascending and been silently GIVEN ascending, so ' + 'the visible behaviour never contradicted them, and only they can say whether the ' - + 'sort they have been reading was the sort they asked for. Registered by the #6350 stock ' - + 'reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half ' + + 'sort they have been reading was the sort they asked for. Registered by the stock ' + + 'reconciliation of the v17 train\'s breaking changesets: the in-code alias tombstone ' + + 'shipped with the change that closed both doors ' + + '(maintainer ruling 2026-08-03), but the ledger half ' + 'never did, and a retirement needs both — the tombstone is the proof the removal was ' + 'declared, the ledger entry is what `spec-changes.json`, the upgrade guide and ' - + '`os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350).', + + '`os migrate meta` project to consumers. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No authored `orderBy` entry — in metadata, in a saved view\'s `sort[]`, or in a REST / ' + 'RPC request body — spells the key `direction`. The upgrade\'s own verify loop is that ' @@ -4304,8 +4325,8 @@ const step17: MigrationStep = { + '`ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or ' + '`Partial`-shaped) types no bare alias denotes.', reason: - 'This entry exists for the reason `data-driver-find-stream-retired` (#4484), ' - + '`storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) ' + 'This entry exists for the reason `data-driver-find-stream-retired`, ' + + '`storage-service-list-retired` and `actor-user-roles-to-positions` ' + '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, ' @@ -4318,16 +4339,18 @@ const step17: MigrationStep = { + '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 ' + + 'that carries the second half, which is precisely the gap ADR-0087 registration ' + + 'exists to close — the `ctx.user` `roles` alias was removed with no ledger entry, and ' + + 'that entry had to land separately. ⚠️ 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).', + + 'retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9 (its ' + + 'phase 2, which moved every bare name to the author state).', acceptanceCriteria: '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 ' @@ -4444,19 +4467,20 @@ const step17: MigrationStep = { + '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 ' + + '`@objectstack/spec` is guaranteed to hit. Registered by the stock reconciliation of the ' + + 'v17 train\'s breaking changesets: the `retiredKey()` tombstone shipped with the change ' + + 'that executed ADR-0033\'s deletion of this unenforced key, 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).', + + 'ADR-0049 / ADR-0087.', acceptanceCriteria: '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 ' + + 'true`, which is what the platform confirmation contract 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 ' @@ -5762,11 +5786,15 @@ const step18: MigrationStep = { + '`latitude` → `lat`, `longitude` → `lng`). A key that names no declared member is removed ' + 'at the producer — never tolerated at a consumer (AGENTS.md #0.1)', reason: - 'Maintainer ruling 2026-09-01 on #13802 (option A). Both value classes were all-optional ' + 'Maintainer ruling 2026-09-01, option A: both value classes refuse undeclared keys. Both ' + + 'value classes were all-optional ' + 'STRIPPING `z.object`s, so a value with a completely wrong key set parsed green and the ' + 'wrong keys vanished from the parse output: the showcase seed wrote `postal_code`, the ' - + 'platform accepted it, dropped it, and rendered an empty ZIP box (#13388, objectui#6812; ' - + '#5143 named the same stripping on the widget round-trip), while a stored-value scan over ' + + 'platform accepted it, dropped it, and rendered an empty ZIP box (found while counting ' + + 'stored address values for objectui\'s survey of which structured values its field ' + + 'validator checks; an earlier report had named the same stripping on the address ' + + 'widget\'s round-trip, whose ZIP input bound `zipCode` against a stored `postalCode`), ' + + 'while a stored-value scan over ' + 'the class could only ever report a clean count it had no way to earn. Closing the two ' + 'shapes restores declared = enforced and pulls "loose" back to the one deliberate ' + 'exception (`FileValueSchema`, untouched). Where the refusal BITES is the ADR-0104 write ' @@ -5916,7 +5944,8 @@ const step18: MigrationStep = { + 'library in `@objectstack/core`: construct `PluginHealthMonitor` and ' + 'pass a `PluginHealthCheck` per plugin, construct `HotReloadManager` and ' + 'pass a `HotReloadConfig` — the `content/docs/protocol/kernel/' - + 'lifecycle.mdx` examples (#11811) are the supported usage, and those ' + + 'lifecycle.mdx` examples — rewritten to show the plugin exposing a method and ' + + 'the host registering it, never a declarative field — are the supported usage, and those ' + 'input vocabularies (`PluginHealthStatus` / `PluginHealthCheck` / ' + '`PluginHealthReport`, `HotReloadConfig` with its embedded ' + '`DistributedStateConfig`, `PluginStateSnapshot`) SURVIVE in the same ' @@ -5924,8 +5953,9 @@ const step18: MigrationStep = { + 'vocabularies return only via the ENFORCE route of ADR-0049 through a ' + 'new ADR — the executor first, the vocabulary second)', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25 on #11825 ' - + '(route 2). The container aggregated six config groups — `health`, ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, route 2: ' + + 'retire the config container and keep the classes as a host-driven ' + + 'library. The container aggregated six config groups — `health`, ' + '`hotReload`, `degradation`, `updates`, `resources`, `observability` — ' + 'and NO group had a runtime reader, re-measured per group at the ' + 'retirement\'s base commit (8cdd696) with positive controls: the kernel ' @@ -5943,12 +5973,14 @@ const step18: MigrationStep = { + 'metadata-type binding ever embedded the container, so no authored ' + 'document could carry it: an author declaring `health: {...}` or ' + '`rollback: { automatic: true }` got a clean parse and NOTHING — the ' - + '#3950 shape at container scale, sharpened by production-safety ' + + 'shape of the plugin sandboxing / integrity / approval config that was ' + + 'never wired to anything, at container scale, sharpened by production-safety ' + 'vocabulary (auto-restart, zero-downtime rolling updates, automatic ' + 'rollback) an AI author (ADR-0033) reads as proof the capability ' + 'exists. With no carrier key and no authored document there is nothing ' - + 'to tombstone and no seam for a D2 conversion: route 3, the #4834 / ' - + '#8715 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the ' + + 'to tombstone and no seam for a D2 conversion: route 3, the shape of ' + + 'the dynamic plugin-loading family\'s removal and of the retired ' + + '`ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the ' + 'declaration.', acceptanceCriteria: 'No code imports any of the 9 retired names from `@objectstack/spec` or ' @@ -6415,8 +6447,10 @@ const step18: MigrationStep = { + '`GET /api/v1/automation/_status` (`client.automation.getRuntimeStatus`), which is ' + 'unchanged', reason: - 'Maintainer ruling on #19543 (door ④, verbatim 「退役,统一走 /meta/flow」, recorded in ' - + 'that card\'s re-derivation comment of 2026-09-25), under ADR-0049 enforce-or-remove. The ' + 'Maintainer ruling of 2026-09-25 on the list doors found declaring `limit` / `cursor` and ' + + 'never reading them (this route is door ④; verbatim 「退役,统一走 /meta/flow」, given when ' + + 'asked why the flow list does not use the standard ' + + 'API), under ADR-0049 enforce-or-remove. The ' + 'route\'s contract described a capability nobody built: ListFlowsRequestSchema declared ' + '`status`, `type`, `limit` (default 50) and `cursor`, and the handler read none of them — ' + 'it asked the automation service for its flow names with no arguments at all. ' @@ -6434,8 +6468,7 @@ const step18: MigrationStep = { + 'There is no alias and no transition window: GET simply stops being mounted there. There is ' + 'no D2 conversion and no tombstone, because the shape is HTTP-only — nobody authors a ' + 'ListFlowsRequest and nothing persists one — so the three schemas are whole-def removals in ' - + 'RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106, ' - + '#19543.', + + 'RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106.', acceptanceCriteria: 'On the composition `objectstack serve` builds, GET is no longer mounted at ' + '/api/v1/automation (nor at its environment-scoped twin), so the host gives its standard ' @@ -6477,8 +6510,9 @@ const step18: MigrationStep = { + 'rather than the constant `false` it used to be, so for the first time it answers the ' + 'question a caller reaching for a cursor was actually asking', reason: - 'ADR-0049 enforce-or-remove (director seat, decision batch #204 item 2, maintainer ' - + '「204 同意」 2026-09-21, letter C of three for this door; letter A — build a cursor ' + 'ADR-0049 enforce-or-remove (maintainer ruling 2026-09-21 on the list doors found ' + + 'declaring `limit` / `cursor` and never reading them — this door is door ①, and the ' + + 'ruling took letter C of three for it; letter A — build a cursor ' + 'protocol for a 100-row window — and letter B — retire the key and leave the ' + '`hasMore` lie standing — were both considered and refused). `cursor` was declared on ' + 'the request, VALIDATED at the boundary, forwarded into a `cursor?: string` slot on ' @@ -6488,7 +6522,8 @@ const step18: MigrationStep = { + 'error. ' + '⭐ The `limit` half of this door was NOT retired, and the distinction is the ruling, ' + 'not an oversight. The sibling `/packages` door retired its `limit` with its `cursor` ' - + '(#17667, decision batch #126 item 1) because nothing read it; the parent ruling ' + + '(the 2026-09-13 ruling aligning that door\'s declaration with its reads: pagination is ' + + 'no part of a small bounded list) because nothing read it; the parent ruling ' + 'explicitly does not transfer here. On this door `limit` is read end to end — the ' + 'boundary enforces the declared 1..100 range off the schema itself, the service takes ' + 'it as an option, and the engine spends it as `RunStore.listHistory`\'s window — and ' @@ -6521,8 +6556,8 @@ const step18: MigrationStep = { + 'in the schema alone would have left the one generated client this repo ships typing it ' + '`string` and sending it into a route that silently drops it — the ADR-0104 shape the ' + 'tombstone exists to prevent, re-created one layer down. The same call was made when ' - + '#6361 retired the notifications `cursor`: the client dropped the option and recorded ' - + 'the removal in its docblock. ADR-0049 / ADR-0087, #19543.', + + 'the notifications `cursor` was retired: the client dropped the option and recorded ' + + 'the removal in its docblock. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No caller sends `cursor` to `GET /api/v1/automation/:name/runs`, and that is true of every ' + 'channel this repo ships rather than of the schema alone. Writing it on a ' @@ -6538,9 +6573,11 @@ const step18: MigrationStep = { + 'the schema typing the key `never` while the shipped client typed it `string` and sent it, ' + 'silently dropped by a route that no longer reads it (ADR-0104). ' + '⚠️ ONE wire behaviour CHANGES and must be verified as such, because it reverses a ' - + 'decision recorded under #7300: a repeated `?cursor=a&cursor=b` used to answer ' + + 'decision recorded when this door\'s query parameters were first validated where they are ' + + 'read (the fix for `?limit=abc` reaching the engine as `NaN`): a repeated ' + + '`?cursor=a&cursor=b` used to answer ' + '`400 VALIDATION_FAILED` with a `details.fields[]` entry naming `cursor`, and now ' - + 'answers `200` with the key ignored like any other unrecognised query name. #7300 ' + + 'answers `200` with the key ignored like any other unrecognised query name. That fix ' + 'validated the key rather than deciding it, so that a future cursor implementation ' + 'would not be the one to discover the type was unenforced; this ruling decides it ' + 'instead — there will be no cursor implementation on this door — so the refusal would ' @@ -6656,11 +6693,12 @@ const step18: MigrationStep = { + 'the vocabulary ever described without pointing outside itself. There is no ' + 'replacement for the cadence: a warmup on a schedule is a job. Declare a `job` with ' + 'schedule.expression (system/job.zod.ts) whose handler does the warming — that is ' - + 'the one cron slot this platform evaluates, and it is the slot #16320 deliberately ' - + 'kept when it deleted the other seven', + + 'the one cron slot this platform evaluates, and it is the slot deliberately kept ' + + 'when the seven cron-typed positions nothing read were deleted', reason: - 'ADR-0049 enforce-or-remove, closing the residue #16320 left inside the schema it had ' - + 'just edited. That card deleted CacheWarmup.schedule — the cron key this enum member ' + 'ADR-0049 enforce-or-remove, closing the residue the retirement of the seven cron-typed ' + + 'positions nothing reads left inside the schema it had just edited. That retirement ' + + 'deleted CacheWarmup.schedule — the cron key this enum member ' + 'selected — and declined the member itself on the reading that it is "a value, not a ' + "position this ruling names\". That is a statement about the ruling's SCOPE, not a " + 'finding that the value was sound: after the deletion the member declared a warmup ' @@ -6682,14 +6720,16 @@ const step18: MigrationStep = { + 'conversion because there is no source to rewrite: CacheWarmup is bound to no ' + 'metadata type and embedded in no stack collection, so no authored document and no ' + 'stored row has ever carried this value, and os migrate meta has nothing to list. ' - + 'Route 3 of the retirement playbook, the #4834 / #11825 shape: this entry IS the ' - + 'declaration. ADR-0049, ADR-0087, #17157, #16320.', + + 'Route 3 of the retirement playbook, the shape of the dynamic plugin-loading family\'s ' + + 'removal and the advanced plugin-lifecycle config\'s retirement: this entry IS the ' + + 'declaration. ADR-0049, ADR-0087.', acceptanceCriteria: "No configuration passes strategy: 'scheduled' to CacheWarmupSchema or to " + 'DistributedCacheConfigSchema.warmup. TypeScript callers cannot: ' + "CacheWarmup['strategy'] is now 'eager' | 'lazy', so the literal is a compile error " + 'at the authoring site. Callers that arrive as JSON get a parse REFUSAL — not the ' - + 'silent strip #16320 left for the schedule key beside it, because a narrowed enum ' + + 'silent strip the cron-position retirement left for ' + + 'the schedule key beside it, because a narrowed enum ' + 'rejects rather than drops — carrying the prescription, which names the job route. ' + 'Concretely, check two places. (1) Any host or deployment config embedding a ' + 'DistributedCacheConfig: a warmup block selecting the retired strategy now fails to ' @@ -7024,12 +7064,14 @@ const step18: MigrationStep = { + 'module docblock\'s Commander.js migration record, which the ' + '`manifest.contributes.commands` tombstone cites)', reason: - 'ADR-0049 enforce-or-remove; #12007, the exported orphan-value-schema ' - + 'class (#3950: an exported schema with no consumer reads as a ' - + 'capability). The schema described a "CLI Command Contribution ' + 'ADR-0049 enforce-or-remove, applied to the exported orphan-value-schema ' + + 'class: an exported schema with no consumer reads as a capability, the ' + + 'lesson of the plugin sandboxing / integrity / approval config that was ' + + 'never wired to anything. The schema described a "CLI Command Contribution ' + 'declaration in the manifest" and claimed retention "for describing ' - + 'command metadata in plugin manifests" — but after #10724 tombstoned ' - + '`manifest.contributes.commands` (protocol 17), no manifest surface ' + + 'command metadata in plugin manifests" — but after the retirement of the ' + + 'plugin manifest\'s nine dead `contributes` members tombstoned ' + + '`manifest.contributes.commands` (protocol 18), no manifest surface ' + 'could legally carry these entries: the export advertised a shape whose ' + 'only declared carrier rejects it. The manifest never referenced this ' + 'schema even before the tombstone — its inline `commands` item schema ' @@ -7038,7 +7080,8 @@ const step18: MigrationStep = { + '(146f448a5) with positive controls in objectstack, objectui (pinned ' + 'sha) and cloud. With no carrier key and no authored document there is ' + 'nothing to tombstone and no seam for a D2 conversion: route 3, the ' - + '#11825 / #8715 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the ' + + 'shape of the advanced plugin-lifecycle config\'s retirement and of the ' + + 'retired `ApiKeySchema` — RETIRED_DEFS_BY_MAJOR plus this entry ARE the ' + 'declaration.', acceptanceCriteria: 'No code imports `CLICommandContributionSchema` or ' @@ -7049,8 +7092,8 @@ const step18: MigrationStep = { + 'document needs editing: the def was reachable from no metadata-type ' + 'binding, stack collection or manifest embed — the only surface that ' + 'ever claimed to carry command contributions ' - + '(`manifest.contributes.commands`) already rejects the key with the ' - + '#10724 prescription, which is unchanged by this retirement. ' + + '(`manifest.contributes.commands`) already rejects the key with its ' + + 'own tombstone prescription, which is unchanged by this retirement. ' + '`OclifPluginConfigSchema` / `OclifPluginConfig` survive on `./kernel` ' + '(same pin). ⚠️ Runtime behaviour is deliberately UNCHANGED: the CLI ' + 'never resolved commands from this declaration — commands are ' @@ -7176,9 +7219,9 @@ const step18: MigrationStep = { + 'and a consumer accepting two spellings is what contract-first exists to prevent. No ' + 'deprecated `deleted?: boolean` transition key ships, for the same reason — a ' + 'transition period is for keys that WORKED, and this one never did. The identical ' - + 'correction one door over is `client-delete-result-success` (#5638); the wire is ' + + 'correction one door over is `client-delete-result-success`; the wire is ' + 'deliberately untouched here, per the 2026-08-29 ruling that reality is the ' - + 'contract. ADR-0087, #13023.', + + 'contract. ADR-0087.', acceptanceCriteria: 'No code reads `.deleted`, `.type` or `.name` off a `client.meta.deleteItem()` / ' + '`client.environment(id).meta.deleteItem()` result; `tsc` names every site for a ' @@ -7305,19 +7348,24 @@ const step18: MigrationStep = { + 'environment-artifact envelope was only ever a re-export and is imported from ' + '`@objectstack/spec/system`. (2) The cloud control plane\'s contracts have NO ' + 'open-source replacement: `environment.zod` and `tenant.zod` are re-declared in the ' - + 'cloud repo beside their producer (objectstack-ai/cloud#2037), and `developer-portal.zod`, ' + + 'cloud repo beside their producer, and `developer-portal.zod`, ' + '`marketplace-admin.zod`, `app-store.zod`, `environment-package.zod` are deleted outright — ' - + 'zero consumers in any repo (maintainer ruling on #16526, option A). Recoverable from git ' + + 'zero consumers in any repo (maintainer ruling 2026-09-07, option A: cloud does not host ' + + 'the four consumer-less files, the move deletes them). Recoverable from git ' + 'history at `d5d8d50db` if a declaration is ever wanted again; that is a new card in the ' + 'cloud repo, not a re-import.', reason: 'Maintainer direction (2026-09-06, verbatim, untranslated): 「我一直觉得 cloud 的协议应该放在云端,' - + '没必要开源」; ruled option B "cut by owner" on #16325 (director batch #62, 2026-09-07, 「同意」). ' + + '没必要开源」; ruled option B "cut by owner" on 2026-09-07: the control-plane half leaves ' + + 'the open-source spec, the package & marketplace format half stays. ' + 'The control-plane schemas\' producer and every consumer live in the closed cloud repo — the ' + 'open-source tree read exactly one type from them (`EnvironmentType`, for the discovery fold ' + 'table). Leaving them published made the obvious-looking binding of `client.environments.*` ' + 'to a camelCase `Environment` row compile and read `undefined` at runtime against a ' - + 'snake_case wire (#11925 / #12036); with the declarations gone the mis-binding is ' + + 'snake_case wire (the client SDK\'s cloud methods carried no return annotation and were ' + + 'typed from `any`, ' + + 'and `@objectstack/spec/cloud` declared camelCase rows for a control plane that speaks ' + + 'snake_case); with the declarations gone the mis-binding is ' + 'structurally impossible rather than warned about in a docblock. No alias and no ' + 'deprecation window, per the standing 2026-08-27 ruling 「项目在创业阶段,用户也很少,短期不考虑渐进。」. ' + 'Not losslessly convertible: an import path is TypeScript source, not a metadata document ' @@ -9287,8 +9335,9 @@ const step18: MigrationStep = { { id: 'evaluated-expression-slots-source-required', surface: - 'every EVALUATED expression slot in the spec — the 34 declaring positions of the #15811 census ' - + 'that survive into this major, enumerated by identity and not by a name scan: the formula ' + 'every EVALUATED expression slot in the spec — the 34 declaring positions of the census of ' + + 'engine-evaluated slots outside the flow ledger that survive into this ' + + 'major, enumerated by identity and not by a name scan: the formula ' + 'Field.expression; the predicate ' + 'keys visibleWhen / visibleOn / readonlyWhen / requiredWhen / visibility / disabledWhen / ' + 'visible / disabled / condition / when, on Field, SelectOption, InlineGridColumn, ' @@ -9308,7 +9357,7 @@ const step18: MigrationStep = { + 'upgrader never meets those two slots under THIS rule — the composite of the two changes is ' + 'the retirement alone, and stating the narrowing for a slot that no longer accepts an ' + 'expression at all would send the upgrader to author one. That absorption is the only reason ' - + 'the count here is not the census figure the #15811 card records. The published ' + + 'the count here is not the census figure of 36. The published ' + 'TypeScript interface RowCrudPredicates narrows with the two slots it mirrors. Reachable ' + 'wherever metadata is authored or stored: defineStack sources, an exported stack passed to ' + 'objectstack validate, a POST body on any of these metadata types, and a row already sitting ' @@ -9316,7 +9365,8 @@ const step18: MigrationStep = { replacement: 'a non-blank `source`. ⭐ For an `ast`-only envelope the recovery is MECHANICAL and lossless ' + 'for `cel`, which is the one dialect in this population that has an AST at all: ' - + '`printCelAst(ast)` from `@objectstack/formula` (#15811, the inverse of `parseCelToAst`) ' + + '`printCelAst(ast)` from `@objectstack/formula` (shipped with this narrowing, the inverse ' + + 'of `parseCelToAst`) ' + 'prints the AST back to surface syntax, and the recovered string is the new `source` — keep ' + 'the `ast` beside it if you want, an `ast` BESIDE a string `source` is untouched and stays ' + 'admitted everywhere. ⚠️ Lossless is about MEANING, not bytes: the printer re-renders from ' @@ -9325,7 +9375,8 @@ const step18: MigrationStep = { + 'back. It answers `null` — never a guess — for an `ast` it cannot round-trip through the ' + 'platform\'s own bounded parser; that `null` is the hand-migration case. ' + 'For a BLANK `source` there is nothing to print from, so this entry delegates the judgment, ' - + 'and it is the same fork #15807 named: author the predicate the slot was meant to carry, or ' + + 'and it is the same fork the flow-edge `condition` narrowing named: author the predicate the ' + + 'slot was meant to carry, or ' + 'REMOVE the key entirely. ⚠️ Those two are not interchangeable and the choice is per slot, ' + 'not per file. A refused predicate reached its evaluator and faulted, and what the fault DID ' + 'differs by slot: on the fail-closed ones (`ObjectFieldGroup.visibleWhen`, ' @@ -9335,9 +9386,11 @@ const step18: MigrationStep = { + 'happening. Removing to clear the refusal is therefore safe on one half of the population ' + 'and a silent disclosure on the other', reason: - 'Card #15811, ruled 2026-09-12 (director seat, decision batch #122 item 2): the rule #15430 ' - + 'set for the flow-node ledger and #15807 carried to `FlowEdgeSchema.condition` generalises ' - + 'to every other slot an engine evaluates. Each of those slots now composes ' + 'Maintainer ruling 2026-09-12, option A: every engine-evaluated expression slot requires a ' + + 'non-blank `source`, with an ADR-0087 migration path, ' + + 'while the persistence contract stays wide. The ' + + 'rule first set for the flow-node ledger, and then carried to `FlowEdgeSchema.condition`, ' + + 'generalises to every other slot an engine evaluates. Each of those slots now composes ' + '`EvaluatedExpressionInputSchema` instead of `ExpressionInputSchema`, so an evaluated slot ' + 'is held to what the engine can actually run. The engine reads `source` alone ' + '(`cel-engine.ts` `evaluate`: "AST-only evaluation not yet supported; persist `source`"), so ' @@ -9376,7 +9429,7 @@ const step18: MigrationStep = { + 'alerts and settings manifests, and is NOT an expression slot elsewhere; and ONE of the ' + 'positions is a union member, `RecordAlertProps.visible`, whose sibling arm is untouched: it ' + 'still takes a boolean literal, so a boolean there is not a hit. ⚠️ The two OTHER union ' - + 'members the #15811 census listed — `ServiceLevelIndicator.successCriteria` and ' + + 'members the census listed — `ServiceLevelIndicator.successCriteria` and ' + '`TraceSamplingConfig.composite[].condition` — are deliberately NOT on this sweep, because ' + 'their expression arms were retired outright in this same major (see `surface`). Sweep those ' + 'two under `observability-cel-predicates-retired` instead, whose instruction is the opposite ' @@ -11440,8 +11493,8 @@ const step18: MigrationStep = { + 'limiting returns only via the ENFORCE route of ADR-0049 through a new ' + 'ADR — the executor first, the vocabulary second)', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15 on #8715 ' - + '(disposition B: delete). `ApiKeySchema` documented better-auth\'s `apiKey` ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15, ' + + 'disposition B: delete the schema. `ApiKeySchema` documented better-auth\'s `apiKey` ' + 'PLUGIN schema — a plugin this platform does not load ' + '(`plugin-auth/src/managed-extension-fields.ts` states the table is ' + 'hand-rolled ObjectStack): `start` and `lastRefetchAt` name columns that do ' @@ -11459,8 +11512,11 @@ const step18: MigrationStep = { + 'declarations and the published one was fiction; the generated reference ' + 'page rendered it faithfully, which is how the defect surfaced as a docs ' + 'card. With no carrier key and no authored document there is nothing to ' - + 'tombstone and no seam for a D2 conversion: route 3, the #4834 / #4988 / ' - + '#5055 / #6486 / #8075 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE ' + + 'tombstone and no seam for a D2 conversion: route 3, the shape of the ' + + 'earlier removals of the dynamic plugin-loading family, the `ui/` ' + + 'interaction configs, the widget / i18n shapes, five declared-but-inert ' + + 'surfaces and two credential-bearing schemas no `sys_metadata` door ' + + 'reached — RETIRED_DEFS_BY_MAJOR plus this entry ARE ' + 'the declaration.', acceptanceCriteria: 'No code imports `ApiKeySchema`, `ApiKey` or `ApiKeyParsed` from ' @@ -13423,8 +13479,8 @@ const step18: MigrationStep = { + 'every installed row after it. A client that sized a buffer to the declared 50 should ' + 'size it to the installed set instead', reason: - 'One capability, both halves, never half-deleted (director seat, decision batch #126 ' - + 'item 1, maintainer 「同意」 2026-09-13, route 2 of three; routes 1 — build paging — ' + 'One capability, both halves, never half-deleted (maintainer ruling 2026-09-13, ' + + 'route 2 of three; routes 1 — build paging — ' + 'and 3 — refuse unknown names — were considered and refused). `limit` and `cursor` ' + 'were declared on the request and honoured on neither: the serving door filters on ' + '`status`, `type` and `enabled` and then returns every remaining row, and no emit site has ever ' @@ -13444,9 +13500,10 @@ const step18: MigrationStep = { + 'have joined by reuse. It does not — no REST list door in the tree paginates, the one ' + 'encode/decode cursor pair in the repo belongs to the storage-adapter list contract ' + 'and is imported by no door, and the travel of this platform is the other way: ' - + '`data.query.cursor` (#4286) and `api/ListNotificationsRequest:cursor` (#6361) were ' + + '`data.query.cursor` and `api/ListNotificationsRequest:cursor` were ' + 'both retired before this one, for the same reason. ' - + 'Route 2, and the bookkeeping splits exactly as #6361 did. There IS a tombstone: the ' + + 'Route 2, and the bookkeeping splits exactly as the notifications `cursor` retirement ' + + 'did. There IS a tombstone: the ' + 'schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever ' + "a generated client kept sending — a clean parse and a parameter that never takes " + "effect, which is this issue's own defect re-created one layer down (ADR-0104). So " @@ -13462,7 +13519,7 @@ const step18: MigrationStep = { + '`version` (by-id) and `keepData` (uninstall) are query parameters the doors already ' + 'executed and no request schema declared, and they are now declared where they are ' + 'executed. No accept set moves — the doors served them before and serve them ' - + 'identically now. ADR-0049 / ADR-0087, #17667.', + + 'identically now. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No caller sends `limit` or `cursor` to `GET /api/v1/packages`: writing either on a ' + '`ListInstalledPackagesRequest` is a `tsc` error (the input type is `never`), which ' @@ -13621,8 +13678,10 @@ const step18: MigrationStep = { + 'the deployment holds, which is what makes this entry semantic rather than a D2 ' + 'conversion.', reason: - '#16296 gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared ' - + 'on `sys_business_unit.timezone` / `sys_organization.timezone` since #14238. It is a ' + 'The change validating `sys_job.timezone` and `sys_report_schedule.timezone` against the ' + + 'IANA domain gave both columns `valueDomain: \'iana_time_zone\'`, which had been declared on ' + + '`sys_business_unit.timezone` / `sys_organization.timezone` since those two objects ' + + 'first gained a timezone column. It is a ' + 'WRITE-TIME narrowing of the `min`/`max`/`maxLength` transition-gate class: a value ' + 'already stored outside the domain is never re-read against it, no DDL is planned, ' + 'and `objectstack migrate meta` has nothing to rewrite — the changeset that shipped ' @@ -13636,12 +13695,14 @@ const step18: MigrationStep = { + 'minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, ' + 'permanently. ⛔ It went out with NO `**BREAKING**` marker, so the repo\'s own ' + 'breaking-change detector classified it non-breaking and asked for no ADR-0087 ' - + 'disposition at all — measured on the shipped changeset. #16421 closed that hole ' + + 'disposition at all — measured on the shipped changeset. A ruling closed that hole ' + '(the declaration now carries a `(narrowing)` arm the gate reads instead of a prose ' + 'banner) and this row is the other half of the same ruling: the narrowing that ' + 'already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. ' - + 'Maintainer ruling, director summon #17, decision batch #2 item 1, option B ' - + '(objectstack#16421 comment 5572145955, 2026-09-07), verbatim and untranslated: 「同意」. The direct precedents for registering a change ' + + 'Maintainer ruling 2026-09-07, option B: the clause-② declaration gains a widen / narrow ' + + 'arm that the ADR-0087 classifier reads, and each narrowing that already shipped ' + + 'without a banner is recorded as one ledger row. ' + + 'The direct precedents for registering a change ' + 'no transform can apply are `schedule-flow-acting-organization-required` (protocol ' + '18) and `rest-requireauth-default-flip` (protocol 12) — behaviour-only, a ' + 'deployment judgement, registered anyway because the prescription is real.', @@ -15114,8 +15175,8 @@ const step18: MigrationStep = { + 'published unchanged as `user.role` — the single exception ADR-0090 D3\'s "role" word ' + 'ban carves out for third-party schema. Minting a `roles` array would revive the exact ' + 'banned identifier `check:role-word` ratchets against, to publish information the ' - + 'payload already carries. Maintainer ruling 2026-09-05 (#15136, director decision ' - + 'batch #39 item 2, verbatim 「同意」): option A, one name, one meaning. ADR-0068 D1/D2, ' + + 'payload already carries. Maintainer ruling 2026-09-05: option A, one name, one meaning ' + + '— `current_user.positions` means the security positions everywhere. ADR-0068 D1/D2, ' + 'ADR-0090 D3/D5, ADR-0057 D4.', acceptanceCriteria: 'No predicate and no client reader treats `current_user.positions` / ' @@ -15153,16 +15214,17 @@ const step18: MigrationStep = { + 'session endpoint wrote it, no client read it (objectui measured zero readers at its ' + 'pinned sha), so a reader trusting the published contract received a constant that was ' + 'not the user\'s language. Meanwhile the user\'s real preference landed as the ' - + 'first-class column `sys_user.locale` (#13881), which the session type could not see — ' + + 'first-class column `sys_user.locale` (ruled 2026-09-01 once measured demand for a ' + + 'per-user notification locale arrived), which the session type could not see — ' + 'three spellings of one concept on the published surface, none of them right. The ' - + 'maintainer ruled option D (2026-09-03, #14788): retire the dead key under ADR-0049 ' + + 'maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 ' + 'enforce-or-remove and make `GET /auth/me/localization` the ONE read face, with its ' + '`locale` projecting the user column first. This is a RESPONSE surface — the server ' + 'mints a `SessionUser` and nobody authors or persists one — so there is no source for ' + 'the chain to rewrite; the schema tombstones the key via retiredKey() and consumers ' + 'move their read to the endpoint. No replacement field joins the session contract ' + 'until a session endpoint really produces one (no dual-spelling window, 不渐进). ' - + 'ADR-0049, ADR-0087, #14788.', + + 'ADR-0049, ADR-0087.', acceptanceCriteria: 'No client reads `user.language` off a `SessionResponse` / `UserProfileResponse`; a ' + 'client that seeded its UI language from it now reads `locale` off ' @@ -15240,7 +15302,7 @@ const step18: MigrationStep = { + 'objectui, driving `--primary`, `--accent` and their derived CSS variables). A palette ' + 'value your own stylesheet consumed has no spec slot any more: move it into your own CSS.', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 on #10485 (disposition B: ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 (disposition B: ' + '退役授权面 — objectui engine code and its unit tests are retained). The pipeline was ' + 'live from the authoring gate (`ObjectStackDefinitionSchema.themes`, `defineTheme`) ' + 'through artifact ingest (`ARTIFACT_FIELD_TO_TYPE.themes`) and stopped there, measured: ' @@ -15254,9 +15316,11 @@ const step18: MigrationStep = { + 'is `app.branding`, and that path is live and untouched.', acceptanceCriteria: 'No stack source authors `themes:`; a stack that still does is refused at parse with the ' - + 'prescription (unrecognized_keys carrying the #10485 guidance — pinned in ' - + '`stack-top-level-strict.test.ts`). `PUT /meta/theme/:name` gets the #8421 ' - + 'unrecognised-type refusal instead of the pre-#10194 store-anything branch (pinned in ' + + 'prescription (unrecognized_keys carrying the retirement\'s guidance — pinned in ' + + '`stack-top-level-strict.test.ts`). `PUT /meta/theme/:name` gets the unrecognised-type ' + + 'refusal (a `/meta` type name the platform does not have is refused, never minted as a ' + + 'namespace) instead of the store-anything branch ' + + 'it had before `theme` was validated at the `/meta` write door (pinned in ' + '`protocol.unrecognised-meta-type.test.ts`). Legacy stored `theme` rows are untouched: ' + '`applyConversionsToStoredItem` passes them through, reads still answer, and DELETE ' + 'still works, so the residue is removable. ⚠️ On-screen behaviour is deliberately ' @@ -15272,18 +15336,24 @@ const step18: MigrationStep = { + 'a near miss (`objectz` → `objects`, `flow` → `flows`), and carrying a curated ' + 'prescription for the known retirements (`approvals`/`approvalProcesses` → Approval-node ' + 'flows per ADR-0019; `workflows` → `state_machine` validation rules per ADR-0020; ' - + '`portals` removed in #3464; `storage` is deployment config, OS_STORAGE_*; `onDisable` ' - + 'was never invoked, #4212). `onEnable` is now DECLARED rather than silently stripped — ' - + 'the runtime has always executed it off the authored bundle (#4095)', - reason: - 'The outermost authoring door was the last strip-mode surface of the #4001 campaign: an ' + + '`portals` removed with the dead `PortalSchema`; `storage` is deployment config, ' + + 'OS_STORAGE_*; `onDisable` was never invoked, and left with the lifecycle-hook family ' + + 'the kernel never implemented). `onEnable` is now DECLARED rather than silently stripped — ' + + 'the runtime has always executed it off the authored bundle (a config-booted app keeps ' + + 'it too, since the fix that stopped the loader dropping it and every script action ' + + 'handler it registered)', + reason: + 'The outermost authoring door was the last strip-mode surface of the unknown-key ' + + 'strictness campaign: an ' + 'unknown top-level stack key parsed green and its value was silently dropped. Measured on ' - + '17.0.0 GA (#8687): three injected bogus top-level keys added ZERO warnings to ' + + '17.0.0 GA: three injected bogus top-level keys added ZERO warnings to ' + '`os validate` and exited 0 — even `--strict` could not catch them, because the ' + '`defineStack:` naming diagnostic printed at load, outside the warning tally. The failure ' + 'population is a typo or stale key (`flow` for `flows`, `approvalProcesses` after the 7.4 ' + 'removal) shipping an artifact with a whole metadata family absent at runtime, debugged ' - + 'from the far end — the root of hotcrm#1141. Unknown top-level keys are now refused at ' + + 'from the far end — the root of a downstream application\'s report of a top-level typo ' + + 'that shipped an artifact minus a whole family with `validate` and `build` both green. ' + + 'Unknown top-level keys are now refused at ' + 'parse time, which fails `validate` (and every other path through this one parse) ' + 'outright; the near-miss guidance that used to arrive as a load-time warning now rides ' + 'the refusal itself.', @@ -15393,8 +15463,9 @@ const step18: MigrationStep = { + 'StartupOptions.context likewise: the kernel starts plugins sequentially ' + 'and passes its own PluginContext)', reason: - 'ADR-0049 enforce-or-remove; maintainer ruling on #16059 (director seat, ' - + 'decision batch #60, 2026-09-06). The module declared an orchestration ' + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a ' + + 'startup-result contract re-declared as the shape the kernel ships, and ' + + 'retire the rest. The module declared an orchestration ' + 'design that never landed, and the spec and the kernel had already drifted ' + 'into disagreement about the one shape that did: PluginStartupResultSchema ' + 'described a plugin object, a required durationMs and a health member, ' @@ -15406,10 +15477,12 @@ const step18: MigrationStep = { + 'controls (defineStack, ManifestSchema); every remaining reference was a ' + 'generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus ' + 'are the sharpest of the four: they name a per-plugin health probe the ' - + 'runtime has never had, which is the #3950 shape an AI author (ADR-0033) ' + + 'runtime has never had, the shape of the plugin sandboxing / integrity / ' + + 'approval config that was never wired to anything, which an AI author (ADR-0033) ' + 'reads as proof the capability exists. With no authored document carrying ' + 'any of the three defs there is no seam for a D2 conversion and no author to ' - + 'tombstone for: route 3, the #4834 / #11825 shape — RETIRED_DEFS_BY_MAJOR ' + + 'tombstone for: route 3, the shape of the dynamic plugin-loading family\'s ' + + 'removal and the advanced plugin-lifecycle config\'s retirement — RETIRED_DEFS_BY_MAJOR ' + 'plus this entry ARE the declaration. The two keys of the SURVIVING result ' + 'schema that leave (plugin, health) are tombstoned instead, and registered ' + 'in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is ' @@ -15417,7 +15490,8 @@ const step18: MigrationStep = { + 'to leave it: core deprecated startTime alias, which held the same elapsed ' + 'milliseconds as durationMs under a name that promises an instant. The ' + 're-declaration had to either mirror it or tombstone it, and mirroring is ' - + 'refused by check:duration-unit-keys (ruling B on #14478) since it is an ' + + 'refused by check:duration-unit-keys (ruling B on duration-shaped number keys: ' + + 'the unit lives in the key name) since it is an ' + 'elapsed number whose key name carries no unit and matches neither of that ' + 'rule two schema-declared exemptions. So the L1 window closes here and the ' + 'kernel stops populating it in the same change.', @@ -15452,10 +15526,13 @@ const step18: MigrationStep = { + 'string-typed value narrows the value to the enum - typing it ' + 'AggregationFunction, or parsing with the spec\'s own AggregationFunction zod ' + 'enum where the value enters from data. Values outside the six were never ' - + 'served: the bridge has parsed-and-refused them at runtime since #11833, and ' + + 'served: the bridge has parsed-and-refused them at runtime since it stopped ' + + 'declaring its own engine type and began parsing the method with the spec ' + + 'enum, and ' + 'that refusal stays as defence in depth', reason: - '#12776, maintainer ruling 2026-08-28 (option A, census-first). Two spec-declared ' + 'Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. ' + + 'Two spec-declared ' + 'surfaces described the same value and disagreed about its type: ' + 'IDataEngine.aggregate\'s aggregations[].function is the closed six-value ' + 'AggregationFunction enum while StrategyContext.executeAggregate declared the ' @@ -15473,8 +15550,9 @@ const step18: MigrationStep = { + 'is the channel that reaches them. In-repo census at the ruling (hard ' + 'precondition, measured before the narrowing landed): every implementor and ' + 'every call site filling method is legal under the enum - ' - + 'ObjectQLStrategy.resolveMeasureAggregation emits only the six post-#12209 ' - + 'refusal, the two literal producers write count, and every test fixture is ' + + 'ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a ' + + 'custom-SQL measure up front, the two literal ' + + 'producers write count, and every test fixture is ' + 'implementor-side and stays assignable by contravariance.', acceptanceCriteria: 'External implementors of StrategyContext stay source-compatible: a handler ' @@ -15483,7 +15561,7 @@ const step18: MigrationStep = { + 'out-of-vocabulary value fail tsc at the executeAggregate call site on upgrade; ' + 'the fix is narrowing the value\'s type to AggregationFunction (parsing with ' + 'the spec enum where it enters from data), never widening a local mirror of ' - + 'the contract. Runtime behaviour is unchanged: the bridge\'s #11833 ' + + 'the contract. Runtime behaviour is unchanged: the bridge\'s ' + 'parse-and-refuse accepts and rejects exactly the same sets before and after, ' + 'and no stored metadata or document needs editing.', }, @@ -15576,14 +15654,15 @@ const step18: MigrationStep = { + '`backfillAccountIssuer` on its own schedule deletes the call; there is no successor pass. ' + 'Existing deployments run the ceremony below before the column is dropped.', reason: - 'better-auth 1.7.3 removed the issuer-scoped account identity outright ' - + '(better-auth/better-auth#10909): `createLocalAccountIssuer` is deleted, `accountSchema.issuer` ' + 'better-auth 1.7.3 removed the issuer-scoped account identity outright: ' + + '`createLocalAccountIssuer` is deleted, `accountSchema.issuer` ' + 'is gone, `AccountKey` is `(providerId, accountId)` again, and the `account.issuer` column ' + 'and its unique index are gone from `get-tables`. There is no drop-in replacement. ' - + 'Maintainer ruling 2026-09-10 on #16629: adopt the rollback rather than own a fork of an ' + + 'Maintainer ruling 2026-09-10: adopt the rollback rather than own a fork of an ' + 'identity model the vendor abandoned — a permanent fork on the authentication library was ' - + 'refused, and staying pinned was refused as the durable answer (#16186 was the stopgap and ' - + 'has done its job). The column was a net liability in its own right: a credential row whose ' + + 'refused, and staying pinned was refused as the durable answer (the exact pin to 1.7.2, ' + + 'taken after a floating 1.7.3 broke a fresh seeded boot, was the stopgap and has done ' + + 'its job). The column was a net liability in its own right: a credential row whose ' + '`issuer` was not the local credential issuer was invisible to `findAccountByKey`, so ' + 'sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the ' + '`sys_user` row rather than at the account — four checklist items rediscovered that ' @@ -15594,7 +15673,8 @@ const step18: MigrationStep = { 'BEFORE the column is dropped, `os migrate account-issuer` reads zero on the deployment: no ' + '`(provider_id, account_id)` key is held by more than one row. That pre-flight reads ROWS, ' + 'never the index declaration, because `syncDeclaredIndexes` logs a plain UNIQUE whose CREATE ' - + 'failed on existing duplicates and lets the boot continue (#14902 / #15479) — so a database ' + + 'failed on existing duplicates and lets the boot continue (a plain unique over duplicate ' + + 'rows was made loud and non-fatal, the MySQL hash-shadow arm included) — so a database ' + 'can carry the declaration without the constraint, and on such a database the drop degrades ' + 'SILENTLY rather than failing. A dirty read refuses; so does a read that throws or a scan ' + 'that truncates. `os migrate apply --allow-destructive` re-runs the same pre-flight and ' @@ -16089,13 +16169,16 @@ const step18: MigrationStep = { replacement: '`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value ' + '(seconds) is unchanged', reason: - 'Director-seat ruling A on #15939, 2026-09-11, carrying the maintainer\'s 「同意」 (decision ' - + 'batch #115), executing the #14478 rule per file. The key carried its unit (seconds) in a ' + 'Maintainer ruling A, 2026-09-11: the gate that reads a duration key\'s JSDoc lands last, ' + + 'after its offenders are fixed file by file — so this entry executes, per file, the rule ' + + 'that a duration number key carries its unit in ' + + 'its name. The key carried its unit (seconds) in a ' + 'source JSDoc only — "Schema cache TTL in seconds" — while `.describe()`, the text ' + '`content/docs/references/**` publishes, said "Schema cache TTL" and named no unit at all. ' + 'So the reader who most needs the unit, the reader of the published reference page, was the ' + 'only reader who never saw it: 3600 is a plausible number of seconds and a plausible number ' - + 'of milliseconds, and nothing on the page decided it. Under the #14478 gate, moving the unit ' + + 'of milliseconds, and nothing on the page decided ' + + 'it. Under that rule\'s gate, moving the unit ' + 'into the describe alone is itself a violation (unit in prose, none in the name), so the key ' + 'is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: ' + 'counted on this tree, the suffixed family already spells it that way in every member ' @@ -16126,13 +16209,16 @@ const step18: MigrationStep = { replacement: '`connectionPool.idleTimeoutSeconds` (default 300) and `accessControl.sessionTimeoutSeconds` ' + '(default 3600) — rename each key; the values (seconds) are unchanged', reason: - 'Maintainer ruling 2026-09-02 on #14478 (ruled B), folding in #14519. Both keys carried their ' + 'Maintainer ruling 2026-09-02, B: a duration number key carries its unit in its name, ' + + 'enforced by a gate with no grandfathered baseline — folding in the finding that these ' + + 'two descriptions named no unit. Both keys carried their ' + 'unit (seconds) in a source JSDoc only; `.describe()` — the text `content/docs/references/**` ' + 'publishes — said "Idle pool timeout" and "Session timeout" with no unit at all. So the one ' + 'reader who most needs the unit, the reader of the published reference page, was the only ' + 'reader who never saw it: 300 is a plausible number of seconds and a plausible number of ' - + 'milliseconds, and nothing on the page decided it. #14519 proposed adding the unit to the two ' - + 'descriptions; under the #14478 gate that exact fix is a violation (unit in prose, none in ' + + 'milliseconds, and nothing on the page decided it. ' + + 'That finding proposed adding the unit to the two ' + + 'descriptions; under the ruled gate that exact fix is a violation (unit in prose, none in ' + 'the name), so the keys are renamed instead — one breaking change per key, and the tree ' + 'never passes through a state the gate refuses. Both are retiredKey tombstones (the nested ' + 'objects are not strict). Why a semantic entry and not a D2 conversion: neither schema is a '