From d339b7de236ed53464c2b03193ea7540bbd5b5b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:50:55 +0000 Subject: [PATCH 1/6] fix(spec): datasource-* migration guidance states each lesson in words, not tracker numbers (#20233 stage 4, wip) Rewrite the reason / replacement / acceptanceCriteria text of the datasource-* ADR-0087 semantic entries in form D: each cited ruling, measurement or fix is stated in words; no tracker number remains. Text only: no id, surface, from/to or matcher moves. registry.ts is regenerated in a later commit. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- ...source-config-inline-credential-refused.ts | 6 +++-- ...7.datasource-config-placeholder-refused.ts | 11 ++++---- ....datasource-config-url-userinfo-refused.ts | 12 +++++---- ...config-mongo-options-credential-refused.ts | 14 +++++----- ...config-postgres-url-unparseable-refused.ts | 8 +++--- ...rce-config-url-query-credential-refused.ts | 8 +++--- ...sref-mongo-composed-no-username-refused.ts | 26 +++++++++++-------- ...redentialsref-mongo-url-no-user-refused.ts | 22 +++++++++------- 8 files changed, 62 insertions(+), 45 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts index 54b2c656ce1..4e0dbb3fba9 100644 --- a/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts @@ -11,8 +11,10 @@ export const entry: SemanticMigration = { 'or a direct `external.credentialsRef` secrets-store reference', reason: 'A datasource artefact is persisted whole into `sys_metadata`, which is served back by ' + - 'the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-' + - 'ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: ' + + 'the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled ' + + 'on 2026-08-12 to close that per artefact: each schema that admitted an inline credential ' + + 'refuses it at publish and points at the secret mechanism the artefact already had, rather ' + + 'than a heuristic guard at the `sys_metadata` write. There is no mechanical rewrite: ' + 'moving the value requires ENCRYPTING it into a `sys_secret` row through a running ' + "secret binder and deleting the cleartext, which a source-file transform cannot do — " + 'auto-deleting the key alone would silently drop a live credential instead.', diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts index 11f0ab81e58..cd708631200 100644 --- a/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts @@ -18,13 +18,14 @@ export const entry: SemanticMigration = { reason: 'A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is ' + 'stored verbatim in `sys_metadata` and handed verbatim to the database client at connect ' + - '(#7990 census, measured during #8078), so the connection fails, or connects somewhere ' + + '(measured by the credential census while the inline-credential refusal was being built), ' + + 'so the connection fails, or connects somewhere ' + 'unintended, with no error naming the unresolved placeholder — the masked-failure shape. ' + 'The syntax looked supported: it parsed green, stored fine, and failed at a distance; two ' + - 'shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn ' + - '"do NOT substitute a placeholder" around the broken escape. Maintainer-ruled direction 2 ' + - 'on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution ' + - 'was explicitly rejected — a new capability with an env-exfiltration security surface and ' + + 'shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had ' + + 'to warn "do NOT substitute a placeholder" around the broken escape. The maintainer ruled ' + + 'on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; ' + + 'implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and ' + 'zero measured pull for actual substitution. There is no mechanical rewrite: the ' + 'placeholder names a value that exists only in the author\'s intended deployment ' + 'environment, which a source-file transform cannot know — substituting anything would ' + diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts index c9b93859523..b811c391f8b 100644 --- a/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts @@ -12,19 +12,21 @@ export const entry: SemanticMigration = { 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + 'or a direct `external.credentialsRef` secrets-store reference', reason: - 'The #7990 closure refused the inline credential KEYS, and #8078 measured that ' + + '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 (#8082, ' + - 'maintainer-ruled Option A, 2026-08-12). Runtime-environment DSNs (`OS_DATABASE_URL` and ' + - 'friends) never pass through the publish door and are unaffected by construction. There ' + + '`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 (#8078, measured).', + 'database client verbatim (measured when the inline-credential refusal was built).', acceptanceCriteria: 'Every datasource parses with a credential-free `config.url` / `config.syncUrl` (no ' + 'userinfo password segment); each affected datasource carries ' + diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-config-mongo-options-credential-refused.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-config-mongo-options-credential-refused.ts index 50bd1c9b76d..10cbab7a481 100644 --- a/packages/spec/src/migrations/entries/semantic/18.datasource-config-mongo-options-credential-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-config-mongo-options-credential-refused.ts @@ -12,16 +12,18 @@ export const entry: SemanticMigration = { 'or a direct `external.credentialsRef` secrets-store reference, with the username kept in ' + 'the URL (`mongodb://user@host/db`)', reason: - 'The FOURTH spelling of the same inline secret: #7990 refused the top-level `password` ' + - 'key, #8082 the URL userinfo, #8337 credential query parameters — and the `options` ' + - 'passthrough stayed open one syntax over. `options: { auth: { username, password } }` ' + + 'The FOURTH spelling of the same inline secret: earlier publish refusals closed the ' + + 'top-level `password` key, the URL userinfo password and the credential-bearing URL query ' + + 'parameters — and the `options` passthrough stayed open one syntax over. `options: { auth: { username, password } }` ' + 'parsed green, persisted the password cleartext into `sys_metadata` (served back by the ' + 'ordinary data API), and genuinely authenticated: mongodb@7.5.0 transforms the block ' + 'into `MongoCredentials` (measured), so the workaround was live, not inert. A non-empty ' + 'string `auth.password` is now refused at publish with the binder prescription; ' + - '`auth.username` alone stays writable (#8876\'s asymmetry — a username is not credential ' + - 'material), as do all non-credential passthrough options. The bound secret wins over a ' + - 'passthrough `auth` block at connect (#8696, measured), so the replacement changes which ' + + '`auth.username` alone stays writable (the asymmetry the URL grammar keeps between its ' + + 'two userinfo halves — a username is not credential material), as do all non-credential ' + + 'passthrough options. The bound secret wins over a passthrough `auth` block at connect ' + + '(measured when the bound secret was made to reach the mongo client on its URL branch), ' + + 'so the replacement changes which ' + 'store holds the secret, never which credential connects. There is no mechanical ' + 'rewrite, for the same reason as the sibling entries ' + '`datasource-config-inline-credential-refused`, `datasource-config-url-userinfo-refused` ' + diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-config-postgres-url-unparseable-refused.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-config-postgres-url-unparseable-refused.ts index 93a2d63dd2f..ad4956e12ec 100644 --- a/packages/spec/src/migrations/entries/semantic/18.datasource-config-postgres-url-unparseable-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-config-postgres-url-unparseable-refused.ts @@ -17,9 +17,11 @@ export const entry: SemanticMigration = { 'key: … }` next to `driver`) instead of file-path query parameters', reason: "`PostgresConfigSchema.url`'s own describe text documents the postgres URL grammar, but " + - 'until protocol 18 the value was only string-scanned for credentials (#8082/#8337) and ' + - 'placeholders (#8336) — deliberately so at the SHARED helper, whose refusal to parse is ' + - "load-bearing for mongo's multi-host/`+srv` forms (#8696). For postgres that leniency " + + 'until protocol 18 the value was only string-scanned for credentials (the URL userinfo ' + + 'password and credential query parameters) and `${…}` placeholders — deliberately so at ' + + 'the SHARED helper, whose refusal to parse is load-bearing for mongo\'s multi-host/`+srv` ' + + 'forms (`new URL()` rejects the multi-host form outright, and the mongo arm hands the ' + + 'authored URL to its client untouched). For postgres that leniency ' + 'was no check at all: `pg@8.22.0` does not implement libpq\'s multi-host DSN — both ' + "`pg-connection-string`'s `parse` and `pg`'s `ConnectionParameters` throw " + '`TypeError [ERR_INVALID_URL]` on `postgresql://app@h1:5432,h2:5433/app` (measured) — ' + diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-config-url-query-credential-refused.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-config-url-query-credential-refused.ts index 50e2d51c452..b66c889f516 100644 --- a/packages/spec/src/migrations/entries/semantic/18.datasource-config-url-query-credential-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-config-url-query-credential-refused.ts @@ -13,9 +13,9 @@ export const entry: SemanticMigration = { 'handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` ' + 'secrets-store reference', reason: - 'The #7990 closure refused the inline credential KEYS and #8082 refused the URL userinfo ' + - 'spelling; the query string was the third spelling of the identical secret, one syntax ' + - 'over (#8337). `libsql://x.turso.io?authToken=eyJ…` landed in `sys_metadata` cleartext ' + + 'The inline-credential closure refused the credential KEYS and the next refusal the URL ' + + 'userinfo spelling; the query string was the third spelling of the identical secret, one ' + + 'syntax over. `libsql://x.turso.io?authToken=eyJ…` landed in `sys_metadata` cleartext ' + 'exactly as `config.authToken` did — and at connect `@libsql/core` assigns the URL token ' + 'OVER the binder-injected one (measured), so the workaround also silently defeated the ' + 'bound secret; `pg-connection-string` likewise honours `?password=` over userinfo ' + @@ -30,7 +30,7 @@ export const entry: SemanticMigration = { 'source-file transform cannot do — auto-stripping the parameter 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 (#8078, measured).', + 'verbatim (measured when the inline-credential refusal was built).', acceptanceCriteria: 'Every datasource parses with no credential-bearing query parameter in `config.url` / ' + '`config.syncUrl` (no `?authToken=` on turso, no `?password=` on postgres); each ' + diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-composed-no-username-refused.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-composed-no-username-refused.ts index ee1841c2a2a..5c6f4ad0a05 100644 --- a/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-composed-no-username-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-composed-no-username-refused.ts @@ -13,10 +13,11 @@ export const entry: SemanticMigration = { '`url` and names no `username`', replacement: 'decide what the datasource is meant to do, then make the two halves agree: ' + 'add `username` to `config` so the bound secret is interpolated beside it into the ' + - 'composed connection URI at connect (#8696) — or, for a datasource genuinely meant to ' + + 'composed connection URI at connect — or, for a datasource genuinely meant to ' + 'connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the ' + 'orphaned `sys_secret` row via the Setup → Datasources form). Authoring a `config.url` ' + - 'that names a user is a third valid shape, judged by the sibling #9041 prescription.', + 'that names a user is a third valid shape, judged by the prescription of the sibling ' + + 'URL-branch entry, `datasource-credentialsref-mongo-url-no-user-refused`.', reason: 'The pair cannot work as written, and until protocol 18 it was accepted in silence at ' + 'every door it passed. With no `config.url` the driver factory COMPOSES the connection ' + @@ -30,14 +31,17 @@ export const entry: SemanticMigration = { 'authenticate from a password alone — the same measured asymmetry behind the sibling ' + 'URL-branch refusal. Both branches had always agreed on this input, so this inherits that ' + 'ruling rather than re-opening it, and lands at the same authoring/publish door — the one ' + - 'place both halves are visible at once — as the "absence must be loud" half of the ' + - '#7314/#7385/#8152/#8875/#8696 family. Deliberately NOT refused, each measured: a ' + - 'discrete `username` that is present and non-empty (the secret is live there — that is ' + - 'the branch #8696 already works on), an empty-string `credentialsRef` (not a binding — ' + - 'the connect path resolves under a truthy check), a non-string `username` (the driver ' + - 'config gate already reports the type error), and every other driver arm (the postgres ' + - 'equivalent is re-judged after #8873, never inherited — `pg` receives the bound password ' + - 'regardless of the DSN naming a user). An EMPTY-STRING `username` IS refused, unlike the ' + + 'place both halves are visible at once — as the "absence must be loud" half of the family ' + + 'of driver-factory arms, closed one driver at a time, that each dropped something declared ' + + 'without a word: the optional-driver arms that answered a missing package with no remedy, ' + + 'the turso arm that never read its bound secret, and the mysql and mongo DSN branches that ' + + 'discarded one. Deliberately NOT refused, each measured: a ' + + 'discrete `username` that is present and non-empty (the secret is live there — the ' + + 'composed branch has always interpolated it), an empty-string `credentialsRef` (not a ' + + 'binding — the connect path resolves under a truthy check), a non-string `username` (the ' + + 'driver config gate already reports the type error), and every other driver arm (the ' + + 'postgres equivalent is judged on its own client\'s measurement, never inherited — `pg` ' + + 'receives the bound password regardless of the DSN naming a user). An EMPTY-STRING `username` IS refused, unlike the ' + 'sibling entry\'s present-but-empty userinfo carve-out: there MongoClient itself throws ' + '(`URI contained empty userinfo section`) so the shape is already loud, while here ' + '`username: \'\'` composes the same userinfo-free URI and connects — silently. There is ' + @@ -48,5 +52,5 @@ export const entry: SemanticMigration = { 'Every mongodb datasource that binds `external.credentialsRef` and authors no `config.url` ' + 'names a non-empty `config.username` and connects authenticated as that user; every ' + 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource parse ' + - 'reports the #9147 refusal.', + 'reports this composed-branch refusal.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-url-no-user-refused.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-url-no-user-refused.ts index 80f02cfc69b..753a81b5bf9 100644 --- a/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-url-no-user-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-credentialsref-mongo-url-no-user-refused.ts @@ -8,36 +8,40 @@ export const entry: SemanticMigration = { 'no user in its userinfo', replacement: 'decide what the datasource is meant to do, then make the two halves agree: ' + 'add the username to the URL\'s userinfo (`mongodb://user@host/db`) so the bound secret ' + - 'is injected at connect (#8696) — or, for a datasource genuinely meant to connect ' + + 'is injected at connect — or, for a datasource genuinely meant to connect ' + 'unauthenticated, remove the `external.credentialsRef` binding (and unbind the orphaned ' + '`sys_secret` row via the Setup → Datasources form)', reason: 'The pair cannot work as written, and until protocol 18 it was accepted in silence at ' + 'every door it passed. MongoClient credentials need a username as well as a password, ' + 'and with `url` present the discrete `username` field is superseded — the only place ' + - 'the username can come from is the URL\'s own userinfo. So the #8696 injection is ' + - 'conditional on the URL naming a user: `mongodb://app@host/db` + bound secret ' + + 'the username can come from is the URL\'s own userinfo. So the connect-time injection of ' + + 'the bound secret on the URL branch is conditional on the URL naming a user: `mongodb://app@host/db` + bound secret ' + 'authenticates, while `mongodb://host/db` + bound secret connects ANONYMOUSLY with the ' + 'secret unused and the operator told nothing. Injecting anyway was measured worse ' + '(mongodb@7.5.0): fabricating an empty username turns a connection that works ' + 'anonymously today into a guaranteed handshake failure, and refusing at connect would ' + 'contradict `MongoConfigSchema.url`\'s published contract ("bind the secret … and it is ' + 'injected at connect time") while planting a per-branch asymmetry inside the driver ' + - 'factory — the defect class #8696 closed. The refusal therefore lands at the ' + + 'factory — the defect class closed when each DSN branch was made to inject the bound ' + + 'secret its composed branch already used. The refusal therefore lands at the ' + 'authoring/publish door, the one place both halves are visible at once, as the ' + - '"absence must be loud" half of the #7314/#7385/#8152/#8875/#8696 family. Deliberately ' + + '"absence must be loud" half of the family of driver-factory arms, closed one driver at a ' + + 'time, that each dropped something declared without a word: the optional-driver arms that ' + + 'answered a missing package with no remedy, the turso arm that never read its bound ' + + 'secret, and the mysql and mongo DSN branches that discarded one. Deliberately ' + 'NOT refused, each measured: the present-but-empty userinfo forms (`mongodb://@h/db`, ' + '`mongodb://:p@h/db` — MongoClient itself throws `MongoParseError: URI contained empty ' + 'userinfo section`), an empty-string `credentialsRef` (not a binding — the connect path ' + 'resolves under a truthy check), the composed branch (no `url`, where the discrete ' + - '`username` is live), and every other driver arm (the postgres equivalent is re-judged ' + - 'after #8873, never inherited — `pg` injects on a user-less DSN by its own measured ' + - 'mechanism). There is no mechanical rewrite because the two valid fixes are ' + + '`username` is live), and every other driver arm (the postgres equivalent is judged on ' + + 'its own client\'s measurement, never inherited — `pg` injects on a user-less DSN by its ' + + 'own measured mechanism). There is no mechanical rewrite because the two valid fixes are ' + 'CONTRADICTORY intents — authenticate (add the username) versus anonymous (drop the ' + 'binding) — and choosing between them requires knowing what the datasource is for.', acceptanceCriteria: 'Every mongodb datasource that binds `external.credentialsRef` and authors `config.url` ' + 'has a username in that URL\'s userinfo and connects authenticated as that user; every ' + 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource ' + - 'parse reports the #9041 refusal.', + 'parse reports this URL-branch refusal.', }; From d8136eabf3a6770538c295fd5657d617c4107841 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:54:46 +0000 Subject: [PATCH 2/6] fix(spec): filter-* migration guidance states each lesson in words, not tracker numbers (wip) Rewrite the reason / replacement / acceptanceCriteria text of the filter-* ADR-0087 semantic entries in form D. Text only: no id, surface, from/to or matcher moves. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../17.filter-regex-options-retired.ts | 18 ++++++++++-------- ...18.filter-between-blank-endpoint-refused.ts | 10 ++++++---- ...between-field-reference-endpoint-refused.ts | 14 ++++++++------ ...-and-widget-nested-slots-refused-at-save.ts | 7 ++++--- ...equality-array-comparand-refused-at-save.ts | 3 ++- ....filter-equality-array-comparand-refused.ts | 4 +++- ...ter-icontains-comparand-refused-at-parse.ts | 8 +++++--- .../18.filter-ne-array-comparand-refused.ts | 6 +++--- ...filter-preset-ordering-comparand-refused.ts | 9 ++++++--- ...er-query-face-comparands-refused-at-save.ts | 5 +++-- ...lter-text-operator-declared-type-refused.ts | 8 +++++--- 11 files changed, 55 insertions(+), 37 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts b/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts index dce0841d3ce..635650a1d90 100644 --- a/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts @@ -21,8 +21,8 @@ export const entry: SemanticMigration = { + 'and never a key on `StringOperatorSchema`. That is measured, not assumed — `git ' + 'log -S\'$regex\'` over `packages/spec/src` returns only doc comments describing how ' + '`$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: ' - + '$regex`), plus #5701 itself, which added the name solely as `RETIRED_FILTER_OPERATORS` ' - + 'prescription data. ⚠️ But it differs from those two in the one way that decides the ' + + '$regex`), plus the retirement\'s own contract-half change, which added the name ' + + 'solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the ' + 'disposition, so a reader should not have to infer it: those were driver CALL ' + 'ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. ' + '`FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) ' @@ -42,13 +42,15 @@ export const entry: SemanticMigration = { + 'rewrite would silently change which rows a dashboard, report or permission filter ' + 'selects, a wrong number rather than a missing one. Choosing the substring the ' + 'pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers ' - + 'BOTH HALVES of the #4706 ruling (B), not just the driver one: the contract half ' - + '(#5701 — the `$icontains` declaration, the `$contains` family pinned ' + + 'BOTH HALVES of the maintainer\'s 2026-08-06 ruling (option B: retire `$regex` loudly ' + + 'and add `$icontains`, rather than make five backends agree on one regex dialect), not ' + + 'just the driver one: the contract half ' + + '(the `$icontains` declaration, the `$contains` family pinned ' + 'case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ' - + 'ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; ' - + 'the driver half (#5702) is where the refusal became executable. One surface, one ' - + 'entry, registered from the half that made it observable. ADR-0049 / ADR-0087, ' - + '#4706 / #5701 / #5702.', + + 'gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was ' + + 'never asked for a ledger entry; ' + + 'the driver half is where the refusal became executable. One surface, one ' + + 'entry, registered from the half that made it observable. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No stored filter and no request `where` spells `$regex` or `$options` — grep the ' + 'stack for both. Each one is rewritten by asking what the pattern MEANT, not by ' diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-between-blank-endpoint-refused.ts b/packages/spec/src/migrations/entries/semantic/18.filter-between-blank-endpoint-refused.ts index e0521409899..95300d74677 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-between-blank-endpoint-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-between-blank-endpoint-refused.ts @@ -47,7 +47,8 @@ export const entry: SemanticMigration = { + 'message prescribes the null predicate because a `null` author was reaching for absence, ' + 'not for a bound', reason: - 'Maintainer ruling A on #18012 (decision batch #146 item 5, 2026-09-17 「146 同意」). ' + 'Maintainer ruling A of 2026-09-17: a blank `$between` endpoint is refused at the ' + + 'authoring door, and the refusal names the blank side. ' + '`FieldOperatorsSchema.safeParse({ $between: [1, \'\'] })` answered `success: true` — ' + 'measured on the card against the installed spec 17.4.0 and re-measured on `origin/main` ' + 'before the change. This is a NEW RULE narrowing a published face, ⛔ not a pull-back to a ' @@ -56,8 +57,8 @@ export const entry: SemanticMigration = { + 'acceptance was conformant. What made it wrong is the other half of the same contract — ' + '"Closed interval [min, max]" — which no backend can honour against a blank: driver-sql ' + 'binds it into `whereBetween`, the JS matchers compare it as a value, and the range stops ' - + 'bounding on that side while still reading as a complete range. #13495 had already taught ' - + 'the reference matcher to survive the null-bound form of exactly this (a bounded range ' + + 'bounding on that side while still reading as a complete range. The reference matcher had ' + + 'already been taught to survive the null-bound form of exactly this (a bounded range ' + 'answered EVERY valued row, because both of the arm\'s comparisons are false against a ' + 'missing bound); the door that admitted it was never addressed. The only producer ever ' + 'measured is a UI builder padding a HALF-TYPED pair with `\'\'` so that a length-based ' @@ -90,7 +91,8 @@ export const entry: SemanticMigration = { + 'operator schema itself, which answers at the endpoint\'s own path with the blank side ' + 'named, and the engine comparand-shape door, which refuses an executed filter carrying ' + 'one. The objectui half — the builder stops padding a half-typed pair, so ' - + 'the console never meets this refusal mid-typing — is objectui#9695 and lands on its own ' + + 'the console never meets this refusal mid-typing — is a change to the console\'s own ' + + 'filter builder and lands on its own ' + 'schedule, either side of this one. ADR-0049 / ADR-0078 / ADR-0087.', acceptanceCriteria: 'Grep every authored `$between` array — view, page and component filter rules, dashboard ' diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-between-field-reference-endpoint-refused.ts b/packages/spec/src/migrations/entries/semantic/18.filter-between-field-reference-endpoint-refused.ts index f8d012d1026..77281fc9906 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-between-field-reference-endpoint-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-between-field-reference-endpoint-refused.ts @@ -35,13 +35,15 @@ export const entry: SemanticMigration = { 'a literal bound — the value the range was meant to stop at, written out. If the range was ' + 'genuinely meant to be COLUMN-TO-COLUMN, that is not a $between at all: write the two ' + 'bounds separately as scalar comparisons, {"$gte": {"$field": "a"}} for the lower bound and ' - + '{"$lte": {"$field": "b"}} for the upper one, which is the position #5222 compiles on every ' + + '{"$lte": {"$field": "b"}} for the upper one, which is the position the column-to-column ' + + 'comparison compiles on every ' + 'face. ⛔ There is no replacement that can be DERIVED from what was written: the literal a ' + 'reference stood for is not recoverable, and dropping the operator would delete a ' + 'constraint the author wrote and WIDEN the result set silently. A reference remains legal, ' + 'unchanged, as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte', reason: - 'Maintainer ruling of 2026-08-11 on #7596, ADR-0049 enforce-or-remove: REMOVE. Both $between ' + 'Maintainer ruling of 2026-08-11 on column-reference range endpoints, ADR-0049 ' + + 'enforce-or-remove: REMOVE. Both $between ' + 'endpoint unions carried FieldReferenceSchema and no backend ever resolved one in a list ' + 'position — matches-filter.ts leaves the list unresolved and orders against the raw ' + 'reference OBJECT, so the range silently matches nothing, and both SQL faces refuse the ' @@ -51,9 +53,9 @@ export const entry: SemanticMigration = { + '⚠️ That ruling shipped at the AUTHORING SCHEMA door alone, and no ledger entry was written ' + 'for it — measured before this change: no semantic entry, no retired key, no spec-changes ' + 'row and no upgrade-guide line named the shape. That was not an omission, and this entry ' - + 'SUPERSEDES a recorded answer rather than filling a silence: the 2026-08-11 changeset (PR ' - + '#7713) carried the disposition not-required (no-migration-prescription), reviewed and ' - + 'accepted on #7596 and shipped in the published CHANGELOG. What changed is the fact that ' + + 'SUPERSEDES a recorded answer rather than filling a silence: the 2026-08-11 changeset ' + + 'carried the disposition not-required (no-migration-prescription), reviewed and ' + + 'accepted with that ruling and shipped in the published CHANGELOG. What changed is the fact that ' + 'disposition rested on. It was claimed for a removal whose reach was believed to be the ' + 'authoring schema alone; the runtime half now ships with a migration prescription of its own ' + '(below), and a body carrying a prescription is exactly what that category refuses. So the ' @@ -65,7 +67,7 @@ export const entry: SemanticMigration = { + 'two truth values, decided by which door a caller came through — and the door that passed ' + 'it is the one an embedder reaches by handing a lowered filter straight to a driver. This ' + 'entry therefore registers the transition for BOTH doors, not only the second, which is ' - + 'why it is filed under #19377 rather than as an already-registered rider. ' + + 'why it is filed as an entry of its own rather than as an already-registered rider. ' + '⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather ' + 'than assumed: applyConversionsToStoredItem — the one primitive every stored-row ' + 'rehydration seam calls — never throws and never validates, and replays only the ' diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts b/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts index 26fb56426f6..601b0a8fdc5 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts @@ -38,9 +38,10 @@ export const entry: SemanticMigration = { + 'string resolved at request time (such as {current_user_id} or {today}) and a bigint ' + 'within 2^53 are untouched, and the save door keeps a bigint as written', reason: - 'The save door narrows to exactly what the query faces already refuse (#20116, stage 2 of ' - + 'the collector). The comparand-type face (normalizeFilterComparandTypes, the #7872 ' - + 'ruling\'s accepted set) refuses these values on every query: parseFilterAST, the engine ' + 'The save door narrows to exactly what the query faces already refuse (the second stage ' + + 'of closing the family of comparand shapes the save door accepted and the query faces ' + + 'refused). The comparand-type face (normalizeFilterComparandTypes, the accepted set the ' + + 'maintainer ruled on 2026-08-12: string, number, bigint, boolean, null and Date) refuses these values on every query: parseFilterAST, the engine ' + 'seam, the analytics where door and the read-scope compiler all run it. Measured on ' + 'origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a ' + 'dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined ' diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused-at-save.ts b/packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused-at-save.ts index 3779e34194f..26543dcba9b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused-at-save.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused-at-save.ts @@ -35,7 +35,8 @@ export const entry: SemanticMigration = { + 'arrays, empty lists included; every scalar equality comparand, null above all, a Date and ' + 'a { $field } reference are untouched; and $ne is NOT judged by this entry', reason: - 'Ruling on #19889 (record 5805248669, letter A): FilterConditionSchema (implicit equality) ' + 'Ruled on 2026-09-24 (option A), applying the standing refusal of an array in the ' + + 'equality slot to the schema door: FilterConditionSchema (implicit equality) ' + 'and FieldOperatorsSchema.$eq refuse an array comparand at parse, with the SAME remedy ' + 'text the shared compile face emits — one constant, two doors; a stored filter carrying ' + 'the shape is refused loudly on its next save, and never silently dropped, because a ' diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts index 337de0dc988..73fff529300 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts @@ -29,7 +29,9 @@ export const entry: SemanticMigration = { + 'arrays, empty lists included; every scalar equality comparand, null above all (the ' + 'has-no-value predicate), is untouched; and $ne is NOT judged by this entry', reason: - 'Maintainer ruling on #19757 (record 5793368540, batch 217 item 3, letter 乙, 「217 同意」): ' + 'Maintainer ruling of 2026-09-23 (option 乙 — rather than declaring an array equality the ' + + 'SQL-family backends would have to invent, or documenting a divergence that stays silent ' + + 'on one backend): ' + 'an array in the implicit-equality slot is refused at the shared face, for every driver at ' + 'once — no alias, no grace window. The comparand-shape face declared that moving a rule ' + 'to it 「closes that door for every driver at once」, and before this change it judged ' diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts b/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts index 30bc1af1278..7b7a40c92ad 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts @@ -24,8 +24,9 @@ export const entry: SemanticMigration = { + 'wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand ' + 'and this rule says nothing about it', reason: - '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' - + 'untranslated): 「the differences are the protocol\'s to close」. The platform already ' + 'The protocol half of the maintainer\'s 2026-09-20 ruling (option C-prime) on the console\'s ' + + 'filter converter, whose first rule reads, verbatim and untranslated: 「the differences are ' + + 'the protocol\'s to close」. The platform already ' + 'DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a ' + 'REJECTION row for an empty comparand and one for a non-string comparand, each with ' + 'code INVALID_FILTER and each requiring the refusal to name the operator. All five ' @@ -38,7 +39,8 @@ export const entry: SemanticMigration = { + 'declared-not-enforced shape ADR-0049 exists to close. ' + 'The narrowing is DERIVED from the table, not transcribed beside it: both doors call ' + 'the published predicate isRefusedTextComparand and the published reason text ' - + 'textComparandRefusalReason, the pair lifted into this package at #18113 for exactly ' + + 'textComparandRefusalReason, the pair published in this package beside FILTER_TEXT_CASES ' + + 'for exactly ' + 'this reason, so a row added to the table reaches both doors without an edit at either. ' + '$contains, $startsWith, ' + '$endsWith, $like and $ilike keep the answer they give today, ' diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-ne-array-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.filter-ne-array-comparand-refused.ts index 93e2640d2b5..12287a49382 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-ne-array-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-ne-array-comparand-refused.ts @@ -28,9 +28,9 @@ export const entry: SemanticMigration = { + 'has-a-value predicate), every scalar, a Date and a { $field } reference are untouched, and ' + 'the list operators ($in / $nin / $between) keep their arrays, empty lists included', reason: - 'Ruling A on #19886 (record 5805254639, the director seat, class 1): the shared ' - + 'comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$ne ' - + 'refuses it at parse, with one remedy text naming the declared list-negation operator by its ' + 'Ruled on 2026-09-24 by the director seat, on the standing contract text (option A): ' + + 'the shared comparand-shape face refuses an array under $ne for every driver, and ' + + 'FieldOperatorsSchema.$ne refuses it at parse, with one remedy text naming the declared list-negation operator by its ' + 'spec spelling — no alias, no window. The governing text is $ne\'s own published describe: ' + 'the comparand is a literal, or a { $field } reference to another column of the same table. ' + 'An array is neither, so the refusal pulls the doors back to what $ne already declared. ' diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts index 5df45c9f062..7d23527821c 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts @@ -37,13 +37,15 @@ export const entry: SemanticMigration = { + 'analytics query\'s timeDimensions[].dateRange. A filter comparand is not one of those ' + 'positions', reason: - 'The C half of #8690, maintainer-ruled 2026-08-15 alongside the engine door (PR #8808). ' + 'The authoring half (option C) of the maintainer\'s 2026-08-15 ruling on uninterpretable ' + + 'temporal comparands, ruled alongside the engine door (option B) that refuses them at ' + + 'query time. ' + 'The preset vocabulary is declared in the dashboard schema and lowered to {date-macro} ' + 'bounds by the shipped console before any query is sent — so the names were declared in ' + 'one layer and unrecognised in the next, with no error at the boundary. Authored as a ' + 'bare comparand (a saved report, an integration, an MCP client, an AI-authored query), ' + 'the name reached the driver as written and compared false against every row: HTTP 200, ' - + 'count 0, indistinguishable from "there is no data" (measured on #8690: $gte ' + + 'count 0, indistinguishable from "there is no data" (measured on the defect report: $gte ' + '"last_30_days" returned 0 of 51 seeded rows where the macro spelling returned the 38 ' + 'in-window). The engine now refuses the bare name on a declared temporal field at query ' + 'time (INVALID_FILTER / 400); this entry records the AUTHORING-time half, and that half ' @@ -63,7 +65,8 @@ export const entry: SemanticMigration = { + 'type in hand. ⚠️ Metadata AT REST is deliberately not rewritten and there is no D2 conversion: ' + 'this shape was never written by any first-party producer (every preset in this repo ' + 'and the example apps sits in a dashboard date-filter position — measured) and never ' - + 'executed usefully (it returned a silent zero before #8808 and a 400 after). Coercing ' + + 'executed usefully (it returned a silent zero before the engine door and a 400 after). ' + + 'Coercing ' + 'it at load would be the platform guessing which bound the author meant. The read path ' + 'does not re-validate stored rows, so no stored dashboard becomes unreadable; what ' + 'changes is that RE-SAVING one is refused with the window named. ' diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-query-face-comparands-refused-at-save.ts b/packages/spec/src/migrations/entries/semantic/18.filter-query-face-comparands-refused-at-save.ts index 15dadf1355a..77a28b07434 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-query-face-comparands-refused-at-save.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-query-face-comparands-refused-at-save.ts @@ -37,8 +37,9 @@ export const entry: SemanticMigration = { + 'with a list. The null predicate itself, a { $field } reference as a whole comparand, ' + 'an empty $in or $nin list and a whitespace endpoint are untouched', reason: - 'The save door narrows to exactly what the query faces already refuse (#20116, the ' - + 'collector for its family; the $ne member is route A, the same reach and the same one ' + 'The save door narrows to exactly what the query faces already refuse (the family of ' + + 'comparand shapes the save door accepted and the query faces refused; the $ne member is ' + + 'route A, the same reach and the same one ' + 'sentence as the equality slot of filter-equality-array-comparand-refused-at-save). The ' + 'shared comparand-shape face refuses on every query a null ordering comparand (ruled ' + '2026-09-01), a non-list $in / $nin and a malformed $between range, a null list member or ' diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-text-operator-declared-type-refused.ts b/packages/spec/src/migrations/entries/semantic/18.filter-text-operator-declared-type-refused.ts index 25335b947a4..1fa8db9a4c6 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-text-operator-declared-type-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-text-operator-declared-type-refused.ts @@ -25,8 +25,9 @@ export const entry: SemanticMigration = { + 'condition — `{ amount: { $contains: \'5\' } }` may have meant `$eq: 5`, a range, ' + 'or a filter on a different column altogether — so the loader must not choose one.', reason: - 'objectstack#15661, ruled 2026-09-05 (decision batch #43, option C-deny), landed at ' - + 'the engine seam as objectstack#15773. A text operator (`$contains` / ' + 'Maintainer ruling of 2026-09-05 (option C-deny: refuse now, over the type sets the ' + + 'contract already declares, minting no new vocabulary), landed at the engine seam. A ' + + 'text operator (`$contains` / ' + '`$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) ' + 'over a field whose DECLARED type can never store a string — `NUMERIC_VALUE_TYPES` ' + '∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ ' @@ -54,6 +55,7 @@ export const entry: SemanticMigration = { + 'and `user` ids, `autonumber` and the file classes — are unaffected and must keep ' + 'answering exactly as before; that is the control which proves a repair pass did ' + 'not over-reach. A DIRECT driver call bypasses this door entirely and keeps ' - + 'answering the `FILTER_TEXT_CASES` stored-value row (objectstack#14079), so a ' + + 'answering the `FILTER_TEXT_CASES` stored-value row (a stored value that is not a string ' + + 'never satisfies a positive text operator and satisfies `$notContains`), so a ' + 'driver-level test is not evidence about this migration in either direction.', }; From dc30cfe02df1756ad53d0df786efd12e970f3c6f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:56:22 +0000 Subject: [PATCH 3/6] fix(spec): action-* migration guidance states each lesson in words, not tracker numbers (wip) Rewrite the reason / replacement / acceptanceCriteria text of the action-* ADR-0087 semantic entries in form D. Text only: no id, surface, from/to or matcher moves. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../17.action-descriptor-is-async-retired.ts | 8 +++---- ...escriptor-resume-authority-default-flip.ts | 16 ++++++++------ .../17.action-session-roles-to-positions.ts | 22 ++++++++++--------- ...ction-bulk-dispatch-contract-undeclared.ts | 3 ++- ...ction-engine-facade-find-query-envelope.ts | 5 +++-- 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts index b1e4330d33c..ef68073a0d8 100644 --- a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts @@ -12,16 +12,16 @@ export const entry: SemanticMigration = { reason: 'ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow ' + 'awaiting an external reply" and NOTHING read it: a fresh three-repo measurement ' - + '(#6748, re-run at pickup) found zero property reads across objectstack, objectui ' - + 'and cloud — every hit was the declaration itself, a generated baseline, one of ' + + '(taken when the key was filed for retirement, and re-run at pickup) found zero ' + + 'property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of ' + 'five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. ' + 'So declaring it never made a node suspend and omitting it never stopped one, ' + 'which is the silently-inert declaration ADR-0049 exists to end. It was always a ' + 'second, weaker spelling of the capability `supportsPause` states, and the two ' + 'diverged in exactly the way a duplicated declaration does: `screen` declared ' + 'both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing ' - + 'anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in ' - + '#6667 — `AutomationEngine` now refuses a suspension whose type does not declare ' + + 'anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — ' + + '`AutomationEngine` now refuses a suspension whose type does not declare ' + '`supportsPause: true` — so the capability this key gestured at is now a real, ' + 'enforced fact under one name. This one had no consumer to grow into and takes ' + 'the remove leg. ' diff --git a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts index 346e0a30f93..221a3159fdf 100644 --- a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts +++ b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts @@ -22,12 +22,13 @@ export const entry: SemanticMigration = { 'A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as ' + "protocol 12's `rest-requireauth-default-flip`, and it is registered here for the " + 'same reason: whether a given pause is genuinely open to the generic route is a ' - + 'trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED ' + + 'trust judgment no transform can make. The generic resume route\'s authorization gate ' + + 'keys on the SUSPENDED ' + "NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a " + 'pausing node type shipped raw-resumable unless its author remembered the field. ' + "It now resolves to `'service'` when absent: an unclaimed pause is refused on the " + 'generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may ' - + 'continue it. #3823 is the incident that decided the direction — ADR-0044 pointed ' + + 'continue it. The revise-window incident decided the direction — ADR-0044 pointed ' + "an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and " + 'the pause standing in a service-owned position inherited a fail-open value nobody ' + 'chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote ' @@ -36,8 +37,8 @@ export const entry: SemanticMigration = { + "guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface " + 'is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no ' + 'source for a D2 conversion to rewrite and deliberately no schema tombstone — the ' - + 'disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` ' - + '(#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from ' + + 'disposition `data-driver-find-stream-retired`, `storage-service-list-retired` ' + + 'and `actor-user-roles-to-positions` already carry. It differs from ' + 'those in one way a reader should not have to infer: nothing is REMOVED, so tsc ' + 'reports nothing at all — the field was already optional after step one and an ' + 'omission still compiles. The enforced channels are all run-time: a registration ' @@ -47,7 +48,8 @@ export const entry: SemanticMigration = { + 'channel that arrives BEFORE a user hits a run that will not continue. In-tree the ' + 'flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, ' + 'approval, approval_revise) declare their authority explicitly. ADR-0044 amendment ' - + '(2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561.', + + '(2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam ' + + 'addendum to ADR-0019 (approval as a flow node).', acceptanceCriteria: 'Every action descriptor your plugin registers for a node type that can suspend ' + 'declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but ' @@ -56,12 +58,12 @@ export const entry: SemanticMigration = { + "through the generic route succeeds for the ones you declared `'any'`, and answers " + "403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue " + 'through your own service API instead. ⚠️ `supportsPause` is no longer the ' - + 'declaration nothing enforced (#5703, closed by #6667): an executor whose ' + + 'declaration nothing enforced: an executor whose ' + '`execute()` returns `suspend: true` while leaving `supportsPause` false is still ' + 'warned about by neither warning channel, but ' + '`AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the ' + 'one seam every suspension passes through — a guard-class failure no `fault` edge ' + 'routes — so it needs no hand-check. The residue that does: an executor registering ' + 'NO descriptor declares nothing for either warning or the refusal to read, so its ' - + 'pauses are still created and refused only later, on the resume route (#5561).', + + 'pauses are still created and refused only later, on the resume route.', }; diff --git a/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts index 54cd943a9da..620def8a96c 100644 --- a/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts +++ b/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts @@ -9,23 +9,25 @@ export const entry: SemanticMigration = { reason: 'The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this ' + 'step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed ' - + 'outright, #5050), while the ACTION body\'s `ctx.session` carries it ' + + 'outright), while the ACTION body\'s `ctx.session` carries it ' + 'produced-and-really-populated. `buildActionSession()` ' + '(`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` ' + 'into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under ' + 'the one spelling that ADR bans — so a body author met two different answers to one ' + 'key name on one platform: rejected in a hook, live and full of values in an action. ' - + '#5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): ' - + 'phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, ' - + 'and phase 2 renames the key. `positions` is now the canonical key on that schema ' - + 'and `roles` a deprecated alias of it (#5779); the producer emits both for one ' - + 'deprecation window (#5613 runtime half), after which `roles` is removed on the path ' - + 'the v16 session-alias removal already walked (#3280 deprecated → #3290 removed). ' + + 'The maintainer ruled contract-first on 2026-08-06 ("C skeleton + A semantics": declare ' + + 'the shape as it stands first, then rename on the typed face): phase 1 declared the ' + + 'previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. ' + + '`positions` is now the canonical key on that schema and `roles` a deprecated alias of ' + + 'it; the producer emits both for one deprecation window (the runtime half of the same ' + + 'ruling), after which `roles` is removed on the path the v16 session-alias removal ' + + 'already walked (the hook `ctx.session.tenantId` alias: deprecated first, removed in ' + + 'the next major). ' + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: ' + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per ' + 'dispatch and never persisted, so no `sys_metadata` row, example or template can ' - + 'carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / ' - + '`hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key ' + + 'carry the key — the `openApi31` / `activationEvents` / ' + + '`hook-context-session-roles-retired` shape. SECOND, the only place the key ' + 'is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed ' + 'script whose `ScriptContext.session` is still `unknown`. A declarative transform ' + 'cannot safely rewrite an identifier inside free-form code — exactly the reason the ' @@ -38,7 +40,7 @@ export const entry: SemanticMigration = { + 'authorable-surface ratchet adjudicates) belongs to the release that closes the ' + 'window. Until then this entry IS the channel: `spec-changes.json` and the generated ' + 'upgrade guide are how a reader learns the rename before the removal reaches them. ' - + 'ADR-0090 D3, ADR-0087, #5613 / #5779.', + + 'ADR-0090 D3, ADR-0087.', acceptanceCriteria: 'No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` ' + 'and observes the same array (the rename is a rename — the VALUE is ' diff --git a/packages/spec/src/migrations/entries/semantic/18.action-bulk-dispatch-contract-undeclared.ts b/packages/spec/src/migrations/entries/semantic/18.action-bulk-dispatch-contract-undeclared.ts index 778f5a1cef5..56d9da5569a 100644 --- a/packages/spec/src/migrations/entries/semantic/18.action-bulk-dispatch-contract-undeclared.ts +++ b/packages/spec/src/migrations/entries/semantic/18.action-bulk-dispatch-contract-undeclared.ts @@ -24,7 +24,8 @@ export const entry: SemanticMigration = { + 'transform has both halves in hand, and `objectstack migrate meta` rewrites stored metadata ' + 'by key. The residue is genuinely a judgement: an action wired BOTH ways has no correct ' + 'value, because one call and N calls have different side effects and the platform will not ' - + 'silently unify them (the #17319 ruling refused exactly that option). Such an action is TWO ' + + 'silently unify them (the 2026-09-12 ruling that made an action declare its dispatch ' + + 'contract refused exactly that option). Such an action is TWO ' + 'actions — split the body along the line the two wirings already draw and declare each half ' + '— or, if the body was deliberately written to serve both, it stays undeclared and the two ' + 'wirings stand. The census that is this migration’s input was taken 2026-09-13 over ' diff --git a/packages/spec/src/migrations/entries/semantic/18.action-engine-facade-find-query-envelope.ts b/packages/spec/src/migrations/entries/semantic/18.action-engine-facade-find-query-envelope.ts index e3344f3b067..9c198dfb4f8 100644 --- a/packages/spec/src/migrations/entries/semantic/18.action-engine-facade-find-query-envelope.ts +++ b/packages/spec/src/migrations/entries/semantic/18.action-engine-facade-find-query-envelope.ts @@ -22,8 +22,9 @@ export const entry: SemanticMigration = { 'The rewrite itself is lossless and mechanical, but it is not automatable here: an action handler ' + 'is authored TypeScript, and the chain rewrites stored metadata by key, so no `os migrate meta` ' + 'step can reach a call expression inside a function body. The change is a WITHDRAWAL of the ' - + 'parameter shape #14175 chose, ruled by the director seat (decision batch #123 item 3, ' - + '2026-09-12, 「同意」) on the long-term axis 「one platform, one query shape」. The facade had been ' + + 'parameter shape an earlier typing fix chose (the filter alone), ruled by the director ' + + 'seat on 2026-09-12, with the maintainer\'s agreement, on the long-term axis 「one platform, ' + + 'one query shape」. The facade had been ' + 'given a shape different from the engine\'s — the `where` half alone — which made the most ' + 'natural spelling the wrong one: an author who passed the engine\'s envelope got ' + '`{ where: { where: … } }`, matching no row and resolving to `[]` with no error, while an ' From bf336a69ec86f939222d584bd9057d5fde720ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:58:13 +0000 Subject: [PATCH 4/6] fix(spec): data-* migration guidance states each lesson in words, not tracker numbers (wip) Rewrite the reason / replacement / acceptanceCriteria text of the data-* ADR-0087 semantic entries in form D, the downstream-repo citation included. Text only: no id, surface, from/to or matcher moves. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../17.data-driver-find-stream-retired.ts | 2 +- .../semantic/17.data-driver-query-omit-object.ts | 16 +++++++++------- .../semantic/17.data-engine-batch-retired.ts | 2 +- .../17.data-field-changed-event-retired.ts | 7 ++++--- .../18.data-file-value-duration-unit-in-key.ts | 13 ++++++++----- ...ta-nosql-query-options-timeout-unit-in-key.ts | 6 ++++-- 6 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts index 5328804c6dd..7c0a6794ea5 100644 --- a/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts @@ -30,7 +30,7 @@ export const entry: SemanticMigration = { + 'no schema tombstone either: nothing ever ran a driver object through ' + '`DriverInterfaceSchema.parse()`, so a prescription there would have no one to ' + 'reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ' - + 'ADR-0078, #4484.', + + 'ADR-0078.', acceptanceCriteria: 'No code calls `driver.findStream(...)`; large reads page through `find()` with ' + '`limit`/`offset` (which guarantees a total order across the whole walk) or go ' diff --git a/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts b/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts index 86ef709dd82..dadfdbc974c 100644 --- a/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts +++ b/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts @@ -19,8 +19,9 @@ export const entry: SemanticMigration = { + 'layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The ' + 'driver side paid in blanket casts: a direct caller holding only a `where` could not ' + 'name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / ' - + '`fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030\'s ' - + '`$like` reached runtime through exactly that hole. This is a TS contract surface with ' + + '`fields` along with it — 20 such sites were measured in the downstream cloud codebase, ' + + 'and a `$like` the type layer would have caught reached runtime there through exactly ' + + 'that hole. This is a TS contract surface with ' + 'no authored source for the chain to rewrite, which is why it is a semantic entry and ' + 'not a D2 conversion; for a typed caller the compiler names every site (TS2353 ' + '`\'object\' does not exist in type \'DriverQuery\'`), and for an untyped JS caller ' @@ -29,14 +30,15 @@ export const entry: SemanticMigration = { + 'unchanged (excess properties are only rejected on fresh literals), and an ' + 'implementation still declaring `query: QueryAST` keeps compiling under parameter ' + 'bivariance. What an implementation may no longer do is READ `query.object` — callers ' - + 'are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was ' - + 'the audit\'s CONTROL sample, drawn to show that not every flagged candidate is an ' + + 'are now entitled to omit it. Registered by the stock reconciliation that compared the ' + + 'breaking changesets already on the v17 release train against this ledger. This change ' + + 'was that audit\'s CONTROL sample, drawn to show that not every flagged candidate is an ' + 'omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose ' + 'inside other entries, and the only subject-level hit on this interface is ' + '`data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver ' - + 'call-parameter changes (#6321, #6083) both registered, and both cite #5181 as ' - + 'background; the larger sibling they derive from never got its own entry. ADR-0087, ' - + '#5181 (backfilled #6350).', + + 'call-parameter changes both registered, and both cite this narrowing as ' + + 'background; the larger sibling they derive from never got its own entry. ADR-0087 ' + + '(backfilled by that reconciliation).', acceptanceCriteria: 'No `IDataDriver` call site passes an inline literal carrying `object:` — `driver.find(' + '"account", { object: "account", where: … })` becomes `driver.find("account", { where: ' diff --git a/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts index c75fc7ba06b..e8e6ba6ec5f 100644 --- a/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts @@ -36,7 +36,7 @@ export const entry: SemanticMigration = { + '`DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one ' + 'to reach; its three `authorable-surface.json` baseline lines and its ' + '`json-schema.manifest.json` entry are dropped in the same change, deliberately. ' - + 'The enforced channel is tsc. ADR-0049 / ADR-0078, #4618.', + + 'The enforced channel is tsc. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No code calls `engine.batch(...)` and no type references `DataEngineBatchRequest`; ' + 'in-process multi-write atomicity goes through `IObjectQLEngine.transaction(cb)`, a ' diff --git a/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts index 59076427cb7..16ceceec282 100644 --- a/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts @@ -11,7 +11,8 @@ export const entry: SemanticMigration = { reason: '`data.field.changed` was declared in `DataEventType` and emitted by nothing — the ' + 'engine\'s `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since ' - + '#4639) `data.records.{updated,deleted}`, and no other producer exists in either ' + + 'multi-record predicate writes were given events of their own) ' + + '`data.records.{updated,deleted}`, and no other producer exists in either ' + 'repository. A subscriber that switched on it was waiting on an event no producer ' + 'sends: the branch never ran, and because the surrounding `switch` still compiled, ' + 'nothing anywhere reported the gap (ADR-0078\'s silently-inert declaration, on the ' @@ -23,7 +24,7 @@ export const entry: SemanticMigration = { + 'event per write rather than N events on a wide table. This is a runtime EVENT ' + 'surface — no stack, example or template authors an event name (webhooks subscribe ' + 'through the separate authorable `WebhookTriggerType`, whose vocabulary was already ' - + 'trimmed to producers that exist, #3196) — so there is no source for the chain to ' + + 'trimmed to producers that exist) — so there is no source for the chain to ' + 'rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a ' + 'retiredKey() fix-it error the way an authorable object key can (the same limit the ' + 'sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, ' @@ -31,7 +32,7 @@ export const entry: SemanticMigration = { + 'fails any consumer still naming the value in a `DataEventType` position, and the ' + 'enum parse, which now rejects the name instead of accepting an event that never ' + 'arrives. A genuine per-field stream, if one is ever wanted, gets its own honest ' - + 'contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673.', + + 'contract the way bulk writes were given theirs. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No consumer subscribes to or switches on `data.field.changed`; per-field change ' + 'detail is read from a `data.record.updated` event\'s `changes` map (with `before` / ' diff --git a/packages/spec/src/migrations/entries/semantic/18.data-file-value-duration-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.data-file-value-duration-unit-in-key.ts index c30fcd81636..04aea637929 100644 --- a/packages/spec/src/migrations/entries/semantic/18.data-file-value-duration-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.data-file-value-duration-unit-in-key.ts @@ -9,7 +9,8 @@ export const entry: SemanticMigration = { replacement: 'durationSeconds — rename the key; the value is unchanged, and a fractional ' + 'second is still legal', reason: - 'Maintainer ruling A on #18669 (2026-09-17, decision batch #151 item 4): rename the key and ' + 'Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type ' + + 'could express: rename the key and ' + 'record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of ' + 'anything already stored. ' + 'This key declared its unit in NO channel at all — no `.describe()`, no JSDoc, no unit ' @@ -31,9 +32,11 @@ export const entry: SemanticMigration = { + 'surfaces that state one fact cannot drift apart. ' + 'The value type is deliberately UNCHANGED at `z.number().optional()`: a fractional second ' + 'is the ordinary shape of a media length, so the closed `DurationSeconds` type ' - + '(`.int().nonnegative()`, #18122) was considered and REFUSED by the ruling, and so was an ' - + '`.int()` floor. That refusal is the load-bearing half — this row is one of the six the ' - + '#18122 unit set was derived from, and it is the one that takes a NAME instead of a TYPE. ' + + '(`.int().nonnegative()`, published beside `EpochMs` as a closed duration type) was ' + + 'considered and REFUSED by the ruling, and so was an ' + + '`.int()` floor. That refusal is the load-bearing half — this row is one of the six ' + + 'genuine durations the closed types\' unit set was derived from, and it is the one that ' + + 'takes a NAME instead of a TYPE. ' + 'Tombstoned with retiredKey(); FileValueSchema is the one deliberate z.looseObject in this ' + 'file, so a bare deletion would wave the old spelling through as an unrecognised extra key ' + 'and the prescription would never be spoken. ' @@ -42,7 +45,7 @@ export const entry: SemanticMigration = { + 'FileReferenceIdValueSchema, an opaque string — so a file value is never authored as this ' + 'shape and never persisted as a sys_metadata row, and the conversion chain has no seam ' + 'that would ever see one. ' - + '#18669, #14478, #18122, ADR-0104, ADR-0087.', + + 'ADR-0104, ADR-0087.', acceptanceCriteria: 'Every producer that BUILDS an expanded file value spells durationSeconds, and every ' + 'consumer that reads a media length reads durationSeconds. Authoring duration fails to ' diff --git a/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts index 9ec8d4ebfc3..36a3eecfcab 100644 --- a/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts @@ -8,7 +8,9 @@ export const entry: SemanticMigration = { + 'unit (data/driver-nosql.zod.ts)', replacement: 'timeoutMs — rename the key; the value is unchanged', reason: - 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + 'Maintainer ruling B on duration units (2026-09-02): the unit of a duration-shaped ' + + 'z.number() lives in the key NAME or in a unit-carrying value, never only in the describe ' + + 'prose, and no existing offender is grandfathered. ' + 'It stands alone because it is the only offender on its file. The neighbour is what ' + 'makes it a real hazard rather than a naming preference: batchSize sits directly beside ' + 'it, a plain row COUNT with the same z.number().int().positive() shape and the same ' @@ -19,7 +21,7 @@ export const entry: SemanticMigration = { + 'the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 ' + 'conversion: these options are a per-call driver argument, reached only through ' + 'AggregationPipeline.options, which no stack.zod.ts collection declares and no ' - + 'sys_metadata row stores, so the chain has no seam. #15680, #14478, ADR-0087.', + + 'sys_metadata row stores, so the chain has no seam. ADR-0087.', acceptanceCriteria: 'Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails ' + 'to compile (input type `never`) and fails to parse with the rename prescription. ' From 95fb97ea60872067b8de601d8db4e87881b3f577 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:02:38 +0000 Subject: [PATCH 5/6] fix(spec): element-* guidance in words; regenerate registry, spec-changes and upgrade guide; widen the pin Rewrite the element-* ADR-0087 semantic entries in form D, regenerate registry.ts, spec-changes.json and docs/protocol-upgrade-guide.md with their generators, widen the os migrate meta guidance pin to the datasource-, filter-, action-, data- and element- families (REWRITTEN floor 55 -> 88), re-pin the one test that matched a removed tracker number on the sentence, and add the patch changeset. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- ...element-migration-guidance-tracker-free.md | 25 ++ docs/protocol-upgrade-guide.md | 24 +- .../test/migrate-meta-engine-guidance.test.ts | 42 +- packages/spec/spec-changes.json | 44 +-- ...urce-and-object-block-filter-rule-array.ts | 31 +- .../18.element-number-filter-rule-array.ts | 21 +- ...element-record-picker-filter-rule-array.ts | 16 +- packages/spec/src/migrations/registry.ts | 367 ++++++++++-------- packages/spec/src/ui/action-params.test.ts | 2 +- 9 files changed, 353 insertions(+), 219 deletions(-) create mode 100644 .changeset/20233-datasource-filter-action-data-element-migration-guidance-tracker-free.md diff --git a/.changeset/20233-datasource-filter-action-data-element-migration-guidance-tracker-free.md b/.changeset/20233-datasource-filter-action-data-element-migration-guidance-tracker-free.md new file mode 100644 index 00000000000..0dfa0a01ff4 --- /dev/null +++ b/.changeset/20233-datasource-filter-action-data-element-migration-guidance-tracker-free.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `os migrate meta` guidance for the `datasource-*`, `filter-*`, `action-*`, `data-*` and `element-*` migration entries states each lesson in words instead of citing tracker numbers + +Clause-②: no + +The ADR-0087 semantic entries of the `datasource-*` family (the publish-time credential, +placeholder and URL refusals, and the bound-secret pairs a mongo datasource cannot use), +the `filter-*` family (the retired `$regex`, the `$between` endpoint refusals and the +comparand shapes the save door now refuses), the `action-*` family (the retired +descriptor key, the `resumeAuthority` default flip, the action-session rename, the bulk +dispatch contract and the engine facade's query envelope), the `data-*` family (the +retired driver and engine contract members, the retired field-changed event and two +duration keys renamed with their unit) and the `element-*` family (the filter rule array +at the page binding and the element and block doors) 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, decision-batch and ruling-record numbers — some of which no longer +resolve, and some in other repositories — for what a ruling, measurement or fix had +decided; it now says what was decided, in the sentence being read. ADR ids are kept. + +Text only: no entry id, `surface`, `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 6b86f623616..dd5f4b508c2 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -210,13 +210,13 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main ### Semantic (delegated to you, with acceptance criteria) - **`action-descriptor-is-async-retired`** — `ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)` → nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need) - - Why not automatic: ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow awaiting an external reply" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down. + - Why not automatic: ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow awaiting an external reply" and NOTHING read it: a fresh three-repo measurement (taken when the key was filed for retirement, and re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down. - Done when: No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, `wait`, `approval`, `approval_revise`), not a plugin's. Every node type that returns `suspend: true` from `execute()` declares `supportsPause: true` on its descriptor together with a `resumeAuthority`, and its runs still pause and resume as before: the behaviour never depended on `isAsync`, so deleting the key changes no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails `defineActionDescriptor()` at runtime with the prescription, instead of parsing clean and being stripped. - **`action-descriptor-resume-authority-default-flip`** — `automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)` → an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning - - Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561. - - Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is no longer the declaration nothing enforced (#5703, closed by #6667): an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is still warned about by neither warning channel, but `AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the one seam every suspension passes through — a guard-class failure no `fault` edge routes — so it needs no hand-check. The residue that does: an executor registering NO descriptor declares nothing for either warning or the refusal to read, so its pauses are still created and refused only later, on the resume route (#5561). + - Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The generic resume route's authorization gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. The revise-window incident decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam addendum to ADR-0019 (approval as a flow node). + - Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is no longer the declaration nothing enforced: an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is still warned about by neither warning channel, but `AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the one seam every suspension passes through — a guard-class failure no `fault` edge routes — so it needs no hand-check. The residue that does: an executor registering NO descriptor declares nothing for either warning or the refusal to read, so its pauses are still created and refused only later, on the resume route. - **`action-session-roles-to-positions`** — `ui.actionSession.roles` → ui.actionSession.positions (an action body reads `ctx.session.positions`) - - Why not automatic: The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v16 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779. + - Why not automatic: The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 ("C skeleton + A semantics": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook `ctx.session.tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087. - Done when: No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` and observes the same array (the rename is a rename — the VALUE is `ExecutionContext.positions` on both sides, which the runtime pin `action-session-shape-contract.test.ts` asserts independently of the key name). Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Verify against a real dispatch, not a fixture: invoke an action as a caller holding positions and assert the body observed them under the canonical key. During the window both keys are present and equal, so a reader can be migrated and verified before the alias is removed; after it, `roles` is absent and a body still reading it sees `undefined` — which is why the read must be moved inside the window rather than at its close. - **`actor-user-roles-to-positions`** — `action body / AI route: ctx.user.roles (req.user.roles)` → ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions - Why not automatic: The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was "kept for the REST/AI shapes", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048). @@ -267,25 +267,25 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform. - Done when: No dashboard widget declares `compareTo.offset`. Each former offset comparison states its window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` (or `'previousYear'`), and `dimension` is named wherever the selection dates more than one time dimension. `objectstack validate` passes, and each affected widget renders a `__compare` column over the window its author intended. - **`data-driver-find-stream-retired`** — `contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream` → find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts) - - Why not automatic: `findStream` was a REQUIRED contract method documented as "optimized for large datasets to avoid memory overflow", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484. + - Why not automatic: `findStream` was a REQUIRED contract method documented as "optimized for large datasets to avoid memory overflow", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078. - Done when: No code calls `driver.findStream(...)`; large reads page through `find()` with `limit`/`offset` (which guarantees a total order across the whole walk) or go through the export surface. Drivers and test doubles no longer implement the method — one left behind still compiles and is simply never reached, so removing it is cleanup rather than a break, while a CALLER of it no longer type-checks. - **`data-driver-query-omit-object`** — `contracts.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain` → `DriverQuery` (`Omit`): delete the redundant `object:` key from the query literal at the call site — the object name is already the FIRST argument - - Why not automatic: Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030's `$like` reached runtime through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was the audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes (#6321, #6083) both registered, and both cite #5181 as background; the larger sibling they derive from never got its own entry. ADR-0087, #5181 (backfilled #6350). + - Why not automatic: Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites were measured in the downstream cloud codebase, and a `$like` the type layer would have caught reached runtime there through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. This change was that audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes both registered, and both cite this narrowing as background; the larger sibling they derive from never got its own entry. ADR-0087 (backfilled by that reconciliation). - Done when: No `IDataDriver` call site passes an inline literal carrying `object:` — `driver.find("account", { object: "account", where: … })` becomes `driver.find("account", { where: … })`. `tsc` is the verify loop for typed callers and reports every remaining site by name; an untyped JS caller must be swept by hand, because nothing will report it. Any driver IMPLEMENTATION that read `query.object` is rewritten to use the object-name argument instead — that read now yields `undefined` whenever a caller exercises its new right to omit the key, and it fails at runtime rather than at compile time, so it is the one change on this surface a type check cannot find for you. - **`data-engine-batch-retired`** — `contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema` → `IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire - - Why not automatic: `batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment ("Batch Operations (Transactional)"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078, #4618. + - Why not automatic: `batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment ("Batch Operations (Transactional)"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078. - Done when: No code calls `engine.batch(...)` and no type references `DataEngineBatchRequest`; in-process multi-write atomicity goes through `IObjectQLEngine.transaction(cb)`, a batch over one object through `batchData` with `options.atomic: true`, and a cross-object batch over the wire through `POST {basePath}/batch`. Because no engine implemented the member, an implementation left behind still compiles and is simply never reached; a CALLER of it no longer type-checks — and there were none. - **`data-field-changed-event-retired`** — `api.DataEventType 'data.field.changed'` → the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after` - - Why not automatic: `data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673. + - Why not automatic: `data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since multi-record predicate writes were given events of their own) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way bulk writes were given theirs. ADR-0049 / ADR-0078. - Done when: No consumer subscribes to or switches on `data.field.changed`; per-field change detail is read from a `data.record.updated` event's `changes` map (with `before` / `after` for the surrounding state). Deleting the dead branch changes no observable behaviour — it never executed — so the migration is removing code that could not run, not rebuilding a capability. - **`datasource-config-inline-credential-refused`** — `datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)` → the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference - - Why not automatic: A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead. + - Why not automatic: A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled on 2026-08-12 to close that per artefact: each schema that admitted an inline credential refuses it at publish and points at the secret mechanism the artefact already had, rather than a heuristic guard at the `sys_metadata` write. There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead. - Done when: Every datasource parses with no `config.password` / `config.authToken` key; each affected datasource carries `external.credentialsRef` (or has its secret bound through the connection form) and still connects; no cleartext credential remains in any stored `sys_metadata` row or authored source. - **`datasource-config-placeholder-refused`** — `connection-material string keys of the built-in driver configs — postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values containing `${…}` placeholder syntax` → the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` and friends are translated into driver config by the boot hosts and never pass through the publish door - - Why not automatic: A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (#7990 census, measured during #8078), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn "do NOT substitute a placeholder" around the broken escape. Maintainer-ruled direction 2 on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target. + - Why not automatic: A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (measured by the credential census while the inline-credential refusal was being built), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had to warn "do NOT substitute a placeholder" around the broken escape. The maintainer ruled on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target. - Done when: Every datasource parses with no `${…}` span in any connection-material config value; environment-driven deployments carry their DSN in the runtime environment (`OS_DATABASE_URL` and friends) or bind secrets via `external.credentialsRef`, and still connect; no unresolved placeholder remains in any stored `sys_metadata` row or authored source. - **`datasource-config-url-userinfo-refused`** — `datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)` → the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference - - Why not automatic: The #7990 closure refused the inline credential KEYS, and #8078 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 (#8082, maintainer-ruled Option A, 2026-08-12). 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 (#8078, measured). + - 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. @@ -359,7 +359,7 @@ ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely - Why not automatic: The `field` registry entry declared `allowRuntimeCreate: true` and the platform never built a read path for it. Measured end-to-end through the real HttpDispatcher -> ObjectStackProtocolImplementation -> SysMetadataRepository (#7893): `PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with {"success":true,"state":"active","message":"Saved field …"}, the row persisted, and `GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with zz_probe ABSENT — forever. The row is even self-readable by name (`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which makes it well-formed and universally inert rather than malformed. The seam is that `field` is the ONE declared type with no standalone existence: fields are authored inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ('field','.'), and nothing composes fragment rows into their parent — `applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` (`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 rather than build the read path, which is a feature spanning at least three packages (a composition step that does not exist, ~20 `gate.fields` call sites, physical schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a separate card — implementation first, declaration second. ⚠️ This is NOT the #5488 (`api`) rationale reused: that ruling rested on "zero business pull", and "add a field" is the opposite — a core Studio/CRM operation. The justification here is that the operation REMAINS AVAILABLE on the route that actually composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, broken SPELLING of adding a field, not the ability to add one. There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and no authored source changes — an `**/*.object.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `api` (#5488) and `BatchOptions.validateOnly` (#4052) take. ADR-0049 / ADR-0087, #7893 (split from #7743). - Done when: No caller creates a standalone `field` item through the runtime metadata API. `PUT /api/v1/meta/field/{object}.{name}` answers 403 with `code: "NOT_CREATABLE"` and a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the prescription `PUT /api/v1/meta/object/:object with the new field in `fields``. The plural spelling `PUT /api/v1/meta/fields/{object}.{name}` folds onto the singular (#7894) and earns the same refusal — verify it, because it was a separate door until 2026-08-12. ⚠️ Verify the OBJECT route is UNAFFECTED, which is the whole point of the change: `PUT /api/v1/meta/object/{name}` with a new entry in `fields` still answers 200, and `GET /api/v1/meta/object/{name}` READS THE NEW FIELD BACK (assert on `body.data.item.fields`, not `body.item`, which is undefined and makes an empty read look like a pass). Assert a DECLARED field is present in the same response, so a dead read cannot be what makes the check pass. ⚠️ #7743's overlay refusal is untouched and must stay: overwriting a field a code package ships is still 403 `NOT_OVERRIDABLE`, a different gate for a different question — making field OVERRIDES legal was never part of this decision. DISPOSITION OF EXISTING ROWS: `field` rows already written through the retired channel stay in `sys_metadata` and are INERT — they were inert before this change too, since no read path ever composed them into an object, so nothing that used to work stops working and no data is silently reinterpreted. They remain self-readable by name and still report `_diagnostics.valid: true`, which asserts only that the isolated document is well-formed (see #8169 — the envelope has no "in effect" axis). They may be deleted at leisure: `deleteMetaItem` is deliberately NOT gated by this refusal, so repair stays possible. An operator who needs the write door back on one deployment sets `OS_METADATA_WRITABLE=field`; note this unlocks the WRITE only — the field still will not reach its object, which is why it is a diagnostic and not a workaround. - **`filter-regex-options-retired`** — `data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request` → $icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement - - Why not automatic: Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus #5701 itself, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702. + - Why not automatic: Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus the retirement's own contract-half change, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the maintainer's 2026-08-06 ruling (option B: retire `$regex` loudly and add `$icontains`, rather than make five backends agree on one regex dialect), not just the driver one: the contract half (the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was never asked for a ledger entry; the driver half is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087. - Done when: No stored filter and no request `where` spells `$regex` or `$options` — grep the stack for both. Each one is rewritten by asking what the pattern MEANT, not by transliterating it: a bare substring pattern becomes `$icontains` (or `$contains` when the match must stay case-sensitive), and its metacharacters are dropped rather than escaped, because they were never honoured as a regex on the SQL family in the first place. ⚠️ Expect the answer to CHANGE on any stack that ran on `driver-memory`, `driver-mongodb` or objectql `having`, where the pattern really was evaluated as a regular expression; on the SQL family the rewritten filter returns what it always returned. A pattern that genuinely needs alternation, anchoring or character classes has no filter-level replacement — move that predicate into a formula field or a server-side view, or open an issue for it. Verify by loading the stack: a surviving `$regex` or `$options` is answered INVALID_FILTER / 400 with a message naming the replacement, on every backend. - **`flow-retry-max-retries-required`** — `flow.errorHandling.maxRetries (under strategy: 'retry')` → an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail' - Why not automatic: maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition (#4247). With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's. diff --git a/packages/cli/test/migrate-meta-engine-guidance.test.ts b/packages/cli/test/migrate-meta-engine-guidance.test.ts index c7b56c01fb8..74406e5bf01 100644 --- a/packages/cli/test/migrate-meta-engine-guidance.test.ts +++ b/packages/cli/test/migrate-meta-engine-guidance.test.ts @@ -3,8 +3,8 @@ /** * `os migrate meta` — the guidance it prints for the ADR-0087 semantic entries * of the COVERED families (`engine-*`, `ui-*`, `plugin-*`, `driver-*`, - * `kernel-*`, `system-*`) states each lesson in words and carries no tracker - * number. + * `kernel-*`, `system-*`, `datasource-*`, `filter-*`, `action-*`, `data-*`, + * `element-*`) states each lesson in words and carries no tracker number. * * ## What this pins * @@ -73,7 +73,10 @@ const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); const TRACKER_ID = /#\d{4,5}\b/; /** The families this pin holds, selected by entry-id prefix. */ -const COVERED_PREFIXES = ['engine-', 'ui-', 'plugin-', 'driver-', 'kernel-', 'system-']; +const COVERED_PREFIXES = [ + 'engine-', 'ui-', 'plugin-', 'driver-', 'kernel-', 'system-', + 'datasource-', 'filter-', 'action-', 'data-', 'element-', +]; /** * The entries rewritten when each family was brought to this line — the @@ -81,6 +84,25 @@ const COVERED_PREFIXES = ['engine-', 'ui-', 'plugin-', 'driver-', 'kernel-', 'sy * is held by its prefix and needs no row here. */ const REWRITTEN = [ + 'action-bulk-dispatch-contract-undeclared', + 'action-descriptor-is-async-retired', + 'action-descriptor-resume-authority-default-flip', + 'action-engine-facade-find-query-envelope', + 'action-session-roles-to-positions', + 'data-driver-find-stream-retired', + 'data-driver-query-omit-object', + 'data-engine-batch-retired', + 'data-field-changed-event-retired', + 'data-file-value-duration-unit-in-key', + 'data-nosql-query-options-timeout-unit-in-key', + 'datasource-config-inline-credential-refused', + 'datasource-config-mongo-options-credential-refused', + 'datasource-config-placeholder-refused', + 'datasource-config-postgres-url-unparseable-refused', + 'datasource-config-url-query-credential-refused', + 'datasource-config-url-userinfo-refused', + 'datasource-credentialsref-mongo-composed-no-username-refused', + 'datasource-credentialsref-mongo-url-no-user-refused', 'driver-aggregate-undeclared-key-aliases-removed', 'driver-capabilities-inert-bits-removed', 'driver-options-timeout-to-timeout-ms', @@ -88,11 +110,25 @@ const REWRITTEN = [ 'driver-sql-unresolvable-where-column-refused', 'driver-sql-upsert-cross-row-identity-merge-refused', 'driver-turso-config-local-path-wasm-retired', + 'element-data-source-and-object-block-filter-rule-array', + 'element-number-filter-rule-array', + 'element-record-picker-filter-rule-array', 'engine-dotted-filter-refused', 'engine-dotted-projection-refused', 'engine-find-formula-filter-refused', 'engine-find-formula-order-by-refused', 'engine-update-upsert-retired', + 'filter-between-blank-endpoint-refused', + 'filter-between-field-reference-endpoint-refused', + 'filter-comparand-types-and-widget-nested-slots-refused-at-save', + 'filter-equality-array-comparand-refused', + 'filter-equality-array-comparand-refused-at-save', + 'filter-icontains-comparand-refused-at-parse', + 'filter-ne-array-comparand-refused', + 'filter-preset-ordering-comparand-refused', + 'filter-query-face-comparands-refused-at-save', + 'filter-regex-options-retired', + 'filter-text-operator-declared-type-refused', 'kernel-compatibility-matrix-estimated-migration-time-unit-in-key', 'kernel-context-preview-mode-retired', 'kernel-event-bus-retention-unit-in-key', diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 28fbdf5c3dd..e39ed7e6c25 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -357,21 +357,21 @@ "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)", "migrationId": "action-descriptor-is-async-retired", "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down." + "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (taken when the key was filed for retirement, and re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down." }, { "surface": "automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)", "replacement": "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning", "migrationId": "action-descriptor-resume-authority-default-flip", "toMajor": 17, - "rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561." + "rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The generic resume route's authorization gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. The revise-window incident decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam addendum to ADR-0019 (approval as a flow node)." }, { "surface": "ui.actionSession.roles", "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)", "migrationId": "action-session-roles-to-positions", "toMajor": 17, - "rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v16 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779." + "rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 (\"C skeleton + A semantics\": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook `ctx.session.tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087." }, { "surface": "action body / AI route: ctx.user.roles (req.user.roles)", @@ -490,49 +490,49 @@ "replacement": "find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts)", "migrationId": "data-driver-find-stream-retired", "toMajor": 17, - "rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484." + "rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078." }, { "surface": "contracts.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain", "replacement": "`DriverQuery` (`Omit`): delete the redundant `object:` key from the query literal at the call site — the object name is already the FIRST argument", "migrationId": "data-driver-query-omit-object", "toMajor": 17, - "rationale": "Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030's `$like` reached runtime through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was the audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes (#6321, #6083) both registered, and both cite #5181 as background; the larger sibling they derive from never got its own entry. ADR-0087, #5181 (backfilled #6350)." + "rationale": "Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites were measured in the downstream cloud codebase, and a `$like` the type layer would have caught reached runtime there through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. This change was that audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes both registered, and both cite this narrowing as background; the larger sibling they derive from never got its own entry. ADR-0087 (backfilled by that reconciliation)." }, { "surface": "contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema", "replacement": "`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire", "migrationId": "data-engine-batch-retired", "toMajor": 17, - "rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078, #4618." + "rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078." }, { "surface": "api.DataEventType 'data.field.changed'", "replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`", "migrationId": "data-field-changed-event-retired", "toMajor": 17, - "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673." + "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since multi-record predicate writes were given events of their own) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way bulk writes were given theirs. ADR-0049 / ADR-0078." }, { "surface": "datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)", "replacement": "the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", "migrationId": "datasource-config-inline-credential-refused", "toMajor": 17, - "rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead." + "rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled on 2026-08-12 to close that per artefact: each schema that admitted an inline credential refuses it at publish and points at the secret mechanism the artefact already had, rather than a heuristic guard at the `sys_metadata` write. There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead." }, { "surface": "connection-material string keys of the built-in driver configs — postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values containing `${…}` placeholder syntax", "replacement": "the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` and friends are translated into driver config by the boot hosts and never pass through the publish door", "migrationId": "datasource-config-placeholder-refused", "toMajor": 17, - "rationale": "A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (#7990 census, measured during #8078), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn \"do NOT substitute a placeholder\" around the broken escape. Maintainer-ruled direction 2 on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target." + "rationale": "A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (measured by the credential census while the inline-credential refusal was being built), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had to warn \"do NOT substitute a placeholder\" around the broken escape. The maintainer ruled on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target." }, { "surface": "datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)", "replacement": "the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", "migrationId": "datasource-config-url-userinfo-refused", "toMajor": 17, - "rationale": "The #7990 closure refused the inline credential KEYS, and #8078 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 (#8082, maintainer-ruled Option A, 2026-08-12). 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 (#8078, measured)." + "rationale": "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)." }, { "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)", @@ -644,7 +644,7 @@ "replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement", "migrationId": "filter-regex-options-retired", "toMajor": 17, - "rationale": "Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus #5701 itself, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702." + "rationale": "Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus the retirement's own contract-half change, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the maintainer's 2026-08-06 ruling (option B: retire `$regex` loudly and add `$icontains`, rather than make five backends agree on one regex dialect), not just the driver one: the contract half (the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was never asked for a ledger entry; the driver half is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087." }, { "surface": "flow.errorHandling.maxRetries (under strategy: 'retry')", @@ -1249,21 +1249,21 @@ "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)", "migrationId": "action-descriptor-is-async-retired", "toMajor": 17, - "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down." + "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (taken when the key was filed for retirement, and re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down." }, { "surface": "automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)", "replacement": "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning", "migrationId": "action-descriptor-resume-authority-default-flip", "toMajor": 17, - "rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561." + "rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The generic resume route's authorization gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. The revise-window incident decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam addendum to ADR-0019 (approval as a flow node)." }, { "surface": "ui.actionSession.roles", "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)", "migrationId": "action-session-roles-to-positions", "toMajor": 17, - "rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v16 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779." + "rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 (\"C skeleton + A semantics\": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook `ctx.session.tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087." }, { "surface": "action body / AI route: ctx.user.roles (req.user.roles)", @@ -1382,49 +1382,49 @@ "replacement": "find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts)", "migrationId": "data-driver-find-stream-retired", "toMajor": 17, - "rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484." + "rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078." }, { "surface": "contracts.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain", "replacement": "`DriverQuery` (`Omit`): delete the redundant `object:` key from the query literal at the call site — the object name is already the FIRST argument", "migrationId": "data-driver-query-omit-object", "toMajor": 17, - "rationale": "Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030's `$like` reached runtime through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was the audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes (#6321, #6083) both registered, and both cite #5181 as background; the larger sibling they derive from never got its own entry. ADR-0087, #5181 (backfilled #6350)." + "rationale": "Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites were measured in the downstream cloud codebase, and a `$like` the type layer would have caught reached runtime there through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. This change was that audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes both registered, and both cite this narrowing as background; the larger sibling they derive from never got its own entry. ADR-0087 (backfilled by that reconciliation)." }, { "surface": "contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema", "replacement": "`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire", "migrationId": "data-engine-batch-retired", "toMajor": 17, - "rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078, #4618." + "rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078." }, { "surface": "api.DataEventType 'data.field.changed'", "replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`", "migrationId": "data-field-changed-event-retired", "toMajor": 17, - "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673." + "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since multi-record predicate writes were given events of their own) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way bulk writes were given theirs. ADR-0049 / ADR-0078." }, { "surface": "datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)", "replacement": "the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", "migrationId": "datasource-config-inline-credential-refused", "toMajor": 17, - "rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead." + "rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled on 2026-08-12 to close that per artefact: each schema that admitted an inline credential refuses it at publish and points at the secret mechanism the artefact already had, rather than a heuristic guard at the `sys_metadata` write. There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead." }, { "surface": "connection-material string keys of the built-in driver configs — postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values containing `${…}` placeholder syntax", "replacement": "the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` and friends are translated into driver config by the boot hosts and never pass through the publish door", "migrationId": "datasource-config-placeholder-refused", "toMajor": 17, - "rationale": "A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (#7990 census, measured during #8078), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn \"do NOT substitute a placeholder\" around the broken escape. Maintainer-ruled direction 2 on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target." + "rationale": "A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (measured by the credential census while the inline-credential refusal was being built), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had to warn \"do NOT substitute a placeholder\" around the broken escape. The maintainer ruled on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target." }, { "surface": "datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)", "replacement": "the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", "migrationId": "datasource-config-url-userinfo-refused", "toMajor": 17, - "rationale": "The #7990 closure refused the inline credential KEYS, and #8078 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 (#8082, maintainer-ruled Option A, 2026-08-12). 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 (#8078, measured)." + "rationale": "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)." }, { "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)", @@ -1536,7 +1536,7 @@ "replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement", "migrationId": "filter-regex-options-retired", "toMajor": 17, - "rationale": "Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus #5701 itself, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702." + "rationale": "Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus the retirement's own contract-half change, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the maintainer's 2026-08-06 ruling (option B: retire `$regex` loudly and add `$icontains`, rather than make five backends agree on one regex dialect), not just the driver one: the contract half (the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was never asked for a ledger entry; the driver half is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087." }, { "surface": "flow.errorHandling.maxRetries (under strategy: 'retry')", diff --git a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts index 9215656b553..dbbe1261d2d 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts @@ -23,30 +23,32 @@ export const entry: SemanticMigration = { + 'also took — becomes `[{ field: \'owner_id\', operator: \'equals\', value: ' + '\'{current_user_id}\' }]`; the value placeholders and date macros are unchanged. Legacy ' + 'operator shorthands (`eq`, `ne`, `gt`, `notIn`, …) are accepted and normalized on parse. ' - + 'The dashboard widget `filter` (`dashboard.zod.ts`) is a different family and is not moved ' - + 'by this entry (#15829); `object-grid.defaultFilters` is a different key and is not named ' + + 'The dashboard widget `filter` (`dashboard.zod.ts`) is a different family, judged on its ' + + 'own, and is not moved by this entry; `object-grid.defaultFilters` is a different key and is not named ' + 'by the ruling this entry records.', reason: - 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' - + '2026-08-25, verbatim 「同意」, Option B) reached two more locations the ComponentPropsMap ' - + 'census could not see (#15442 anchor, #15449 member; decision batch #55, 2026-09-06, ' - + 'verbatim 「同意」, option A: converge family-wide, one entry). The binding-level ' + 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: a ' + + 'filter door takes the `ViewFilterRule` array rather than keeping a record-shaped ' + + 'exception every author and AI would have to remember) reached two more locations the ' + + 'ComponentPropsMap census could not see (ruled 2026-09-06, option A: converge the binding ' + + 'and the four block doors family-wide, under one entry, rather than record an exception). The binding-level ' + '`dataSource.filter` alone still said `FilterConditionSchema`: it refused the array the ' + 'consumer\'s own pins author at that key, and `element:record_picker` carried two ' + 'orthographies at two keys (`properties.filter` the rule array, `dataSource.filter` the ' + 'record) resolved through one `??` in the renderer — the shape in which a dropped or ' + 'misread filter returns the wrong rows without an error. The four `object-*` doors said ' - + '`z.unknown()`: a read-point record derived from the renderers on 2026-08-13 (#7751), ' + + '`z.unknown()`: a read-point record derived from the renderers on 2026-08-13, when the ' + + '`object-*` blocks first got props schemas in the map, ' + 'twelve days before the ruling, not an exception to it — so an author following the ' + 'showcase wrote the record and an author following the manifest wrote an array, and each ' + 'got a silent success receipt while the html tier already declared `array` for the grid ' + 'and the metric. The record\'s `$and` / `$or` / `$not` keys were misread by every gate ' - + 'block anyway (objectui#6948), so the exception would have preserved a capability the ' + + 'block anyway (the console\'s filter converter had no branch for them), so the exception would have preserved a capability the ' + 'consumer does not honour. Sequenced measurement-first, as the family had to be: at the ' + 'objectui pin `a472b07` the `object-metric` aggregate path posted an array `where` that ' - + '`POST /analytics/query` refused with 400 on every array form (#15828), so the converge ' - + 'was parked behind the pin bump #16626; at the pin this repo builds against (`53ded82b`, ' - + 'objectui#7754) the adapter lowers an authored array through `translateFilterArray` and ' + + '`POST /analytics/query` refused with 400 on every array form, so the converge ' + + 'was parked behind a bump of that pin; at the pin this repo builds against (`53ded82b`) ' + + 'the adapter lowers an authored array through `translateFilterArray` and ' + 'the spec\'s own `parseFilterAST` sink before the wire, `ObjectGrid.tsx` lowers a rule ' + 'array through `toFilterNode`, `ObjectKanban.tsx` / `ObjectCalendar.tsx` hand it verbatim ' + 'to `$filter` where `convertQueryParams` lowers it, and the binding\'s composition seam ' @@ -58,7 +60,9 @@ export const entry: SemanticMigration = { + 'in the same change, and zero outside those files; this entry carries the prescription ' + 'for authors outside the repo. ' + 'Metadata AT REST: the mappable part of the table above is a D2 conversion, ' - + '`page-component-filter-record-to-rule-array` (#17321, ruling B), so ' + + '`page-component-filter-record-to-rule-array` (ruled 2026-09-12, option B: convert what ' + + 'maps losslessly and name what does not, rather than leave every stored row to its next ' + + 'save or flatten combinators), so ' + '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) rewrites ' + 'a stored page whose `filter` is a flat record, an operator object whose operators the ' + 'rule vocabulary spells, several such keys, or a single-level AST tuple array, and every ' @@ -95,7 +99,8 @@ export const entry: SemanticMigration = { + 'a record-form `filter: { status: \'active\' }` is refused at the `filter` path of all ' + 'five doors (`invalid_type`, expected array), and an AST tuple array is refused at ' + '`filter.0` (expected object). No `filter` door in `ComponentPropsMap` accepts the ' - + 'record any more (the twin of the #14406 census pin). At runtime each block and the ' + + 'record any more (the twin of the census pin that asks whether any `filter` door still ' + + 'refuses the array). At runtime each block and the ' + 'binding select exactly the rows the array selects — the same filter a list view ' + 'renders — including the `object-metric` aggregate tile, whose analytics `where` is the ' + 'lowered condition. Downstream (objectui, after a released spec version reaches the ' diff --git a/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts index ba6ef476e84..cfa60fc34fd 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts @@ -17,27 +17,32 @@ export const entry: SemanticMigration = { + 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are ' + 'accepted and normalized on parse', reason: - 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' - + "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:number'].filter` " + 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: ' + + 'align the element to the `ViewFilterRule` array rather than keep it the record-shaped ' + + "exception). `ComponentPropsMap['element:number'].filter` " + 'was the one `filter` input in the map declared as the MongoDB-style record ' + '(`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so ' + 'the filter a list view stores and renders was refused by the KPI element beside it, ' + 'and the objectui parity gate had to carry a reasoned exemption to look away. The ' + 'convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): ' - + 'objectui#6828 made `ObjectStackAdapter.aggregate()` run the same `translateFilterArray` ' + + 'the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same ' + + '`translateFilterArray` ' + 'its `find()` path runs, and the objectui pin carrying it was re-measured before this ' - + 'entry moved — but that measurement named the wrong hop, and #15828 corrects it here. ' + + 'entry moved — but that measurement named the wrong hop, and the runtime route\'s refusal ' + + 'of the array corrects it here. ' + '`translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only ' + 'sugar — so the real path is: authored array → `translateFilterArray` → lowered by ' + '`parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock ' - + 'names, #5158 ruling C) in the adapter, BEFORE the wire → a `FilterCondition` on the ' + + 'names, since the maintainer\'s 2026-08-04 ruling C declared the array input-only sugar ' + + 'with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the ' + 'body. The hop that decides it is the runtime route `POST /analytics/query`, which ' + 'parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing ' + 'else — so an un-lowered array is refused there before any service code runs. ' + '`lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, ' - + 'is the IN-PROCESS door (#5334) for callers reaching `analyticsService.query` ' + + 'is the IN-PROCESS door (added when an array `where` was found silently dropped on the ' + + 'analytics path) for callers reaching `analyticsService.query` ' + "directly, not the wire's; it too still refuses a RAW rule-object array by design. " - + 'objectui#7752 lands the adapter-side lowering. ' + + 'The adapter-side lowering lands in the console\'s own repository. ' + 'The ruled migration check ran with the change: the ' + 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, ' + 'packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form ' @@ -53,5 +58,5 @@ export const entry: SemanticMigration = { + 'filter a list view renders. Downstream (objectui, after a released spec version reaches ' + "the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` " + '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes ' - + 'objectui#6206.', + + 'the console-side half of this convergence.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts index 210ba6590b9..6dc4604beb8 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts @@ -11,7 +11,7 @@ export const entry: SemanticMigration = { '`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` ' + "the map's array-declared `filter` doors already carry (`record:related_list`, its nested " + 'Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as ' - + '`z.unknown()`, #15449). A record-form filter ' + + '`z.unknown()`, a gap measured on its own). A record-form filter ' + "`{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; " + "an operator object `{ amount: { $gt: 100 } }` becomes " + "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become " @@ -19,16 +19,18 @@ export const entry: SemanticMigration = { + 'accepted and normalized on parse. The binding-level `dataSource.filter` on the same node ' + 'is a different key (`ElementDataSourceSchema`) and is not moved by this entry', reason: - 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' - + "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:record_picker'].filter` " + 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: ' + + 'every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped ' + + "exceptions). `ComponentPropsMap['element:record_picker'].filter` " + 'was the LAST `filter` input in the map still declared as the MongoDB-style record ' - + '(`FilterConditionSchema`) after `element:number` converged (#12039 Key 2): the three ' + + '(`FilterConditionSchema`) after `element:number` converged: the three ' + 'array-declared doors (`record:related_list`, its nested Add-affordance picker, ' + '`element:number`) carried the `ViewFilterRule` array and the four `object-*` doors ' - + 'declare `z.unknown()` (#15449), so the filter a list view stores and renders was refused ' + + 'declare `z.unknown()`, so the filter a list view stores and renders was refused ' + 'by the picker beside them, and a lone holdout is the state where the next author copies ' + 'the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 ' - + 'Option-A ordering ruling, #14406): at the objectui pin `00d3f09c` the renderer hands ' + + 'Option-A ordering ruling: measure the consumer\'s read path before the contract moves): ' + + 'at the objectui pin `00d3f09c` the renderer hands ' + '`filter` to `query.$filter` and calls `adapter.find()` ' + '(`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` ' + 'lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples ' @@ -50,5 +52,5 @@ export const entry: SemanticMigration = { + "a released spec version reaches the pin): the registry's `inputs.filter` entry for " + "`element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and " + 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — ' - + 'objectui#7663, filed from #14406 with a Blocked-by line.', + + 'a console-side change filed in the objectui repository, blocked on that release.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f3d2c181696..0b23fee3ff9 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1156,16 +1156,16 @@ const step17: MigrationStep = { reason: 'ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow ' + 'awaiting an external reply" and NOTHING read it: a fresh three-repo measurement ' - + '(#6748, re-run at pickup) found zero property reads across objectstack, objectui ' - + 'and cloud — every hit was the declaration itself, a generated baseline, one of ' + + '(taken when the key was filed for retirement, and re-run at pickup) found zero ' + + 'property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of ' + 'five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. ' + 'So declaring it never made a node suspend and omitting it never stopped one, ' + 'which is the silently-inert declaration ADR-0049 exists to end. It was always a ' + 'second, weaker spelling of the capability `supportsPause` states, and the two ' + 'diverged in exactly the way a duplicated declaration does: `screen` declared ' + 'both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing ' - + 'anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in ' - + '#6667 — `AutomationEngine` now refuses a suspension whose type does not declare ' + + 'anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — ' + + '`AutomationEngine` now refuses a suspension whose type does not declare ' + '`supportsPause: true` — so the capability this key gestured at is now a real, ' + 'enforced fact under one name. This one had no consumer to grow into and takes ' + 'the remove leg. ' @@ -1207,12 +1207,13 @@ const step17: MigrationStep = { 'A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as ' + "protocol 12's `rest-requireauth-default-flip`, and it is registered here for the " + 'same reason: whether a given pause is genuinely open to the generic route is a ' - + 'trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED ' + + 'trust judgment no transform can make. The generic resume route\'s authorization gate ' + + 'keys on the SUSPENDED ' + "NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a " + 'pausing node type shipped raw-resumable unless its author remembered the field. ' + "It now resolves to `'service'` when absent: an unclaimed pause is refused on the " + 'generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may ' - + 'continue it. #3823 is the incident that decided the direction — ADR-0044 pointed ' + + 'continue it. The revise-window incident decided the direction — ADR-0044 pointed ' + "an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and " + 'the pause standing in a service-owned position inherited a fail-open value nobody ' + 'chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote ' @@ -1221,8 +1222,8 @@ const step17: MigrationStep = { + "guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface " + 'is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no ' + 'source for a D2 conversion to rewrite and deliberately no schema tombstone — the ' - + 'disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` ' - + '(#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from ' + + 'disposition `data-driver-find-stream-retired`, `storage-service-list-retired` ' + + 'and `actor-user-roles-to-positions` already carry. It differs from ' + 'those in one way a reader should not have to infer: nothing is REMOVED, so tsc ' + 'reports nothing at all — the field was already optional after step one and an ' + 'omission still compiles. The enforced channels are all run-time: a registration ' @@ -1232,7 +1233,8 @@ const step17: MigrationStep = { + 'channel that arrives BEFORE a user hits a run that will not continue. In-tree the ' + 'flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, ' + 'approval, approval_revise) declare their authority explicitly. ADR-0044 amendment ' - + '(2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561.', + + '(2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam ' + + 'addendum to ADR-0019 (approval as a flow node).', acceptanceCriteria: 'Every action descriptor your plugin registers for a node type that can suspend ' + 'declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but ' @@ -1241,14 +1243,14 @@ const step17: MigrationStep = { + "through the generic route succeeds for the ones you declared `'any'`, and answers " + "403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue " + 'through your own service API instead. ⚠️ `supportsPause` is no longer the ' - + 'declaration nothing enforced (#5703, closed by #6667): an executor whose ' + + 'declaration nothing enforced: an executor whose ' + '`execute()` returns `suspend: true` while leaving `supportsPause` false is still ' + 'warned about by neither warning channel, but ' + '`AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the ' + 'one seam every suspension passes through — a guard-class failure no `fault` edge ' + 'routes — so it needs no hand-check. The residue that does: an executor registering ' + 'NO descriptor declares nothing for either warning or the refusal to read, so its ' - + 'pauses are still created and refused only later, on the resume route (#5561).', + + 'pauses are still created and refused only later, on the resume route.', }, { id: 'action-session-roles-to-positions', @@ -1257,23 +1259,25 @@ const step17: MigrationStep = { reason: 'The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this ' + 'step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed ' - + 'outright, #5050), while the ACTION body\'s `ctx.session` carries it ' + + 'outright), while the ACTION body\'s `ctx.session` carries it ' + 'produced-and-really-populated. `buildActionSession()` ' + '(`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` ' + 'into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under ' + 'the one spelling that ADR bans — so a body author met two different answers to one ' + 'key name on one platform: rejected in a hook, live and full of values in an action. ' - + '#5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): ' - + 'phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, ' - + 'and phase 2 renames the key. `positions` is now the canonical key on that schema ' - + 'and `roles` a deprecated alias of it (#5779); the producer emits both for one ' - + 'deprecation window (#5613 runtime half), after which `roles` is removed on the path ' - + 'the v16 session-alias removal already walked (#3280 deprecated → #3290 removed). ' + + 'The maintainer ruled contract-first on 2026-08-06 ("C skeleton + A semantics": declare ' + + 'the shape as it stands first, then rename on the typed face): phase 1 declared the ' + + 'previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. ' + + '`positions` is now the canonical key on that schema and `roles` a deprecated alias of ' + + 'it; the producer emits both for one deprecation window (the runtime half of the same ' + + 'ruling), after which `roles` is removed on the path the v16 session-alias removal ' + + 'already walked (the hook `ctx.session.tenantId` alias: deprecated first, removed in ' + + 'the next major). ' + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: ' + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per ' + 'dispatch and never persisted, so no `sys_metadata` row, example or template can ' - + 'carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / ' - + '`hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key ' + + 'carry the key — the `openApi31` / `activationEvents` / ' + + '`hook-context-session-roles-retired` shape. SECOND, the only place the key ' + 'is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed ' + 'script whose `ScriptContext.session` is still `unknown`. A declarative transform ' + 'cannot safely rewrite an identifier inside free-form code — exactly the reason the ' @@ -1286,7 +1290,7 @@ const step17: MigrationStep = { + 'authorable-surface ratchet adjudicates) belongs to the release that closes the ' + 'window. Until then this entry IS the channel: `spec-changes.json` and the generated ' + 'upgrade guide are how a reader learns the rename before the removal reaches them. ' - + 'ADR-0090 D3, ADR-0087, #5613 / #5779.', + + 'ADR-0090 D3, ADR-0087.', acceptanceCriteria: 'No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` ' + 'and observes the same array (the rename is a rename — the VALUE is ' @@ -2024,7 +2028,7 @@ const step17: MigrationStep = { + 'no schema tombstone either: nothing ever ran a driver object through ' + '`DriverInterfaceSchema.parse()`, so a prescription there would have no one to ' + 'reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ' - + 'ADR-0078, #4484.', + + 'ADR-0078.', acceptanceCriteria: 'No code calls `driver.findStream(...)`; large reads page through `find()` with ' + '`limit`/`offset` (which guarantees a total order across the whole walk) or go ' @@ -2049,8 +2053,9 @@ const step17: MigrationStep = { + 'layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The ' + 'driver side paid in blanket casts: a direct caller holding only a `where` could not ' + 'name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / ' - + '`fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030\'s ' - + '`$like` reached runtime through exactly that hole. This is a TS contract surface with ' + + '`fields` along with it — 20 such sites were measured in the downstream cloud codebase, ' + + 'and a `$like` the type layer would have caught reached runtime there through exactly ' + + 'that hole. This is a TS contract surface with ' + 'no authored source for the chain to rewrite, which is why it is a semantic entry and ' + 'not a D2 conversion; for a typed caller the compiler names every site (TS2353 ' + '`\'object\' does not exist in type \'DriverQuery\'`), and for an untyped JS caller ' @@ -2059,14 +2064,15 @@ const step17: MigrationStep = { + 'unchanged (excess properties are only rejected on fresh literals), and an ' + 'implementation still declaring `query: QueryAST` keeps compiling under parameter ' + 'bivariance. What an implementation may no longer do is READ `query.object` — callers ' - + 'are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was ' - + 'the audit\'s CONTROL sample, drawn to show that not every flagged candidate is an ' + + 'are now entitled to omit it. Registered by the stock reconciliation that compared the ' + + 'breaking changesets already on the v17 release train against this ledger. This change ' + + 'was that audit\'s CONTROL sample, drawn to show that not every flagged candidate is an ' + 'omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose ' + 'inside other entries, and the only subject-level hit on this interface is ' + '`data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver ' - + 'call-parameter changes (#6321, #6083) both registered, and both cite #5181 as ' - + 'background; the larger sibling they derive from never got its own entry. ADR-0087, ' - + '#5181 (backfilled #6350).', + + 'call-parameter changes both registered, and both cite this narrowing as ' + + 'background; the larger sibling they derive from never got its own entry. ADR-0087 ' + + '(backfilled by that reconciliation).', acceptanceCriteria: 'No `IDataDriver` call site passes an inline literal carrying `object:` — `driver.find(' + '"account", { object: "account", where: … })` becomes `driver.find("account", { where: ' @@ -2111,7 +2117,7 @@ const step17: MigrationStep = { + '`DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one ' + 'to reach; its three `authorable-surface.json` baseline lines and its ' + '`json-schema.manifest.json` entry are dropped in the same change, deliberately. ' - + 'The enforced channel is tsc. ADR-0049 / ADR-0078, #4618.', + + 'The enforced channel is tsc. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No code calls `engine.batch(...)` and no type references `DataEngineBatchRequest`; ' + 'in-process multi-write atomicity goes through `IObjectQLEngine.transaction(cb)`, a ' @@ -2129,7 +2135,8 @@ const step17: MigrationStep = { reason: '`data.field.changed` was declared in `DataEventType` and emitted by nothing — the ' + 'engine\'s `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since ' - + '#4639) `data.records.{updated,deleted}`, and no other producer exists in either ' + + 'multi-record predicate writes were given events of their own) ' + + '`data.records.{updated,deleted}`, and no other producer exists in either ' + 'repository. A subscriber that switched on it was waiting on an event no producer ' + 'sends: the branch never ran, and because the surrounding `switch` still compiled, ' + 'nothing anywhere reported the gap (ADR-0078\'s silently-inert declaration, on the ' @@ -2141,7 +2148,7 @@ const step17: MigrationStep = { + 'event per write rather than N events on a wide table. This is a runtime EVENT ' + 'surface — no stack, example or template authors an event name (webhooks subscribe ' + 'through the separate authorable `WebhookTriggerType`, whose vocabulary was already ' - + 'trimmed to producers that exist, #3196) — so there is no source for the chain to ' + + 'trimmed to producers that exist) — so there is no source for the chain to ' + 'rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a ' + 'retiredKey() fix-it error the way an authorable object key can (the same limit the ' + 'sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, ' @@ -2149,7 +2156,7 @@ const step17: MigrationStep = { + 'fails any consumer still naming the value in a `DataEventType` position, and the ' + 'enum parse, which now rejects the name instead of accepting an event that never ' + 'arrives. A genuine per-field stream, if one is ever wanted, gets its own honest ' - + 'contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673.', + + 'contract the way bulk writes were given theirs. ADR-0049 / ADR-0078.', acceptanceCriteria: 'No consumer subscribes to or switches on `data.field.changed`; per-field change ' + 'detail is read from a `data.record.updated` event\'s `changes` map (with `before` / ' @@ -2166,8 +2173,10 @@ const step17: MigrationStep = { 'or a direct `external.credentialsRef` secrets-store reference', reason: 'A datasource artefact is persisted whole into `sys_metadata`, which is served back by ' + - 'the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-' + - 'ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: ' + + 'the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled ' + + 'on 2026-08-12 to close that per artefact: each schema that admitted an inline credential ' + + 'refuses it at publish and points at the secret mechanism the artefact already had, rather ' + + 'than a heuristic guard at the `sys_metadata` write. There is no mechanical rewrite: ' + 'moving the value requires ENCRYPTING it into a `sys_secret` row through a running ' + "secret binder and deleting the cleartext, which a source-file transform cannot do — " + 'auto-deleting the key alone would silently drop a live credential instead.', @@ -2193,13 +2202,14 @@ const step17: MigrationStep = { reason: 'A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is ' + 'stored verbatim in `sys_metadata` and handed verbatim to the database client at connect ' + - '(#7990 census, measured during #8078), so the connection fails, or connects somewhere ' + + '(measured by the credential census while the inline-credential refusal was being built), ' + + 'so the connection fails, or connects somewhere ' + 'unintended, with no error naming the unresolved placeholder — the masked-failure shape. ' + 'The syntax looked supported: it parsed green, stored fine, and failed at a distance; two ' + - 'shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn ' + - '"do NOT substitute a placeholder" around the broken escape. Maintainer-ruled direction 2 ' + - 'on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution ' + - 'was explicitly rejected — a new capability with an env-exfiltration security surface and ' + + 'shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had ' + + 'to warn "do NOT substitute a placeholder" around the broken escape. The maintainer ruled ' + + 'on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; ' + + 'implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and ' + 'zero measured pull for actual substitution. There is no mechanical rewrite: the ' + 'placeholder names a value that exists only in the author\'s intended deployment ' + 'environment, which a source-file transform cannot know — substituting anything would ' + @@ -2221,19 +2231,21 @@ const step17: MigrationStep = { 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + 'or a direct `external.credentialsRef` secrets-store reference', reason: - 'The #7990 closure refused the inline credential KEYS, and #8078 measured that ' + + '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 (#8082, ' + - 'maintainer-ruled Option A, 2026-08-12). Runtime-environment DSNs (`OS_DATABASE_URL` and ' + - 'friends) never pass through the publish door and are unaffected by construction. There ' + + '`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 (#8078, measured).', + 'database client verbatim (measured when the inline-credential refusal was built).', acceptanceCriteria: 'Every datasource parses with a credential-free `config.url` / `config.syncUrl` (no ' + 'userinfo password segment); each affected datasource carries ' + @@ -3107,8 +3119,8 @@ const step17: MigrationStep = { + 'and never a key on `StringOperatorSchema`. That is measured, not assumed — `git ' + 'log -S\'$regex\'` over `packages/spec/src` returns only doc comments describing how ' + '`$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: ' - + '$regex`), plus #5701 itself, which added the name solely as `RETIRED_FILTER_OPERATORS` ' - + 'prescription data. ⚠️ But it differs from those two in the one way that decides the ' + + '$regex`), plus the retirement\'s own contract-half change, which added the name ' + + 'solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the ' + 'disposition, so a reader should not have to infer it: those were driver CALL ' + 'ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. ' + '`FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) ' @@ -3128,13 +3140,15 @@ const step17: MigrationStep = { + 'rewrite would silently change which rows a dashboard, report or permission filter ' + 'selects, a wrong number rather than a missing one. Choosing the substring the ' + 'pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers ' - + 'BOTH HALVES of the #4706 ruling (B), not just the driver one: the contract half ' - + '(#5701 — the `$icontains` declaration, the `$contains` family pinned ' + + 'BOTH HALVES of the maintainer\'s 2026-08-06 ruling (option B: retire `$regex` loudly ' + + 'and add `$icontains`, rather than make five backends agree on one regex dialect), not ' + + 'just the driver one: the contract half ' + + '(the `$icontains` declaration, the `$contains` family pinned ' + 'case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ' - + 'ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; ' - + 'the driver half (#5702) is where the refusal became executable. One surface, one ' - + 'entry, registered from the half that made it observable. ADR-0049 / ADR-0087, ' - + '#4706 / #5701 / #5702.', + + 'gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was ' + + 'never asked for a ledger entry; ' + + 'the driver half is where the refusal became executable. One surface, one ' + + 'entry, registered from the half that made it observable. ADR-0049 / ADR-0087.', acceptanceCriteria: 'No stored filter and no request `where` spells `$regex` or `$options` — grep the ' + 'stack for both. Each one is rewritten by asking what the pattern MEANT, not by ' @@ -5591,7 +5605,8 @@ const step18: MigrationStep = { + 'transform has both halves in hand, and `objectstack migrate meta` rewrites stored metadata ' + 'by key. The residue is genuinely a judgement: an action wired BOTH ways has no correct ' + 'value, because one call and N calls have different side effects and the platform will not ' - + 'silently unify them (the #17319 ruling refused exactly that option). Such an action is TWO ' + + 'silently unify them (the 2026-09-12 ruling that made an action declare its dispatch ' + + 'contract refused exactly that option). Such an action is TWO ' + 'actions — split the body along the line the two wirings already draw and declare each half ' + '— or, if the body was deliberately written to serve both, it stays undeclared and the two ' + 'wirings stand. The census that is this migration’s input was taken 2026-09-13 over ' @@ -5632,8 +5647,9 @@ const step18: MigrationStep = { 'The rewrite itself is lossless and mechanical, but it is not automatable here: an action handler ' + 'is authored TypeScript, and the chain rewrites stored metadata by key, so no `os migrate meta` ' + 'step can reach a call expression inside a function body. The change is a WITHDRAWAL of the ' - + 'parameter shape #14175 chose, ruled by the director seat (decision batch #123 item 3, ' - + '2026-09-12, 「同意」) on the long-term axis 「one platform, one query shape」. The facade had been ' + + 'parameter shape an earlier typing fix chose (the filter alone), ruled by the director ' + + 'seat on 2026-09-12, with the maintainer\'s agreement, on the long-term axis 「one platform, ' + + 'one query shape」. The facade had been ' + 'given a shape different from the engine\'s — the `where` half alone — which made the most ' + 'natural spelling the wrong one: an author who passed the engine\'s envelope got ' + '`{ where: { where: … } }`, matching no row and resolving to `[]` with no error, while an ' @@ -7929,7 +7945,8 @@ const step18: MigrationStep = { replacement: 'durationSeconds — rename the key; the value is unchanged, and a fractional ' + 'second is still legal', reason: - 'Maintainer ruling A on #18669 (2026-09-17, decision batch #151 item 4): rename the key and ' + 'Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type ' + + 'could express: rename the key and ' + 'record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of ' + 'anything already stored. ' + 'This key declared its unit in NO channel at all — no `.describe()`, no JSDoc, no unit ' @@ -7951,9 +7968,11 @@ const step18: MigrationStep = { + 'surfaces that state one fact cannot drift apart. ' + 'The value type is deliberately UNCHANGED at `z.number().optional()`: a fractional second ' + 'is the ordinary shape of a media length, so the closed `DurationSeconds` type ' - + '(`.int().nonnegative()`, #18122) was considered and REFUSED by the ruling, and so was an ' - + '`.int()` floor. That refusal is the load-bearing half — this row is one of the six the ' - + '#18122 unit set was derived from, and it is the one that takes a NAME instead of a TYPE. ' + + '(`.int().nonnegative()`, published beside `EpochMs` as a closed duration type) was ' + + 'considered and REFUSED by the ruling, and so was an ' + + '`.int()` floor. That refusal is the load-bearing half — this row is one of the six ' + + 'genuine durations the closed types\' unit set was derived from, and it is the one that ' + + 'takes a NAME instead of a TYPE. ' + 'Tombstoned with retiredKey(); FileValueSchema is the one deliberate z.looseObject in this ' + 'file, so a bare deletion would wave the old spelling through as an unrecognised extra key ' + 'and the prescription would never be spoken. ' @@ -7962,7 +7981,7 @@ const step18: MigrationStep = { + 'FileReferenceIdValueSchema, an opaque string — so a file value is never authored as this ' + 'shape and never persisted as a sys_metadata row, and the conversion chain has no seam ' + 'that would ever see one. ' - + '#18669, #14478, #18122, ADR-0104, ADR-0087.', + + 'ADR-0104, ADR-0087.', acceptanceCriteria: 'Every producer that BUILDS an expanded file value spells durationSeconds, and every ' + 'consumer that reads a media length reads durationSeconds. Authoring duration fails to ' @@ -7981,7 +8000,9 @@ const step18: MigrationStep = { + 'unit (data/driver-nosql.zod.ts)', replacement: 'timeoutMs — rename the key; the value is unchanged', reason: - 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + 'Maintainer ruling B on duration units (2026-09-02): the unit of a duration-shaped ' + + 'z.number() lives in the key NAME or in a unit-carrying value, never only in the describe ' + + 'prose, and no existing offender is grandfathered. ' + 'It stands alone because it is the only offender on its file. The neighbour is what ' + 'makes it a real hazard rather than a naming preference: batchSize sits directly beside ' + 'it, a plain row COUNT with the same z.number().int().positive() shape and the same ' @@ -7992,7 +8013,7 @@ const step18: MigrationStep = { + 'the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 ' + 'conversion: these options are a per-call driver argument, reached only through ' + 'AggregationPipeline.options, which no stack.zod.ts collection declares and no ' - + 'sys_metadata row stores, so the chain has no seam. #15680, #14478, ADR-0087.', + + 'sys_metadata row stores, so the chain has no seam. ADR-0087.', acceptanceCriteria: 'Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails ' + 'to compile (input type `never`) and fails to parse with the rename prescription. ' @@ -8219,16 +8240,18 @@ const step18: MigrationStep = { 'or a direct `external.credentialsRef` secrets-store reference, with the username kept in ' + 'the URL (`mongodb://user@host/db`)', reason: - 'The FOURTH spelling of the same inline secret: #7990 refused the top-level `password` ' + - 'key, #8082 the URL userinfo, #8337 credential query parameters — and the `options` ' + - 'passthrough stayed open one syntax over. `options: { auth: { username, password } }` ' + + 'The FOURTH spelling of the same inline secret: earlier publish refusals closed the ' + + 'top-level `password` key, the URL userinfo password and the credential-bearing URL query ' + + 'parameters — and the `options` passthrough stayed open one syntax over. `options: { auth: { username, password } }` ' + 'parsed green, persisted the password cleartext into `sys_metadata` (served back by the ' + 'ordinary data API), and genuinely authenticated: mongodb@7.5.0 transforms the block ' + 'into `MongoCredentials` (measured), so the workaround was live, not inert. A non-empty ' + 'string `auth.password` is now refused at publish with the binder prescription; ' + - '`auth.username` alone stays writable (#8876\'s asymmetry — a username is not credential ' + - 'material), as do all non-credential passthrough options. The bound secret wins over a ' + - 'passthrough `auth` block at connect (#8696, measured), so the replacement changes which ' + + '`auth.username` alone stays writable (the asymmetry the URL grammar keeps between its ' + + 'two userinfo halves — a username is not credential material), as do all non-credential ' + + 'passthrough options. The bound secret wins over a passthrough `auth` block at connect ' + + '(measured when the bound secret was made to reach the mongo client on its URL branch), ' + + 'so the replacement changes which ' + 'store holds the secret, never which credential connects. There is no mechanical ' + 'rewrite, for the same reason as the sibling entries ' + '`datasource-config-inline-credential-refused`, `datasource-config-url-userinfo-refused` ' + @@ -8295,9 +8318,11 @@ const step18: MigrationStep = { 'key: … }` next to `driver`) instead of file-path query parameters', reason: "`PostgresConfigSchema.url`'s own describe text documents the postgres URL grammar, but " + - 'until protocol 18 the value was only string-scanned for credentials (#8082/#8337) and ' + - 'placeholders (#8336) — deliberately so at the SHARED helper, whose refusal to parse is ' + - "load-bearing for mongo's multi-host/`+srv` forms (#8696). For postgres that leniency " + + 'until protocol 18 the value was only string-scanned for credentials (the URL userinfo ' + + 'password and credential query parameters) and `${…}` placeholders — deliberately so at ' + + 'the SHARED helper, whose refusal to parse is load-bearing for mongo\'s multi-host/`+srv` ' + + 'forms (`new URL()` rejects the multi-host form outright, and the mongo arm hands the ' + + 'authored URL to its client untouched). For postgres that leniency ' + 'was no check at all: `pg@8.22.0` does not implement libpq\'s multi-host DSN — both ' + "`pg-connection-string`'s `parse` and `pg`'s `ConnectionParameters` throw " + '`TypeError [ERR_INVALID_URL]` on `postgresql://app@h1:5432,h2:5433/app` (measured) — ' + @@ -8336,9 +8361,9 @@ const step18: MigrationStep = { 'handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` ' + 'secrets-store reference', reason: - 'The #7990 closure refused the inline credential KEYS and #8082 refused the URL userinfo ' + - 'spelling; the query string was the third spelling of the identical secret, one syntax ' + - 'over (#8337). `libsql://x.turso.io?authToken=eyJ…` landed in `sys_metadata` cleartext ' + + 'The inline-credential closure refused the credential KEYS and the next refusal the URL ' + + 'userinfo spelling; the query string was the third spelling of the identical secret, one ' + + 'syntax over. `libsql://x.turso.io?authToken=eyJ…` landed in `sys_metadata` cleartext ' + 'exactly as `config.authToken` did — and at connect `@libsql/core` assigns the URL token ' + 'OVER the binder-injected one (measured), so the workaround also silently defeated the ' + 'bound secret; `pg-connection-string` likewise honours `?password=` over userinfo ' + @@ -8353,7 +8378,7 @@ const step18: MigrationStep = { 'source-file transform cannot do — auto-stripping the parameter 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 (#8078, measured).', + 'verbatim (measured when the inline-credential refusal was built).', acceptanceCriteria: 'Every datasource parses with no credential-bearing query parameter in `config.url` / ' + '`config.syncUrl` (no `?authToken=` on turso, no `?password=` on postgres); each ' + @@ -8372,10 +8397,11 @@ const step18: MigrationStep = { '`url` and names no `username`', replacement: 'decide what the datasource is meant to do, then make the two halves agree: ' + 'add `username` to `config` so the bound secret is interpolated beside it into the ' + - 'composed connection URI at connect (#8696) — or, for a datasource genuinely meant to ' + + 'composed connection URI at connect — or, for a datasource genuinely meant to ' + 'connect unauthenticated, remove the `external.credentialsRef` binding (and unbind the ' + 'orphaned `sys_secret` row via the Setup → Datasources form). Authoring a `config.url` ' + - 'that names a user is a third valid shape, judged by the sibling #9041 prescription.', + 'that names a user is a third valid shape, judged by the prescription of the sibling ' + + 'URL-branch entry, `datasource-credentialsref-mongo-url-no-user-refused`.', reason: 'The pair cannot work as written, and until protocol 18 it was accepted in silence at ' + 'every door it passed. With no `config.url` the driver factory COMPOSES the connection ' + @@ -8389,14 +8415,17 @@ const step18: MigrationStep = { 'authenticate from a password alone — the same measured asymmetry behind the sibling ' + 'URL-branch refusal. Both branches had always agreed on this input, so this inherits that ' + 'ruling rather than re-opening it, and lands at the same authoring/publish door — the one ' + - 'place both halves are visible at once — as the "absence must be loud" half of the ' + - '#7314/#7385/#8152/#8875/#8696 family. Deliberately NOT refused, each measured: a ' + - 'discrete `username` that is present and non-empty (the secret is live there — that is ' + - 'the branch #8696 already works on), an empty-string `credentialsRef` (not a binding — ' + - 'the connect path resolves under a truthy check), a non-string `username` (the driver ' + - 'config gate already reports the type error), and every other driver arm (the postgres ' + - 'equivalent is re-judged after #8873, never inherited — `pg` receives the bound password ' + - 'regardless of the DSN naming a user). An EMPTY-STRING `username` IS refused, unlike the ' + + 'place both halves are visible at once — as the "absence must be loud" half of the family ' + + 'of driver-factory arms, closed one driver at a time, that each dropped something declared ' + + 'without a word: the optional-driver arms that answered a missing package with no remedy, ' + + 'the turso arm that never read its bound secret, and the mysql and mongo DSN branches that ' + + 'discarded one. Deliberately NOT refused, each measured: a ' + + 'discrete `username` that is present and non-empty (the secret is live there — the ' + + 'composed branch has always interpolated it), an empty-string `credentialsRef` (not a ' + + 'binding — the connect path resolves under a truthy check), a non-string `username` (the ' + + 'driver config gate already reports the type error), and every other driver arm (the ' + + 'postgres equivalent is judged on its own client\'s measurement, never inherited — `pg` ' + + 'receives the bound password regardless of the DSN naming a user). An EMPTY-STRING `username` IS refused, unlike the ' + 'sibling entry\'s present-but-empty userinfo carve-out: there MongoClient itself throws ' + '(`URI contained empty userinfo section`) so the shape is already loud, while here ' + '`username: \'\'` composes the same userinfo-free URI and connects — silently. There is ' + @@ -8407,7 +8436,7 @@ const step18: MigrationStep = { 'Every mongodb datasource that binds `external.credentialsRef` and authors no `config.url` ' + 'names a non-empty `config.username` and connects authenticated as that user; every ' + 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource parse ' + - 'reports the #9147 refusal.', + 'reports this composed-branch refusal.', }, { id: 'datasource-credentialsref-mongo-url-no-user-refused', @@ -8415,38 +8444,42 @@ const step18: MigrationStep = { 'no user in its userinfo', replacement: 'decide what the datasource is meant to do, then make the two halves agree: ' + 'add the username to the URL\'s userinfo (`mongodb://user@host/db`) so the bound secret ' + - 'is injected at connect (#8696) — or, for a datasource genuinely meant to connect ' + + 'is injected at connect — or, for a datasource genuinely meant to connect ' + 'unauthenticated, remove the `external.credentialsRef` binding (and unbind the orphaned ' + '`sys_secret` row via the Setup → Datasources form)', reason: 'The pair cannot work as written, and until protocol 18 it was accepted in silence at ' + 'every door it passed. MongoClient credentials need a username as well as a password, ' + 'and with `url` present the discrete `username` field is superseded — the only place ' + - 'the username can come from is the URL\'s own userinfo. So the #8696 injection is ' + - 'conditional on the URL naming a user: `mongodb://app@host/db` + bound secret ' + + 'the username can come from is the URL\'s own userinfo. So the connect-time injection of ' + + 'the bound secret on the URL branch is conditional on the URL naming a user: `mongodb://app@host/db` + bound secret ' + 'authenticates, while `mongodb://host/db` + bound secret connects ANONYMOUSLY with the ' + 'secret unused and the operator told nothing. Injecting anyway was measured worse ' + '(mongodb@7.5.0): fabricating an empty username turns a connection that works ' + 'anonymously today into a guaranteed handshake failure, and refusing at connect would ' + 'contradict `MongoConfigSchema.url`\'s published contract ("bind the secret … and it is ' + 'injected at connect time") while planting a per-branch asymmetry inside the driver ' + - 'factory — the defect class #8696 closed. The refusal therefore lands at the ' + + 'factory — the defect class closed when each DSN branch was made to inject the bound ' + + 'secret its composed branch already used. The refusal therefore lands at the ' + 'authoring/publish door, the one place both halves are visible at once, as the ' + - '"absence must be loud" half of the #7314/#7385/#8152/#8875/#8696 family. Deliberately ' + + '"absence must be loud" half of the family of driver-factory arms, closed one driver at a ' + + 'time, that each dropped something declared without a word: the optional-driver arms that ' + + 'answered a missing package with no remedy, the turso arm that never read its bound ' + + 'secret, and the mysql and mongo DSN branches that discarded one. Deliberately ' + 'NOT refused, each measured: the present-but-empty userinfo forms (`mongodb://@h/db`, ' + '`mongodb://:p@h/db` — MongoClient itself throws `MongoParseError: URI contained empty ' + 'userinfo section`), an empty-string `credentialsRef` (not a binding — the connect path ' + 'resolves under a truthy check), the composed branch (no `url`, where the discrete ' + - '`username` is live), and every other driver arm (the postgres equivalent is re-judged ' + - 'after #8873, never inherited — `pg` injects on a user-less DSN by its own measured ' + - 'mechanism). There is no mechanical rewrite because the two valid fixes are ' + + '`username` is live), and every other driver arm (the postgres equivalent is judged on ' + + 'its own client\'s measurement, never inherited — `pg` injects on a user-less DSN by its ' + + 'own measured mechanism). There is no mechanical rewrite because the two valid fixes are ' + 'CONTRADICTORY intents — authenticate (add the username) versus anonymous (drop the ' + 'binding) — and choosing between them requires knowing what the datasource is for.', acceptanceCriteria: 'Every mongodb datasource that binds `external.credentialsRef` and authors `config.url` ' + 'has a username in that URL\'s userinfo and connects authenticated as that user; every ' + 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource ' + - 'parse reports the #9041 refusal.', + 'parse reports this URL-branch refusal.', }, { id: 'device-request-response-interval-unit-in-key', @@ -8714,30 +8747,32 @@ const step18: MigrationStep = { + 'also took — becomes `[{ field: \'owner_id\', operator: \'equals\', value: ' + '\'{current_user_id}\' }]`; the value placeholders and date macros are unchanged. Legacy ' + 'operator shorthands (`eq`, `ne`, `gt`, `notIn`, …) are accepted and normalized on parse. ' - + 'The dashboard widget `filter` (`dashboard.zod.ts`) is a different family and is not moved ' - + 'by this entry (#15829); `object-grid.defaultFilters` is a different key and is not named ' + + 'The dashboard widget `filter` (`dashboard.zod.ts`) is a different family, judged on its ' + + 'own, and is not moved by this entry; `object-grid.defaultFilters` is a different key and is not named ' + 'by the ruling this entry records.', reason: - 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' - + '2026-08-25, verbatim 「同意」, Option B) reached two more locations the ComponentPropsMap ' - + 'census could not see (#15442 anchor, #15449 member; decision batch #55, 2026-09-06, ' - + 'verbatim 「同意」, option A: converge family-wide, one entry). The binding-level ' + 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: a ' + + 'filter door takes the `ViewFilterRule` array rather than keeping a record-shaped ' + + 'exception every author and AI would have to remember) reached two more locations the ' + + 'ComponentPropsMap census could not see (ruled 2026-09-06, option A: converge the binding ' + + 'and the four block doors family-wide, under one entry, rather than record an exception). The binding-level ' + '`dataSource.filter` alone still said `FilterConditionSchema`: it refused the array the ' + 'consumer\'s own pins author at that key, and `element:record_picker` carried two ' + 'orthographies at two keys (`properties.filter` the rule array, `dataSource.filter` the ' + 'record) resolved through one `??` in the renderer — the shape in which a dropped or ' + 'misread filter returns the wrong rows without an error. The four `object-*` doors said ' - + '`z.unknown()`: a read-point record derived from the renderers on 2026-08-13 (#7751), ' + + '`z.unknown()`: a read-point record derived from the renderers on 2026-08-13, when the ' + + '`object-*` blocks first got props schemas in the map, ' + 'twelve days before the ruling, not an exception to it — so an author following the ' + 'showcase wrote the record and an author following the manifest wrote an array, and each ' + 'got a silent success receipt while the html tier already declared `array` for the grid ' + 'and the metric. The record\'s `$and` / `$or` / `$not` keys were misread by every gate ' - + 'block anyway (objectui#6948), so the exception would have preserved a capability the ' + + 'block anyway (the console\'s filter converter had no branch for them), so the exception would have preserved a capability the ' + 'consumer does not honour. Sequenced measurement-first, as the family had to be: at the ' + 'objectui pin `a472b07` the `object-metric` aggregate path posted an array `where` that ' - + '`POST /analytics/query` refused with 400 on every array form (#15828), so the converge ' - + 'was parked behind the pin bump #16626; at the pin this repo builds against (`53ded82b`, ' - + 'objectui#7754) the adapter lowers an authored array through `translateFilterArray` and ' + + '`POST /analytics/query` refused with 400 on every array form, so the converge ' + + 'was parked behind a bump of that pin; at the pin this repo builds against (`53ded82b`) ' + + 'the adapter lowers an authored array through `translateFilterArray` and ' + 'the spec\'s own `parseFilterAST` sink before the wire, `ObjectGrid.tsx` lowers a rule ' + 'array through `toFilterNode`, `ObjectKanban.tsx` / `ObjectCalendar.tsx` hand it verbatim ' + 'to `$filter` where `convertQueryParams` lowers it, and the binding\'s composition seam ' @@ -8749,7 +8784,9 @@ const step18: MigrationStep = { + 'in the same change, and zero outside those files; this entry carries the prescription ' + 'for authors outside the repo. ' + 'Metadata AT REST: the mappable part of the table above is a D2 conversion, ' - + '`page-component-filter-record-to-rule-array` (#17321, ruling B), so ' + + '`page-component-filter-record-to-rule-array` (ruled 2026-09-12, option B: convert what ' + + 'maps losslessly and name what does not, rather than leave every stored row to its next ' + + 'save or flatten combinators), so ' + '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) rewrites ' + 'a stored page whose `filter` is a flat record, an operator object whose operators the ' + 'rule vocabulary spells, several such keys, or a single-level AST tuple array, and every ' @@ -8786,7 +8823,8 @@ const step18: MigrationStep = { + 'a record-form `filter: { status: \'active\' }` is refused at the `filter` path of all ' + 'five doors (`invalid_type`, expected array), and an AST tuple array is refused at ' + '`filter.0` (expected object). No `filter` door in `ComponentPropsMap` accepts the ' - + 'record any more (the twin of the #14406 census pin). At runtime each block and the ' + + 'record any more (the twin of the census pin that asks whether any `filter` door still ' + + 'refuses the array). At runtime each block and the ' + 'binding select exactly the rows the array selects — the same filter a list view ' + 'renders — including the `object-metric` aggregate tile, whose analytics `where` is the ' + 'lowered condition. Downstream (objectui, after a released spec version reaches the ' @@ -8878,27 +8916,32 @@ const step18: MigrationStep = { + 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are ' + 'accepted and normalized on parse', reason: - 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' - + "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:number'].filter` " + 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: ' + + 'align the element to the `ViewFilterRule` array rather than keep it the record-shaped ' + + "exception). `ComponentPropsMap['element:number'].filter` " + 'was the one `filter` input in the map declared as the MongoDB-style record ' + '(`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so ' + 'the filter a list view stores and renders was refused by the KPI element beside it, ' + 'and the objectui parity gate had to carry a reasoned exemption to look away. The ' + 'convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): ' - + 'objectui#6828 made `ObjectStackAdapter.aggregate()` run the same `translateFilterArray` ' + + 'the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same ' + + '`translateFilterArray` ' + 'its `find()` path runs, and the objectui pin carrying it was re-measured before this ' - + 'entry moved — but that measurement named the wrong hop, and #15828 corrects it here. ' + + 'entry moved — but that measurement named the wrong hop, and the runtime route\'s refusal ' + + 'of the array corrects it here. ' + '`translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only ' + 'sugar — so the real path is: authored array → `translateFilterArray` → lowered by ' + '`parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock ' - + 'names, #5158 ruling C) in the adapter, BEFORE the wire → a `FilterCondition` on the ' + + 'names, since the maintainer\'s 2026-08-04 ruling C declared the array input-only sugar ' + + 'with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the ' + 'body. The hop that decides it is the runtime route `POST /analytics/query`, which ' + 'parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing ' + 'else — so an un-lowered array is refused there before any service code runs. ' + '`lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, ' - + 'is the IN-PROCESS door (#5334) for callers reaching `analyticsService.query` ' + + 'is the IN-PROCESS door (added when an array `where` was found silently dropped on the ' + + 'analytics path) for callers reaching `analyticsService.query` ' + "directly, not the wire's; it too still refuses a RAW rule-object array by design. " - + 'objectui#7752 lands the adapter-side lowering. ' + + 'The adapter-side lowering lands in the console\'s own repository. ' + 'The ruled migration check ran with the change: the ' + 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, ' + 'packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form ' @@ -8914,7 +8957,7 @@ const step18: MigrationStep = { + 'filter a list view renders. Downstream (objectui, after a released spec version reaches ' + "the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` " + '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes ' - + 'objectui#6206.', + + 'the console-side half of this convergence.', }, { id: 'element-record-picker-filter-rule-array', @@ -8925,7 +8968,7 @@ const step18: MigrationStep = { '`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` ' + "the map's array-declared `filter` doors already carry (`record:related_list`, its nested " + 'Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as ' - + '`z.unknown()`, #15449). A record-form filter ' + + '`z.unknown()`, a gap measured on its own). A record-form filter ' + "`{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; " + "an operator object `{ amount: { $gt: 100 } }` becomes " + "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become " @@ -8933,16 +8976,18 @@ const step18: MigrationStep = { + 'accepted and normalized on parse. The binding-level `dataSource.filter` on the same node ' + 'is a different key (`ElementDataSourceSchema`) and is not moved by this entry', reason: - 'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication ' - + "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:record_picker'].filter` " + 'One filter orthography platform-wide (the maintainer\'s 2026-08-25 ruling, option B: ' + + 'every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped ' + + "exceptions). `ComponentPropsMap['element:record_picker'].filter` " + 'was the LAST `filter` input in the map still declared as the MongoDB-style record ' - + '(`FilterConditionSchema`) after `element:number` converged (#12039 Key 2): the three ' + + '(`FilterConditionSchema`) after `element:number` converged: the three ' + 'array-declared doors (`record:related_list`, its nested Add-affordance picker, ' + '`element:number`) carried the `ViewFilterRule` array and the four `object-*` doors ' - + 'declare `z.unknown()` (#15449), so the filter a list view stores and renders was refused ' + + 'declare `z.unknown()`, so the filter a list view stores and renders was refused ' + 'by the picker beside them, and a lone holdout is the state where the next author copies ' + 'the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 ' - + 'Option-A ordering ruling, #14406): at the objectui pin `00d3f09c` the renderer hands ' + + 'Option-A ordering ruling: measure the consumer\'s read path before the contract moves): ' + + 'at the objectui pin `00d3f09c` the renderer hands ' + '`filter` to `query.$filter` and calls `adapter.find()` ' + '(`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` ' + 'lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples ' @@ -8964,7 +9009,7 @@ const step18: MigrationStep = { + "a released spec version reaches the pin): the registry's `inputs.filter` entry for " + "`element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and " + 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — ' - + 'objectui#7663, filed from #14406 with a Blocked-by line.', + + 'a console-side change filed in the objectui repository, blocked on that release.', }, { id: 'engine-dotted-filter-refused', @@ -9845,7 +9890,8 @@ const step18: MigrationStep = { + 'message prescribes the null predicate because a `null` author was reaching for absence, ' + 'not for a bound', reason: - 'Maintainer ruling A on #18012 (decision batch #146 item 5, 2026-09-17 「146 同意」). ' + 'Maintainer ruling A of 2026-09-17: a blank `$between` endpoint is refused at the ' + + 'authoring door, and the refusal names the blank side. ' + '`FieldOperatorsSchema.safeParse({ $between: [1, \'\'] })` answered `success: true` — ' + 'measured on the card against the installed spec 17.4.0 and re-measured on `origin/main` ' + 'before the change. This is a NEW RULE narrowing a published face, ⛔ not a pull-back to a ' @@ -9854,8 +9900,8 @@ const step18: MigrationStep = { + 'acceptance was conformant. What made it wrong is the other half of the same contract — ' + '"Closed interval [min, max]" — which no backend can honour against a blank: driver-sql ' + 'binds it into `whereBetween`, the JS matchers compare it as a value, and the range stops ' - + 'bounding on that side while still reading as a complete range. #13495 had already taught ' - + 'the reference matcher to survive the null-bound form of exactly this (a bounded range ' + + 'bounding on that side while still reading as a complete range. The reference matcher had ' + + 'already been taught to survive the null-bound form of exactly this (a bounded range ' + 'answered EVERY valued row, because both of the arm\'s comparisons are false against a ' + 'missing bound); the door that admitted it was never addressed. The only producer ever ' + 'measured is a UI builder padding a HALF-TYPED pair with `\'\'` so that a length-based ' @@ -9888,7 +9934,8 @@ const step18: MigrationStep = { + 'operator schema itself, which answers at the endpoint\'s own path with the blank side ' + 'named, and the engine comparand-shape door, which refuses an executed filter carrying ' + 'one. The objectui half — the builder stops padding a half-typed pair, so ' - + 'the console never meets this refusal mid-typing — is objectui#9695 and lands on its own ' + + 'the console never meets this refusal mid-typing — is a change to the console\'s own ' + + 'filter builder and lands on its own ' + 'schedule, either side of this one. ADR-0049 / ADR-0078 / ADR-0087.', acceptanceCriteria: 'Grep every authored `$between` array — view, page and component filter rules, dashboard ' @@ -9953,13 +10000,15 @@ const step18: MigrationStep = { 'a literal bound — the value the range was meant to stop at, written out. If the range was ' + 'genuinely meant to be COLUMN-TO-COLUMN, that is not a $between at all: write the two ' + 'bounds separately as scalar comparisons, {"$gte": {"$field": "a"}} for the lower bound and ' - + '{"$lte": {"$field": "b"}} for the upper one, which is the position #5222 compiles on every ' + + '{"$lte": {"$field": "b"}} for the upper one, which is the position the column-to-column ' + + 'comparison compiles on every ' + 'face. ⛔ There is no replacement that can be DERIVED from what was written: the literal a ' + 'reference stood for is not recoverable, and dropping the operator would delete a ' + 'constraint the author wrote and WIDEN the result set silently. A reference remains legal, ' + 'unchanged, as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte', reason: - 'Maintainer ruling of 2026-08-11 on #7596, ADR-0049 enforce-or-remove: REMOVE. Both $between ' + 'Maintainer ruling of 2026-08-11 on column-reference range endpoints, ADR-0049 ' + + 'enforce-or-remove: REMOVE. Both $between ' + 'endpoint unions carried FieldReferenceSchema and no backend ever resolved one in a list ' + 'position — matches-filter.ts leaves the list unresolved and orders against the raw ' + 'reference OBJECT, so the range silently matches nothing, and both SQL faces refuse the ' @@ -9969,9 +10018,9 @@ const step18: MigrationStep = { + '⚠️ That ruling shipped at the AUTHORING SCHEMA door alone, and no ledger entry was written ' + 'for it — measured before this change: no semantic entry, no retired key, no spec-changes ' + 'row and no upgrade-guide line named the shape. That was not an omission, and this entry ' - + 'SUPERSEDES a recorded answer rather than filling a silence: the 2026-08-11 changeset (PR ' - + '#7713) carried the disposition not-required (no-migration-prescription), reviewed and ' - + 'accepted on #7596 and shipped in the published CHANGELOG. What changed is the fact that ' + + 'SUPERSEDES a recorded answer rather than filling a silence: the 2026-08-11 changeset ' + + 'carried the disposition not-required (no-migration-prescription), reviewed and ' + + 'accepted with that ruling and shipped in the published CHANGELOG. What changed is the fact that ' + 'disposition rested on. It was claimed for a removal whose reach was believed to be the ' + 'authoring schema alone; the runtime half now ships with a migration prescription of its own ' + '(below), and a body carrying a prescription is exactly what that category refuses. So the ' @@ -9983,7 +10032,7 @@ const step18: MigrationStep = { + 'two truth values, decided by which door a caller came through — and the door that passed ' + 'it is the one an embedder reaches by handing a lowered filter straight to a driver. This ' + 'entry therefore registers the transition for BOTH doors, not only the second, which is ' - + 'why it is filed under #19377 rather than as an already-registered rider. ' + + 'why it is filed as an entry of its own rather than as an already-registered rider. ' + '⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather ' + 'than assumed: applyConversionsToStoredItem — the one primitive every stored-row ' + 'rehydration seam calls — never throws and never validates, and replays only the ' @@ -10067,9 +10116,10 @@ const step18: MigrationStep = { + 'string resolved at request time (such as {current_user_id} or {today}) and a bigint ' + 'within 2^53 are untouched, and the save door keeps a bigint as written', reason: - 'The save door narrows to exactly what the query faces already refuse (#20116, stage 2 of ' - + 'the collector). The comparand-type face (normalizeFilterComparandTypes, the #7872 ' - + 'ruling\'s accepted set) refuses these values on every query: parseFilterAST, the engine ' + 'The save door narrows to exactly what the query faces already refuse (the second stage ' + + 'of closing the family of comparand shapes the save door accepted and the query faces ' + + 'refused). The comparand-type face (normalizeFilterComparandTypes, the accepted set the ' + + 'maintainer ruled on 2026-08-12: string, number, bigint, boolean, null and Date) refuses these values on every query: parseFilterAST, the engine ' + 'seam, the analytics where door and the read-scope compiler all run it. Measured on ' + 'origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a ' + 'dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined ' @@ -10137,7 +10187,9 @@ const step18: MigrationStep = { + 'arrays, empty lists included; every scalar equality comparand, null above all (the ' + 'has-no-value predicate), is untouched; and $ne is NOT judged by this entry', reason: - 'Maintainer ruling on #19757 (record 5793368540, batch 217 item 3, letter 乙, 「217 同意」): ' + 'Maintainer ruling of 2026-09-23 (option 乙 — rather than declaring an array equality the ' + + 'SQL-family backends would have to invent, or documenting a divergence that stays silent ' + + 'on one backend): ' + 'an array in the implicit-equality slot is refused at the shared face, for every driver at ' + 'once — no alias, no grace window. The comparand-shape face declared that moving a rule ' + 'to it 「closes that door for every driver at once」, and before this change it judged ' @@ -10226,7 +10278,8 @@ const step18: MigrationStep = { + 'arrays, empty lists included; every scalar equality comparand, null above all, a Date and ' + 'a { $field } reference are untouched; and $ne is NOT judged by this entry', reason: - 'Ruling on #19889 (record 5805248669, letter A): FilterConditionSchema (implicit equality) ' + 'Ruled on 2026-09-24 (option A), applying the standing refusal of an array in the ' + + 'equality slot to the schema door: FilterConditionSchema (implicit equality) ' + 'and FieldOperatorsSchema.$eq refuse an array comparand at parse, with the SAME remedy ' + 'text the shared compile face emits — one constant, two doors; a stored filter carrying ' + 'the shape is refused loudly on its next save, and never silently dropped, because a ' @@ -10298,8 +10351,9 @@ const step18: MigrationStep = { + 'wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand ' + 'and this rule says nothing about it', reason: - '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' - + 'untranslated): 「the differences are the protocol\'s to close」. The platform already ' + 'The protocol half of the maintainer\'s 2026-09-20 ruling (option C-prime) on the console\'s ' + + 'filter converter, whose first rule reads, verbatim and untranslated: 「the differences are ' + + 'the protocol\'s to close」. The platform already ' + 'DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a ' + 'REJECTION row for an empty comparand and one for a non-string comparand, each with ' + 'code INVALID_FILTER and each requiring the refusal to name the operator. All five ' @@ -10312,7 +10366,8 @@ const step18: MigrationStep = { + 'declared-not-enforced shape ADR-0049 exists to close. ' + 'The narrowing is DERIVED from the table, not transcribed beside it: both doors call ' + 'the published predicate isRefusedTextComparand and the published reason text ' - + 'textComparandRefusalReason, the pair lifted into this package at #18113 for exactly ' + + 'textComparandRefusalReason, the pair published in this package beside FILTER_TEXT_CASES ' + + 'for exactly ' + 'this reason, so a row added to the table reaches both doors without an edit at either. ' + '$contains, $startsWith, ' + '$endsWith, $like and $ilike keep the answer they give today, ' @@ -10366,9 +10421,9 @@ const step18: MigrationStep = { + 'has-a-value predicate), every scalar, a Date and a { $field } reference are untouched, and ' + 'the list operators ($in / $nin / $between) keep their arrays, empty lists included', reason: - 'Ruling A on #19886 (record 5805254639, the director seat, class 1): the shared ' - + 'comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$ne ' - + 'refuses it at parse, with one remedy text naming the declared list-negation operator by its ' + 'Ruled on 2026-09-24 by the director seat, on the standing contract text (option A): ' + + 'the shared comparand-shape face refuses an array under $ne for every driver, and ' + + 'FieldOperatorsSchema.$ne refuses it at parse, with one remedy text naming the declared list-negation operator by its ' + 'spec spelling — no alias, no window. The governing text is $ne\'s own published describe: ' + 'the comparand is a literal, or a { $field } reference to another column of the same table. ' + 'An array is neither, so the refusal pulls the doors back to what $ne already declared. ' @@ -10443,13 +10498,15 @@ const step18: MigrationStep = { + 'analytics query\'s timeDimensions[].dateRange. A filter comparand is not one of those ' + 'positions', reason: - 'The C half of #8690, maintainer-ruled 2026-08-15 alongside the engine door (PR #8808). ' + 'The authoring half (option C) of the maintainer\'s 2026-08-15 ruling on uninterpretable ' + + 'temporal comparands, ruled alongside the engine door (option B) that refuses them at ' + + 'query time. ' + 'The preset vocabulary is declared in the dashboard schema and lowered to {date-macro} ' + 'bounds by the shipped console before any query is sent — so the names were declared in ' + 'one layer and unrecognised in the next, with no error at the boundary. Authored as a ' + 'bare comparand (a saved report, an integration, an MCP client, an AI-authored query), ' + 'the name reached the driver as written and compared false against every row: HTTP 200, ' - + 'count 0, indistinguishable from "there is no data" (measured on #8690: $gte ' + + 'count 0, indistinguishable from "there is no data" (measured on the defect report: $gte ' + '"last_30_days" returned 0 of 51 seeded rows where the macro spelling returned the 38 ' + 'in-window). The engine now refuses the bare name on a declared temporal field at query ' + 'time (INVALID_FILTER / 400); this entry records the AUTHORING-time half, and that half ' @@ -10469,7 +10526,8 @@ const step18: MigrationStep = { + 'type in hand. ⚠️ Metadata AT REST is deliberately not rewritten and there is no D2 conversion: ' + 'this shape was never written by any first-party producer (every preset in this repo ' + 'and the example apps sits in a dashboard date-filter position — measured) and never ' - + 'executed usefully (it returned a silent zero before #8808 and a 400 after). Coercing ' + + 'executed usefully (it returned a silent zero before the engine door and a 400 after). ' + + 'Coercing ' + 'it at load would be the platform guessing which bound the author meant. The read path ' + 'does not re-validate stored rows, so no stored dashboard becomes unreadable; what ' + 'changes is that RE-SAVING one is refused with the window named. ' @@ -10524,8 +10582,9 @@ const step18: MigrationStep = { + 'with a list. The null predicate itself, a { $field } reference as a whole comparand, ' + 'an empty $in or $nin list and a whitespace endpoint are untouched', reason: - 'The save door narrows to exactly what the query faces already refuse (#20116, the ' - + 'collector for its family; the $ne member is route A, the same reach and the same one ' + 'The save door narrows to exactly what the query faces already refuse (the family of ' + + 'comparand shapes the save door accepted and the query faces refused; the $ne member is ' + + 'route A, the same reach and the same one ' + 'sentence as the equality slot of filter-equality-array-comparand-refused-at-save). The ' + 'shared comparand-shape face refuses on every query a null ordering comparand (ruled ' + '2026-09-01), a non-list $in / $nin and a malformed $between range, a null list member or ' @@ -10595,8 +10654,9 @@ const step18: MigrationStep = { + 'condition — `{ amount: { $contains: \'5\' } }` may have meant `$eq: 5`, a range, ' + 'or a filter on a different column altogether — so the loader must not choose one.', reason: - 'objectstack#15661, ruled 2026-09-05 (decision batch #43, option C-deny), landed at ' - + 'the engine seam as objectstack#15773. A text operator (`$contains` / ' + 'Maintainer ruling of 2026-09-05 (option C-deny: refuse now, over the type sets the ' + + 'contract already declares, minting no new vocabulary), landed at the engine seam. A ' + + 'text operator (`$contains` / ' + '`$notContains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) ' + 'over a field whose DECLARED type can never store a string — `NUMERIC_VALUE_TYPES` ' + '∪ `BOOLEAN_VALUE_TYPES` ∪ `CALENDAR_DATE_TYPES` ∪ `INSTANT_TYPES` ∪ ' @@ -10624,7 +10684,8 @@ const step18: MigrationStep = { + 'and `user` ids, `autonumber` and the file classes — are unaffected and must keep ' + 'answering exactly as before; that is the control which proves a repair pass did ' + 'not over-reach. A DIRECT driver call bypasses this door entirely and keeps ' - + 'answering the `FILTER_TEXT_CASES` stored-value row (objectstack#14079), so a ' + + 'answering the `FILTER_TEXT_CASES` stored-value row (a stored value that is not a string ' + + 'never satisfies a positive text operator and satisfies `$notContains`), so a ' + 'driver-level test is not evidence about this migration in either direction.', }, // The absent half of the decision-branch predicate rule. A SEPARATE entry from diff --git a/packages/spec/src/ui/action-params.test.ts b/packages/spec/src/ui/action-params.test.ts index b8653b26fb4..9cdee733722 100644 --- a/packages/spec/src/ui/action-params.test.ts +++ b/packages/spec/src/ui/action-params.test.ts @@ -391,7 +391,7 @@ describe('#5779 — ActionSession `positions` canonical + `roles` deprecated ali // neighbour (`hook-context-session-roles-retired`, #5050) was — the reason // must not read as a removal notice. expect(entry!.reason).toMatch(/deprecation window/); - expect(entry!.reason).toMatch(/#5613/); + expect(entry!.reason).toMatch(/ruled contract-first/); expect(entry!.acceptanceCriteria).toMatch(/ctx\.session\.positions/); }); }); From d8bcc46ad4eb6c25fc83c7487e4e06730441ec57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 19:32:39 +0000 Subject: [PATCH 6/6] fix(spec): name the retired hook tenant alias without spelling its removed read check:org-identifier refuses the literal removed read in author-facing source; the action-session entry now names the alias in words. Registry, spec-changes and upgrade guide regenerated. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 2 +- packages/spec/spec-changes.json | 4 ++-- .../entries/semantic/17.action-session-roles-to-positions.ts | 4 ++-- packages/spec/src/migrations/registry.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index dd5f4b508c2..60261b98318 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -216,7 +216,7 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The generic resume route's authorization gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. The revise-window incident decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired`, `storage-service-list-retired` and `actor-user-roles-to-positions` already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam addendum to ADR-0019 (approval as a flow node). - Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is no longer the declaration nothing enforced: an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is still warned about by neither warning channel, but `AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the one seam every suspension passes through — a guard-class failure no `fault` edge routes — so it needs no hand-check. The residue that does: an executor registering NO descriptor declares nothing for either warning or the refusal to read, so its pauses are still created and refused only later, on the resume route. - **`action-session-roles-to-positions`** — `ui.actionSession.roles` → ui.actionSession.positions (an action body reads `ctx.session.positions`) - - Why not automatic: The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 ("C skeleton + A semantics": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook `ctx.session.tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087. + - Why not automatic: The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 ("C skeleton + A semantics": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook session's `tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087. - Done when: No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` and observes the same array (the rename is a rename — the VALUE is `ExecutionContext.positions` on both sides, which the runtime pin `action-session-shape-contract.test.ts` asserts independently of the key name). Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Verify against a real dispatch, not a fixture: invoke an action as a caller holding positions and assert the body observed them under the canonical key. During the window both keys are present and equal, so a reader can be migrated and verified before the alias is removed; after it, `roles` is absent and a body still reading it sees `undefined` — which is why the read must be moved inside the window rather than at its close. - **`actor-user-roles-to-positions`** — `action body / AI route: ctx.user.roles (req.user.roles)` → ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions - Why not automatic: The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was "kept for the REST/AI shapes", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048). diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index e39ed7e6c25..f336bd81d66 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -371,7 +371,7 @@ "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)", "migrationId": "action-session-roles-to-positions", "toMajor": 17, - "rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 (\"C skeleton + A semantics\": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook `ctx.session.tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087." + "rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 (\"C skeleton + A semantics\": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook session's `tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087." }, { "surface": "action body / AI route: ctx.user.roles (req.user.roles)", @@ -1263,7 +1263,7 @@ "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)", "migrationId": "action-session-roles-to-positions", "toMajor": 17, - "rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 (\"C skeleton + A semantics\": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook `ctx.session.tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087." + "rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 (\"C skeleton + A semantics\": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which `roles` is removed on the path the v16 session-alias removal already walked (the hook session's `tenantId` alias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` / `activationEvents` / `hook-context-session-roles-retired` shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087." }, { "surface": "action body / AI route: ctx.user.roles (req.user.roles)", diff --git a/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts index 620def8a96c..27b963603fc 100644 --- a/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts +++ b/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts @@ -21,8 +21,8 @@ export const entry: SemanticMigration = { + '`positions` is now the canonical key on that schema and `roles` a deprecated alias of ' + 'it; the producer emits both for one deprecation window (the runtime half of the same ' + 'ruling), after which `roles` is removed on the path the v16 session-alias removal ' - + 'already walked (the hook `ctx.session.tenantId` alias: deprecated first, removed in ' - + 'the next major). ' + + 'already walked (the hook session\'s `tenantId` alias: deprecated first, removed in the ' + + 'next major). ' + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: ' + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per ' + 'dispatch and never persisted, so no `sys_metadata` row, example or template can ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 0b23fee3ff9..7001c1025f6 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1271,8 +1271,8 @@ const step17: MigrationStep = { + '`positions` is now the canonical key on that schema and `roles` a deprecated alias of ' + 'it; the producer emits both for one deprecation window (the runtime half of the same ' + 'ruling), after which `roles` is removed on the path the v16 session-alias removal ' - + 'already walked (the hook `ctx.session.tenantId` alias: deprecated first, removed in ' - + 'the next major). ' + + 'already walked (the hook session\'s `tenantId` alias: deprecated first, removed in the ' + + 'next major). ' + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: ' + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per ' + 'dispatch and never persisted, so no `sys_metadata` row, example or template can '