diff --git a/.changeset/unknown-key-strictness-ui-batch17.md b/.changeset/unknown-key-strictness-ui-batch17.md new file mode 100644 index 0000000000..7a1851bf9d --- /dev/null +++ b/.changeset/unknown-key-strictness-ui-batch17.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': major +--- + +**`ui/component.zod.ts` 的 29 个站点判定为 `no gate`,不收紧(#4001 批 17,ADR-0078)** + +⚠️ **本条没有破坏性变更,没有迁移,没有任何键的行为改变。** 它记录的是一次测量结果:SDUI 组件 props 这块 `ui/` 目录最大的待收紧面,**根本没有 parse**,所以 `.strict()` 在这里不会强制任何东西。 + +## 为什么不收紧 + +`.strict()` 是 **parse 的属性**。三条独立测量(2026-08-04,控制组同轮为真): + +1. **承载键活着,但它是个开放口袋。** `PageComponentSchema.properties` 是 `z.record(z.string(), z.unknown())`。`PageComponentSchema` 自 ADR-0089 D3a 起确实是 `.strict()`,但**严格性不递归**——它守住 component 节点自己的键,`properties` 里面完全不校验,也没有任何地方按 `type` 分派 `ComponentPropsMap`。 +2. **BFS 不可达。** 从 24 个 metadata-type root 加 `ObjectStackSchema` 出发(复用 `build-schemas.ts` 自己的 `zodChildSchemas`/`zodShapeOf`,即 #4650 闭包,6899 个节点),本文件 **52 个目标全部 UNREACHABLE**(21 个导出 schema + `ComponentPropsMap` 全部 31 个条目);同一轮里 `PageSchema` / `PageComponentSchema` / `PageRegionSchema` / `ThemeSchema` / `ChartConfigSchema` / `ResponsiveConfigSchema` 六个正控制组全部 `root-graph`,批 13 的 no-door 形状保持 unreachable。BFS 正好停在 `properties`。 +3. **三个仓库无生产 parse 点。** `objectstack` / `objectui` / `cloud` 中,对本文件任何 schema 的 `.parse()`/`.safeParse()` 全部落在本文件自己的单测里。objectui 手写平行的 React interface、只引用推断类型;cloud 引用为 0;`react-blocks.ts` 只用 `Object.keys(ComponentPropsMap)` 取类型名。 + +经验证据(`definePage()` 就是 `PageSchema.parse()`,活的授权门):example 语料 10/10 个页面上,写进 `components[].properties` 的未声明键**原样通过并被保留**;同一个键放到外面一层(`properties` 的兄弟位)10/10 被拒——这个负控制组才让前一个数字有意义。 + +## `no gate`,不是 `no door` —— 不要退役 + +这些词汇是**活的**,不能按 ADR-0049 退役:objectui 的 `SchemaRenderer` 把 `properties` 整个 hoist 到节点上,再把不在固定 deny-list 上的**每一个**作者键 spread 成 React prop。所以拼错的键既不被拒、也不被丢,而是安静地流到渲染器再被忽略——正是 ADR-0078 要消灭的形状,只是位置比本 ratchet 能触及的层更低一层。 + +这确实是 #4909 的 open-slot 形状,但在一个没人 parse 的 schema 上,`.passthrough()` 和 `.strict()` 一样空洞,所以**没有改任何 posture**。 + +contract-first 的修法是把 parse 接到承载键自己的闸门上,已单独立为 **#5068**;那个 issue 同时记录了两条使它不能顺手做的约束:`type` 是开放 union(`record:line_items` 这类未注册类型在现实中被使用),以及真实页面已经写了这些 schema 未声明的形状(`record:details` 的 `sections[].fields[]`/`hideFields[]`、record picker 的 `labelField`)。#5068 落地后本文件才重新变成 `authorable`,收紧才有意义。 + +判定写在三处(文件头、`component.test.ts` 的钉子——含一条 `properties` 一旦获得类型化分派就变红的断言、账本 `ui/` 两张表),改要一起改。 + +账本连带效果(与 #5042 批 14、#5069 批 16、#5070 批 18 合并后,从存活行重算):`ui/` 的 authorable strip 从 35 降到 **6 of 75**——重分类只是换类不是出列,总数不变,文件保留 29/29 行,`check:strictness-ledger` 的反向钉子仍然管着它;`no gate` 从 2 涨到 **31**。批 18 关掉 15 个真门、批 17 测出 29 个没有门,两件事叠加后 `ui/` 只剩 **6 个** authorable strip 站点。由此得到这个目录现在最大的一个事实:**`ui/` 剩余 75 个 strip 站点里有 69 个(92%)根本不是本 ratchet 的工作**(38 个 `no door` + 31 个 `no gate`)。排后续 `ui/` 收紧批次前请先读这个数——这个目录的 ratchet 已经接近完成,剩下的绝大多数是别的 issue 的工作。 diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index a8de812bca..cbbacf95eb 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -21,7 +21,7 @@ One question decides the class: **who writes this schema's input?** | **wire** | Another machine: server responses, connector payloads, runtime envelopes, persisted runtime state | stay tolerant (`.strip` / `.passthrough`); strictness here turns an upstream *addition* into our parse crash | | **open** | Deliberately schemaless user data (record bodies, per-node-type `config`, React props) | stay open; a *sibling* contract validates it (e.g. a node executor's `configSchema`, #4027/#4040) | | **no door** | **Nobody — nothing parses it.** The shape is exported and typed, but no schema declares a carrier key for it, so it is unreachable from every metadata-type root and from `defineStack`, and nothing calls `.parse()` on it outside its own test. Added at 批 13, when the first run of files resolved its `(p)` this way | **out of this ratchet's scope.** `.strict()` is a property of a PARSE; with no parse it enforces nothing and only makes a dead slot look load-bearing — *"a precisely-validated dead slot is the more convincing lie"* (#4583). The live question is ADR-0049 enforce-or-remove — retire the vocabulary or give it a carrier — so a row here points at an issue, never at a batch (#4988, #5015) | -| **no gate** | **An author — through a carrier this protocol does not PARSE.** The carrier key exists and is live (authors write it, a renderer reads it), but no `.parse()` sits between them; whatever checking exists re-derives the schema's rules by hand. Added at 批 15 on `ChartAggregateSchema` (``) | **out of this ratchet's scope, for the opposite reason.** Same absent parse, so closing it still enforces nothing — but the vocabulary is ALIVE, so the fix is to wire the parse at the carrier's own gate, not to retire anything. A row here points at that wiring issue | +| **no gate** | **An author — through a carrier this protocol does not PARSE.** The carrier key exists and is live (authors write it, a renderer reads it), but no `.parse()` sits between them; whatever checking exists re-derives the schema's rules by hand. Added at 批 15 on `ChartAggregateSchema` (``); 批 17 then found the same shape at scale — all 29 sites of `ui/component.zod.ts`, behind `PageComponentSchema.properties`, making this the largest class in `ui/` | **out of this ratchet's scope, for the opposite reason.** Same absent parse, so closing it still enforces nothing — but the vocabulary is ALIVE, so the fix is to wire the parse at the carrier's own gate, not to retire anything. A row here points at that wiring issue | A fourth answer to "who writes this input" is **nobody**, and it is only reachable by measurement rather than by reading the file: `no door` was added at @@ -577,7 +577,7 @@ not verdicts). | `action.zod.ts` | 8 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `ActionParamSchema` was strict from #3746, but strictness does not recurse and its `options[]` entry was still strip: an option carrying `color` / `visibleWhen` / `icon` / `disabled` parsed clean through `getMetadataTypeSchema('action')` and came back `{ label, value }`. Closed with `strictObject`, NOT `.passthrough()` — the opposite call from `bulk-action.zod.ts`'s option entry two rows up, and made on measurement rather than symmetry: that def reaches the grid verbatim with no spec door in between and objectui's `BulkActionParam` declares an explicit `[key: string]: unknown`, whereas this path has a door that ALREADY strips and lands in the CLOSED `SelectOptionMetadata`. Whether this surface should carry the field-level per-option vocabulary at all is #5016. Earlier note: **9 → 8 at the #4001 re-measurement** — no schema changed; the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code **9 → 8 at the #4001 re-measurement** — no schema changed: the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code | | `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | | `bulk-action.zod.ts` | 3 | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open** and both now `.passthrough()` — the param because objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so passthrough is the honest mirror and strictness would reject valid config (same call as `dashboard.zod.ts`'s widget `config`); the OPTION ENTRY on separate measured evidence, since its objectui type is closed and only the runtime path is open — `bulkParamToField` spreads each entry (`plugin-grid/src/components/bulkParamToField.ts:131`) into `SelectOptionMetadata` (`types/src/field-types.ts:288`), which declares and reads `color` / `icon` / `disabled` / `visibleWhen`. **This row said "both deliberately open" while only the parent was `passthrough`** — one intent, two postures, caught by the 2026-08-03 re-measure and closed by the ruling's verdict A (make the code match the prose). The lesson is the campaign's own: prose in this ledger is not a posture reading, which is why the remaining-strip map is gated and this column is not. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached | -| `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) | +| `component.zod.ts` | 29 | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`. ⚠️ The #5056 bridge defect does not touch this row: it makes the derived-clone bridge report dead shapes as REACHABLE, the opposite direction, and nothing here rests on that bridge — all six positive controls resolve `root-graph` and all 52 targets miss BOTH `root-graph` and `derived-clone`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) | | `theme.zod.ts` | 14 | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **Separately filed, not answered here**: `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read). That is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live | | `app.zod.ts` | 18 | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | | `dashboard.zod.ts` | 11 | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ `compareTo` is a UNION, so its curated prescription is produced but not delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014). The REJECTION is unaffected | @@ -759,7 +759,7 @@ next person to open that file will look. | File | Strip | Sites | Class | Batch | |---|---|---|---|---| -| `component.zod.ts` | 29 | 29 | authorable (p) | Largest single block left. SDUI component props — **verify the React-prop open slots first**; `check:react-declaration-parity` compares two DECLARATIONS and cannot tell you which props a renderer reads | +| `component.zod.ts` | 29 | 29 | **no gate** | ⛔ **not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would gate nothing (#4583). Blocked on wiring the parse at the carrier — **#5068**. See the triage row for the full measurement | | `view.zod.ts` | 5 | 50 | mixed | **15 of 20 closed at #4001 批 18**; the 5 that remain are each measured, and none is unfinished work. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed and then REVERTED, and that is the batch's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. The end state is #5074's authoring/wire split applied one level down; until then the shape stays open rather than half-closed against the platform's own writes. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter reads them as `strict` because `postureOf` returns early on the `strictObject` idiom without walking the chain (**#5072**); it inflates the strict count and does not affect this row's strip count. **Still open, all five measured:** `UserFiltersSchema` — closing it would 422 `allowAddTab`, which objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; `saveMetaItem` validates but persists the ORIGINAL body, so the stripped key still reaches the renderer and the capability WORKS today — closing removes a capability rather than making a silent failure loud (**#5073**). The 批 6e reliance question IS answered: `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flips from "drops" to "rejects" — that flip is wanted, and gated only on `allowAddTab`. `ViewItemSchema` ×2 — **wire, not authorable**: objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so it lands on THIS member (the flattened members are excluded by their `config: z.undefined()` guard) and closing it would 422 pinning a saved view (**#5074**). `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. `ListView.sort` — reverted, see above. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` + this row) | | `widget.zod.ts` | 9 | 9 | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | | `chart.zod.ts` | 2 | 7 | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (``) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row | @@ -827,18 +827,47 @@ kept before the arithmetic was redone from them. Worth naming because 批 16 and sites it did not touch, 批 18 by closing 15 it did — and the merged subtotal is not reachable by applying either delta to the other's base. -**Authorable strip in `ui/`: 35 of 75** (was 123 of 123 when the ruling was -written). Recomputed from the surviving rows at 批 18, not decremented: -29+5+9+2+7+5+4+4+4+3+1+1+1 = 75, of which 40 are the two no-parse classes, so -the authorable half is `component` 29 + `view` 5 + `app` 1 = 35. `app.zod.ts`'s -single site is held pending the finding-16 `.extend()` check rather than counted -as ready. **40 of the 75 are the two no-parse classes**: 38 `no door` — `touch` -(7), `animation` (4), `dnd` (4), `keyboard` (4) and `offline` (3) from 批 13, -`sharing.zod.ts`'s `EmbedConfig` and `notification.zod.ts`'s `NotificationAction` -from 批 14, and `widget.zod.ts` (9) plus `i18n.zod.ts`'s remaining 5 from 批 16 -(#4988, #5015, #5055) — and 2 `no gate`, `chart.zod.ts`'s remaining pair from -批 15. Read the difference before acting on either: they imply OPPOSITE -follow-ups. +**批 17 is the tenth instance, and it has now hit this same line three times +inside one branch.** It first computed `47 of 100` against a tree where 批 14's +four rows (`dataset`, `dashboard`, `report`, `action`) still existed and +`sharing`/`notification` were still `authorable`; merging 批 14 made that +`36 of 91`; merging 批 16 — which closed `AriaProps` and moved `widget` (9) plus +five of `i18n` out of `authorable` — made it `21 of 90`; merging 批 18, which +CLOSED 15 of `view`'s 20, makes it **`6 of 75`**. Four right answers against four +trees, none of them the merge. Every time git merged the ROWS and conflicted only +the prose, because 批 17 changes just its own row's Class column — so the table +was right and this paragraph was wrong on every side, every time. That is ten for +ten, and it is why the subtotal below is recomputed from the surviving rows +rather than adjusted by anyone's delta. + +**Authorable strip in `ui/`: 6 of 75** (was 123 of 123 when the ruling was +written). Recomputed from the surviving rows after the 批 18 + 批 17 merge, not +decremented: 29+5+9+2+7+5+4+4+4+3+1+1+1 = 75, of which **69** are the two +no-parse classes, leaving the authorable half as `view` 5 + `app` 1 = **6**. +`app.zod.ts`'s single site is held pending the finding-16 `.extend()` check +rather than counted as ready. + +**69 of the 75 — 92% of what is left in this directory — are the two no-parse +classes.** After 批 18 closed 15 real doors and 批 17 measured 29 sites as having +none, `ui/` has **six** authorable strip sites left in total. That is the single +largest fact about this directory now, and it should be read before any further +`ui/` strictness batch is scheduled — the ratchet is very nearly done here, and +what remains open is overwhelmingly work for OTHER issues: + +- **38 `no door`** — `touch` (7), `animation` (4), `dnd` (4), `keyboard` (4) and + `offline` (3) from 批 13; `sharing.zod.ts`'s `EmbedConfig` and + `notification.zod.ts`'s `NotificationAction` from 批 14; `widget.zod.ts` (9) + plus `i18n.zod.ts`'s remaining 5 from 批 16 (#4988, #5015, #5055). +- **31 `no gate`** — `chart.zod.ts`'s remaining pair from 批 15, plus **all 29 + sites of `component.zod.ts` from 批 17** (#5068). That single row is the + campaign's largest reclassification and the reason this subtotal fell by 29 + without one site being closed. + +Read the difference before acting on either: they imply OPPOSITE follow-ups +(`no door` → ADR-0049 enforce-or-remove; `no gate` → wire the parse at the +carrier). Acting on the wrong one is not merely wasteful but destructive — +retiring a `no gate` vocabulary deletes something authors use and renderers +run. ## What the three `ui/` batches measured, and why the answers differ diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index a4e9ccdecb..2607f37eba 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -18,7 +18,7 @@ import { ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, } from './component.zod'; -import { PageComponentSchema } from './page.zod'; +import { PageComponentSchema, PageSchema } from './page.zod'; describe('PageHeaderProps', () => { it('should accept minimal header', () => { @@ -865,3 +865,72 @@ describe('ComponentPropsMap record:chatter', () => { expect(result.unifiedTimeline).toBe(true); }); }); + +/** + * ── #4001 批 17: the `no gate` verdict, pinned ────────────────────────────── + * + * These schemas are NOT a pending `.strict()` batch. Nothing parses them, so + * closing them would enforce nothing (#4583). The full measurement and the + * reasoning live in `component.zod.ts`'s file header and in the `ui/` tables of + * `docs/audits/2026-07-unknown-key-strictness-ledger.md`. + * + * This block exists so the verdict cannot outlive its truth. Each assertion is + * written to go RED the day the world changes underneath it — at which point the + * correct response is to update all three places together, not to relax the test. + */ +describe('#4001 批 17 — component props are `no gate` (carrier live, parse absent)', () => { + it('the carrier is still an OPEN bag — goes red the day `properties` gets a typed dispatch', () => { + // `PageComponentSchema` is `.strict().transform(…)`, so unwrap the pipe to + // reach the object shape. + const def = (PageComponentSchema as any)._zod.def; + const shape = def.type === 'pipe' ? def.in._zod.def.shape : def.shape; + // `properties` is `z.record(z.string(), z.unknown()).optional().default({})`. + let node = shape.properties; + while (node?._zod?.def?.innerType) node = node._zod.def.innerType; + expect(node._zod.def.type).toBe('record'); + // The value type must still be the fully-open `unknown`. A dispatch on + // `type` (the #5068 fix) replaces this, and that is the signal to reclassify + // this file back to `authorable` and schedule the ratchet. + expect(node._zod.def.valueType._zod.def.type).toBe('unknown'); + }); + + it('an unknown key inside `properties` survives the LIVE page parse — with the strict sibling as negative control', () => { + const page = { + name: 'batch17_probe', + label: 'Probe', + type: 'home' as const, + regions: [ + { name: 'header', components: [{ type: 'page:header', properties: { title: 'T' } }] }, + ], + }; + + // A. unknown key INSIDE the carrier slot — accepted AND retained today. + const inside = structuredClone(page) as any; + inside.regions[0].components[0].properties.zzUndeclared = 'x'; + const a = PageSchema.safeParse(inside); + expect(a.success).toBe(true); + expect((a as any).data.regions[0].components[0].properties.zzUndeclared).toBe('x'); + + // B. NEGATIVE CONTROL — the same key one level out, on the component node + // itself, which IS strict (ADR-0089 D3a). If this ever stops failing, the + // assertion above proves nothing and this whole block is measuring air. + const outside = structuredClone(page) as any; + outside.regions[0].components[0].zzUndeclared = 'x'; + expect(PageSchema.safeParse(outside).success).toBe(false); + }); + + it('every ComponentPropsMap entry is still non-strict — a sweep that closes them without wiring #5068 fails here', () => { + const stillOpen: string[] = []; + for (const [type, schema] of Object.entries(ComponentPropsMap)) { + const def = (schema as any)._zod.def; + // zod records an unknown-key policy on the object def; `.strict()` sets a + // `never` catchall. Anything else means the site is still open. + if (def.catchall?._zod?.def?.type === 'never') continue; + stillOpen.push(type); + } + // All 31 registered component types are open. When #5068 wires the parse and + // a later batch closes them, this expectation flips — update the verdict in + // component.zod.ts and the ledger in the same PR. + expect(stillOpen.length).toBe(Object.keys(ComponentPropsMap).length); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index edab5ffd24..d07e00fca0 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -7,6 +7,87 @@ import { InlineActionSchema } from './action.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; +// --------------------------------------------------------------------------- +// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT +// (#4001 batch 17 / 批 17, ADR-0078). Read this before "finishing" the file. +// +// SDUI component prop schemas: the declarative shape of every `page:*`, +// `record:*`, `element:*`, `nav:*` and `ai:*` node a page can carry. +// +// These 29 object sites are `no gate` -- carrier live, parse absent -- NOT a +// pending `.strict()` batch. Do not sweep `strictObject` across this file. +// +// It was scheduled as the #4001 campaign's largest remaining `ui/` block and +// the measurement came back NEGATIVE: nothing parses these schemas, so +// `.strict()` here would enforce exactly nothing while spending a v17 breaking +// change to produce what #4583 calls "a precisely validated dead slot -- the +// more convincing lie". +// +// `.strict()` is a property of a PARSE. Three independent measurements, each +// with its controls green in the same run (2026-08-04): +// +// 1. THE CARRIER IS AN OPEN BAG. `PageComponentSchema.properties` is +// `z.record(z.string(), z.unknown())` (`page.zod.ts`). `PageComponentSchema` +// itself has been `.strict()` since ADR-0089 D3a — but strictness does NOT +// recurse, so it closes the component node's own keys and leaves everything +// under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by +// `type`. +// 2. BFS-UNREACHABLE. From all 24 metadata-type roots plus `defineStack`'s +// `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s +// own `zodChildSchemas` / `zodShapeOf` (the #4650 walk), all 52 targets here +// (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries) come +// back UNREACHABLE — while `PageSchema`, `PageComponentSchema`, +// `PageRegionSchema`, `ThemeSchema`, `ChartConfigSchema` and +// `ResponsiveConfigSchema` all resolve `root-graph` in that same run, and 批 13's +// measured no-door shapes stay unreachable. The walk stops at `properties`. +// 3. NO PRODUCTION PARSE. Across `objectstack`, `objectui` and `cloud`, every +// `.parse()` / `.safeParse()` on anything in this file is inside this file's +// own unit tests. `objectui` mirrors the props as hand-written React +// interfaces and imports only the inferred TYPES; `cloud` references none. +// `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type names only — +// its `REACT_BLOCKS[].schema` entries all point at view/chart schemas. +// +// The #5056 bridge defect does NOT touch this result. That defect makes the +// derived-clone bridge report dead shapes as REACHABLE (shared `.describe()` +// clones under common leaves like `SnakeCaseIdentifier` / `I18nLabel`), so its +// error direction is the opposite of this verdict -- it could only have hidden a +// no-gate finding, never manufactured one. And nothing here rests on that bridge +// anyway: all six positive controls resolve `root-graph` (their own instances are +// in the closure), and all 52 targets miss BOTH `root-graph` and `derived-clone`. +// The two non-BFS measurements below stand on their own regardless. +// +// Empirically, through the live door (`definePage()` IS `PageSchema.parse()`): on +// the example corpus an undeclared key written inside `components[].properties` +// parses clean and is RETAINED on 10/10 pages, while the same key one level out +// — a sibling of `properties` — is rejected on 10/10. The negative control is +// what makes the first number mean something. +// +// WHY `no gate` AND NOT `no door` (批 13 vs 批 15) +// +// The vocabulary here is ALIVE — this is not dead surface to retire under +// ADR-0049. Authors write these keys on real pages, and objectui's +// `SchemaRenderer` hoists `properties` onto the node and spreads every key that +// is not on its fixed metadata deny-list straight into the React component. So +// a misspelled key is neither rejected nor dropped: it reaches the renderer and +// is ignored there. That is the ADR-0078 failure mode, one layer below where +// this campaign can reach. +// +// The contract-first fix is therefore to WIRE THE PARSE at the carrier's own +// gate, not to close schemas nobody calls — filed as #5068, which also +// records the two constraints that stop it being a drive-by: `type` is an open +// union (unregistered types like `record:line_items` are authored in the wild), +// and real pages already author shapes these schemas do not declare +// (`record:details` `sections[].fields[]` / `hideFields[]`, the record picker's +// `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`, which +// has documented the untyped bag all along). +// +// When #5068 lands, this file becomes `authorable` and the ratchet applies. The +// verdict is pinned in `component.test.ts` and in the `ui/` tables of +// `docs/audits/2026-07-unknown-key-strictness-ledger.md` — change all three +// together or none. +// --------------------------------------------------------------------------- + + /** * Empty Properties Schema */