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',