背景
#4001 批 17 的任务是把 packages/spec/src/ui/component.zod.ts 的 29 个 strip 站点收紧为 strictObject。测量结果是:这些站点根本没有 parse,因此 .strict() 在这里什么都不会强制——按 ledger 的分类规则,这是 no gate(carrier 活着、parse 缺席),不是 authorable。
批 17 因此没有收紧该文件,而是记录判定并把「接上闸门」这件事单独立为本 issue(与 批 15 对 ChartAggregateSchema 的处理一致:contract-first 的修法属于 packages/lint / 载体那一侧,不应塞进 spec 的 strictness 批次里)。
事实(三条独立测量,控制组同轮为真)
1. carrier 是活的,但它是一个开放 record
packages/spec/src/ui/page.zod.ts:115
properties: z.record(z.string(), z.unknown()).optional().default({}).describe('Component props passed to the widget. See component.zod.ts for schemas.')
PageComponentSchema 自 ADR-0089 D3a 起是 .strict(),但严格性不递归:它守住的是 component 节点自己的键,properties 里面是一个未校验的口袋。
2. BFS 不可达
从 24 个 metadata-type 根 + defineStack 的 ObjectStackSchema 出发,复用 build-schemas.ts 自己的 zodChildSchemas / zodShapeOf(#4650 闭包),闭包 6899 个 zod 节点:
component.zod.ts 的 52 个目标(21 个导出 schema + ComponentPropsMap 全部 31 个条目)全部 UNREACHABLE;
- 同一轮里
PageSchema / PageComponentSchema / PageRegionSchema / ThemeSchema / ChartConfigSchema / ResponsiveConfigSchema 六个正控制组全部 root-graph;
- 批 13 测定的三个 no-door 形状(
TouchInteractionSchema / MotionConfigSchema / OfflineConfigSchema)保持 unreachable。
BFS 在 PageComponentSchema.properties 处停下——props schema 挂在任何东西上都没有。
3. 三个仓库里没有任何生产 parse 点
objectstack / objectui / cloud 全仓搜索 .parse( / .safeParse(:命中全部落在 packages/spec/src/ui/component.test.ts 与 inline-action.test.ts(该文件自己的单测)内。objectui 只引用推断出的类型并手写平行的 React interface;cloud 引用数为 0。
ui/react-blocks.ts 只用 Object.keys(ComponentPropsMap) 取类型名派生 RECORD_CONTEXT_BLOCK_TAGS,不 parse;它的 REACT_BLOCKS[].schema 指向 FormViewSchema / ListViewSchema / ChartConfigSchema,没有一个来自 component.zod.ts。
经验证据(批 14 方法,负控制组同轮为红)
definePage() 就是 PageSchema.parse(),所以这是活的授权门。对 example 语料里 21 个真实页面:
=== A. unknown key INSIDE `components[].properties` ===
parsed clean & key SURVIVED : 10
rejected : 0
=== B. NEGATIVE CONTROL — 同一个键放到 `properties` 的兄弟位上 ===
rejected (expected) : 10
wrongly accepted : 0
即:未声明的键写进 properties 会原样通过并被保留;同一个键写在外面一层立刻被拒。负控制组证明探针能测出闸门——这里就是没有闸门。
为什么这是个真问题(不是「无害」)
objectui 的 packages/react/src/SchemaRenderer.tsx 会把 properties 整个 hoist 到节点上,然后
React.createElement(Component, {
schema: schemaForComponent,
...componentProps, // Spread non-metadata schema properties as props
把除固定 deny-list 外的每一个作者写的键转发成 React prop(约 457 行)。所以一个拼错的键既不会被拒、也不会被丢,而是安静地流到渲染器,由渲染器忽略——ADR-0078 要消灭的正是这种「作者拿到成功回执、配置被无声忽略」的形状。
packages/lint/src/validate-page-field-bindings.ts 的文件头早就把这件事写下来了:
PageComponent.properties is z.record(z.string(), z.unknown()) — an untyped bag. The typed prop schemas exist (ComponentPropsMap in @objectstack/spec/ui) but nothing validates properties against them, so every field name a component references ships exactly as typed.
建议的修法(contract-first,需要维护者定方向)
把 parse 接到载体自己的闸门上,而不是在 spec 里做一个没人调用的 .strict()。两个已知约束必须先解决,这也是为什么不能顺手做:
type 的取值域是开放的——PageComponentSchema.type 是 z.union([PageComponentType, z.string()]),现实中存在 record:line_items 这类未注册类型(validate-page-field-bindings 明确说明未知类型静默跳过)。所以按 type 分派解析必须对未注册类型保持开放,否则会拒掉在用的页面。
- 现有真实页面写了 props schema 未声明的形状——同一文件记录:
record:details 的 sections[].fields[] 与 hideFields[]、record picker 的 labelField,「they pass only because properties is unvalidated」。今天直接接上严格解析会打红在用的 example / 客户页面,所以需要先补齐 schema 或先以 warning 级别落地。
可选方向(择一,建议 A):
- A. 在 publish / lint 侧按
type 分派 ComponentPropsMap 解析,未注册类型跳过,先 warning 后 error。好处:与 validate-page-field-bindings 同层、同 severity 习惯,不动 page 协议形状;坏处:存储路径(metadata REST 写入)仍然不校验。
- B. 把
PageComponentSchema.properties 改成按 type 的判别式 schema。好处:所有路径(defineStack / REST / Studio)一次性覆盖;坏处:改的是 page 协议本身的形状,对开放 type 与未声明形状的兼容代价大得多,属于 breaking。
任一方向落地之后,component.zod.ts 才重新变成 authorable,#4001 的收紧才有意义——届时把 ledger 行从 no gate 改回 authorable 并排进后续批次。
参考
背景
#4001 批 17 的任务是把
packages/spec/src/ui/component.zod.ts的 29 个 strip 站点收紧为strictObject。测量结果是:这些站点根本没有 parse,因此.strict()在这里什么都不会强制——按 ledger 的分类规则,这是no gate(carrier 活着、parse 缺席),不是authorable。批 17 因此没有收紧该文件,而是记录判定并把「接上闸门」这件事单独立为本 issue(与 批 15 对
ChartAggregateSchema的处理一致:contract-first 的修法属于packages/lint/ 载体那一侧,不应塞进 spec 的 strictness 批次里)。事实(三条独立测量,控制组同轮为真)
1. carrier 是活的,但它是一个开放 record
packages/spec/src/ui/page.zod.ts:115PageComponentSchema自 ADR-0089 D3a 起是.strict(),但严格性不递归:它守住的是 component 节点自己的键,properties里面是一个未校验的口袋。2. BFS 不可达
从 24 个 metadata-type 根 +
defineStack的ObjectStackSchema出发,复用build-schemas.ts自己的zodChildSchemas/zodShapeOf(#4650 闭包),闭包 6899 个 zod 节点:component.zod.ts的 52 个目标(21 个导出 schema +ComponentPropsMap全部 31 个条目)全部 UNREACHABLE;PageSchema/PageComponentSchema/PageRegionSchema/ThemeSchema/ChartConfigSchema/ResponsiveConfigSchema六个正控制组全部root-graph;TouchInteractionSchema/MotionConfigSchema/OfflineConfigSchema)保持 unreachable。BFS 在
PageComponentSchema.properties处停下——props schema 挂在任何东西上都没有。3. 三个仓库里没有任何生产 parse 点
objectstack/objectui/cloud全仓搜索.parse(/.safeParse(:命中全部落在packages/spec/src/ui/component.test.ts与inline-action.test.ts(该文件自己的单测)内。objectui只引用推断出的类型并手写平行的 React interface;cloud引用数为 0。ui/react-blocks.ts只用Object.keys(ComponentPropsMap)取类型名派生RECORD_CONTEXT_BLOCK_TAGS,不 parse;它的REACT_BLOCKS[].schema指向FormViewSchema/ListViewSchema/ChartConfigSchema,没有一个来自component.zod.ts。经验证据(批 14 方法,负控制组同轮为红)
definePage()就是PageSchema.parse(),所以这是活的授权门。对 example 语料里 21 个真实页面:即:未声明的键写进
properties会原样通过并被保留;同一个键写在外面一层立刻被拒。负控制组证明探针能测出闸门——这里就是没有闸门。为什么这是个真问题(不是「无害」)
objectui的packages/react/src/SchemaRenderer.tsx会把properties整个 hoist 到节点上,然后把除固定 deny-list 外的每一个作者写的键转发成 React prop(约 457 行)。所以一个拼错的键既不会被拒、也不会被丢,而是安静地流到渲染器,由渲染器忽略——ADR-0078 要消灭的正是这种「作者拿到成功回执、配置被无声忽略」的形状。
packages/lint/src/validate-page-field-bindings.ts的文件头早就把这件事写下来了:建议的修法(contract-first,需要维护者定方向)
把 parse 接到载体自己的闸门上,而不是在 spec 里做一个没人调用的
.strict()。两个已知约束必须先解决,这也是为什么不能顺手做:type的取值域是开放的——PageComponentSchema.type是z.union([PageComponentType, z.string()]),现实中存在record:line_items这类未注册类型(validate-page-field-bindings明确说明未知类型静默跳过)。所以按type分派解析必须对未注册类型保持开放,否则会拒掉在用的页面。record:details的sections[].fields[]与hideFields[]、record picker 的labelField,「they pass only becausepropertiesis unvalidated」。今天直接接上严格解析会打红在用的 example / 客户页面,所以需要先补齐 schema 或先以 warning 级别落地。可选方向(择一,建议 A):
type分派ComponentPropsMap解析,未注册类型跳过,先 warning 后 error。好处:与validate-page-field-bindings同层、同 severity 习惯,不动page协议形状;坏处:存储路径(metadata REST 写入)仍然不校验。PageComponentSchema.properties改成按type的判别式 schema。好处:所有路径(defineStack / REST / Studio)一次性覆盖;坏处:改的是page协议本身的形状,对开放type与未声明形状的兼容代价大得多,属于 breaking。任一方向落地之后,
component.zod.ts才重新变成authorable,#4001 的收紧才有意义——届时把 ledger 行从no gate改回authorable并排进后续批次。参考
no door)/ 批 15(no gate,ChartAggregateSchema同形状)docs/audits/2026-07-unknown-key-strictness-ledger.md,ui/两张表的component.zod.ts行