From f24cd8159f45b2dc3d18fe0e87b327836396d046 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:15:56 +0000 Subject: [PATCH] chore(spec): register the #4001 unknown-key strictness wave as one v17 ADR-0087 entry (#7630) PR #7624's stock reconciliation judged eleven declared-breaking v17 changesets owed an ADR-0087 ledger entry and deliberately did not write one: the grouping was the open question, and the registries are consumed as a SET, so a wrong grouping produces no error anywhere. The maintainer ruled on #7630 (2026-08-12) for one entry per major, mirroring the registry's only two precedents of this shape (`ui-schemas-strict-unknown-keys` at 15, `dashboard-widget-strict-unknown-keys` at 16), against the alternatives of eleven entries or a per-surface grouping. The eleven batches fold into a single D3 semantic migration, `authoring-schemas-strict-unknown-keys`, with the per-batch provenance written inside the entry so an archaeologist can still walk it batch by batch while an upgrader reads one prescription covering the whole wave. Each of the eleven stock changesets carries its `` disposition marker, so the judgement is recorded where the next auditor reads. `registry.ts`'s generated region, `spec-changes.json` and `docs/protocol-upgrade-guide.md` are regenerated, never hand-edited. The two adjacent seams recorded on #7630 are deliberately untouched: the 45 `~` rows and the 7 borderline candidates PR #7624 judged not-owed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPerimH1rzBZRsQvk4VvBD --- .../adr-0087-v17-strictness-wave-entry.md | 49 +++++++++ .changeset/rare-jars-shave.md | 2 + ...t-automation-control-flow-state-machine.md | 2 + ...known-key-strictness-automation-batch11.md | 2 + .changeset/unknown-key-strictness-step2.md | 2 + .changeset/unknown-key-strictness-tier-a.md | 2 + .../unknown-key-strictness-ui-batch13.md | 2 + .../unknown-key-strictness-ui-batch15.md | 2 + .../unknown-key-strictness-ui-batch16.md | 2 + ...filters-allow-add-tab-promote-and-close.md | 2 + .../view-subblock-strictness-batch18.md | 2 + .../view-union-identity-precondition.md | 2 + docs/protocol-upgrade-guide.md | 3 + packages/spec/spec-changes.json | 14 +++ ...7.authoring-schemas-strict-unknown-keys.ts | 99 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 95 ++++++++++++++++++ 16 files changed, 282 insertions(+) create mode 100644 .changeset/adr-0087-v17-strictness-wave-entry.md create mode 100644 packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts diff --git a/.changeset/adr-0087-v17-strictness-wave-entry.md b/.changeset/adr-0087-v17-strictness-wave-entry.md new file mode 100644 index 0000000000..3722161dda --- /dev/null +++ b/.changeset/adr-0087-v17-strictness-wave-entry.md @@ -0,0 +1,49 @@ +--- +"@objectstack/spec": minor +--- + +chore(spec): register the #4001 unknown-key strictness wave as one v17 ADR-0087 entry (#7630) + +PR #7624's stock reconciliation judged **eleven** declared-breaking v17 changesets +*owed* an ADR-0087 ledger entry and deliberately did not write one — the grouping +was the open question, and the registries are consumed as a **set**, so a wrong +grouping produces no error anywhere. It would simply be wrong, silently, in the +artifact whose entire purpose is to be the trustworthy record. The maintainer ruled +on #7630 (2026-08-12): **one entry per major**, mirroring the registry's only two +precedents of this shape — `ui-schemas-strict-unknown-keys` (15) and +`dashboard-widget-strict-unknown-keys` (16) — against the alternatives of eleven +entries or a per-surface grouping. + +So the eleven batches fold into a single D3 semantic migration, +`authoring-schemas-strict-unknown-keys`. An upgrader reads one prescription covering +the whole wave; the per-batch trace is written **inside** the entry, so an +archaeologist still walks it batch by batch: + +| batch | closed | +| --- | --- | +| `unknown-key-strictness-tier-a` | `security/permission.zod.ts`; `automation/flow.zod.ts`'s four outer shapes | +| `unknown-key-strictness-step2` | `security/rls.zod.ts`, `security/sharing.zod.ts`, `identity/position.zod.ts` | +| `strict-automation-control-flow-state-machine` | five `control-flow` + six `state-machine` shapes | +| `unknown-key-strictness-automation-batch11` | flow's six nested blocks, time-relative trigger, flow function, webhook | +| `unknown-key-strictness-ui-batch13` | all four shapes in `ui/responsive.zod.ts` | +| `unknown-key-strictness-ui-batch15` | `ui/theme.zod.ts` 14/14, `ui/chart.zod.ts` 5/7 | +| `unknown-key-strictness-ui-batch16` | `AriaPropsSchema`, carried on ~30 live shapes | +| `view-subblock-strictness-batch18` | fifteen `ui/view.zod.ts` sub-blocks | +| `rare-jars-shave` | `ViewItemSchema` split into a strict authoring gate + `.strip()` wire variant | +| `user-filters-allow-add-tab-promote-and-close` | `userFilters` promoted then closed; the three page-only keys | +| `view-union-identity-precondition` | the `view` union stops matching every object | + +Each of the eleven stock changesets now carries its +`` disposition marker, so the judgement is recorded +where the next auditor reads rather than only in a PR body. `spec-changes.json` and +`docs/protocol-upgrade-guide.md` are regenerated from the registry. + +Measured with `check-adr-0087-registration.mjs --audit-stock` on a full (unshallow) +history, before → after: **answered 51 → 62, residue 97 → 86, flagged (`!`) 52 → +41**. The two adjacent seams stay exactly as recorded on #7630 and are **not** +touched here: the 45 `~` rows (a ledger file was touched by some commit, which does +not prove the entry covers that face) and the 7 borderline candidates PR #7624 +judged not-owed. + +No runtime behaviour changes and no schema changes — this is the ledger half of +breaks that already shipped. diff --git a/.changeset/rare-jars-shave.md b/.changeset/rare-jars-shave.md index 5b1a616386..f2da72fb8c 100644 --- a/.changeset/rare-jars-shave.md +++ b/.changeset/rare-jars-shave.md @@ -47,3 +47,5 @@ validation — the write-path mirror of `stripReadDecorations` — and round-trip to the renderer untouched. `id` was deliberately **not** declared: it is a React list key, and declaring it would put a UI artifact on the authorable surface. + + diff --git a/.changeset/strict-automation-control-flow-state-machine.md b/.changeset/strict-automation-control-flow-state-machine.md index b27e14b972..f144d11342 100644 --- a/.changeset/strict-automation-control-flow-state-machine.md +++ b/.changeset/strict-automation-control-flow-state-machine.md @@ -70,3 +70,5 @@ behaviour was not openness but strip — an authored `meta` arrived as `{}`. All three example apps (`app-showcase`, `app-crm`, `app-todo`) validate unchanged, so no ADR-0087 conversion accompanies this change. + + diff --git a/.changeset/unknown-key-strictness-automation-batch11.md b/.changeset/unknown-key-strictness-automation-batch11.md index f3a797c75b..b04bf8a126 100644 --- a/.changeset/unknown-key-strictness-automation-batch11.md +++ b/.changeset/unknown-key-strictness-automation-batch11.md @@ -74,3 +74,5 @@ feature: both metadata load paths call `applyProtection` on every type, so a package-loaded webhook already carried those keys when `plugin-webhooks` re-parsed it at boot. Closing the shape without declaring them would have turned every package-shipped webhook into a skipped subscription after a redeploy. + + diff --git a/.changeset/unknown-key-strictness-step2.md b/.changeset/unknown-key-strictness-step2.md index 6b03120dc6..2b64c6eaf3 100644 --- a/.changeset/unknown-key-strictness-step2.md +++ b/.changeset/unknown-key-strictness-step2.md @@ -42,3 +42,5 @@ The error carries the fix; FROM → TO mappings baked in include: bindings (`sys_position_permission_set` / `sys_user_position`), never authored on the position; `parent` is rejected with the flatness rule (ADR-0090 D3 — hierarchy is the business-unit tree, not a position tree). + + diff --git a/.changeset/unknown-key-strictness-tier-a.md b/.changeset/unknown-key-strictness-tier-a.md index 44e92752b4..e90d8c2c4d 100644 --- a/.changeset/unknown-key-strictness-tier-a.md +++ b/.changeset/unknown-key-strictness-tier-a.md @@ -54,3 +54,5 @@ error message carries the fix; the FROM → TO mappings baked into it include: `guard`/`when`/`expression`→`condition`; a top-level `object`/`objectName`/`schedule` belongs on the START node's `config` (`{ objectName, triggerType, condition, schedule }`), not on the flow. + + diff --git a/.changeset/unknown-key-strictness-ui-batch13.md b/.changeset/unknown-key-strictness-ui-batch13.md index 55239b5ed3..5472557764 100644 --- a/.changeset/unknown-key-strictness-ui-batch13.md +++ b/.changeset/unknown-key-strictness-ui-batch13.md @@ -64,3 +64,5 @@ any of them exists in this repo, objectui, or the example apps. `.strict()` is a property of a parse; there is no parse. Retiring them or giving them a carrier is ADR-0049 enforce-or-remove, tracked in #4988 — not a breaking change to spend here. + + diff --git a/.changeset/unknown-key-strictness-ui-batch15.md b/.changeset/unknown-key-strictness-ui-batch15.md index 2d538a4c32..827b01467b 100644 --- a/.changeset/unknown-key-strictness-ui-batch15.md +++ b/.changeset/unknown-key-strictness-ui-batch15.md @@ -50,3 +50,5 @@ `.strict()` 是 parse 的属性,这里没有 parse —— 收紧只会让文件看起来完成,并留下*一个被精确校验的死槽位*(#4583)。账本因此新增第四类 **`no gate`**(承载键活、parse 缺),与批 13 的 `no door`(承载键本身不存在)并列:两者处方相反,前者该接闸门,后者该走 ADR-0049 退役。已归档为独立 issue。 主题里那些**发出后无人读取**的 CSS 变量(`--font-size-*` / `--z-*` / `--duration-*` …)是 ADR-0049 的 liveness 题目,不是未知键题目,同样单独归档 —— 收紧能让被丢弃的键变响,不能让一个槽位变活。 + + diff --git a/.changeset/unknown-key-strictness-ui-batch16.md b/.changeset/unknown-key-strictness-ui-batch16.md index b32cf81324..6c19e5265a 100644 --- a/.changeset/unknown-key-strictness-ui-batch16.md +++ b/.changeset/unknown-key-strictness-ui-batch16.md @@ -93,3 +93,5 @@ spec `check:*` gates, `objectstack validate` on app-showcase / app-crm / app-tod and an ADR-0087 direct-parse probe over the three apps' **built** artifacts — zero `aria` slots present, with the probe's negative control proven red on a legacy-spelled block. + + diff --git a/.changeset/user-filters-allow-add-tab-promote-and-close.md b/.changeset/user-filters-allow-add-tab-promote-and-close.md index eba6bb6143..61e0c046ac 100644 --- a/.changeset/user-filters-allow-add-tab-promote-and-close.md +++ b/.changeset/user-filters-allow-add-tab-promote-and-close.md @@ -39,3 +39,5 @@ FROM → TO:未声明的键 → 删除它,或改成它想表达的那个已声 FROM → TO:`userFilters.tabs` → 对象的 `listViews` 具名条目;`showAllRecords` → 默认列表视图本身就是「全部记录」入口;`allowAddTab` → 由 ViewTabBar 自带的新增控件承担。三条拒绝各自带 `guidance` 处方,不是裸的 "unrecognized key"。 派生变体同时改为携带**自己的**错误映射:`.omit()` 会连基类的 `knownKeys` 一起继承,而那份候选列表是从基类形状读的、仍然含被 omit 掉的键 —— 实测在对象视图上写 `tab` 会被答复 *"Did you mean `tab` → `tabs`?"*,把作者指向这个形状唯一拒绝的键。形状仍由 `.omit()` 派生(#2231 不变),候选池改为按 omit 后的形状构建。 + + diff --git a/.changeset/view-subblock-strictness-batch18.md b/.changeset/view-subblock-strictness-batch18.md index af7c3565b4..95528dea11 100644 --- a/.changeset/view-subblock-strictness-batch18.md +++ b/.changeset/view-subblock-strictness-batch18.md @@ -40,3 +40,5 @@ closed nested block 422s a console PUT regardless). `UserFiltersSchema`, `ViewItemSchema` and the private `FormFieldBase` also stay open, each for a measured reason recorded in the schema's own JSDoc, in `view-strictness-batch18.test.ts`, and in the `ui/` row of `docs/audits/2026-07-unknown-key-strictness-ledger.md`. + + diff --git a/.changeset/view-union-identity-precondition.md b/.changeset/view-union-identity-precondition.md index af97797bfc..7cf6821768 100644 --- a/.changeset/view-union-identity-precondition.md +++ b/.changeset/view-union-identity-precondition.md @@ -94,3 +94,5 @@ Direction A from the issue — giving the form arm a required floor — remains deliberately **not** taken. It needs Studio's flattened round-trip bodies measured first, or it 422s writes the platform itself makes; the ruling on #5599 deferred it as a possible second tightening on top of this one. + + diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index f2b777c758..2d0c448d44 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -331,6 +331,9 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, - **`auth-config-unadvertised-reserved-features`** — `api.authConfig.features.passkeys / api.authConfig.features.magicLink` → (removed — no replacement flag; the capabilities are not advertised) - Why not automatic: Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481. - Done when: No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; a client that gated UI on either now treats the capability as absent rather than reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` with either key fails to parse with its own prescription instead of being silently stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts `/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call directly. +- **`authoring-schemas-strict-unknown-keys`** — `the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them` → declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`) + - Why not automatic: zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, #4001, #5073, #5599 (registered #7630, backfilling #6350). + - Done when: `objectstack validate` passes with no unknown-key parse errors on any authoring surface — the sweep is "fix until nothing raises", and every rejection carries its own fix. ⚠️ Parsing clean is the weaker half on three of these faces, because the key was being dropped rather than refused and a config that silently did nothing looked exactly like one that worked: re-check that responsive/theme/chart styling actually renders as authored, that every `aria` block still names the element it was written for, and that each state machine and loop config still carries the transitions and caps you declared. For stored `view` bodies, `GET /api/v1/meta/diagnostics?type=view` lists every overlay the identity precondition now rejects, one row per view with the reason; each is fixed by giving the body a real view shape or deleting an overlay that was never a view. - **`batch-options-validate-only-retired`** — `api.batchOptions.validateOnly` → (removed — no dry-run today; open an issue to design a no-commit batch preview) - Why not automatic: The `validateOnly` key promised a dry-run ("validate records without persisting") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it. - Done when: No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that includes it answers 400 VALIDATION_FAILED with the retirement prescription. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 783c3af95a..5c591596dd 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -546,6 +546,13 @@ "toMajor": 17, "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481." }, + { + "surface": "the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them", + "replacement": "declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)", + "migrationId": "authoring-schemas-strict-unknown-keys", + "toMajor": 17, + "rationale": "zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, #4001, #5073, #5599 (registered #7630, backfilling #6350)." + }, { "surface": "api.batchOptions.validateOnly", "replacement": "(removed — no dry-run today; open an issue to design a no-commit batch preview)", @@ -1500,6 +1507,13 @@ "toMajor": 17, "rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481." }, + { + "surface": "the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them", + "replacement": "declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)", + "migrationId": "authoring-schemas-strict-unknown-keys", + "toMajor": 17, + "rationale": "zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, #4001, #5073, #5599 (registered #7630, backfilling #6350)." + }, { "surface": "api.batchOptions.validateOnly", "replacement": "(removed — no dry-run today; open an issue to design a no-commit batch preview)", diff --git a/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts b/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts new file mode 100644 index 0000000000..87ea962f18 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The protocol-17 leg of the #4001 unknown-key strictness wave, registered as ONE +// entry for the major rather than one per batch — the shape both existing +// precedents already take (`ui-schemas-strict-unknown-keys` at 15, +// `dashboard-widget-strict-unknown-keys` at 16), and the maintainer's ruling on +// #7630 (2026-08-12) against the two alternatives (eleven entries, or a +// per-surface grouping). An upgrader reads ONE prescription covering the whole +// wave; the per-batch trace lives here so an archaeologist can still walk it +// batch by batch. Batches folded into this entry, in landing order: +// +// 1. `unknown-key-strictness-tier-a` — #4001 Tier-A: `security/permission.zod.ts` +// (permission set / object / field / admin scope) and `automation/flow.zod.ts`'s four outer +// shapes (flow / node / edge / variable), plus the shared `strictUnknownKeyError` factory. +// 2. `unknown-key-strictness-step2` — #4001 step 2: `security/rls.zod.ts`, +// `security/sharing.zod.ts` (base + criteria + recipient), `identity/position.zod.ts`. +// 3. `strict-automation-control-flow-state-machine` — #4001 batch 10: five shapes in +// `automation/control-flow.zod.ts`, six in `automation/state-machine.zod.ts`. +// 4. `unknown-key-strictness-automation-batch11` — #4001 batch 11: flow's six NESTED blocks, +// `time-relative-trigger.zod.ts`, `flow-function.zod.ts`, `webhook.zod.ts`. +// 5. `unknown-key-strictness-ui-batch13` — #4001 batch 13: all four shapes in +// `ui/responsive.zod.ts` (strictness does not recurse, so `PageComponentSchema.strict()` +// had never reached them). +// 6. `unknown-key-strictness-ui-batch15` — #4001 batch 15: `ui/theme.zod.ts` 14/14, +// `ui/chart.zod.ts` 5/7 (two arms left open, measured `no gate`). +// 7. `unknown-key-strictness-ui-batch16` — #4001 batch 16: `AriaPropsSchema` +// (`ui/i18n.zod.ts`), carried as `aria:` on ~30 live shapes under six metadata roots. +// 8. `view-subblock-strictness-batch18` — #4001 batch 18: fifteen `ui/view.zod.ts` +// sub-blocks (view data arms, filter/gantt options, conditional formatting, empty state, +// subforms, submit behaviour). +// 9. `rare-jars-shave` — `ViewItemSchema` split into a strict +// authoring gate and a `.strip()` wire variant; `ViewFilterRuleSchema` and `ListView.sort[]` +// closed with it. +// 10. `user-filters-allow-add-tab-promote-and-close` — #5073: `userFilters.allowAddTab` promoted +// into the contract, `UserFiltersSchema` closed behind it, `ObjectUserFiltersSchema` +// rejecting the three page-only keys. +// 11. `view-union-identity-precondition` — #5599: the `view` write-path union stops +// matching every object — one level ABOVE the object schemas the ten batches closed. +// +// Two adjacent seams are deliberately NOT covered here and are recorded on #7630: the 45 `~` +// rows of the #6350 audit (a ledger file was touched by some commit, which does not prove the +// entry covers that face) and the 7 borderline candidates PR #7624 judged not-owed. +export const entry: SemanticMigration = { + id: 'authoring-schemas-strict-unknown-keys', + surface: + 'the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` ' + + '(flow and its six nested blocks, control-flow, state-machine, webhook, time-relative ' + + 'trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and ' + + '`identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` ' + + 'sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition ' + + 'one level above them', + replacement: + 'declared keys only. Each rejection names the surface, echoes the offending key and — where ' + + 'the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a ' + + 'prescription where a rename would be wrong. A `view` body must additionally carry at ' + + 'least one key some union member declares, discounting the identity keys the write path ' + + 'stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)', + reason: + "zod's default `.strip` discarded any key these schemas did not declare and let the parse " + + 'SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata ' + + 'that quietly ignored what they wrote. Closing them turns that into a loud parse error ' + + '(ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly ' + + 'convertible for the same reason the two precedent entries at majors 15 and 16 are not: ' + + 'an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly ' + + 'the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix ' + + 'the typo, move it to the layer that owns it, or delete dead metadata. The named renames ' + + 'the errors carry are a help, not a transform: a large share of this wave is ' + + 'PRESCRIPTIONS rather than renames precisely because renaming would be wrong ' + + "(`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` " + + 'counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` ' + + 'bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint ' + + 'vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real ' + + 'on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on ' + + '`try_catch` and `context` on a state machine have no key at all). The eleventh member ' + + 'is not an unknown-key close but the same defect one level up — the `view` union had an ' + + 'arm that both stripped and required nothing, so it matched every object and `saveMetaItem` ' + + 'persisted garbage as an ACTIVE view overlay that read back badged valid. ' + + 'This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the ' + + "registry's only two precedents of this shape; the eleven batches it folds are the " + + 'changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, ' + + '`-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, ' + + '`view-subblock-strictness-batch18`, `rare-jars-shave`, ' + + '`user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, ' + + 'each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, ' + + '#4001, #5073, #5599 (registered #7630, backfilling #6350).', + acceptanceCriteria: + '`objectstack validate` passes with no unknown-key parse errors on any authoring surface — ' + + 'the sweep is "fix until nothing raises", and every rejection carries its own fix. ' + + '⚠️ Parsing clean is the weaker half on three of these faces, because the key was being ' + + 'dropped rather than refused and a config that silently did nothing looked exactly like ' + + 'one that worked: re-check that responsive/theme/chart styling actually renders as ' + + 'authored, that every `aria` block still names the element it was written for, and that ' + + 'each state machine and loop config still carries the transitions and caps you declared. ' + + 'For stored `view` bodies, `GET /api/v1/meta/diagnostics?type=view` lists every overlay ' + + 'the identity precondition now rejects, one row per view with the reason; each is fixed by ' + + 'giving the body a real view shape or deleting an overlay that was never a view.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 7762dd4666..974f861c26 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1830,6 +1830,101 @@ const step17: MigrationStep = { + '`/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call ' + 'directly.', }, + // The protocol-17 leg of the #4001 unknown-key strictness wave, registered as ONE + // entry for the major rather than one per batch — the shape both existing + // precedents already take (`ui-schemas-strict-unknown-keys` at 15, + // `dashboard-widget-strict-unknown-keys` at 16), and the maintainer's ruling on + // #7630 (2026-08-12) against the two alternatives (eleven entries, or a + // per-surface grouping). An upgrader reads ONE prescription covering the whole + // wave; the per-batch trace lives here so an archaeologist can still walk it + // batch by batch. Batches folded into this entry, in landing order: + // + // 1. `unknown-key-strictness-tier-a` — #4001 Tier-A: `security/permission.zod.ts` + // (permission set / object / field / admin scope) and `automation/flow.zod.ts`'s four outer + // shapes (flow / node / edge / variable), plus the shared `strictUnknownKeyError` factory. + // 2. `unknown-key-strictness-step2` — #4001 step 2: `security/rls.zod.ts`, + // `security/sharing.zod.ts` (base + criteria + recipient), `identity/position.zod.ts`. + // 3. `strict-automation-control-flow-state-machine` — #4001 batch 10: five shapes in + // `automation/control-flow.zod.ts`, six in `automation/state-machine.zod.ts`. + // 4. `unknown-key-strictness-automation-batch11` — #4001 batch 11: flow's six NESTED blocks, + // `time-relative-trigger.zod.ts`, `flow-function.zod.ts`, `webhook.zod.ts`. + // 5. `unknown-key-strictness-ui-batch13` — #4001 batch 13: all four shapes in + // `ui/responsive.zod.ts` (strictness does not recurse, so `PageComponentSchema.strict()` + // had never reached them). + // 6. `unknown-key-strictness-ui-batch15` — #4001 batch 15: `ui/theme.zod.ts` 14/14, + // `ui/chart.zod.ts` 5/7 (two arms left open, measured `no gate`). + // 7. `unknown-key-strictness-ui-batch16` — #4001 batch 16: `AriaPropsSchema` + // (`ui/i18n.zod.ts`), carried as `aria:` on ~30 live shapes under six metadata roots. + // 8. `view-subblock-strictness-batch18` — #4001 batch 18: fifteen `ui/view.zod.ts` + // sub-blocks (view data arms, filter/gantt options, conditional formatting, empty state, + // subforms, submit behaviour). + // 9. `rare-jars-shave` — `ViewItemSchema` split into a strict + // authoring gate and a `.strip()` wire variant; `ViewFilterRuleSchema` and `ListView.sort[]` + // closed with it. + // 10. `user-filters-allow-add-tab-promote-and-close` — #5073: `userFilters.allowAddTab` promoted + // into the contract, `UserFiltersSchema` closed behind it, `ObjectUserFiltersSchema` + // rejecting the three page-only keys. + // 11. `view-union-identity-precondition` — #5599: the `view` write-path union stops + // matching every object — one level ABOVE the object schemas the ten batches closed. + // + // Two adjacent seams are deliberately NOT covered here and are recorded on #7630: the 45 `~` + // rows of the #6350 audit (a ledger file was touched by some commit, which does not prove the + // entry covers that face) and the 7 borderline candidates PR #7624 judged not-owed. + { + id: 'authoring-schemas-strict-unknown-keys', + surface: + 'the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` ' + + '(flow and its six nested blocks, control-flow, state-machine, webhook, time-relative ' + + 'trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and ' + + '`identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` ' + + 'sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition ' + + 'one level above them', + replacement: + 'declared keys only. Each rejection names the surface, echoes the offending key and — where ' + + 'the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a ' + + 'prescription where a rename would be wrong. A `view` body must additionally carry at ' + + 'least one key some union member declares, discounting the identity keys the write path ' + + 'stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)', + reason: + "zod's default `.strip` discarded any key these schemas did not declare and let the parse " + + 'SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata ' + + 'that quietly ignored what they wrote. Closing them turns that into a loud parse error ' + + '(ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly ' + + 'convertible for the same reason the two precedent entries at majors 15 and 16 are not: ' + + 'an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly ' + + 'the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix ' + + 'the typo, move it to the layer that owns it, or delete dead metadata. The named renames ' + + 'the errors carry are a help, not a transform: a large share of this wave is ' + + 'PRESCRIPTIONS rather than renames precisely because renaming would be wrong ' + + "(`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` " + + 'counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` ' + + 'bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint ' + + 'vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real ' + + 'on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on ' + + '`try_catch` and `context` on a state machine have no key at all). The eleventh member ' + + 'is not an unknown-key close but the same defect one level up — the `view` union had an ' + + 'arm that both stripped and required nothing, so it matched every object and `saveMetaItem` ' + + 'persisted garbage as an ACTIVE view overlay that read back badged valid. ' + + 'This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the ' + + "registry's only two precedents of this shape; the eleven batches it folds are the " + + 'changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, ' + + '`-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, ' + + '`view-subblock-strictness-batch18`, `rare-jars-shave`, ' + + '`user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, ' + + 'each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, ' + + '#4001, #5073, #5599 (registered #7630, backfilling #6350).', + acceptanceCriteria: + '`objectstack validate` passes with no unknown-key parse errors on any authoring surface — ' + + 'the sweep is "fix until nothing raises", and every rejection carries its own fix. ' + + '⚠️ Parsing clean is the weaker half on three of these faces, because the key was being ' + + 'dropped rather than refused and a config that silently did nothing looked exactly like ' + + 'one that worked: re-check that responsive/theme/chart styling actually renders as ' + + 'authored, that every `aria` block still names the element it was written for, and that ' + + 'each state machine and loop config still carries the transitions and caps you declared. ' + + 'For stored `view` bodies, `GET /api/v1/meta/diagnostics?type=view` lists every overlay ' + + 'the identity precondition now rejects, one row per view with the reason; each is fixed by ' + + 'giving the body a real view shape or deleting an overlay that was never a view.', + }, { id: 'batch-options-validate-only-retired', surface: 'api.batchOptions.validateOnly',