From 4bb03e3c3e6b74d86a313596ac1d6c727ea6b960 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 02:01:19 +0000 Subject: [PATCH 1/5] =?UTF-8?q?docs(spec):=20=E6=8A=8A=20ui/component.zod.?= =?UTF-8?q?ts=20=E7=9A=84=2029=20=E4=B8=AA=E7=AB=99=E7=82=B9=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E4=B8=BA=20no=20gate=20=E8=80=8C=E9=9D=9E=E6=94=B6?= =?UTF-8?q?=E7=B4=A7=20(#4001=20=E6=89=B9=2017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDUI 组件 props 是 ui/ 目录最大的待收紧面,批 17 的测量结果是它根本没有 parse:.strict() 在这里不会强制任何东西,只会花掉一次 v17 破坏性变更换来 #4583 所说的「一个被精确校验的死槽位」。 三条独立测量,控制组同轮为真: - 承载键 PageComponentSchema.properties 是 z.record(z.string(), z.unknown()); PageComponentSchema 自 ADR-0089 D3a 起是 .strict(),但严格性不递归。 - 从 24 个 metadata-type root + ObjectStackSchema 做 BFS(复用 build-schemas.ts 的 #4650 闭包,6899 节点),52 个目标全部 UNREACHABLE;六个正控制组全部 root-graph;批 13 的 no-door 形状保持 unreachable。 - 三个仓库里对本文件的 parse/safeParse 全部落在本文件自己的单测内。 经验证据(definePage() 即 PageSchema.parse()):example 语料 10/10 页面上, 写进 components[].properties 的未声明键原样通过并被保留;同一个键放到 properties 的兄弟位上 10/10 被拒(负控制组)。 判定是 no gate 而非 no door —— 词汇是活的(objectui SchemaRenderer 把 properties 里每个键 spread 成 React prop),因此不能按 ADR-0049 退役。修法是 把 parse 接到承载键的闸门上,已立为 #5068。 判定写在三处:文件头、component.test.ts 的钉子(含一条 properties 一旦获得 类型化分派就变红的断言)、账本 ui/ 两张表。账本 authorable strip 76 → 47, no gate 2 → 31,均从存活行重算。 Part of objectstack-ai/objectstack#4001 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .../unknown-key-strictness-ui-batch17.md | 29 ++++++++ .../2026-07-unknown-key-strictness-ledger.md | 34 ++++++--- packages/spec/src/ui/component.test.ts | 71 ++++++++++++++++++- packages/spec/src/ui/component.zod.ts | 67 +++++++++++++++++ 4 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 .changeset/unknown-key-strictness-ui-batch17.md diff --git a/.changeset/unknown-key-strictness-ui-batch17.md b/.changeset/unknown-key-strictness-ui-batch17.md new file mode 100644 index 0000000000..ba8c7271e6 --- /dev/null +++ b/.changeset/unknown-key-strictness-ui-batch17.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': major +--- + +**`ui/component.zod.ts` 的 29 个站点判定为 `no gate`,不收紧(#4001 批 17,ADR-0078)** + +⚠️ **本条没有破坏性变更,没有迁移,没有任何键的行为改变。** 它记录的是一次测量结果:SDUI 组件 props 这块 `ui/` 目录最大的待收紧面,**根本没有 parse**,所以 `.strict()` 在这里不会强制任何东西。 + +## 为什么不收紧 + +`.strict()` 是 **parse 的属性**。三条独立测量(2026-08-04,控制组同轮为真): + +1. **承载键活着,但它是个开放口袋。** `PageComponentSchema.properties` 是 `z.record(z.string(), z.unknown())`。`PageComponentSchema` 自 ADR-0089 D3a 起确实是 `.strict()`,但**严格性不递归**——它守住 component 节点自己的键,`properties` 里面完全不校验,也没有任何地方按 `type` 分派 `ComponentPropsMap`。 +2. **BFS 不可达。** 从 24 个 metadata-type root 加 `ObjectStackSchema` 出发(复用 `build-schemas.ts` 自己的 `zodChildSchemas`/`zodShapeOf`,即 #4650 闭包,6899 个节点),本文件 **52 个目标全部 UNREACHABLE**(21 个导出 schema + `ComponentPropsMap` 全部 31 个条目);同一轮里 `PageSchema` / `PageComponentSchema` / `PageRegionSchema` / `ThemeSchema` / `ChartConfigSchema` / `ResponsiveConfigSchema` 六个正控制组全部 `root-graph`,批 13 的 no-door 形状保持 unreachable。BFS 正好停在 `properties`。 +3. **三个仓库无生产 parse 点。** `objectstack` / `objectui` / `cloud` 中,对本文件任何 schema 的 `.parse()`/`.safeParse()` 全部落在本文件自己的单测里。objectui 手写平行的 React interface、只引用推断类型;cloud 引用为 0;`react-blocks.ts` 只用 `Object.keys(ComponentPropsMap)` 取类型名。 + +经验证据(`definePage()` 就是 `PageSchema.parse()`,活的授权门):example 语料 10/10 个页面上,写进 `components[].properties` 的未声明键**原样通过并被保留**;同一个键放到外面一层(`properties` 的兄弟位)10/10 被拒——这个负控制组才让前一个数字有意义。 + +## `no gate`,不是 `no door` —— 不要退役 + +这些词汇是**活的**,不能按 ADR-0049 退役:objectui 的 `SchemaRenderer` 把 `properties` 整个 hoist 到节点上,再把不在固定 deny-list 上的**每一个**作者键 spread 成 React prop。所以拼错的键既不被拒、也不被丢,而是安静地流到渲染器再被忽略——正是 ADR-0078 要消灭的形状,只是位置比本 ratchet 能触及的层更低一层。 + +这确实是 #4909 的 open-slot 形状,但在一个没人 parse 的 schema 上,`.passthrough()` 和 `.strict()` 一样空洞,所以**没有改任何 posture**。 + +contract-first 的修法是把 parse 接到承载键自己的闸门上,已单独立为 **#5068**;那个 issue 同时记录了两条使它不能顺手做的约束:`type` 是开放 union(`record:line_items` 这类未注册类型在现实中被使用),以及真实页面已经写了这些 schema 未声明的形状(`record:details` 的 `sections[].fields[]`/`hideFields[]`、record picker 的 `labelField`)。#5068 落地后本文件才重新变成 `authorable`,收紧才有意义。 + +判定写在三处(文件头、`component.test.ts` 的钉子——含一条 `properties` 一旦获得类型化分派就变红的断言、账本 `ui/` 两张表),改要一起改。 + +账本连带效果:`ui/` 的 authorable strip 从 76 降到 47(总数仍是 100,重分类只是换类不是出列),`no gate` 从 2 涨到 31。**`ui/` 剩余 100 个 strip 站点里有 53 个根本不是本 ratchet 的工作**(22 个 `no door` + 31 个 `no gate`)。 diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index caea1abe38..e3fac2758a 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -21,7 +21,7 @@ One question decides the class: **who writes this schema's input?** | **wire** | Another machine: server responses, connector payloads, runtime envelopes, persisted runtime state | stay tolerant (`.strip` / `.passthrough`); strictness here turns an upstream *addition* into our parse crash | | **open** | Deliberately schemaless user data (record bodies, per-node-type `config`, React props) | stay open; a *sibling* contract validates it (e.g. a node executor's `configSchema`, #4027/#4040) | | **no door** | **Nobody — nothing parses it.** The shape is exported and typed, but no schema declares a carrier key for it, so it is unreachable from every metadata-type root and from `defineStack`. Added at 批 13, when the first run of files resolved its `(p)` this way | **out of this ratchet's scope.** `.strict()` is a property of a PARSE; with no parse it enforces nothing and only makes a dead slot look load-bearing (#4583). The live question is ADR-0049 enforce-or-remove — retire the vocabulary or give it a carrier — so a row here points at an issue, never at a batch | -| **no gate** | **An author — through a carrier this protocol does not PARSE.** The carrier key exists and is live (authors write it, a renderer reads it), but no `.parse()` sits between them; whatever checking exists re-derives the schema's rules by hand. Added at 批 15 on `ChartAggregateSchema` (``) | **out of this ratchet's scope, for the opposite reason.** Same absent parse, so closing it still enforces nothing — but the vocabulary is ALIVE, so the fix is to wire the parse at the carrier's own gate, not to retire anything. A row here points at that wiring issue | +| **no gate** | **An author — through a carrier this protocol does not PARSE.** The carrier key exists and is live (authors write it, a renderer reads it), but no `.parse()` sits between them; whatever checking exists re-derives the schema's rules by hand. Added at 批 15 on `ChartAggregateSchema` (``); 批 17 then found the same shape at scale — all 29 sites of `ui/component.zod.ts`, behind `PageComponentSchema.properties`, making this the largest class in `ui/` | **out of this ratchet's scope, for the opposite reason.** Same absent parse, so closing it still enforces nothing — but the vocabulary is ALIVE, so the fix is to wire the parse at the carrier's own gate, not to retire anything. A row here points at that wiring issue | A fourth answer to "who writes this input" is **nobody**, and it is only reachable by measurement rather than by reading the file: `no door` was added at @@ -517,7 +517,7 @@ not verdicts). | `action.zod.ts` | 8 | authorable | param schema strict (#3746); remaining blocks ride later steps. **9 → 8 at the #4001 re-measurement** — no schema changed: the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code | | `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | | `bulk-action.zod.ts` | 3 | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open** and both now `.passthrough()` — the param because objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so passthrough is the honest mirror and strictness would reject valid config (same call as `dashboard.zod.ts`'s widget `config`); the OPTION ENTRY on separate measured evidence, since its objectui type is closed and only the runtime path is open — `bulkParamToField` spreads each entry (`plugin-grid/src/components/bulkParamToField.ts:131`) into `SelectOptionMetadata` (`types/src/field-types.ts:288`), which declares and reads `color` / `icon` / `disabled` / `visibleWhen`. **This row said "both deliberately open" while only the parent was `passthrough`** — one intent, two postures, caught by the 2026-08-03 re-measure and closed by the ruling's verdict A (make the code match the prose). The lesson is the campaign's own: prose in this ledger is not a posture reading, which is why the remaining-strip map is gated and this column is not. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached | -| `component.zod.ts` | 29 | authorable | **next candidate** — SDUI component defs; check React-prop open slots first (p) | +| `component.zod.ts` | 29 | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) | | `theme.zod.ts` | 14 | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **Separately filed, not answered here**: `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read). That is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live | | `app.zod.ts` | 18 | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | | `dashboard.zod.ts` | 11 | authorable | partially strict | @@ -699,7 +699,7 @@ next person to open that file will look. | File | Strip | Sites | Class | Batch | |---|---|---|---|---| -| `component.zod.ts` | 29 | 29 | authorable (p) | Largest single block left. SDUI component props — **verify the React-prop open slots first**; `check:react-declaration-parity` compares two DECLARATIONS and cannot tell you which props a renderer reads | +| `component.zod.ts` | 29 | 29 | **no gate** | ⛔ **not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would gate nothing (#4583). Blocked on wiring the parse at the carrier — **#5068**. See the triage row for the full measurement | | `view.zod.ts` | 20 | 50 | mixed | Top level and the form/page shapes are closed (ADR-0089 + the final batch). Remaining are sub-blocks; `UserFiltersSchema` is the one the last batch **named as deliberately left open** — it strips page-only keys with a test pinning that, so closing it needs its own verification | | `widget.zod.ts` | 9 | 9 | authorable (p) | Widget manifest + lifecycle/event/property/source | | `chart.zod.ts` | 2 | 7 | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (``) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row | @@ -729,16 +729,28 @@ line, which conflicts with nothing, merged clean and wrong on both sides. merged alongside #4876, which edits this same section, so the conflict was expected and both sides' row edits were kept before recomputing. -**Authorable strip in `ui/`: 76 of 100** — recomputed from the surviving -rows on every merge (29 + 20 + 9 + 2 + 7 + 6 + 4 + 4 + 4 + 3 + 3 + 2 + 2 + 2 + 1 + 1 + 1 = 100), never decremented by a batch's own count. 批 13 moved it +**Authorable strip in `ui/`: 47 of 100** — recomputed from the surviving +rows on every merge (all rows: 29 + 20 + 9 + 2 + 7 + 6 + 4 + 4 + 4 + 3 + 3 + 2 + 2 + 2 + 1 + 1 + 1 = 100; +of those, the authorable ones are `view` 20 + `widget` 9 + `i18n` 6 + `dataset` 3 ++ `dashboard` 2 + `report` 2 + `sharing` 2 + `action` 1 + `app` 1 + +`notification` 1 = **47**), never decremented by a batch's own count. 批 13 moved it to 97 of 119 (from 123 of 123); 批 15 then closed all 14 `theme.zod.ts` sites -and 5 of `chart.zod.ts`'s 7. Both batches edited this same section and the -conflict was expected: every row from both sides was kept before recomputing, -and `check:strictness-ledger`'s header arithmetic is what settled the result. +and 5 of `chart.zod.ts`'s 7; **批 17 moved it from 76 to 47 without closing a +single site** — `component.zod.ts`'s 29 were reclassified `authorable (p)` → +`no gate`, the largest single reclassification the campaign has made. Note what +that does to the arithmetic: the "of 100" is unchanged, because a reclassification +moves a row between classes rather than out of the worklist, and the file keeps +its 29/29 row so `check:strictness-ledger`'s reverse pin still holds it. Every +one of those batches edited this same section and the conflict was expected: +every row from both sides was kept before recomputing, and +`check:strictness-ledger`'s header arithmetic is what settled the result. The 22-site gap 批 13 opened is unchanged and is described below; 批 15 adds a -2-site gap of its own, in a DIFFERENT class. Of what remains, `app.zod.ts`'s -single site is still held pending the finding-16 `.extend()` check rather than -counted as ready. +2-site gap of its own, and 批 17 a 29-site one — all three in the *same two* +out-of-scope classes, which is now the single largest fact about this +directory: **53 of `ui/`'s 100 remaining strip sites are not this ratchet's +work at all** (22 `no door` + 31 `no gate`). Of what remains genuinely +authorable, `app.zod.ts`'s single site is still held pending the finding-16 +`.extend()` check rather than counted as ready. `keyboard` (4) and `offline` (3) were reclassified out of `authorable` because The reclassification is worth reading as a method note, because batch 13 is the diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index a4e9ccdecb..2607f37eba 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -18,7 +18,7 @@ import { ElementRecordPickerPropsSchema, ElementTextInputPropsSchema, } from './component.zod'; -import { PageComponentSchema } from './page.zod'; +import { PageComponentSchema, PageSchema } from './page.zod'; describe('PageHeaderProps', () => { it('should accept minimal header', () => { @@ -865,3 +865,72 @@ describe('ComponentPropsMap record:chatter', () => { expect(result.unifiedTimeline).toBe(true); }); }); + +/** + * ── #4001 批 17: the `no gate` verdict, pinned ────────────────────────────── + * + * These schemas are NOT a pending `.strict()` batch. Nothing parses them, so + * closing them would enforce nothing (#4583). The full measurement and the + * reasoning live in `component.zod.ts`'s file header and in the `ui/` tables of + * `docs/audits/2026-07-unknown-key-strictness-ledger.md`. + * + * This block exists so the verdict cannot outlive its truth. Each assertion is + * written to go RED the day the world changes underneath it — at which point the + * correct response is to update all three places together, not to relax the test. + */ +describe('#4001 批 17 — component props are `no gate` (carrier live, parse absent)', () => { + it('the carrier is still an OPEN bag — goes red the day `properties` gets a typed dispatch', () => { + // `PageComponentSchema` is `.strict().transform(…)`, so unwrap the pipe to + // reach the object shape. + const def = (PageComponentSchema as any)._zod.def; + const shape = def.type === 'pipe' ? def.in._zod.def.shape : def.shape; + // `properties` is `z.record(z.string(), z.unknown()).optional().default({})`. + let node = shape.properties; + while (node?._zod?.def?.innerType) node = node._zod.def.innerType; + expect(node._zod.def.type).toBe('record'); + // The value type must still be the fully-open `unknown`. A dispatch on + // `type` (the #5068 fix) replaces this, and that is the signal to reclassify + // this file back to `authorable` and schedule the ratchet. + expect(node._zod.def.valueType._zod.def.type).toBe('unknown'); + }); + + it('an unknown key inside `properties` survives the LIVE page parse — with the strict sibling as negative control', () => { + const page = { + name: 'batch17_probe', + label: 'Probe', + type: 'home' as const, + regions: [ + { name: 'header', components: [{ type: 'page:header', properties: { title: 'T' } }] }, + ], + }; + + // A. unknown key INSIDE the carrier slot — accepted AND retained today. + const inside = structuredClone(page) as any; + inside.regions[0].components[0].properties.zzUndeclared = 'x'; + const a = PageSchema.safeParse(inside); + expect(a.success).toBe(true); + expect((a as any).data.regions[0].components[0].properties.zzUndeclared).toBe('x'); + + // B. NEGATIVE CONTROL — the same key one level out, on the component node + // itself, which IS strict (ADR-0089 D3a). If this ever stops failing, the + // assertion above proves nothing and this whole block is measuring air. + const outside = structuredClone(page) as any; + outside.regions[0].components[0].zzUndeclared = 'x'; + expect(PageSchema.safeParse(outside).success).toBe(false); + }); + + it('every ComponentPropsMap entry is still non-strict — a sweep that closes them without wiring #5068 fails here', () => { + const stillOpen: string[] = []; + for (const [type, schema] of Object.entries(ComponentPropsMap)) { + const def = (schema as any)._zod.def; + // zod records an unknown-key policy on the object def; `.strict()` sets a + // `never` catchall. Anything else means the site is still open. + if (def.catchall?._zod?.def?.type === 'never') continue; + stillOpen.push(type); + } + // All 31 registered component types are open. When #5068 wires the parse and + // a later batch closes them, this expectation flips — update the verdict in + // component.zod.ts and the ledger in the same PR. + expect(stillOpen.length).toBe(Object.keys(ComponentPropsMap).length); + }); +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index edab5ffd24..2337310fa8 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1,5 +1,72 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +/** + * SDUI component prop schemas — the declarative shape of every `page:*`, + * `record:*`, `element:*`, `nav:*` and `ai:*` node a page can carry. + * + * ## ⛔ These 29 sites are `no gate`, NOT a pending `.strict()` batch (#4001 批 17) + * + * Do not sweep `strictObject` across this file. It was scheduled as the #4001 + * campaign's largest remaining `ui/` block and the measurement came back + * negative: **nothing parses these schemas**, so `.strict()` here would enforce + * exactly nothing while spending a v17 breaking change to produce what #4583 + * calls *"a precisely validated dead slot — the more convincing lie"*. + * + * `.strict()` is a property of a PARSE. Three independent measurements, each + * with its controls green in the same run (2026-08-04): + * + * 1. **The carrier is an open bag.** `PageComponentSchema.properties` is + * `z.record(z.string(), z.unknown())` (`page.zod.ts`). `PageComponentSchema` + * itself has been `.strict()` since ADR-0089 D3a — but **strictness does not + * recurse**, so it closes the component node's own keys and leaves everything + * under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by + * `type`. + * 2. **BFS-unreachable.** From all 24 metadata-type roots plus `defineStack`'s + * `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s + * own `zodChildSchemas` / `zodShapeOf` (the #4650 walk), all 52 targets here + * (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries) come + * back UNREACHABLE — while `PageSchema`, `PageComponentSchema`, + * `PageRegionSchema`, `ThemeSchema`, `ChartConfigSchema` and + * `ResponsiveConfigSchema` all resolve `root-graph` in that same run, and 批 13's + * measured no-door shapes stay unreachable. The walk stops at `properties`. + * 3. **No production parse.** Across `objectstack`, `objectui` and `cloud`, every + * `.parse()` / `.safeParse()` on anything in this file is inside this file's + * own unit tests. `objectui` mirrors the props as hand-written React + * interfaces and imports only the inferred TYPES; `cloud` references none. + * `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type names only — + * its `REACT_BLOCKS[].schema` entries all point at view/chart schemas. + * + * Empirically, through the live door (`definePage()` IS `PageSchema.parse()`): on + * the example corpus an undeclared key written inside `components[].properties` + * parses clean and is RETAINED on 10/10 pages, while the same key one level out + * — a sibling of `properties` — is rejected on 10/10. The negative control is + * what makes the first number mean something. + * + * ## Why this is `no gate` and not `no door` (批 13 vs 批 15) + * + * The vocabulary here is ALIVE — this is not dead surface to retire under + * ADR-0049. Authors write these keys on real pages, and objectui's + * `SchemaRenderer` hoists `properties` onto the node and spreads every key that + * is not on its fixed metadata deny-list straight into the React component. So + * a misspelled key is neither rejected nor dropped: it reaches the renderer and + * is ignored there. That is the ADR-0078 failure mode, one layer below where + * this campaign can reach. + * + * The contract-first fix is therefore to WIRE THE PARSE at the carrier's own + * gate, not to close schemas nobody calls — filed as **#5068**, which also + * records the two constraints that stop it being a drive-by: `type` is an open + * union (unregistered types like `record:line_items` are authored in the wild), + * and real pages already author shapes these schemas do not declare + * (`record:details` `sections[].fields[]` / `hideFields[]`, the record picker's + * `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`, which + * has documented the untyped bag all along). + * + * When #5068 lands, this file becomes `authorable` and the ratchet applies. The + * verdict is pinned in `component.test.ts` and in the `ui/` tables of + * `docs/audits/2026-07-unknown-key-strictness-ledger.md` — change all three + * together or none. + */ + import { z } from 'zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { ViewFilterRuleSchema } from './view.zod'; From 75cad39df8f52260999704e00fcb83794e602337 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 02:10:14 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs(spec):=20=E6=8A=8A=20=E6=89=B9=2017=20?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E4=BB=8E=20JSDoc=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E8=A1=8C=E6=B3=A8=E9=87=8A,=E9=81=BF=E5=85=8D=E6=B3=84?= =?UTF-8?q?=E6=BC=8F=E8=BF=9B=E9=9D=A2=E5=90=91=E7=94=A8=E6=88=B7=E7=9A=84?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-docs.ts 会把文件第一个 JSDoc 块当作模块描述,原样发布到 content/docs/references/ui/component.mdx 和 skills/objectstack-ui/references/ _index.md。内部战役判定不该出现在协议参考文档里(而且生成器会把段落打散, 渲染也是坏的)。 改为 imports 之后的 // 注释块,与 批 13 在 touch.zod.ts 的做法一致 —— touch.mdx 至今仍显示 "Touch Target Configuration Schema",证明这个位置对 生成器不可见。check:generated 8/8 恢复全绿。 WIP checkpoint:实现 + 测试钉子 + 账本 + changeset 已完成并已验证; 待办 = 全量 typecheck/test、三个 example app 的 validate、os-regen 四步、 开 draft PR。 Part of objectstack-ai/objectstack#4001 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- packages/spec/src/ui/component.zod.ts | 139 +++++++++++++------------- 1 file changed, 72 insertions(+), 67 deletions(-) diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 2337310fa8..88d3ea6dfa 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1,72 +1,5 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -/** - * SDUI component prop schemas — the declarative shape of every `page:*`, - * `record:*`, `element:*`, `nav:*` and `ai:*` node a page can carry. - * - * ## ⛔ These 29 sites are `no gate`, NOT a pending `.strict()` batch (#4001 批 17) - * - * Do not sweep `strictObject` across this file. It was scheduled as the #4001 - * campaign's largest remaining `ui/` block and the measurement came back - * negative: **nothing parses these schemas**, so `.strict()` here would enforce - * exactly nothing while spending a v17 breaking change to produce what #4583 - * calls *"a precisely validated dead slot — the more convincing lie"*. - * - * `.strict()` is a property of a PARSE. Three independent measurements, each - * with its controls green in the same run (2026-08-04): - * - * 1. **The carrier is an open bag.** `PageComponentSchema.properties` is - * `z.record(z.string(), z.unknown())` (`page.zod.ts`). `PageComponentSchema` - * itself has been `.strict()` since ADR-0089 D3a — but **strictness does not - * recurse**, so it closes the component node's own keys and leaves everything - * under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by - * `type`. - * 2. **BFS-unreachable.** From all 24 metadata-type roots plus `defineStack`'s - * `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s - * own `zodChildSchemas` / `zodShapeOf` (the #4650 walk), all 52 targets here - * (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries) come - * back UNREACHABLE — while `PageSchema`, `PageComponentSchema`, - * `PageRegionSchema`, `ThemeSchema`, `ChartConfigSchema` and - * `ResponsiveConfigSchema` all resolve `root-graph` in that same run, and 批 13's - * measured no-door shapes stay unreachable. The walk stops at `properties`. - * 3. **No production parse.** Across `objectstack`, `objectui` and `cloud`, every - * `.parse()` / `.safeParse()` on anything in this file is inside this file's - * own unit tests. `objectui` mirrors the props as hand-written React - * interfaces and imports only the inferred TYPES; `cloud` references none. - * `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type names only — - * its `REACT_BLOCKS[].schema` entries all point at view/chart schemas. - * - * Empirically, through the live door (`definePage()` IS `PageSchema.parse()`): on - * the example corpus an undeclared key written inside `components[].properties` - * parses clean and is RETAINED on 10/10 pages, while the same key one level out - * — a sibling of `properties` — is rejected on 10/10. The negative control is - * what makes the first number mean something. - * - * ## Why this is `no gate` and not `no door` (批 13 vs 批 15) - * - * The vocabulary here is ALIVE — this is not dead surface to retire under - * ADR-0049. Authors write these keys on real pages, and objectui's - * `SchemaRenderer` hoists `properties` onto the node and spreads every key that - * is not on its fixed metadata deny-list straight into the React component. So - * a misspelled key is neither rejected nor dropped: it reaches the renderer and - * is ignored there. That is the ADR-0078 failure mode, one layer below where - * this campaign can reach. - * - * The contract-first fix is therefore to WIRE THE PARSE at the carrier's own - * gate, not to close schemas nobody calls — filed as **#5068**, which also - * records the two constraints that stop it being a drive-by: `type` is an open - * union (unregistered types like `record:line_items` are authored in the wild), - * and real pages already author shapes these schemas do not declare - * (`record:details` `sections[].fields[]` / `hideFields[]`, the record picker's - * `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`, which - * has documented the untyped bag all along). - * - * When #5068 lands, this file becomes `authorable` and the ratchet applies. The - * verdict is pinned in `component.test.ts` and in the `ui/` tables of - * `docs/audits/2026-07-unknown-key-strictness-ledger.md` — change all three - * together or none. - */ - import { z } from 'zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { ViewFilterRuleSchema } from './view.zod'; @@ -74,6 +7,78 @@ import { InlineActionSchema } from './action.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; +// --------------------------------------------------------------------------- +// NOT CLOSED AGAINST UNKNOWN KEYS -- AND THAT IS THE MEASURED VERDICT +// (#4001 batch 17 / 批 17, ADR-0078). Read this before "finishing" the file. +// +// SDUI component prop schemas: the declarative shape of every `page:*`, +// `record:*`, `element:*`, `nav:*` and `ai:*` node a page can carry. +// +// These 29 object sites are `no gate` -- carrier live, parse absent -- NOT a +// pending `.strict()` batch. Do not sweep `strictObject` across this file. +// +// It was scheduled as the #4001 campaign's largest remaining `ui/` block and +// the measurement came back NEGATIVE: nothing parses these schemas, so +// `.strict()` here would enforce exactly nothing while spending a v17 breaking +// change to produce what #4583 calls "a precisely validated dead slot -- the +// more convincing lie". +// +// `.strict()` is a property of a PARSE. Three independent measurements, each +// with its controls green in the same run (2026-08-04): +// +// 1. THE CARRIER IS AN OPEN BAG. `PageComponentSchema.properties` is +// `z.record(z.string(), z.unknown())` (`page.zod.ts`). `PageComponentSchema` +// itself has been `.strict()` since ADR-0089 D3a — but strictness does NOT +// recurse, so it closes the component node's own keys and leaves everything +// under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by +// `type`. +// 2. BFS-UNREACHABLE. From all 24 metadata-type roots plus `defineStack`'s +// `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s +// own `zodChildSchemas` / `zodShapeOf` (the #4650 walk), all 52 targets here +// (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries) come +// back UNREACHABLE — while `PageSchema`, `PageComponentSchema`, +// `PageRegionSchema`, `ThemeSchema`, `ChartConfigSchema` and +// `ResponsiveConfigSchema` all resolve `root-graph` in that same run, and 批 13's +// measured no-door shapes stay unreachable. The walk stops at `properties`. +// 3. NO PRODUCTION PARSE. Across `objectstack`, `objectui` and `cloud`, every +// `.parse()` / `.safeParse()` on anything in this file is inside this file's +// own unit tests. `objectui` mirrors the props as hand-written React +// interfaces and imports only the inferred TYPES; `cloud` references none. +// `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type names only — +// its `REACT_BLOCKS[].schema` entries all point at view/chart schemas. +// +// Empirically, through the live door (`definePage()` IS `PageSchema.parse()`): on +// the example corpus an undeclared key written inside `components[].properties` +// parses clean and is RETAINED on 10/10 pages, while the same key one level out +// — a sibling of `properties` — is rejected on 10/10. The negative control is +// what makes the first number mean something. +// +// WHY `no gate` AND NOT `no door` (批 13 vs 批 15) +// +// The vocabulary here is ALIVE — this is not dead surface to retire under +// ADR-0049. Authors write these keys on real pages, and objectui's +// `SchemaRenderer` hoists `properties` onto the node and spreads every key that +// is not on its fixed metadata deny-list straight into the React component. So +// a misspelled key is neither rejected nor dropped: it reaches the renderer and +// is ignored there. That is the ADR-0078 failure mode, one layer below where +// this campaign can reach. +// +// The contract-first fix is therefore to WIRE THE PARSE at the carrier's own +// gate, not to close schemas nobody calls — filed as #5068, which also +// records the two constraints that stop it being a drive-by: `type` is an open +// union (unregistered types like `record:line_items` are authored in the wild), +// and real pages already author shapes these schemas do not declare +// (`record:details` `sections[].fields[]` / `hideFields[]`, the record picker's +// `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`, which +// has documented the untyped bag all along). +// +// When #5068 lands, this file becomes `authorable` and the ratchet applies. The +// verdict is pinned in `component.test.ts` and in the `ui/` tables of +// `docs/audits/2026-07-unknown-key-strictness-ledger.md` — change all three +// together or none. +// --------------------------------------------------------------------------- + + /** * Empty Properties Schema */ From 5dde6457da30d76438a75891e1dff7371532feac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 02:42:40 +0000 Subject: [PATCH 3/5] =?UTF-8?q?docs(spec):=20=E8=AE=B0=E5=BD=95=20#5056=20?= =?UTF-8?q?=E6=A1=A5=E6=8E=A5=E7=BC=BA=E9=99=B7=E5=AF=B9=20=E6=89=B9=2017?= =?UTF-8?q?=20=E5=88=A4=E5=AE=9A=E4=B8=8D=E9=80=82=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E7=90=86=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批 16 发现 derived-clone 桥(共享 .describe() 克隆)会把死形状误报为 REACHABLE。其误差方向与本判定相反——只可能掩盖 no-gate 发现,不可能制造 一个。且本判定不依赖该桥:六个正控制组全部 root-graph(自身实例在闭包内), 52 个目标 root-graph 与 derived-clone 双双未命中。两条非 BFS 测量(开放 承载键 + 三仓零 parse 站点)独立成立。 Part of objectstack-ai/objectstack#4001 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- docs/audits/2026-07-unknown-key-strictness-ledger.md | 2 +- packages/spec/src/ui/component.zod.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index e3fac2758a..d29fe096d1 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -517,7 +517,7 @@ not verdicts). | `action.zod.ts` | 8 | authorable | param schema strict (#3746); remaining blocks ride later steps. **9 → 8 at the #4001 re-measurement** — no schema changed: the ninth "site" was a `z.object(…)` inside a JSDoc paragraph, which the old textual counter could not tell from code | | `view.zod.ts` | 50 | authorable | partially strict (ADR-0089); long tail of sub-blocks. `bulkActionDefs` left this file in #4457 — see the row below | | `bulk-action.zod.ts` | 3 | authorable | **strict as of #4457** — `BulkActionDefSchema` (the def itself). It was `z.array(z.record(z.string(), z.any()))` inline in `view.zod.ts`: a selection-bar button with **no shape at all**, so `opeartion` / `excution: 'aggregate'` parsed and shipped as a button that ran the default behaviour. Its two other sites are `BulkActionParamSchema` and that param's `options` entry, both deliberately **open** and both now `.passthrough()` — the param because objectui's `BulkActionParam` declares a `[key: string]: unknown` catch-all for widget config (min/max/step/format), so passthrough is the honest mirror and strictness would reject valid config (same call as `dashboard.zod.ts`'s widget `config`); the OPTION ENTRY on separate measured evidence, since its objectui type is closed and only the runtime path is open — `bulkParamToField` spreads each entry (`plugin-grid/src/components/bulkParamToField.ts:131`) into `SelectOptionMetadata` (`types/src/field-types.ts:288`), which declares and reads `color` / `icon` / `disabled` / `visibleWhen`. **This row said "both deliberately open" while only the parent was `passthrough`** — one intent, two postures, caught by the 2026-08-03 re-measure and closed by the ruling's verdict A (make the code match the prose). The lesson is the campaign's own: prose in this ledger is not a posture reading, which is why the remaining-strip map is gated and this column is not. The def also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `batchSize` on an aggregate) and a hand-written `actionDef`, which is renderer-attached | -| `component.zod.ts` | 29 | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) | +| `component.zod.ts` | 29 | ~~authorable (p)~~ **no gate** | **no parse anywhere (measured, #4001 批 17)** — the `(p)` resolved NEGATIVE, and this is the campaign's largest single reclassification. The standing warning said to verify objectui's React-prop open slots first; doing so found the question was moot one level up. **The carrier is live but it is an open bag**: `PageComponentSchema.properties` is `z.record(z.string(), z.unknown())`, and although `PageComponentSchema` has been `.strict()` since ADR-0089 D3a, **strictness does not recurse** — it closes the component node's own keys and leaves everything under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by `type`. Three measurements on 2026-08-04, controls green in the same run: (1) a BFS from all 24 metadata-type roots plus `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s own `zodChildSchemas`/`zodShapeOf` (the #4650 walk), returns **UNREACHABLE for all 52 targets** (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries), while `PageSchema`/`PageComponentSchema`/`PageRegionSchema`/`ThemeSchema`/`ChartConfigSchema`/`ResponsiveConfigSchema` all resolve `root-graph` and 批 13's no-door shapes stay unreachable — the walk stops dead at `properties`. ⚠️ The #5056 bridge defect does not touch this row: it makes the derived-clone bridge report dead shapes as REACHABLE, the opposite direction, and nothing here rests on that bridge — all six positive controls resolve `root-graph` and all 52 targets miss BOTH `root-graph` and `derived-clone`; (2) across `objectstack`, `objectui` and `cloud`, every `.parse()`/`.safeParse()` on anything in this file is inside the file's own unit tests — objectui mirrors the props as hand-written React interfaces and imports only the inferred TYPES, `cloud` references none, and `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type NAMES only (its `REACT_BLOCKS[].schema` entries all point at view/chart schemas); (3) empirically through the live door — `definePage()` IS `PageSchema.parse()` — an undeclared key written inside `components[].properties` parses clean and is RETAINED on 10/10 example-corpus pages, while the same key one level out is rejected on 10/10 (the negative control that makes the first number mean anything). ⚠️ **`no gate`, not `no door`** — the vocabulary is ALIVE and must not be retired: objectui's `SchemaRenderer` hoists `properties` onto the node and spreads every key not on its fixed deny-list straight into the React component, so a misspelled key is neither rejected nor dropped — it reaches the renderer and is ignored there, the ADR-0078 failure mode one layer below where this ratchet reaches. That IS the #4909 open-slot shape, but `.passthrough()` would be exactly as vacuous as `.strict()` on a schema nothing parses, so no posture change was made. The fix is to wire the parse at the carrier's own gate — a `packages/lint`/carrier change, filed as **#5068**, which also records the two constraints that stop it being a drive-by: `type` is an open union (`z.union([PageComponentType, z.string()])`, so `record:line_items`-style unregistered types are authored in the wild) and real pages already author shapes these schemas do not declare (`record:details` `sections[].fields[]`/`hideFields[]`, the record picker's `labelField` — `packages/lint/src/validate-page-field-bindings.ts` has documented the untyped bag all along). **Do not reschedule this as strictness work** — that is what the `(p)` was for, and it has been answered. Recorded in three places (file header, `component.test.ts` pin incl. a standing assertion that goes red the day `properties` gets a typed dispatch, this row) | | `theme.zod.ts` | 14 | authorable | **strict as of #4001 批 15** — all 14 sites. The `(p)` resolved to authorable on two doors, both measured: `stack.zod.ts` declares `themes: z.array(ThemeSchema)` (so `defineStack()` parses every theme on boot and on `objectstack build`), and `defineTheme()` parses one directly. A BFS from all 24 metadata-type roots plus `ObjectStackSchema` reaches every schema in the file, with `PageSchema`/`DashboardSchema`/`ReportSchema`/`WebhookSchema`/`StateMachineSchema` passing as positive controls and 批 13's no-door shapes failing as negative controls **in the same run**. Note what is NOT claimed: `theme` is deliberately absent from `BUILTIN_METADATA_TYPE_SCHEMAS`, so a stored theme row is not validated by the metadata REST door — the gate is the authoring one, and the file says so rather than implying reach it lacks. **The `passthrough` question was asked per BLOCK, not per file**, and the answer split: objectui's `ThemeEngine` reads `colors`/`borderRadius`/`shadows`/`typography.fontFamily` through FIXED maps (an extra key is read by nothing, ever), but spreads `fontSize`/`fontWeight`/`lineHeight`/`letterSpacing`/`duration`/`timing`/`zIndex` with `Object.entries` into `--font-size-` … — the #4909 open shape at the runtime. Closed anyway, on two measurements: `.strip` already discarded those extras before the engine saw them (so no author depends on the openness and nothing the renderer receives changes), and `customVars` is a DECLARED escape hatch that emits an arbitrary CSS custom property by name, so closing the token scales removes no capability and only removes a second, undocumented way to spell one — the way whose typos are indistinguishable from intent. Curation is measured throughout: the shadcn vocabulary (`card`→`surface`, `foreground`→`text`, `destructive`→`error`) comes from objectui's own `COLOR_TO_CSS_MAP`, which RENAMES every palette key on the way out; `md`→`base` on `fontSize` and `base`→`normal` on `fontWeight` are a same-file scale disagreement (`borderRadius`/`shadows` declare `md`, `fontSize` does not); `radius`→`base` because `base` is emitted as the bare `--radius`, the one radius variable objectui's CSS actually reads; and `easeIn`→`ease_in` because `animation.timing` is the file's single snake_case vocabulary, so the camelCase spelling is an author obeying AGENTS.md #3 rather than making a typo. The eight #3494 removals get one distinct tombstone each. ⚠️ **Two of those tombstones deliberately prescribe NO replacement slot**: `touchTarget`/`keyboardNavigation` read like they should point at `ui/touch.zod.ts`/`ui/keyboard.zod.ts`, which 批 13 measured as having no carrier at all (#4988) — prescribing them would walk an author out of a loud rejection into a silent one, the ledger's finding 7. ⚠️ **Separately filed, not answered here**: `--font-size-*`, `--font-weight-*`, `--line-height-*`, `--letter-spacing-*`, `--z-*`, `--duration-*`, `--timing-*`, `--font-heading` and `--font-mono` have ZERO first-party consumers (only the colour vars, `--radius*`, `--shadow*` and `--font-sans` are read). That is ADR-0049 liveness, not unknown keys, and the two must not be run together — strictness makes a dropped key loud, it cannot make a slot live | | `app.zod.ts` | 18 | authorable | **strict as of #4001 PR B** — `AppSchema` + branding / area / context-selector / contribution, and the nav-item union converted to `z.discriminatedUnion('type', …)` (the union-error question, settled empirically: matched-branch-only errors, exact recursive paths, `toJSONSchema` clean). Per-target `params` stay open. PR A (#4142) tombstoned the seven audit-dead keys first | | `dashboard.zod.ts` | 11 | authorable | partially strict | diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 88d3ea6dfa..d07e00fca0 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -47,6 +47,15 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; // `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type names only — // its `REACT_BLOCKS[].schema` entries all point at view/chart schemas. // +// The #5056 bridge defect does NOT touch this result. That defect makes the +// derived-clone bridge report dead shapes as REACHABLE (shared `.describe()` +// clones under common leaves like `SnakeCaseIdentifier` / `I18nLabel`), so its +// error direction is the opposite of this verdict -- it could only have hidden a +// no-gate finding, never manufactured one. And nothing here rests on that bridge +// anyway: all six positive controls resolve `root-graph` (their own instances are +// in the closure), and all 52 targets miss BOTH `root-graph` and `derived-clone`. +// The two non-BFS measurements below stand on their own regardless. +// // Empirically, through the live door (`definePage()` IS `PageSchema.parse()`): on // the example corpus an undeclared key written inside `components[].properties` // parses clean and is RETAINED on 10/10 pages, while the same key one level out From bc098e92ba8195fbe0b9dcb4d9daf569ae0b4f34 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:01:34 +0000 Subject: [PATCH 4/5] =?UTF-8?q?docs(changeset):=20=E6=8A=8A=20=E6=89=B9=20?= =?UTF-8?q?17=20changeset=20=E7=9A=84=E8=B4=A6=E6=9C=AC=E7=AE=97=E6=9C=AF?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=BA=E5=90=88=E5=B9=B6=E5=90=8E=E6=95=B0?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 #5042(批 14)后总数 100 → 91,authorable 65 → 36,两个 no-parse 类 合计 55 of 91。changeset 正文会作为 CHANGELOG 发给使用者,不能停留在 合并前的分支局部数值。 Part of objectstack-ai/objectstack#4001 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .changeset/unknown-key-strictness-ui-batch17.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/unknown-key-strictness-ui-batch17.md b/.changeset/unknown-key-strictness-ui-batch17.md index ba8c7271e6..f74e49d389 100644 --- a/.changeset/unknown-key-strictness-ui-batch17.md +++ b/.changeset/unknown-key-strictness-ui-batch17.md @@ -26,4 +26,4 @@ contract-first 的修法是把 parse 接到承载键自己的闸门上,已单独 判定写在三处(文件头、`component.test.ts` 的钉子——含一条 `properties` 一旦获得类型化分派就变红的断言、账本 `ui/` 两张表),改要一起改。 -账本连带效果:`ui/` 的 authorable strip 从 76 降到 47(总数仍是 100,重分类只是换类不是出列),`no gate` 从 2 涨到 31。**`ui/` 剩余 100 个 strip 站点里有 53 个根本不是本 ratchet 的工作**(22 个 `no door` + 31 个 `no gate`)。 +账本连带效果(与 #5042 批 14 合并后、从存活行重算):`ui/` 的 authorable strip 从 65 降到 **36 of 91**——重分类只是换类不是出列,总数不变,文件保留 29/29 行,`check:strictness-ledger` 的反向钉子仍然管着它;`no gate` 从 2 涨到 **31**。由此得到这个目录现在最大的一个事实:**`ui/` 剩余 91 个 strip 站点里有 55 个(过半)根本不是本 ratchet 的工作**(24 个 `no door` + 31 个 `no gate`)。排后续 `ui/` 收紧批次前请先读这个数。 From db33578990401c3a2454dfef953b063979979788 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 05:25:09 +0000 Subject: [PATCH 5/5] =?UTF-8?q?docs(changeset):=20=E6=8A=8A=20=E6=89=B9=20?= =?UTF-8?q?17=20changeset=20=E7=AE=97=E6=9C=AF=E6=9B=B4=E6=96=B0=E4=B8=BA?= =?UTF-8?q?=E5=90=88=E5=B9=B6=20=E6=89=B9=2016=20=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E6=95=B0=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5069(批 16)关掉 AriaProps 并把 widget/i18n 移出 authorable,总数 91 → 90。批 17 的 changeset 正文会作为 CHANGELOG 发给使用者,不能停留在 上一次合并时的数值:authorable 50 → 21 of 90,两个 no-parse 类合计 69 of 90(38 no door + 31 no gate)。 Part of objectstack-ai/objectstack#4001 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --- .changeset/unknown-key-strictness-ui-batch17.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/unknown-key-strictness-ui-batch17.md b/.changeset/unknown-key-strictness-ui-batch17.md index f74e49d389..efae42186c 100644 --- a/.changeset/unknown-key-strictness-ui-batch17.md +++ b/.changeset/unknown-key-strictness-ui-batch17.md @@ -26,4 +26,4 @@ contract-first 的修法是把 parse 接到承载键自己的闸门上,已单独 判定写在三处(文件头、`component.test.ts` 的钉子——含一条 `properties` 一旦获得类型化分派就变红的断言、账本 `ui/` 两张表),改要一起改。 -账本连带效果(与 #5042 批 14 合并后、从存活行重算):`ui/` 的 authorable strip 从 65 降到 **36 of 91**——重分类只是换类不是出列,总数不变,文件保留 29/29 行,`check:strictness-ledger` 的反向钉子仍然管着它;`no gate` 从 2 涨到 **31**。由此得到这个目录现在最大的一个事实:**`ui/` 剩余 91 个 strip 站点里有 55 个(过半)根本不是本 ratchet 的工作**(24 个 `no door` + 31 个 `no gate`)。排后续 `ui/` 收紧批次前请先读这个数。 +账本连带效果(与 #5042 批 14、#5069 批 16 合并后,从存活行重算):`ui/` 的 authorable strip 从 50 降到 **21 of 90**——重分类只是换类不是出列,总数不变,文件保留 29/29 行,`check:strictness-ledger` 的反向钉子仍然管着它;`no gate` 从 2 涨到 **31**。由此得到这个目录现在最大的一个事实:**`ui/` 剩余 90 个 strip 站点里有 69 个(超过四分之三)根本不是本 ratchet 的工作**(38 个 `no door` + 31 个 `no gate`)。排后续 `ui/` 收紧批次前请先读这个数。