From 7ad607f35655e55801048565b1b786f44590f5aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 11:06:51 +0000 Subject: [PATCH 1/4] feat(spec): close the 31 SDUI component-props shapes against unknown keys (#4001 batch A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ComponentPropsMap` — the declared shape of every `page:*`, `record:*`, `element:*`, `nav:*` and `ai:*` node a page can carry — stripped unknown keys in silence. All 31 object sites in `ui/component.zod.ts` are `strictObject` now, including the two union arms (`RecordHighlightsField`'s object arm and `record:related_list`'s sort entry) and every nested block, since strictness does not recurse. #5068 already REPORTED these keys, by walking a strip-mode object and reconstructing what the parse would have dropped. Now the parse rejects the key itself — same rule id, same warning tier, and three things the reconstruction could not give: curated per-surface prescriptions, a rejection that holds for every caller rather than only inside the gate, and coverage of union arms. Five renderer-honoured props are DECLARED rather than rejected, on the rule this file has applied three times before (#5611/#5775/#6276 — the delivered, authorized shape is the contract): `page:header` `maxVisible`/`mobileMaxVisible`, `page:tabs.alwaysShowStrip`, `record:details` `inlineEdit`/`showHeader`. Each is read by objectui through `schema?.X ?? schema?.properties?.X` with its own comment inviting authors. All optional with no schema default — the defaults are the renderer's, and declaring them would turn an unset key into an authored one. Curated tables, each backed by a producer measured in the wild rather than guessed: a tab item's `key` → `value` (objectui's Studio designer publishes `key`; the renderer reads `it.value`), a header's `description` → `subtitle` (the rename its own ADR-0087 conversion performs — the one path that had no diagnostic at all), a container's `body` → `children`, and a wrong-layer family for keys that belong on the component NODE. `@objectstack/lint` gains one piece of wiring: zod 4 collapses union-arm failures into a single `invalid_union`, so a lone arm's `unrecognized_keys` is unpacked back onto the unknown-key rule id — and deliberately is not when two arms could both have been meant. Deliberately unchanged: the carrier is still `z.record(z.string(), z.unknown())` (direction B stays declined, unregistered `record:*` types are still skipped), the storage path still parses no props (#4463), and the gate is still warning level. Verified with direct build-artifact `.parse()` probes over the example corpus — `objectstack validate` never parses through `PageSchema` (#5000), so "the examples validate clean" would have been no evidence. 244 registered props bags across the three example apps' build artifacts, the same three from source, and the three published platform pages: zero undeclared keys, zero new refusals. The probe's negative control moved from 0/244 injections caught to 244/244. Filed out of scope: #7973 (objectui's Studio designer publishes three page component inputs no renderer reads). Part of #4001 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012NLJ8PWLdwLSyi84LHzrAx --- .changeset/component-props-strict-batch-a.md | 57 ++ content/docs/references/ui/component.mdx | 5 + ...07-unknown-key-strictness-ledger.counts.md | 17 +- .../2026-07-unknown-key-strictness-ledger.md | 3 +- .../lint/src/validate-component-props.test.ts | 60 +- packages/lint/src/validate-component-props.ts | 59 ++ packages/spec/authorable-surface/ui.json | 5 + .../spec/src/conversions/conversions.test.ts | 31 +- packages/spec/src/ui/component.test.ts | 177 +++++- packages/spec/src/ui/component.zod.ts | 557 ++++++++++++++++-- 10 files changed, 875 insertions(+), 96 deletions(-) create mode 100644 .changeset/component-props-strict-batch-a.md diff --git a/.changeset/component-props-strict-batch-a.md b/.changeset/component-props-strict-batch-a.md new file mode 100644 index 0000000000..4c76ddef78 --- /dev/null +++ b/.changeset/component-props-strict-batch-a.md @@ -0,0 +1,57 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": patch +--- + +feat(spec): close the 31 SDUI component-props shapes against unknown keys (#4001 batch A) + +`ComponentPropsMap` — the declared shape of every `page:*`, `record:*`, +`element:*`, `nav:*` and `ai:*` node a page can carry — stripped unknown keys in +silence. All 31 object sites in `ui/component.zod.ts` are `strictObject` now. + +**What actually changes for an author.** #5068 already reported these keys: it +dispatches `ComponentPropsMap` by the component's `type` and judges `properties` +at `os validate` / `os build` / `os lint`. It did that by walking a strip-mode +object and reconstructing what the parse would have dropped. Now the parse +rejects the key itself. Same rule id (`component-props-unknown-key`), same +warning tier — and three things a reconstruction could not give: + +- **Curated prescriptions per surface.** A tab item's `key` is answered with + `value` (objectui's Studio designer publishes `key`; the renderer reads + `it.value` and falls back to `tab-`, so an authored `key` yields tab + tokens that move when the item list changes). A header's `description` is + answered with `subtitle`, the rename its own ADR-0087 conversion performs — + the one path that had no diagnostic at all, as `conversions/walk.ts` records. + A container's `body` is answered with `children`. Anything spelled like a + component-level key (`visibleWhen`, `id`, `dataSource`, `className`, …) is told + it belongs one level up on the node, where the runtime actually reads it. +- **A rejection that holds for every caller**, not only inside the gate. +- **Union arms.** `RecordHighlightsField`'s object arm and + `record:related_list`'s sort entry are closed too. Zod 4 collapses arm + failures into one `invalid_union`, so `@objectstack/lint` now unpacks a lone + arm's `unrecognized_keys` back onto the unknown-key rule — and declines to, + deliberately, when two arms could both have been meant. + +**Five props are newly DECLARED, not rejected**, on the rule this file has +applied three times before (#5611 / #5775 / #6276: the delivered, authorized +shape is the contract). Each is read by objectui through +`schema?.X ?? schema?.properties?.X` with its own comment inviting authors, and +closing the shape around them would have turned an invited affordance into a +rejection: `page:header` `maxVisible` / `mobileMaxVisible` (the inline-vs-overflow +action budget, desktop and mobile), `page:tabs.alwaysShowStrip` (keep a one-tab +strip visible), `record:details` `inlineEdit` / `showHeader`. All optional with +no schema default — the defaults are the renderer's, and declaring them would +turn an unset key into an authored one. + +**Deliberately unchanged, and worth stating because a reader will assume +otherwise.** The carrier is still `z.record(z.string(), z.unknown())`: direction +B (a discriminated `properties`) stays declined, because `type` is an open union +and the example corpus alone authors 87 nodes across ten types this map does not +carry — those are still skipped, not rejected. The storage path still parses no +props (#4463). The gate is still warning-level. + +**Zero refusals on shipped metadata.** Verified by parsing the three example +apps' build artifacts and the three published platform pages directly through +`ComponentPropsMap` — 244 registered props bags, no undeclared key, no new +refusal — because `objectstack validate` never parses through `PageSchema` +(#5000), so "the examples validate clean" would have been no evidence at all. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index db2a4dba5a..5216fb42cc 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -249,6 +249,8 @@ const result = AIChatWindowProps.parse(data); | **recordChrome** | `boolean` | ✅ | Render the record chrome — the title as a record chip with its follow star and copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. | | **showStar** | `boolean` | ✅ | Show the follow (favourite) star beside the record title. Part of the record chrome — no effect when `recordChrome` is false. | | **showCopyId** | `boolean` | ✅ | Show the copy-record-id button beside the record title. Part of the record chrome — no effect when `recordChrome` is false. | +| **maxVisible** | `integer` | optional | How many header actions render as inline buttons before the rest fold into the overflow menu (renderer default 3). | +| **mobileMaxVisible** | `integer` | optional | The `maxVisible` budget on mobile viewports (renderer default 1). | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -263,6 +265,7 @@ const result = AIChatWindowProps.parse(data); | **tabStyle** | `Enum<'line' \| 'card' \| 'pill'>` | optional | Tab-strip visual style: 'line' underlines the active tab, 'card' frames each tab, 'pill' renders rounded pills | | **type** | `never` | optional | [REMOVED] `page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (#6776, ADR-0087 D2) — a props key named `type` collides with the page component's own dispatch key, so it is unauthorable in the flat and JSX carriers and was never validated in them. Rename the key to `tabStyle`; the value (`line` \| `card` \| `pill`) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **position** | `Enum<'top' \| 'left'>` | optional | | +| **alwaysShowStrip** | `boolean` | optional | Render the tab strip even when only one tab is visible (renderer default: a one-tab strip is hidden). | | **items** | `{ label: string \| Record; icon?: string; visibleWhen?: string \| object; value?: string; … }[]` | ✅ | | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -318,6 +321,8 @@ const result = AIChatWindowProps.parse(data); | **sections** | `{ name?: string; label?: string \| Record; columns?: integer; fields: string[] }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`. | | **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) | | **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) | +| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). | +| **showHeader** | `boolean` | optional | Render the detail body's own heading (renderer default: off). | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 8f4303cbde..2f11760056 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -22,14 +22,14 @@ regenerate. |---|---| | Triaged directories | 5 | | Object sites in them | 438 | -| Still-open (strip) sites | 180 | -| Files carrying at least one | 27 | +| Still-open (strip) sites | 149 | +| Files carrying at least one | 26 | Remaining strip sites by class: | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 40 | +| authorable — the ruling's forced scope | 9 | | unresolved — needs a per-schema verdict | 34 | | wire / open — out of forced scope | 104 | | no door — no carrier, ADR-0049 territory | 1 | @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 161 | 119 | 5 | 0 | 37 | +| `ui/` | 161 | 150 | 5 | 0 | 6 | | `data/` | 165 | 57 | 1 | 0 | 107 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **438** | **252** | **6** | **0** | **180** | +| **total** | **438** | **283** | **6** | **0** | **149** | ## File-level triage — site counts @@ -157,20 +157,19 @@ over it is here. ### `ui/` — open -**37 strip of 161**, in 5 file(s). +**6 strip of 161**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 18 | -| `component.zod.ts` | 31 | 31 | | `view.zod.ts` | 3 | 54 | | `widget.zod.ts` | 1 | 1 | -| **total** | **37** | **161** | +| **total** | **6** | **161** | | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 32 | +| authorable — the ruling's forced scope | 1 | | unresolved — needs a per-schema verdict | 0 | | wire / open — out of forced scope | 3 | | no door — no carrier, ADR-0049 territory | 1 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 36b3b34f48..818ed4fa95 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -647,7 +647,7 @@ sites left to be a verdict about. | `action-params.zod.ts` | wire | **never strict, deliberately** — one site: `ActionSessionSchema`, the action-body `ctx.session` declared at **#5697** (phase 1 of #5613's contract-first ruling). It is the RUNTIME shape `packages/runtime`'s `buildActionSession()` hands a body, not an authoring surface — nobody writes it — so closing it would turn a future engine-side enrichment into a parse failure for whoever parses a context they were GIVEN: the same call, and the same reason, as `data/hook.zod.ts`'s `HookContextSchema` row. The file had no row until now because everything else it exports is a function or an interface (`validateActionParams`, `ActionHandlerContext`), i.e. zero sites to classify. ⚠️ **Read the arrival direction, because it is the opposite of every other row in this table**: this site did not survive a strictness sweep, it is NEW surface — a shape that was being produced with no declaration anywhere (`actionContext` is a bare `any` at both dispatch sites), which is why neither this ledger nor any gate could see that its `roles` key carries `ExecutionContext.positions` under the spelling ADR-0090 D3 forbids. The key is declared as-built and marked deprecated in its `.describe()`; the rename is #5613 phase 2. A declaration is not an endorsement — do not read this row as "the shape is settled" | | `view.zod.ts` | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | | `bulk-action.zod.ts` | 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` | ~~authorable (p)~~ ~~no gate~~ **authorable (gate wired at #5068)** | **no parse anywhere (measured, #4001 批 17; the parse landed at #5068 — see the end of this cell)** — 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. ✅ **#5068 answered it in turn: the parse is wired (lint side, warning level) and the class is `authorable` again** — the strip-table row below carries the full flip, including the three things it did not do. One correction belongs here rather than there, because this row is where the wrong expectation was written down: the pin in `component.test.ts` said its carrier assertion *"goes red the day `properties` gets a typed dispatch"*, which assumed direction B. The dispatch that landed is direction A — on `packages/lint`'s authoring gate — so the assertion is GREEN after the fix, and it was measured that way rather than predicted. The pin now says which dispatch landed and what a future red there would mean (the carrier reshaped: a protocol change, not a lint one). Recorded in three places (file header, `component.test.ts` pin, this row) | +| `component.zod.ts` | ~~authorable (p)~~ ~~no gate~~ **authorable (gate wired at #5068)** | **strict as of #4001 batch A — 0 strip sites remain (all 31).** The `(gate wired at #5068)` qualifier is spent in its turn: the parse it wired is now a parse of CLOSED shapes, so an undeclared prop is reported by `safeParse` as `unrecognized_keys` rather than reconstructed by the walker — same rule id, same warning tier, one fewer moving part. What batch A did NOT do is the half this row keeps insisting on: the carrier is still `z.record(z.string(), z.unknown())` (direction B stays declined), and the storage path still parses no props at all (#4463), so this closes the AUTHORING door and nothing else. Three renderer-honoured keys were DECLARED in the same pass rather than rejected — `page:header` `maxVisible`/`mobileMaxVisible`, `page:tabs.alwaysShowStrip`, `record:details` `inlineEdit`/`showHeader` — each read by objectui through `schema?.X ?? schema?.properties?.X` with its own comment inviting authors, which is the #5611/#5775/#6276 rule applied a fourth time (enumerate by the RENDERER'S read pattern, not by the key list a previous ruling quoted). The evidence below is unchanged and still the reason the file took this long. **no parse anywhere (measured, #4001 批 17; the parse landed at #5068 — see the end of this cell)** — 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. ✅ **#5068 answered it in turn: the parse is wired (lint side, warning level) and the class is `authorable` again** — the strip-table row below carries the full flip, including the three things it did not do. One correction belongs here rather than there, because this row is where the wrong expectation was written down: the pin in `component.test.ts` said its carrier assertion *"goes red the day `properties` gets a typed dispatch"*, which assumed direction B. The dispatch that landed is direction A — on `packages/lint`'s authoring gate — so the assertion is GREEN after the fix, and it was measured that way rather than predicted. The pin now says which dispatch landed and what a future red there would mean (the carrier reshaped: a protocol change, not a lint one). Recorded in three places (file header, `component.test.ts` pin, this row) | | `theme.zod.ts` | 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 — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. **#4988 then retired both modules outright**, so the two tombstones' refusal to name a replacement is now the only correct wording available: had they pointed at `ui/touch.zod.ts` / `ui/keyboard.zod.ts`, that prescription would today name a deleted file — finding 7 with an extra major on top. ⚠️ **Separately filed — and ANSWERED at #5021, which is why this row's site count fell 14 → 6.** 批 15 recorded that `--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), and refused to act on it inside a strictness batch: 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. The refusal was correct and the separation is what made the follow-up answerable. #5021 re-measured against objectui `main` (2026-08-04) with `--font-sans`/`--radius`/`--shadow`/`--primary` as positive controls **in the same run**, the maintainer ruled RETIRE over both alternatives (wire consumers / bless as a public token surface — the latter rejected as a stability promise attached to a slot the platform's own UI ignores, the #4583 shape), and `typography.fontSize`/`.fontWeight`/`.lineHeight`/`.letterSpacing`, `typography.fontFamily.heading`/`.mono`, `animation` and `zIndex` are now `retiredKey()` tombstones prescribing `customVars`. **Note what this row's arithmetic does NOT say**: the eight sites left `ui/` from the `strict` column (120 → 112), and `strip` is unchanged at 75 — a retirement removes closed doors, so it cannot move this ratchet's open-site debt in either direction. The two campaigns stayed disjoint to the end. ⚠️ The prescription is `customVars` **because it was measured live**, not because it is the nearest-looking slot: the engine emits each entry as `--: ` verbatim, so every retired variable is reproducible byte for byte and the retirement removes no capability — the distinction from `touchTarget`/`keyboardNavigation` two sentences up, which got NO replacement precisely because theirs would have been a guess. The five aliases pointing at the retired keys (`animations`/`motion`/`transitions` → `animation`, `layers`/`stacking` → `zIndex`) and the seven pointing into the retired typography scales were **deleted with their targets**, not re-pointed — leaving them would answer an author with "did you mean `zIndex`?" and then reject `zIndex`, finding 7's exact shape, and this file has now signposted that failure mode three times | | `app.zod.ts` | 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` | 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. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction. ⚠️ **#5010 retired four more widget keys and moved this row's posture by nothing, which is the point.** The `#4956` drill gave `DashboardWidgetSchema`'s 22 widget-level keys their first per-key verdicts and found six dead; `actionUrl`/`actionType`/`actionIcon` (a per-widget action BUTTON no renderer in either repo has ever drawn — all 14 `actionUrl` reads in `DashboardRenderer` are scoped to `header.actions[]`) and `aria` (ARIA attributes that never reached the DOM — the dashboard-level `aria` the #3896 sweep removed, one level down) are now `retiredKey` tombstones beside `responsive`. **Strip sites remain 0 and the strictness verdict is untouched**, because a retirement is ADR-0049 work and this ratchet is not: closing a door makes a *dropped* key loud, it cannot make a *declared* one live — the same boundary `theme.zod.ts` records two rows up, met here from the other side. The removal also settled a second-order cost the strictness campaign could never have reached: `packages/lint`'s dashboard action-ref rule enforced ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key "the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so an author could FAIL A BUILD because a control that cannot render pointed at an action that also did not — an enforcement gate sustaining the very false affordance ADR-0049 wrote it to delete. That widget branch is gone, pinned. ⚠️ **`colorVariant`, the fifth dead key, is deliberately NOT retired here and this row must not be read as closing it**: the rewrite target the #4956 triage assumed (`options.colorVariant`) measured dead too — `options` only reaches a renderer through `componentSchema` on the INLINE path, and `dataset` is required on this schema, so every spec-authorable widget is dataset-bound and renders through `DatasetWidget`, which has no colour affordance at all. Moving the key there would relocate 16 authored sites from one dead slot to another and mint a second inert key. Returned for adjudication; `chartConfig`'s dashboard-face inertness (11 of 12 keys, #5175) is the same shape on the neighbouring slot | @@ -886,7 +886,6 @@ next person to open that file will look. | File | Class | Batch | |---|---|---| -| `component.zod.ts` | **authorable** | **was `no gate` until #5068** (one verdict per cell on purpose — it is the machine-readable input to the generated subtotal, so the history lives here in the evidence). ⛔ **was 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 have gated nothing (#4583), so the batch recorded the verdict and filed the wiring as **#5068**. ✅ **#5068 wired it, and this row's `no gate` verdict is spent — the sites are `authorable`.** `packages/lint/src/validate-component-props.ts` dispatches `ComponentPropsMap` by the component's `type` and judges `properties`: undeclared keys through `lintUnknownKeysAgainstSchema` (the same walker `lintUnknownAuthoringKeys` runs on every metadata collection — one implementation of the posture rules, not a second), values through `safeParse`. It runs on all three authoring commands from the shared registry. **Read the flip precisely, exactly as at #5020: what changed is the PARSE, not the posture.** All 31 entries still STRIP; the gate reports an undeclared key because the walker reads a strip-mode object, and converting these sites to `strictObject` moves that same report into the gate's `safeParse` half (`unrecognized_keys`, routed to the same rule id) — which is what makes the ratchet meaningful rather than cosmetic. Three things the flip did NOT do, each of which someone will otherwise assume: (1) **the carrier is unchanged, by decision** — the maintainer's 2026-08-05 ruling took direction A (gate at the authoring door) and DECLINED direction B (a discriminated `properties`) as breaking against an open `type` union, so `PageComponentSchema.properties` is still `z.record(z.string(), z.unknown())` and `component.test.ts`'s three standing assertions stay GREEN — measured against the landed gate, with their prose updated to say which dispatch landed; (2) **unregistered types are SKIPPED**, a required semantic rather than leniency — the example corpus alone authors 87 nodes across ten types this map does not carry (`flex`, `grid`, `object-metric`, `object-chart`, `record:line_items`, …), and judging them against an absent schema would report every one as broken; (3) **the storage path is still open** — a `saveMetaItem` / REST `/meta` write stores an unvalidated props bag (#4463's fourth wall), recorded rather than fixed. ⚠️ The gate is **WARNING-level** in this first step, and the reason is a measurement: on the example corpus + the three published platform pages it reports **52 findings** (44 value verdicts, 8 undeclared keys), of which 34 are inline `{ en, 'zh-CN' }` label maps against an `I18nLabelSchema` that is a plain `z.string()` (**#5728**, undecided) and 8 more are the same shape on `element:text.content`. Gating those would fail the platform's own pages to enforce declarations the platform does not keep — the `groupBy` judgement #5020 had to make, at corpus scale. That inventory is the acceptance baseline for the error upgrade, which is its own step. See the triage row for the full 批 17 measurement | | `view.zod.ts` | mixed · 1 authorable, 2 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. 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, REVERTED, and closed again at #5074 — the round trip is the file'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. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. 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 used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — 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 the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 visibility error map (`strictVisibilityError` when this was measured; folded into the shared template as `VISIBILITY_STRICT_OPTIONS` + `strictObjectError` at #6619, deliberately WITHOUT closing the base — the site keeps its literal `z.object(` spelling so this instrument keeps counting it); the door is closed, the ledger counts the base. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | | `widget.zod.ts` | **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 was **#5055**, and it is ANSWERED: eight of the nine sites were REMOVED (the whole widget-registration vocabulary). The row does not disappear, because the NINTH — `FieldWidgetPropsSchema` — was deliberately KEPT: it is a React props contract rather than authorable metadata, it never appeared in the authorable surface at all, and objectui PR #3289 gave it a live compile-time consumer. ⛔ **Do not close it and do not finish this file** — this is the fourth row in the ledger parked at a deliberate floor (after `flow` 批 11, `etl` 批 12 and `i18n` above), and the reverse pin fires on ZERO either way, so only this cell separates "parked" from "unfinished". See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | | `app.zod.ts` | covered | **批 19 ran the check and it came back NEGATIVE — no posture change; the `Class` was held at `verify` pending #5249 and is now `covered`, the verdict that ruling created (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question was the VOCABULARY, not the measurement** — which is why 批 19 left the cell alone, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolved carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. ✅ **RESOLVED at #5249 (maintainer ruling 2026-08-06, option A): the vocabulary grew a ninth verdict, `covered`, and this row is its first and — as of the sweep below — its ONLY instance.** The ruling took the same route 批 15 took for `no gate` rather than rounding to the nearest wrong answer, on the ground that the cell's readers are later agents and a verdict naming the wrong ACTION is amplified by whoever acts on it. The re-review the ruling required was run over all **197** strip sites in the five triaged directories, not just this file, and it is mechanical rather than a reading: `covered` requires the keys to reach consumers by `...X.shape` SPREAD (a spread lands them in a fresh `z.object` with its own posture, so the base is inert), whereas `.extend()`/`.merge()`/`.omit()` inherit posture and keep the base a real door. Exactly **one** of the 197 sites spreads — this one, into eight of the nine branches (`SeparatorNavItemSchema` declares its own two keys and spreads nothing, and is `.strict()` all the same). The three other module-private strip bases all resolve elsewhere and stay put: `view.zod.ts`'s `FormFieldBaseSchema` is `.extend()`ed at `:1475` → posture inherits → a real door → stays `authorable`; `query.zod.ts`'s `BaseQuerySchema` is `.extend()`ed at `:485` into `QuerySchema` → same → stays `open`; `component.zod.ts`'s `EmptyProps` is used as a VALUE under eleven `ComponentPropsMap` carrier keys → carrier present → not carrier-absent at all. The remaining ~50 sites are inline nested literals under a property, so they carry a carrier by construction and cannot be `covered`. Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 808f0c284e..9cd577db5c 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -50,7 +50,16 @@ describe('validateComponentProps — undeclared keys', () => { expect(f.severity).toBe('warning'); expect(f.path).toBe('pages[0].regions[0].components[0].properties.titel'); expect(f.where).toBe('page "probe_page" · page:header'); - expect(f.message).toContain('Did you mean `title`?'); + // ⚠️ The rename PHRASING moved at #4001 batch A and the assertion follows + // the fact rather than the wording: a strip-mode shape got the walker's + // `Did you mean \`title\`?`, a `strictObject` gets its error map's + // `Did you mean \`titel\` → \`title\`?` — which echoes the offending + // spelling as well as the fix. Asserted as both spellings present, so this + // pin measures the diagnostic's content rather than which of the two + // channels produced it. + expect(f.message).toContain('`titel`'); + expect(f.message).toContain('`title`'); + expect(f.message).toContain('Did you mean'); }); it('is silent on a fully declared props bag', () => { @@ -133,7 +142,10 @@ describe('validateComponentProps — the second layer (union arm below an array) expect(unknownKeys(findings)[0].path).toBe( 'pages[0].regions[0].components[0].properties.fields.0.readOnly', ); - expect(unknownKeys(findings)[0].message).toContain('Did you mean `readonly`?'); + // Same phrasing move as above (#4001 batch A) — content, not wording. + expect(unknownKeys(findings)[0].message).toContain('`readOnly`'); + expect(unknownKeys(findings)[0].message).toContain('`readonly`'); + expect(unknownKeys(findings)[0].message).toContain('Did you mean'); }); it('leaves a bare string field name alone (the union\'s other arm)', () => { @@ -462,3 +474,47 @@ describe('validateComponentProps — wiring', () => { expect(mine[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); }); }); + +/** + * #4001 batch A closed all 31 `ComponentPropsMap` shapes, including two UNION + * ARMS. An arm's rejection travels a different road out of zod than an object's, + * and this block pins both that it arrives and that it is not over-claimed. + */ +describe('validateComponentProps — a STRICT union arm reports as an unknown key (#4001 batch A)', () => { + const highlights = (fields: unknown) => + validateComponentProps(stackWith([{ type: 'record:highlights', properties: { fields } }])); + + it('routes an arm-level `unrecognized_keys` to the unknown-key rule, with the surface and the rename intact', () => { + const findings = highlights([{ name: 'status', labell: 'Status' }]); + expect(findings).toHaveLength(1); + // The rule id is the point: a closed arm and a closed object are ONE + // diagnostic for the author, not two with different hints. + expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); + expect(findings[0].path).toBe('pages[0].regions[0].components[0].properties.fields.0.labell'); + // Zod collapses arm failures into one `invalid_union` whose own message is + // the bare "Invalid input" — everything below survives only because + // `zod-issue-format.ts` unpacks the arm. Delete that unpacking and these + // three assertions go red while `packages/spec`'s own suite stays green, + // which is exactly the blind spot they exist for. + expect(findings[0].message).toContain('record:highlights` field'); + expect(findings[0].message).toContain('`labell`'); + expect(findings[0].message).toContain('label'); + expect(findings[0].message).not.toContain('Invalid input —'); + }); + + it('does NOT claim a rename when the author may have meant another arm', () => { + // The bare-string arm is legal here, so a value that fails BOTH arms for + // key reasons cannot be attributed to one of them. It falls through to the + // value verdict, which prints every arm's own message rather than guessing. + const findings = highlights([{ labell: 'Status' }]); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(COMPONENT_PROPS_INVALID); + // Still legible — the arms are unpacked, just not attributed. + expect(findings[0].message).toContain('no accepted form matched'); + }); + + it('positive control — the string arm and the full object arm both stay silent', () => { + expect(highlights(['status'])).toEqual([]); + expect(highlights([{ name: 'status', label: 'Status', readonly: true }])).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-component-props.ts b/packages/lint/src/validate-component-props.ts index 3fe1374287..2c381e4c3f 100644 --- a/packages/lint/src/validate-component-props.ts +++ b/packages/lint/src/validate-component-props.ts @@ -177,6 +177,47 @@ function suppliedByDataSource(issue: LintZodIssue, component: AnyRec): boolean { return strName(dataSource?.object) !== undefined; } +/** + * The `unrecognized_keys` issue hiding inside a collapsed `invalid_union`, when + * exactly one arm produced one and produced nothing else. + * + * Why this is needed at all: zod 4 does not surface arm failures. A union + * reports as ONE `invalid_union` whose message is the bare `"Invalid input"`, + * with each arm's issues tucked inside `issue.errors`. `describeIssue` already + * unpacks that for the human message (#5583), so the named surface and the + * rename DO reach the author — but the finding was still filed as a value + * verdict, so a strict union arm and a strict object reported the same fact + * under two different rule ids with two different hints. #4001 batch A closed + * `RecordHighlightsField`'s object arm and `record:related_list`'s sort-entry + * arm, which made that split load-bearing rather than theoretical. + * + * ⚠️ The one-arm condition is the whole correctness of this. A union arm + * rejecting a key does NOT mean the author meant that arm — `fields: ['status']` + * is a perfectly good `record:highlights` entry through the STRING arm. So the + * rename is only claimed when every other arm rejected the value for a + * different reason (a type mismatch: it is an object, they wanted a string) and + * exactly one arm got far enough to judge keys. Anything else — two arms both + * complaining about keys, or one arm complaining about both a key and a value — + * falls through to the ordinary value verdict, where `describeIssue` still + * prints every arm's own message. Guessing which arm was meant is the failure + * mode this rule exists to prevent, not one to introduce here. + */ +function unrecognizedKeysFromUnionArm(issue: LintZodIssue): LintZodIssue | undefined { + if (issue.code !== 'invalid_union') return undefined; + const arms = issue.errors; + if (!arms || arms.length === 0) return undefined; + let found: LintZodIssue | undefined; + for (const arm of arms) { + const keyIssues = arm.filter((inner) => inner.code === 'unrecognized_keys'); + if (keyIssues.length === 0) continue; + // This arm judged keys — it must have judged ONLY keys, and be the only + // such arm, or we cannot say which shape the author was reaching for. + if (keyIssues.length !== arm.length || found) return undefined; + found = keyIssues[0]; + } + return found; +} + export function validateComponentProps(stack: AnyRec): ComponentPropsFinding[] { const findings: ComponentPropsFinding[] = []; if (!isRec(stack)) return findings; @@ -233,6 +274,24 @@ export function validateComponentProps(stack: AnyRec): ComponentPropsFinding[] { for (const issue of parsed.error?.issues ?? []) { if (suppliedByDataSource(issue, component)) continue; const at = issue.path.length ? `${base}.${issue.path.join('.')}` : base; + // A strict UNION ARM reports the same fact one layer in — see + // `unrecognizedKeysFromUnionArm`. Routed to the unknown-key rule id + // rather than left as a value verdict, so a closed arm and a closed + // object are one diagnostic for the author. + const armIssue = unrecognizedKeysFromUnionArm(issue); + if (armIssue) { + for (const key of armIssue.keys ?? []) { + findings.push({ + severity: 'warning', + rule: COMPONENT_PROPS_UNKNOWN_KEY, + where, + path: `${at}.${key}`, + message: `\`${key}\` is not a prop \`${type}\` declares (ComponentPropsMap, @objectstack/spec/ui): ${armIssue.message}`, + hint: `Remove \`${key}\`, or declare it on \`${type}\`'s props schema if the component honours it.`, + }); + } + continue; + } // A strict props schema reports its undeclared keys HERE instead of // through the walker above. Same fact, same rule id — see the header. if (issue.code === 'unrecognized_keys') { diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index f8b9576c20..39511aa23e 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -809,6 +809,8 @@ "ui/PageHeaderProps:aria", "ui/PageHeaderProps:breadcrumb", "ui/PageHeaderProps:icon [RETIRED]", + "ui/PageHeaderProps:maxVisible", + "ui/PageHeaderProps:mobileMaxVisible", "ui/PageHeaderProps:recordChrome", "ui/PageHeaderProps:showCopyId", "ui/PageHeaderProps:showStar", @@ -830,6 +832,7 @@ "ui/PageRegion:components", "ui/PageRegion:name", "ui/PageRegion:width", + "ui/PageTabsProps:alwaysShowStrip", "ui/PageTabsProps:aria", "ui/PageTabsProps:items", "ui/PageTabsProps:position", @@ -863,8 +866,10 @@ "ui/RecordDetailsProps:columns", "ui/RecordDetailsProps:fields", "ui/RecordDetailsProps:hideFields", + "ui/RecordDetailsProps:inlineEdit", "ui/RecordDetailsProps:layout [RETIRED]", "ui/RecordDetailsProps:sections", + "ui/RecordDetailsProps:showHeader", "ui/RecordHighlightsProps:aria", "ui/RecordHighlightsProps:fields", "ui/RecordHighlightsProps:layout", diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index a1a8be8fe4..b22689b022 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -998,15 +998,36 @@ describe('conversion layer (ADR-0087 D2)', () => { /** * The premise pin, and the one test that fails if the alias ever comes * BACK. This conversion is only correct while the spec declares exactly one - * spelling: `PageHeaderProps` accepts `subtitle` and silently strips - * `description` (the schema is not `.strict()`), which is precisely why an - * authored `description` needed a conversion rather than an error. + * spelling. + * + * ⚠️ The SECOND half of this pin changed at #4001 batch A, and the change + * is the point rather than an accommodation of it. It used to assert that + * `PageHeaderProps` *silently strips* `description` — "which is precisely + * why an authored `description` needed a conversion rather than an error". + * That reading was upside down: the silent strip is what left the + * conversion as the ONLY thing between an author and a dropped key, and + * `conversions/walk.ts` records the consequence in as many words — a + * `description` at a nested site got no diagnostic from any layer, because + * the conversion did not fire there, `PageSchema` stayed green, and the + * props gate is CLI-only. + * + * With the shape closed there are two lines of defence rather than one: the + * conversion still canonicalizes on every load and rehydration (the tests + * above, unchanged), and a `description` that reaches this parse + * unconverted is now rejected BY NAME carrying the rename. The alias entry + * is what makes the second one useful rather than merely loud. */ it('the canonical props schema declares `subtitle` and no `description`', () => { expect(PageHeaderProps.parse({ title: 'Leads', subtitle: 'All open leads' }).subtitle) .toBe('All open leads'); - expect(PageHeaderProps.parse({ title: 'Leads', description: 'All open leads' })) - .not.toHaveProperty('description'); + + const rejected = PageHeaderProps.safeParse({ title: 'Leads', description: 'All open leads' }); + expect(rejected.success).toBe(false); + const message = rejected.error!.issues.map((i) => i.message).join('\n'); + expect(message).toContain('`description`'); + // The rename the conversion performs, said out loud to the author who is + // typing the key right now. + expect(message).toContain('subtitle'); }); /** diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index d0c70e4600..0d9d27d47b 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -10,6 +10,7 @@ import { RecordHighlightsProps, RecordActivityProps, RecordChatterProps, + PageAccordionProps, ComponentPropsMap, ElementTextPropsSchema, ElementNumberPropsSchema, @@ -185,13 +186,28 @@ describe('PageTabsProps', () => { it('does NOT accept the deprecated `visibility` alias on tab items (new surface, canonical key only)', () => { // ADR-0089 D2 aliases exist for keys with legacy metadata; tab items never - // had a visibility key, so only canonical `visibleWhen` is declared. The - // alias is not folded — it is dropped by parse like any unknown key. - const result = PageTabsProps.parse({ - items: [{ label: 'Contracts', visibility: 'record.status == "customer"', children: [] }], + // had a visibility key, so only canonical `visibleWhen` is declared. + // + // ⚠️ What changed at #4001 batch A is the CHANNEL, not the verdict: the + // alias used to be dropped by the parse like any unknown key, and is now + // rejected by it. The assertion below is the same claim measured on the + // other side of the same fact — `visibility` is not a spelling this surface + // accepts — and it is strictly stronger, because a drop is a claim about + // the output while a rejection is one the author actually sees. + const rejected = PageTabsProps.safeParse({ + items: [{ label: 'Contracts', visibility: 'record.status == \"customer\"', children: [] }], }); - expect(result.items[0].visibleWhen).toBeUndefined(); - expect('visibility' in result.items[0]).toBe(false); + expect(rejected.success).toBe(false); + expect(JSON.stringify(rejected.error!.issues)).toContain('visibility'); + + // And the canonical spelling on the same surface still parses — the + // positive control that keeps the assertion above from passing for the + // wrong reason (a tab item that rejects everything would satisfy it too). + expect( + PageTabsProps.parse({ + items: [{ label: 'Contracts', visibleWhen: 'record.status == \"customer\"', children: [] }], + }).items[0].visibleWhen, + ).toBeDefined(); }); }); @@ -363,8 +379,18 @@ describe('PageContainerProps — page:section / page:footer / page:sidebar (#577 // `body` is NOT a second authorable spelling here (Prime Directive #12). The // renderers keep reading it as a back-compat fallback for stored documents; // that fallback is objectui's to retire on its own schedule. + // + // #4001 batch A closed this shape, so the same verdict now arrives as a + // rejection carrying the rename rather than as a silent drop — which is the + // whole difference the campaign is buying, and the reason the prescription is + // a hand-written `guidance` entry: `body` → `children` is not a distance the + // suggester can cross. it('does not declare `body` as a second composition key', () => { - expect(PageContainerProps.parse({ body: ['x'] })).not.toHaveProperty('body'); + const rejected = PageContainerProps.safeParse({ body: ['x'] }); + expect(rejected.success).toBe(false); + const message = rejected.error!.issues.map((i) => i.message).join('\n'); + expect(message).toContain('`body`'); + expect(message).toContain('children'); }); }); @@ -1455,7 +1481,25 @@ describe('批 17 / #5068 — the carrier stays an open bag; the gate is on the l expect(PageSchema.safeParse(outside).success).toBe(false); }); - it('every ComponentPropsMap entry is still non-strict — #5068 wired the parse, it did not close them', () => { + /** + * ⚠️ THE FLIP. Until #4001 batch A this asserted the opposite — that every + * entry was still open — and its own comment named the conditions for + * flipping it: "When a later batch DOES close them, this expectation flips — + * update the verdict in component.zod.ts and the ledger in the same PR." + * Both were updated in the PR that changed this line. + * + * The two assertions ABOVE are deliberately untouched and still green: the + * carrier is still an open `z.record(z.string(), z.unknown())` and an unknown + * key still survives `PageSchema.parse()`. That is not a leftover — it is the + * precise scope of this batch. Direction B (a discriminated `properties`) + * stays declined, so closing these shapes moves the rejection into the #5068 + * authoring gate's `safeParse` half, not onto the page protocol. The storage + * path (`saveMetaItem` / REST `/meta`) still runs no props parse at all + * (#4463's fourth wall), which is why the assertion above must keep passing: + * a reader who mistook this batch for a closed storage door would be wrong in + * the direction that matters. + */ + it('every ComponentPropsMap entry is now STRICT — batch A closed all 31 sites', () => { const stillOpen: string[] = []; for (const [type, schema] of Object.entries(ComponentPropsMap)) { const def = (schema as any)._zod.def; @@ -1464,15 +1508,112 @@ describe('批 17 / #5068 — the carrier stays an open bag; the gate is on the l if (def.catchall?._zod?.def?.type === 'never') continue; stillOpen.push(type); } - // All 31 registered component types are open. #5068 wired the parse without - // touching the posture — deliberately, because the live corpus violates - // these declarations in places that are open contract questions (#5728's - // inline i18n label maps) and closing them in the same step would turn a - // warning inventory into a wall of hard rejections. When a later batch DOES - // close them, this expectation flips — update the verdict in - // component.zod.ts and the ledger in the same PR. The coverage survives the - // flip: the gate's unknown-key half goes quiet on a strict node and its - // `safeParse` half reports `unrecognized_keys` under the same rule id. - expect(stillOpen.length).toBe(Object.keys(ComponentPropsMap).length); + expect(stillOpen).toEqual([]); + + // Positive control in the same run: the strictness is REACHABLE through the + // map, on every registered type, with a key no schema declares. Without + // this the assertion above is a claim about a `catchall` field rather than + // about behaviour — and the campaign has twice shipped a pin that read the + // shape and not the parse. + const rejects: string[] = []; + for (const [type, schema] of Object.entries(ComponentPropsMap)) { + if ((schema as any).safeParse({ zzUndeclared: 'x' }).success) rejects.push(type); + } + expect(rejects).toEqual([]); + }); +}); + +/** + * The curated half of #4001 batch A — the `aliases` / `guidance` a closed shape + * can carry and the #5068 walker could not. + * + * `alias-integrity.test.ts` already proves every entry is a TRUE claim about + * its schema (the key it is filed under is rejected, the key it prescribes is + * accepted). What it cannot ask is whether the entry still EXISTS — a table + * emptied by a later edit is a table that passes every integrity check. These + * assertions are that half: each one names a producer measured in the wild, so + * deleting the entry is a decision about that producer rather than a cleanup. + */ +describe('#4001 batch A — the prescriptions, each backed by a measured producer', () => { + const refuse = (schema: { safeParse(v: unknown): any }, value: unknown): string => { + const r = schema.safeParse(value); + expect(r.success).toBe(false); + return r.error.issues.map((i: { message: string }) => i.message).join('\n'); + }; + + it('a tab item `key` is answered with `value` — objectui\'s Studio designer publishes `key`', () => { + // Producer: `previews/block-config.ts`, `page:tabs.items.itemFields`. The + // renderer reads `it.value` and falls back to `tab-` — so an + // authored `key` is not a typo, it is a spelling that silently yields + // index-derived tab tokens. + const message = refuse(PageTabsProps, { items: [{ label: 'A', key: 'a', children: [] }] }); + expect(message).toContain('`key`'); + expect(message).toContain('value'); + }); + + it('an accordion item `value` is answered with "the renderer derives it" — NOT a rename', () => { + // The same designer publishes `value` here too, but this renderer + // OVERWRITES it (`{ ...it, value: `panel-${idx}` }`). The prescription must + // therefore say the key is dead, not offer a spelling — the distinction + // between this entry and the tab one is the whole point of both (#7973). + const message = refuse(PageAccordionProps, { + items: [{ label: 'A', value: 'a', children: [] }], + }); + expect(message).toContain('panel-'); + expect(message).not.toContain('Did you mean'); + }); + + it('a component-NODE key inside `properties` is told to move up a level — by family', () => { + // Two families, two prescriptions, and they must NOT be collapsed: the + // visibility one is a pattern (it has to catch spellings nobody + // enumerated — `visibleIf`, `hiddenWhen`), while the dispatch one is an + // enumerated list narrowed to keys no props schema declares. + for (const [schema, key] of [ + [PageCardProps, 'visible'], + [PageHeaderProps, 'visibleWhen'], + [RecordDetailsProps, 'hiddenWhen'], + ] as const) { + const message = refuse(schema, { [key]: 'x' }); + expect(message).toContain('move it up one level'); + expect(message).toContain('visibleWhen'); + } + for (const [schema, key] of [ + [RecordDetailsProps, 'dataSource'], + [PageCardProps, 'className'], + ] as const) { + expect(refuse(schema, { [key]: 'x' })).toContain('component NODE'); + } + }); + + it('a container `body` is answered with `children`', () => { + expect(refuse(PageContainerProps, { body: [] })).toContain('children'); + }); + + it('a no-props component names ITSELF in the rejection, not "this component"', () => { + // The reason `emptyProps` is a factory: an empty shape has no candidate + // keys, so the distance fallback can say nothing and the surface name is + // the entire diagnostic. Seven types share the shape; none may share a name. + expect(refuse(ComponentPropsMap['element:divider'], { color: 'red' })) + .toContain('`element:divider`'); + expect(refuse(ComponentPropsMap['nav:menu'], { color: 'red' })) + .toContain('`nav:menu`'); + }); + + it('the five renderer-honoured keys batch A declared are ACCEPTED, not prescribed', () => { + // The other side of the same judgement: these were measured as read by + // objectui through `schema?.X ?? schema?.properties?.X`, so a rejection + // here would be the declaration disagreeing with the delivered platform. + expect(PageHeaderProps.parse({ maxVisible: 5, mobileMaxVisible: 2 }).maxVisible).toBe(5); + expect(PageTabsProps.parse({ items: [], alwaysShowStrip: true }).alwaysShowStrip).toBe(true); + const details = RecordDetailsProps.parse({ inlineEdit: false, showHeader: true }); + expect(details.inlineEdit).toBe(false); + expect(details.showHeader).toBe(true); + + // …and none of them acquired a schema DEFAULT, which would turn "the author + // said nothing" into "the author asked for the renderer's fallback". + const empty = RecordDetailsProps.parse({}); + expect('inlineEdit' in empty).toBe(false); + expect('showHeader' in empty).toBe(false); + expect('maxVisible' in PageHeaderProps.parse({})).toBe(false); }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e529fe9f16..e6e3be7a49 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -8,26 +8,37 @@ import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; // --------------------------------------------------------------------------- -// NOT CLOSED AGAINST UNKNOWN KEYS -- and until #5068 that was the whole -// verdict (#4001 batch 17 / 批 17, ADR-0078). Read this before "finishing" the -// file. +// CLOSED AGAINST UNKNOWN KEYS as of #4001 batch A -- all 31 object sites. // // SDUI component prop schemas: the declarative shape of every `page:*`, // `record:*`, `element:*`, `nav:*` and `ai:*` node a page can carry. // -// ⚠️ STATUS AS OF #5068: the `no gate` verdict below is SPENT -- a parse now -// exists -- and this file is `authorable` again. What that does and does not -// mean is spelled out in the "#5068: the gate is wired" section at the end; -// read it before scheduling the ratchet, because the gate is on the LINT side -// and the carrier's own shape is deliberately unchanged. +// ⚠️ READ THE SCOPE BEFORE ACTING ON THIS. Closing these shapes moved the +// rejection into ONE door -- the #5068 authoring gate's `safeParse` half. It +// did NOT close the carrier and it did NOT close storage: // -// The measurement that produced the verdict is kept verbatim below: it is the -// evidence base for the ratchet, and every sentence of it is still true of the -// SCHEMA path. +// - `PageComponentSchema.properties` is still `z.record(z.string(), +// z.unknown())`. Direction B (a discriminated `properties`) stays DECLINED +// by the maintainer's 2026-08-05 ruling, because `type` is an open union and +// a discriminated carrier would reject the unregistered types real pages +// author. An unknown key inside `properties` therefore still survives +// `PageSchema.parse()` -- pinned, deliberately, in `component.test.ts`. +// - A `saveMetaItem` / REST `/meta` write still stores an unvalidated props +// bag (#4463's fourth wall). Recorded, not fixed. +// - The gate is still WARNING level. Batch A did not upgrade it; what stands +// between it and `error` is the page rewrites named at the end of this +// header, not a declaration in this file. // -// These 29 object sites were `no gate` -- carrier live, parse absent -- NOT a -// pending `.strict()` batch. Do not sweep `strictObject` across this file -// without reading the #5068 section first. +// So the honest one-line summary is: an undeclared prop is now rejected BY THE +// PARSE at authoring time, with the surface named and the rename offered, +// instead of being reconstructed by a walker reading a strip-mode object. Same +// rule id, same tier, one fewer moving part -- and a shape that can now carry +// its own `aliases` / `guidance`, which a walker's reconstruction could not. +// +// The 批 17 measurement that produced the earlier `no gate` verdict is kept +// verbatim below. It is why this file took three batches, and every sentence of +// it was true when written; the two flips since (#5068 wired the parse, batch A +// closed the shapes) are recorded at the points where they land. // // It was scheduled as the #4001 campaign's largest remaining `ui/` block and // the measurement came back NEGATIVE: nothing parses these schemas, so @@ -140,12 +151,23 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; // `type` union. So the three standing assertions in `component.test.ts` // stay GREEN — measured, not assumed — and their prose was updated to say // which dispatch actually landed. -// 2. **Nothing here became strict.** All 31 entries still STRIP. The gate -// reports an undeclared key because the walker reads a strip-mode object; -// converting these sites to `strictObject` moves that same report into the -// gate's `safeParse` half (`unrecognized_keys`, routed to the same rule id) -// — which is what makes the ratchet meaningful rather than cosmetic, and it -// is ordinary strictness work again. +// 2. **Nothing here became strict** — at #5068. All 31 entries still STRIPPED, +// and the gate reported an undeclared key because the walker read a +// strip-mode object. ✅ **#4001 batch A did the conversion this sentence +// predicted**: every site is a `strictObject` now, so the same report +// arrives through the gate's `safeParse` half (`unrecognized_keys`, routed +// to the same rule id). Two things came with it that the walker could not +// produce, and they are the reason the conversion was not cosmetic: +// hand-written `aliases`/`guidance` per surface (`key` → `value` on a tab +// item, `description` → `subtitle` on a header, the wrong-layer +// component-node family), and a rejection that holds on ANY caller of these +// schemas rather than only inside the gate that walks them. +// +// Union arms needed one piece of wiring on the lint side to arrive at all: +// zod 4 collapses arm failures into a single `invalid_union`, so +// `validate-component-props.ts` unpacks a lone arm's `unrecognized_keys` +// back onto the unknown-key rule id (`unrecognizedKeysFromUnionArm`), and +// deliberately declines to do so when two arms could both have been meant. // 3. **The storage path is still open.** The gate is an AUTHORING door. A // `saveMetaItem` / REST `/meta` write still stores an unvalidated props bag // (#4463's fourth wall). That is recorded, not fixed, by #5068. @@ -169,6 +191,15 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; // (`page:card.visible` → the component-level `visibleWhen`, #5776's tab `key` // → `value`), not a declaration in this file. // +// ⚠️ One inventory item batch A ADDED rather than closed, because measuring the +// renderers turned it up: objectui's Studio block designer publishes inputs that +// no renderer reads — `page:accordion` `title` and its items' `value` (the +// renderer overwrites `value` with `panel-`), and `page:header.icon`, +// which #6946 retired here. Those are producer-side defects in the sibling repo +// (filed as #7973), not keys to declare; the accordion item's +// `value` carries a `guidance` entry so an author who copies the designer's +// output is told what happened rather than merely refused. +// // 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. @@ -185,7 +216,110 @@ import { retiredKey } from '../shared/retired-key'; // `ElementDataSourceSchema.sort` (page.zod.ts) — one shape, imported from the // shared source rather than re-spelled here (#6276). import { SortItemSchema } from '../shared/enums.zod'; -const EmptyProps = z.object({}); +import { strictObject } from '../shared/strict-object'; +import type { KeySetGuidance } from '../shared/suggestions.zod'; + +/** + * What silently happened to an undeclared prop before these shapes were closed + * — the one sentence every rejection on this file carries. + * + * Two layers of silence, not one, which is why the sentence names both: the + * schema STRIPPED the key (nothing in `ComponentPropsMap` was strict), and the + * carrier never parsed it anyway (`PageComponent.properties` is + * `z.record(z.string(), z.unknown())`). #5068 wired the parse; this closes the + * shapes behind it, so the rejection is now the parse's own rather than a + * walker's reconstruction of it. + */ +const PROPS_HISTORY = + 'Until #4001 batch A an undeclared prop was dropped in silence: the props schema stripped it ' + + 'and `PageComponent.properties` is an open bag, so the key reached objectui\'s renderer, was ' + + 'not read there, and the author got a success receipt for configuration that did nothing.'; + +/** + * The keys that belong on the component NODE, written one level down inside + * `properties` — the wrong-layer trap this carrier creates by construction. + * + * objectui's `SchemaRenderer` HOISTS `properties` onto the node before + * rendering, which is what makes the confusion durable: for a renderer read + * the two spellings are interchangeable, so an author who writes + * `properties.visibleWhen` sees the key "work" in some places. It does not + * work where it matters — `visibleWhen` is evaluated by the page runtime off + * the NODE, and `SchemaRenderer` deliberately skips `type` and `id` when + * hoisting (hoisting `type` would shadow which renderer to dispatch to). So + * the inner spelling is honoured by nothing that decides anything. + * + * A pattern rather than a list for the visibility family, on the #6619 + * precedent: the point is to catch the spellings nobody enumerated + * (`visibleIf`, `hiddenWhen`, `visibility`), and ADR-0089 made `visibleWhen` + * canonical on the node, so an author borrowing it here is not making a typo. + * `page:card.visible` is the live specimen the file header has carried since + * #5775 — deliberately never declared, because it is a page to rewrite rather + * than a key to add. + */ +const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [ + { + name: 'COMPONENT_NODE_VISIBILITY_KEYS', + keys: /^(visible|visibility|visibleOn|visibleIf|visibleWhen|hidden|hiddenWhen|conceal|showWhen)$/, + examples: ['visible', 'visibleWhen', 'visibleIf', 'hiddenWhen', 'visibility'], + prescription: + 'Visibility is a COMPONENT-level predicate, not a prop: move it up one level to the ' + + 'component node\'s own `visibleWhen` (ADR-0089 canonical spelling), beside `type` and ' + + '`id`. Inside `properties` it is hoisted onto the node by the renderer but evaluated by ' + + 'nothing — the component renders unconditionally, which is a visibility gate that ' + + 'silently does not gate.', + }, + { + name: 'COMPONENT_NODE_KEYS', + /** + * Read off `PageComponentSchema`'s own shape (`page.zod.ts`) and then + * NARROWED, twice, because a set member the shape declares is a dead entry + * the `alias-integrity` audit rejects — and it caught both of these: + * + * - `type` is out. It really is a prop on `element:metadata_viewer` (the + * metadata view kind — `state_machine` | `flow` | `permission`) and a + * tombstone on `page:tabs` (#6776), so a blanket "this belongs on the + * node" would be a WRONG answer on the two surfaces most likely to see it. + * - `label`, `aria` and `properties` are out for the same reason: `label` + * and `aria` are declared props almost everywhere in this file. + * + * What is left is node-only in both directions: nothing in this file + * declares any of them, and the page runtime reads each off the node. + */ + keys: ['id', 'events', 'style', 'className', 'responsiveStyles', 'dataSource', 'responsive'], + prescription: + 'This key belongs on the component NODE, not inside `properties` — write it as a sibling ' + + 'of `type`. `SchemaRenderer` skips `id` when it hoists `properties`, and `dataSource` / ' + + '`responsive` / `events` / `style` / `className` / `responsiveStyles` are read off the ' + + 'node by the page runtime, so the inner spelling is parsed by nothing.', + }, +]; + +/** + * A component that declares no props at all — `app:launcher`, `nav:menu`, + * `nav:breadcrumb`, `global:search`, `global:notifications`, `user:profile`, + * `element:divider`. + * + * A factory rather than one shared `EmptyProps` const, because the surface name + * is the whole value of the rejection here: an empty shape has no candidate + * keys, so the edit-distance fallback can say nothing, and "unrecognized key on + * this component" would leave the author guessing which of the seven it meant. + * One `strictObject(` call site either way — the ledger counts sites from the + * AST, and this is one. + * + * Closing them is not vacuous even with nothing to declare: `element:divider` + * carries an authored `{}` on 9 nodes of the example corpus, and the whole + * point of the class is that these components take no configuration. Before + * this, `` parsed clean and drew a divider with no colour. + */ +const emptyProps = (type: string) => + strictObject( + { + surface: `this \`${type}\` component`, + history: `\`${type}\` declares no props at all. ${PROPS_HISTORY}`, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, + }, + {}, + ); /** * The composition slot every thin container renders: `page:section`, @@ -210,9 +344,27 @@ const EmptyProps = z.object({}); * Shared by all three entries rather than copied: they are the same contract, * and three identical defs would be three places for it to drift. */ -export const PageContainerProps = z.object({ - children: z.array(z.unknown()).optional().describe('Child components rendered inside this container, in order'), -}); +export const PageContainerProps = strictObject( + { + surface: 'this container component (`page:section` / `page:footer` / `page:sidebar`)', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, + guidance: { + // Not a typo the suggester can reach (`body` → `children` is five edits), + // and not a second spelling either: the renderers read `body` as a + // back-compat fallback for STORED documents (`renderChildren(schema.children + // || schema.body)`), which #5775 settled is objectui's to retire on its own + // schedule rather than an authorable key. Closing the shape is what makes + // that distinction reach the author. + body: '`body` is not an authorable spelling of the composition slot — write `children`. ' + + 'The renderers still read `body` as a back-compat fallback for documents stored before ' + + '#5775, but one composition key is the contract (Prime Directive #12).', + }, + }, + { + children: z.array(z.unknown()).optional().describe('Child components rendered inside this container, in order'), + }, +); export type PageContainerProps = z.input; /** @@ -221,7 +373,29 @@ export type PageContainerProps = z.input; * ---------------------------------------------------------------------- */ -export const PageHeaderProps = z.object({ +export const PageHeaderProps = strictObject({ + surface: 'this `page:header`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, + aliases: { + /** + * The ADR-0087 D2 conversion `page-header-subtitle-alias` (#4827, + * objectui#3226) renames this on load and on stored-row rehydration, so the + * canonical paths never reach here. What DOES reach here is the source an + * author is typing right now — and until this shape closed, that was the + * one path with no diagnostic at all: `conversions/walk.ts` records the + * hole in as many words, that `description` "is tombstoned nowhere + * (`description` is a live declared prop on other components), got no + * diagnostic at a nested site from any layer". + * + * An alias rather than a `retiredKey` tombstone precisely because of that + * parenthesis: `description` is a live prop elsewhere in this file + * (`element:text_input`), so the answer is a rename on THIS surface, not a + * removal notice. Grounded in the conversion registry rather than guessed. + */ + description: 'subtitle', + }, +}, { /** * Page title (#7702, maintainer ruling 2026-08-11 「接受你的建议,开始加速处理」 * on the lane's A/B recommendation). OPTIONAL, not required: the platform's @@ -305,11 +479,40 @@ export const PageHeaderProps = z.object({ showCopyId: z.boolean().default(true).describe( 'Show the copy-record-id button beside the record title. Part of the record chrome — no effect when `recordChrome` is false.', ), + /** + * How many header actions render as inline buttons before the rest fold into + * the overflow menu — desktop and mobile budgets (#4001 batch A). + * + * Declared on the #5611/#5775/#6276 rule, for the same reason and by the same + * evidence: the renderer has always read them and the schema had not caught + * up. `containers.tsx:1358` resolves + * `schema?.maxVisible ?? schema?.properties?.maxVisible` (and the `mobile*` + * twin), the `?? 3` / `?? 1` are its own fallbacks, and its comment says out + * loud that both are "overridable on the page:header". Closing this shape + * without declaring them would turn an invited affordance into a hard + * rejection — the #6276 lesson, which is to enumerate by the RENDERER'S read + * pattern rather than by the key list a previous ruling happened to quote. + * + * Optional with NO schema default, deliberately: 3 and 1 are the renderer's + * fallbacks, and declaring them here would materialize a `maxVisible` on + * every parsed header — turning an unset key into an authored one, exactly + * as the record picker's `limit` docblock records for its own 50. + */ + maxVisible: z.number().int().positive().optional().describe( + 'How many header actions render as inline buttons before the rest fold into the overflow menu (renderer default 3).', + ), + mobileMaxVisible: z.number().int().positive().optional().describe( + 'The `maxVisible` budget on mobile viewports (renderer default 1).', + ), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); -export const PageTabsProps = z.object({ +export const PageTabsProps = strictObject({ + surface: 'this `page:tabs`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { /** * Tab-strip visual style. **Renamed from `type` at protocol 17 (#6776, * ADR-0087 D2)** — the same concept, the same three values, a spelling an @@ -348,7 +551,41 @@ export const PageTabsProps = z.object({ + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.', ), position: z.enum(['top', 'left']).default('top'), - items: z.array(z.object({ + /** + * Keep the tab strip visible when there is only one tab (#4001 batch A). + * + * The renderer hides a one-tab strip by default — "a single pill labelled + * 'Details' is visual clutter rather than an affordance" — and its own + * comment invites the override: *"Authors who want the strip even at length 1 + * can pass `properties.alwaysShowStrip: true`"* (`containers.tsx:637`, read as + * `schema?.properties?.alwaysShowStrip === true`). Declared on the same + * #5611/#5775/#6276 rule as `page:header`'s action budget: the delivered, + * invited shape is the contract, and a closed schema that rejected it would + * be the declaration disagreeing with the renderer in the direction that + * costs the author. + */ + alwaysShowStrip: z.boolean().optional().describe( + 'Render the tab strip even when only one tab is visible (renderer default: a one-tab strip is hidden).', + ), + items: z.array(strictObject({ + surface: 'this `page:tabs` item', + history: PROPS_HISTORY, + aliases: { + /** + * NOT a typo — `key` → `value` is four edits, so the distance fallback + * cannot reach it, and this is the alias category the helper's docblock + * describes: a different WORD for the same intent, correct on a + * neighbouring surface. Measured producers, both live: objectui's Studio + * block designer publishes `key` as the tab item's text input + * (`previews/block-config.ts`, `page:tabs.items.itemFields`), and #5776 + * recorded the showcase authoring the same spelling. The renderer reads + * neither — `containers.tsx:566` takes `it.value` and falls back to + * `tab-${idx}` — so an authored `key` silently yields index-derived tab + * tokens that move the moment the item list changes. + */ + key: 'value', + }, + }, { label: I18nLabelSchema, icon: z.string().optional(), /** @@ -385,7 +622,11 @@ export const PageTabsProps = z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); -export const PageCardProps = z.object({ +export const PageCardProps = strictObject({ + surface: 'this `page:card`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { title: I18nLabelSchema.optional(), bordered: z.boolean().default(true), /** @@ -449,7 +690,11 @@ export const PageCardProps = z.object({ * ---------------------------------------------------------------------- */ -export const RecordDetailsProps = z.object({ +export const RecordDetailsProps = strictObject({ + surface: 'this `record:details`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { columns: z.enum(['1', '2', '3', '4']).default('2').describe('Number of columns for field layout (1-4)'), /** * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3818 — @@ -494,7 +739,10 @@ export const RecordDetailsProps = z.object({ * producer and no consumer, so it is gone rather than unioned in: one shape, * not two de-facto contracts (Prime Directive #12). */ - sections: z.array(z.object({ + sections: z.array(strictObject({ + surface: 'this `record:details` section', + history: PROPS_HISTORY, + }, { /** * Stable section identifier, snake_case. This is the i18n anchor: the * heading resolves through `objects.._sections..label`, so a @@ -530,11 +778,40 @@ export const RecordDetailsProps = z.object({ * does not silently strip a live platform page's hidden-field list. */ hideFields: z.array(z.string()).optional().describe('Field names to omit from the body — applied to `fields` and to every section\'s `fields` (used to dedupe fields already shown in `record:highlights` or as the page title)'), + /** + * Inline editing on the detail body, and the body's own heading (#4001 + * batch A) — the two remaining `record:details` keys the renderer reads and + * this schema did not declare. + * + * `RecordDetailsRenderer` resolves `schema.inlineEdit ?? true` against the + * object's own editability and gates the affordance on the result — its + * comment states the author's half directly (*"Authors can still force-disable + * with `inlineEdit: false`"*) — and passes `schema.showHeader ?? false` + * straight through to the body. Both arrive on `schema` because + * `SchemaRenderer` hoists `properties` onto the node, so `properties.inlineEdit` + * is exactly how a page authors them. + * + * Optional with no schema default, for the `maxVisible` reason: `true` and + * `false` are the RENDERER'S fallbacks, and a schema default would write them + * onto every parsed component — turning "the author said nothing" into "the + * author asked for the default", which is a different fact and the one a + * later liveness audit would read. + */ + inlineEdit: z.boolean().optional().describe( + 'Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off).', + ), + showHeader: z.boolean().optional().describe( + 'Render the detail body\'s own heading (renderer default: off).', + ), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); -export const RecordRelatedListProps = z.object({ +export const RecordRelatedListProps = strictObject({ + surface: 'this `record:related_list`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { objectName: z.string().describe('Related object name (e.g., "task", "opportunity")'), relationshipField: z.string().describe('Field on related object that points to this record (e.g., "account_id")'), /** @@ -550,7 +827,29 @@ export const RecordRelatedListProps = z.object({ columns: z.array(z.string()).optional().describe('Fields to display in the related list. Optional: when omitted, columns derive from the related object\'s highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list.'), sort: z.union([ z.string(), - z.array(z.object({ + z.array(strictObject({ + surface: 'this `record:related_list` sort entry', + history: PROPS_HISTORY, + aliases: { + /** + * Both entries are borrowed-from-a-neighbour spellings, not typos, and + * both are grounded in a spelling this repo really carries: + * + * - `direction` — the same alias `view.zod.ts` already ships for its + * own sort rows (`:1333`), where the pre-conversion tuple was + * `{ field, direction }`. An author moving a sort between the two + * surfaces brings it along. + * - `name` — the field spelling `record:highlights` uses for its own + * object form. That divergence is documented from the other side in + * `packages/lint/src/validate-page-field-bindings.ts`'s + * `fieldRefsFrom`, which exists precisely because "`record:highlights` + * keys its object form `name`, while columns/sort/filter key theirs + * `field`". + */ + direction: 'order', + name: 'field', + }, + }, { field: z.string(), order: z.enum(['asc', 'desc']) })) @@ -573,8 +872,22 @@ export const RecordRelatedListProps = z.object({ * relationshipField=`permission_set_id`, picker.object=`sys_user`, * linkField=`user_id`). */ - add: z.object({ - picker: z.object({ + add: strictObject({ + surface: 'this `record:related_list` `add` config', + history: PROPS_HISTORY, + }, { + picker: strictObject({ + surface: 'this `record:related_list` add picker', + history: PROPS_HISTORY, + aliases: { + // `object` is the spelling here and on every element data source; the + // list's own far-side key one level up is `objectName`, so an author + // carrying that spelling down into the picker is the near-miss this + // entry exists for. Distance cannot reach it (`objectName` → `object` + // is four edits, and both are real keys on the same component). + objectName: 'object', + }, + }, { object: z.string().describe('Object to pick records from (the far side of an m2m, or the child object for a 1:m re-parent).'), valueField: z.string().default('id').describe('Field on the picked record used as the link value (default `id`).'), labelField: z.string().optional().describe('Field shown in the picker rows (defaults to the object title field).'), @@ -587,9 +900,33 @@ export const RecordRelatedListProps = z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); +/** + * ⚠️ The object arm is CLOSED, and closing a union arm is a different act from + * closing a plain shape — worth reading before the next arm is touched. + * + * Zod 4 collapses arm failures: the whole union reports as ONE `invalid_union` + * whose message is the bare string `"Invalid input"`, with each arm's real + * issues tucked inside `issue.errors`. So the named surface and the rename this + * `strictObject` produces reach the author only because + * `packages/lint/src/zod-issue-format.ts` unpacks them — the same wiring + * #5583 needed when `ChartGroupBySchema`'s object arm was closed, which is the + * precedent this follows. Deleting that unpacking leaves `packages/spec`'s own + * tests green and turns the author-facing message back into "Invalid input"; + * `component-props-union-arm.test.ts` pins it from this side. + */ export const RecordHighlightsField = z.union([ z.string(), - z.object({ + strictObject({ + surface: 'this `record:highlights` field', + history: PROPS_HISTORY, + aliases: { + // The sibling spelling, in the direction opposite to the sort entry's: + // this arm keys its field `name`, while columns/sort/filter key theirs + // `field` (documented in `validate-page-field-bindings.ts`'s + // `fieldRefsFrom`, which had to handle both). + field: 'name', + }, + }, { name: z.string().describe('Field name on the record'), label: z.string().optional().describe('Display label (overrides schema label)'), icon: z.string().optional().describe('Icon name (lucide icon key)'), @@ -605,14 +942,22 @@ export const RecordHighlightsField = z.union([ ]).describe('Highlight field: bare name, or {name,label?,icon?,type?,readonly?}'); export type RecordHighlightsField = z.input; -export const RecordHighlightsProps = z.object({ +export const RecordHighlightsProps = strictObject({ + surface: 'this `record:highlights`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { fields: z.array(RecordHighlightsField).min(1).max(7).describe('Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or {name, label?, icon?, type?, readonly?} for inline overrides.'), layout: z.enum(['horizontal', 'vertical']).default('horizontal').describe('Layout orientation for highlight fields'), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); -export const RecordActivityProps = z.object({ +export const RecordActivityProps = strictObject({ + surface: 'this `record:activity`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { /** Activity types to display (unified enum including comment, field_change, etc.) */ types: z.array(FeedItemType).optional().describe('Feed item types to show (default: all)'), /** Default filter mode (Airtable-style dropdown) */ @@ -639,7 +984,11 @@ export const RecordActivityProps = z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); -export const RecordChatterProps = z.object({ +export const RecordChatterProps = strictObject({ + surface: 'this `record:chatter`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { /** Panel position */ position: z.enum(['sidebar', 'inline', 'drawer']).default('sidebar').describe('Where to render the chatter panel'), /** Panel width (for sidebar/drawer) */ @@ -654,9 +1003,24 @@ export const RecordChatterProps = z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); -export const RecordPathProps = z.object({ +export const RecordPathProps = strictObject({ + surface: 'this `record:path`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { statusField: z.string().describe('Field name representing the current status/stage'), - stages: z.array(z.object({ + stages: z.array(strictObject({ + surface: 'this `record:path` stage', + history: PROPS_HISTORY, + aliases: { + // A stage's key is `value` — the status value it stands for. `name` is + // the identifier spelling every other authored collection in this file + // uses (`record:details` sections, `record:highlights` fields), so it is + // the near-miss an author arrives with rather than a typo distance could + // reach. + name: 'value', + }, + }, { value: z.string(), label: I18nLabelSchema, /** @@ -675,8 +1039,37 @@ export const RecordPathProps = z.object({ }); export type RecordPathProps = z.input; -export const PageAccordionProps = z.object({ - items: z.array(z.object({ +export const PageAccordionProps = strictObject({ + surface: 'this `page:accordion`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { + items: z.array(strictObject({ + surface: 'this `page:accordion` item', + history: PROPS_HISTORY, + guidance: { + /** + * ⚠️ NOT declared, deliberately, and the measurement is why: objectui's + * Studio block designer publishes `value` as an accordion item input + * (`previews/block-config.ts`, `page:accordion.items.itemFields`), but + * the renderer OVERWRITES it — `containers.tsx:793` maps every item to + * `{ ...it, value: \`panel-${idx}\` }` before rendering, so an authored + * `value` reaches the Radix item as a discarded key. + * + * That is the difference between this key and `page:tabs`'s `value`, one + * component over, where the renderer really does read what is authored + * (`it.value` with a `tab-${idx}` fallback). Declaring it here on the + * #5611 rule would be reading the rule backwards: the rule is that the + * DELIVERED shape is the contract, and what is delivered here is an + * index-derived panel id. The designer input is objectui's to fix + * (filed at #7973); until then this prescription is what stops an author + * being told a dead key is fine. + */ + value: '`page:accordion` items have no author-settable `value` — the renderer derives ' + + '`panel-` and discards whatever is written here. Remove the key. (A `page:tabs` ' + + 'item DOES take a `value`, which is where this spelling usually comes from.)', + }, + }, { label: I18nLabelSchema, icon: z.string().optional(), collapsed: z.boolean().default(false), @@ -701,7 +1094,11 @@ export const PageAccordionProps = z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); -export const AIChatWindowProps = z.object({ +export const AIChatWindowProps = strictObject({ + surface: 'this `ai:chat_window`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { mode: z.enum(['float', 'sidebar', 'inline']).default('float').describe('Display mode for the chat window'), agentId: z.string().optional().describe('Specific AI agent to use'), context: z.record(z.string(), z.unknown()).optional().describe('Contextual data to pass to the AI'), @@ -715,7 +1112,11 @@ export const AIChatWindowProps = z.object({ * ---------------------------------------------------------------------- */ -export const ElementTextPropsSchema = lazySchema(() => z.object({ +export const ElementTextPropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:text`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { /** * Text or Markdown body copy. * @@ -737,7 +1138,11 @@ export const ElementTextPropsSchema = lazySchema(() => z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); -export const ElementNumberPropsSchema = lazySchema(() => z.object({ +export const ElementNumberPropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:number`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { object: z.string().describe('Source object'), field: z.string().optional().describe('Field to aggregate'), aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']) @@ -751,7 +1156,11 @@ export const ElementNumberPropsSchema = lazySchema(() => z.object({ })); export type ElementNumberProps = z.input; -export const ElementImagePropsSchema = lazySchema(() => z.object({ +export const ElementImagePropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:image`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { src: z.string().describe('Image URL or attachment field'), alt: z.string().optional().describe('Alt text for accessibility'), fit: z.enum(['cover', 'contain', 'fill']) @@ -773,7 +1182,11 @@ export const ElementImagePropsSchema = lazySchema(() => z.object({ * author-controlled altitude projection). `object` embeds are deferred * (ADR-0051 §5). */ -export const ElementMetadataViewerPropsSchema = lazySchema(() => z.object({ +export const ElementMetadataViewerPropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:metadata_viewer`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { type: z.enum(['state_machine', 'flow', 'permission']) .describe('Metadata view kind (ADR-0051): state_machine | flow | permission'), name: z.string() @@ -794,7 +1207,11 @@ export const ElementMetadataViewerPropsSchema = lazySchema(() => z.object({ * ---------------------------------------------------------------------- */ -export const ElementButtonPropsSchema = lazySchema(() => z.object({ +export const ElementButtonPropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:button`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { label: I18nLabelSchema.describe('Button display label'), variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'link']) .optional().default('primary').describe('Button visual variant'), @@ -821,7 +1238,11 @@ export const ElementButtonPropsSchema = lazySchema(() => z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); -export const ElementFilterPropsSchema = lazySchema(() => z.object({ +export const ElementFilterPropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:filter`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { object: z.string().describe('Object to filter'), fields: z.array(z.string()).describe('Filterable field names'), targetVariable: z.string().optional().describe('Page variable to store filter state'), @@ -832,7 +1253,11 @@ export const ElementFilterPropsSchema = lazySchema(() => z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); -export const ElementFormPropsSchema = lazySchema(() => z.object({ +export const ElementFormPropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:form`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { object: z.string().describe('Object for the form'), fields: z.array(z.string()).optional().describe('Fields to display (defaults to all editable fields)'), mode: z.enum(['create', 'edit']).optional().default('create').describe('Form mode'), @@ -893,7 +1318,11 @@ export const ElementFormPropsSchema = lazySchema(() => z.object({ * through a second spelling, so a divergent shape here would be a third * dialect rather than a shorthand. */ -export const ElementRecordPickerPropsSchema = lazySchema(() => z.object({ +export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:record_picker`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { object: z.string().describe('Object to pick records from'), /** * Field rendered as each row's text. Defaults to `name`, which is what the @@ -976,7 +1405,11 @@ export type ElementRecordPickerProps = z.input z.object({ +export const ElementTextInputPropsSchema = lazySchema(() => strictObject({ + surface: 'this `element:text_input`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, +}, { inputType: z.enum(['text', 'email', 'number', 'tel', 'url', 'password']) .optional().default('text') .describe('Native input type — drives keyboard/validation affordance and how the bound value is coerced (number → numeric).'), @@ -1020,25 +1453,29 @@ export const ComponentPropsMap = { 'record:path': RecordPathProps, // Navigation - 'app:launcher': EmptyProps, - 'nav:menu': EmptyProps, - 'nav:breadcrumb': EmptyProps, + 'app:launcher': emptyProps('app:launcher'), + 'nav:menu': emptyProps('nav:menu'), + 'nav:breadcrumb': emptyProps('nav:breadcrumb'), // Utility - 'global:search': EmptyProps, - 'global:notifications': EmptyProps, - 'user:profile': EmptyProps, + 'global:search': emptyProps('global:search'), + 'global:notifications': emptyProps('global:notifications'), + 'user:profile': emptyProps('user:profile'), // AI 'ai:chat_window': AIChatWindowProps, - 'ai:suggestion': z.object({ context: z.string().optional() }), + 'ai:suggestion': strictObject({ + surface: 'this `ai:suggestion`', + history: PROPS_HISTORY, + guidanceSets: COMPONENT_LEVEL_GUIDANCE, + }, { context: z.string().optional() }), // Content Elements 'element:text': ElementTextPropsSchema, 'element:number': ElementNumberPropsSchema, 'element:image': ElementImagePropsSchema, 'element:metadata_viewer': ElementMetadataViewerPropsSchema, // ADR-0051: inline read-only metadata view (embeddableInDoc) - 'element:divider': EmptyProps, + 'element:divider': emptyProps('element:divider'), // Interactive Elements 'element:button': ElementButtonPropsSchema, From ef3afdb9c85e515edc5635b0ddeceb1891f102d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 11:10:54 +0000 Subject: [PATCH 2/4] =?UTF-8?q?docs(ui):=20stop=20teaching=20`record:highl?= =?UTF-8?q?ights.actions`=20=E2=80=94=20nothing=20has=20ever=20rendered=20?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the #3746-style corpus scan this batch owes: every `content/docs/**` and `skills/**` code block carrying a registered component type was parsed and its props judged against the now-closed `ComponentPropsMap`. One hit, in the "Complete Example" of the pages guide — an `os:check` block, so it is an example the docs gate compiles and an AI author copies. `RecordHighlightsProps` has never declared `actions`, and objectui's `record-highlights.tsx` reads `schema.fields` and nothing else. Under strip the key was dropped in silence, which is precisely why a doc could teach it for this long: the example parsed clean and rendered without the actions, and no layer disagreed. With the shape closed it is a rejection, so the doc had to be right before the batch could land. The actions move to `record:quick_actions`, which is the component that renders them (`actionNames`, per objectui's own designer config) and which the same page already lists two sections up. Scan after the fix: 392 mdx + 2299 markdown files, 8 registered-type props bags, 0 teaching a key these schemas reject. `check:skill-examples` — 209 prose examples type-check. Part of #4001 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012NLJ8PWLdwLSyi84LHzrAx --- content/docs/ui/pages.mdx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 55e4e13323..0d9ddb09f3 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -196,7 +196,14 @@ const accountRecordPage = { id: 'header', properties: { fields: ['name', 'type', 'industry', 'owner'], - actions: ['edit', 'delete', 'clone'], + }, + }, + // Record actions are their own component — `record:highlights` renders + // field chips and nothing else. + { + type: 'record:quick_actions', + properties: { + actionNames: ['edit', 'delete', 'clone'], }, }, ], From 3ede555ce204992ecac72f2a6089f712ab826b62 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:59:00 +0000 Subject: [PATCH 3/4] =?UTF-8?q?chore(spec):=20regenerate=20authorable-surf?= =?UTF-8?q?ace=20after=20merging=20main=20=E2=80=94=20the=20os-regen=20dri?= =?UTF-8?q?ver's=20second=20half?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `authorable-surface/ui.json` carries `merge=os-regen`, so a merge resolves it by taking a side and leaving it REGEN-PENDING rather than by textually splicing two generated files. That is the driver working as designed; the half that must follow is the regeneration, and it is not optional here — measured on the merged tree before pushing, `check:authorable-surface` fails: ❌ authorable-surface/ is out of date (1 key(s) not recorded). + ui/GlobalFilter:object The missing key is main's own, from #7892 (`GlobalFilterSchema` gains an optional `object` for i18n label resolution). Nothing of this branch's is involved: the regenerated diff is exactly that one line, and batch A's five declared keys were already recorded. Worth stating because the failure mode is quiet: `pnpm build` REWRITES this artifact, so a `check:generated` run that happens to follow a build reports all 13 green against the file the build just fixed. Only `check:authorable-surface` on its own, against the merged bytes, shows the drift — which is how this was caught before the merge queue caught it. Merged tree verified: spec 381 files / 10091 tests, lint 71 / 1932, check:generated 13/13, check:strictness-ledger current (438 sites / 149 strip / 9 authorable — unmoved by the merge). The ledger counts and liveness counts were regenerated too and came back byte-identical. Part of #4001 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012NLJ8PWLdwLSyi84LHzrAx --- packages/spec/authorable-surface/ui.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 39511aa23e..8d274e953c 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -537,6 +537,7 @@ "ui/GlobalFilter:field", "ui/GlobalFilter:label", "ui/GlobalFilter:name", + "ui/GlobalFilter:object", "ui/GlobalFilter:options", "ui/GlobalFilter:optionsFrom", "ui/GlobalFilter:scope", From 5705acb24cf3e5639c9c8f52ec8d35004cefeba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 13:09:22 +0000 Subject: [PATCH 4/4] chore(spec): recompute the strictness ledger counts from the tree merged with batch B MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second turn of the os-regen relay. `#7985` (batch B — the memory driver's five persistence sub-shapes) and `#7972` landed while this branch was in the queue, so the shared counts artifact met two batches that each decremented it correctly and independently. That is the exact arithmetic #5107 built this artifact to prevent: the rows do not overlap, git merges them without complaint, and the SUBTOTAL — which overlaps nothing — merges clean and wrong. The artifact carries `merge=os-regen` so the merge defers rather than splices, and the only correct resolution is to recompute from the merged tree. Regenerated, never hand-edited: still-open (strip) 149 → 144 (batch B's five) files carrying at least one 26 → 25 authorable — forced scope 9 → 4 data/ strip 107 → 102, data/ strict 57 → 62 total strict 283 → 288 Neither batch's own numbers moved; the combined ones did. `check:strictness-ledger` agrees with the merged tree in both directions (25 open files / 144 strip sites, no closed file still carrying a worklist row — batch B's `driver/memory.zod.ts` row left with their side of the prose). Merged tree verified: spec 382 files / 10109 tests, lint 71 / 1932, check:generated 13/13. Part of #4001 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012NLJ8PWLdwLSyi84LHzrAx --- ...6-07-unknown-key-strictness-ledger.counts.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 2f11760056..67f97f89b9 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -22,14 +22,14 @@ regenerate. |---|---| | Triaged directories | 5 | | Object sites in them | 438 | -| Still-open (strip) sites | 149 | -| Files carrying at least one | 26 | +| Still-open (strip) sites | 144 | +| Files carrying at least one | 25 | Remaining strip sites by class: | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 9 | +| authorable — the ruling's forced scope | 4 | | unresolved — needs a per-schema verdict | 34 | | wire / open — out of forced scope | 104 | | no door — no carrier, ADR-0049 territory | 1 | @@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| | `ui/` | 161 | 150 | 5 | 0 | 6 | -| `data/` | 165 | 57 | 1 | 0 | 107 | +| `data/` | 165 | 62 | 1 | 0 | 102 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **438** | **283** | **6** | **0** | **149** | +| **total** | **438** | **288** | **6** | **0** | **144** | ## File-level triage — site counts @@ -178,7 +178,7 @@ over it is here. ### `data/` — open -**107 strip of 165**, in 16 file(s). +**102 strip of 165**, in 15 file(s). | File | Strip | Sites | |---|---|---| @@ -188,7 +188,6 @@ over it is here. | `driver-nosql.zod.ts` | 10 | 10 | | `driver-sql.zod.ts` | 2 | 2 | | `driver.zod.ts` | 9 | 9 | -| `driver/memory.zod.ts` | 5 | 6 | | `external-catalog.zod.ts` | 4 | 4 | | `external-lookup.zod.ts` | 12 | 12 | | `field-value.zod.ts` | 2 | 3 | @@ -198,11 +197,11 @@ over it is here. | `object.zod.ts` | 1 | 20 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **107** | **165** | +| **total** | **102** | **165** | | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 8 | +| authorable — the ruling's forced scope | 3 | | unresolved — needs a per-schema verdict | 34 | | wire / open — out of forced scope | 65 | | no door — no carrier, ADR-0049 territory | 0 |