Repository navigation
finding(types): 28 zod-mirror keys are declared z.function(), which NO JSON document can satisfy — 14 in-repo corpus files author them and are silently invalid #6124
Description
Activity
Triage:
needs-user-decision+domain:ui(落点@object-ui/typeszod mirror,objectui 发布库契约)。立卡人已正确停手 —— 两个方向都改已发布契约,人工地板。查重结论照准:与 #5927/#5853/#6058(mirror↔TS 声明漂移轴)不同轴,本卡是 mirror↔JSON 可作者性轴;#5250 是反方向。姊妹卡:#6132(文档 action props 无声明槽位)同属 action-prop 契约族,裁决时宜同场对读。四维分析(决策输入,非裁决)
实际业务需求 —— JSON authoring 是本平台的核心面,而 28 个键今天对 JSON 作者是结构性不可满足:仓内 14 个 corpus 文件实际作者了
onClick: {action: …}声明式动作对象并在safeParse下全数被拒(且因 #5127 结构键盲区而不可见)。真实用法存在、真实被拒 —— 这不是投机性能力面。项目长远合理性 —— contract-first:mirror 应当声明运行时真正接受并兑现的形状。
z.function()在 JSON 协议层等于「声明了一个任何文档都写不出的键」,declared ≠ authorable,两个方向(收窄退役 / 拓宽到声明式形状)都比现状诚实。但拓宽必须逐键对准运行时:凡 renderer 的 action 管线并不消费声明式对象的键,拓宽即是「声明一个运行时不兑现的能力」。防 AI 写代码/元数据犯错 —— 现状是最坏形态:AI 作者照 corpus 学会
onClick: {action: …},schema 静默拒绝、门禁又看不见(#5127 盲区),错误既不响也不可修。严格声明真实形状(或响亮移除)任一方向都消除这个温床;维持现状是唯一不可接受项。创业阶段不扩散需求 —— 不新造能力:声明式 action 对象已是 corpus 与 renderer 管线的既有用法,收敛的是「声明与现实的差距」,不是扩面。28 键一刀切拓宽才是扩散 —— 应以实测消费为界。
四棱块
- 业务:14 corpus 文件实测被拒,真实用法 > 投机面 → 指向「按实测收敛」
- 长远:declared=enforced,mirror 对准运行时真实接受形状 → 指向「按实测收敛」
- 防错:现状 = 静默拒绝 + 门禁盲区双重掩盖,任一方向皆优于维持 → 指向「按实测收敛」
- 聚焦:不新造能力,以 renderer 实测消费为界拆两半 → 指向「按实测收敛」
推荐(四棱同向,但改已发布契约 = 人工地板,恒交维护者):先量后裁的逐键收敛 —— 对 28 键逐键实测 renderer action 管线是否消费声明式对象:① 有消费的键,mirror 拓宽为声明式 ActionConfig 形状(14 corpus 文件随之转合法);② 零消费的键,按 ADR-0049 enforce-or-remove 退役或降级为 runtime-only 非作者面。测量卡先行、裁决后分两腿实施;与 #6132 的槽位裁决同场对读避免两次裁同族。
Generated by Claude Code
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Aug 24, 2026 维护者提问(2026-08-25 批次回批,逐字):「6124 服务端协议中 hook 函数被保存成json时转换过,前端是否要类似的思路。」—— 本卡暂留决策箱,以下是对服务端机制的实测对照与细化提案(读数取 objectstack
origin/main)。服务端实际怎么做(两件套 + 一条边界):
- Hook 体:函数从不进 JSON,进的是「声明式信封」(
packages/spec/src/data/hook-body.zod.ts):JSON 面是 strict 的结构化对象{language: 'expression'|'js', source: <字符串>, capabilities, timeoutMs},运行时用new AsyncFunction('ctx', source)在带能力令牌的沙箱里再水化。校验器声明的是可序列化形,不是z.function()。 - Flow functions:双面 union + build 期降级(
packages/spec/src/automation/flow-function.zod.ts:220-232):FlowFunctionEntrySchema = z.union([z.function(), 声明记录, 字符串 ref, 降级记录])—— 程序面(defineStack 里的真函数)合法,但objectstack build在 parse 前把每个内联 callable 降级成字符串 ref,真函数乘 sibling ESM 模块、加载时再挂回。一个 schema 同时诚实声明「作者写的形」和「构建产物形」。 - 纯运行时契约面保留
z.function()(driver.zod.ts/data-engine.zod.ts):那些 schema 描述的是从不作为 JSON 作者化的运行时接口 ——z.function()在那里是对的。本卡的病根恰是一个 schema 同时伺候两个面。
前端要不要同思路?—— 要,且比服务端更省:前端已有闭合的声明式词汇(ActionDef),不需要发明 source-string 信封。 细化提案(原推荐 2+1 的服务端对齐版):
- A(推荐):28 键逐键测渲染器消费面 ——
- 有声明式消费(渲染器吃
{"action":…}对象形)⇒ 镜像改z.union([z.function(), ActionDefSchema])(照 FlowFunctionEntrySchema 先例:函数臂伺候程序面/React 回调,对象臂伺候 JSON 作者;JSON 文档永远走对象臂);14 个语料文件随之转绿或修正; - 无声明式消费 ⇒ 退出 JSON 镜像(runtime-only face,ADR-0049)—— 照 driver.zod.ts 先例。
- 有声明式消费(渲染器吃
- 边界纪律(rider 测量,命中则另立小卡):若 objectui 存在任何「schema → JSON 序列化」出口(designer 保存等),函数值在出口必须响亮拒绝或降级为声明式形 ——
JSON.stringify对函数值是静默丢弃,正是本仓最忌的形状;服务端的lowerCallables即此步的先例。 - B(否决理由存档):纯 function-only(全 28 键保持 z.function())= 校验器继续对 JSON 作者撒谎;纯对象-only(删函数臂)= 程序面/React 用法失去声明。
一致性核对:#6182(handler 表达式方言)已出箱,其裁决若与本案相交,claim 时先读、以后者不推翻前者为准。
置信缺口:28 键逐键消费面未测(语料只证明 toast/sonner 一角);ActionDef 联合形的确切边界(是否含字符串命名 handler ref,对齐 flow 的 ref 臂)待测量后定。低摩擦裁决:回「A」即按上式入队(Clause-②,契约复审档);「A,但 …」照常。
Generated by Claude Code
- Hook 体:函数从不进 JSON,进的是「声明式信封」(
Maintainer ruling (2026-08-25, batch adjudication close-out; verbatim: 「6124 同意 A」): Option A — the server-pattern alignment as refined in the previous comment.
Ruled execution shape:
- Per-key measurement across all 28
z.function()keys: does the renderer consume a declarative object form for this key? - Consumed ⇒ mirror becomes
z.union([z.function(), <closed ActionDef shape>])— the function arm serves the programmatic/React face, the object arm serves JSON authors (theFlowFunctionEntrySchemaprecedent, objectstackflow-function.zod.ts); the exact ActionDef union boundary (including whether a string-named handler ref arm belongs, mirroring flow's ref arm) is fixed by the measurements. - Not consumed ⇒ the key leaves the JSON mirror (runtime-only face, ADR-0049 — the
driver.zod.tsprecedent). - The 14 silently-invalid corpus files go green (or get corrected) in the same PR — that flip is the proof the contract now tells authors the truth.
- Rider measurement: if any schema→JSON serialization door exists in objectui (designer save etc.), function values must be loudly refused or lowered at that door (
JSON.stringifydrops functions silently — the server'slowerCallablesis the precedent); a hit files its own follow-up card, not a rider fix. - Consistency check at claim: read [Decision] Is an authored handler EXPRESSION a supported dialect? 56 mirrors say function, 11 say string, and both appear in files that mirror the other #6182's landed ruling first; the later ruling must not silently overturn the earlier one — conflict = fork back to triage.
State transition:
needs-user-decision→pm:queue(this comment is the authoritative record if the label lags). Lanedomain:ui. Clause-②: yes (published-validator accept-set change) — contract-review tier at dispatch. Size M (28-key measurement + union edits + corpus verification).
Generated by Claude Code
- Per-key measurement across all 28
Scope extension (triage, same-day ruling #6182-A, maintainer verbatim 「同意」): the 11
z.string()handler-expression mirrors measured in #6182 (rg -n 'on[A-Z][A-Za-z]*:\s*z\.(function|string|any)' packages/types/src/zod/— the string rows, e.g.onClick/onInstall/onPreview/onSave/onCancel/onBack/onViewChange/onChange) join this card's per-key sweep with the SAME ruled treatment as the 28z.function()keys: renderer consumes a declarative object form ⇒ union arm; otherwise runtime-only retirement. The expression-string dialect is not a supported authoring form (it is measurably dropped at runtime, #4453). The 3z.any()rows from the same census get the same per-key walk while you are in the files. #6182 ispm:blockedon this card and closes on its landing.
Generated by Claude Code
Claim:
domain:uiexecution seat, PM sessionsession_01CRJge11jso9TpXRWFt1Z49, branchclaude/issue-6124-zfunction-mirror-sweep.Claimed after re-reading the whole thread: four comments, no prior
Claim:with a different session ID, unassigned. The maintainer's 2026-08-25 ruling (verbatim 「6124 同意 A」) is the authoritative record and the card is execution, not decision.⚠️ The ruling comment is SANITIZER-TRUNCATED at its most load-bearing line — read the comment before itThe ruling's item 2 reads:
mirror becomes
z.union([z.function(), ])That is not what was ruled. GitHub's sanitizer eats generic-looking type arguments, and it ate the object arm — leaving a shorter string that still parses as a complete sentence, which is exactly why this class of damage survives unnoticed. The arm is spelled out intact in the immediately preceding comment (2026-08-25T06:01Z):
镜像改
z.union([z.function(), ActionDefSchema])(照 FlowFunctionEntrySchema 先例:函数臂伺候程序面/React 回调,对象臂伺候 JSON 作者;JSON 文档永远走对象臂)⛔ Do not implement
z.union([z.function(), ])literally. The ruled shape is a function arm plus a declarative-object arm, with the exactActionDefunion boundary — including whether a string-named handler ref arm belongs, mirroring flow's ref arm — fixed by the per-key measurements, not assumed.Scope, as ruled and as extended
The sweep is not 28 keys. Per the same-day scope extension (#6182-A, maintainer 「同意」):
- the 28
z.function()keys, and - the 11
z.string()handler-expression mirrors from [Decision] Is an authored handler EXPRESSION a supported dialect? 56 mirrors say function, 11 say string, and both appear in files that mirror the other #6182's census, with the same treatment — the expression-string dialect is not a supported authoring form and is measurably dropped at runtime (plugin-calendar: an authoredonEventClickreaches CalendarView through the spread and throws an UNCAUGHT error on click — needs a contract decision, not a strip #4453), and - the 3
z.any()rows get the same per-key walk while the dev is in those files.
Per key: renderer consumes a declarative object form ⇒ union arm; not consumed ⇒ the key leaves the JSON mirror as a runtime-only face under ADR-0049 (the
driver.zod.tsprecedent).Item 4 is the proof obligation and is not optional: the 14 silently-invalid corpus files go green (or get corrected) in the same PR — that flip is what demonstrates the contract now tells authors the truth.
Item 5 is a rider measurement, not a rider fix: if any schema→JSON serialization door exists in objectui (designer save, etc.), a function value at that door must be loudly refused or lowered —
JSON.stringifydrops functions silently, the shape this repo least tolerates. A hit files its own card; ⛔ it is not fixed inside this PR.Item 6: read #6182's landed ruling first. The later ruling must not silently overturn the earlier one; a conflict forks back to triage rather than being resolved by the dev.
Unlock value
#6182 is
pm:blockedon this card and closes on its landing, with an executable close condition (re-run itsrgon the merged ref and confirm the 11 mirrors no longer carry the barez.string()expression dialect).Tier
Clause-②: yes (published-validator accept-set change) — contract-review tier at dispatch: draft PR, this seat reviews before it goes ready. ⛔
needs:contract-reviewis not attached: it is a phantom label in this repo with zero readers, and the standing maintainer order for this tier is 「直接入队」 after seat review.
Generated by Claude Code
- the 28
os-dev-report
{ "issue": 6124, "status": "needs_decision", "branch": "claude/issue-6124-zfunction-mirror-sweep", "pr": null, "premise_still_valid": false, "summary": "No code written. The ruled sweep is not executable as ruled, for three measured reasons, and the card's headline premise is dead on today's main. (1) CENSUS re-run on merge-base 813bf8323 reproduces the card exactly: 28 distinct z.function() keys / 60 call sites / 9 files, 11 on* z.string() rows, 3 on* z.any() rows. (2) PER-KEY: zero of the 42 distinct keys is consumed as a declarative object, so the ruled union arm z.union([z.function(), ActionDefSchema]) has NO qualifying key and its boundary lands EMPTY; every measured consumption is a function CALL, a React-prop forward, or an authored string used as a CustomEvent NAME. (3) ITEM 4 IS ALREADY DISCHARGED ELSEWHERE: PR #6498 (merged 2026-08-26, one day after the ruling) rewrote all 14 toast/sonner fixtures; the corpus reds attributable to this card's defect are 0, so this PR cannot produce the flip the ruling made its proof. (4) THE BLOCKER: executing the retirement branch under BaseSchema.passthrough() converts a LOUD parse-time rejection into a SILENT accept that preserves the bad value — measured, both directions — and onClick is in SDUI_DOM_PASS_THROUGH_KEYS, so the preserved object reaches the DOM listener slot and throws at click. That is a regression on the maintainer's own decisive axis, whose triage wording was '响亮移除'; under passthrough the removal is silent, not loud. Forking back rather than landing it.", "tests": "No repo files changed (git status clean, zero commits on the branch), so no gates were owed or run — reporting that plainly rather than manufacturing a gate list. All measurement ran through the shared verify lock (bash /home/user/objectstack/scripts/pm/os-verify-lock.sh -c ..., OS_VERIFY_LOCK_SLOT=issue-6124): three runs, verdict lines 'os-verify-lock: VERDICT command-exit 0' (waits 154s / 0s / 0s). Harness was a temporary probe at packages/types/src/__tests__/zz-6124-probe.test.ts, run as 'npx vitest run <that file> --maxWorkers=2' — 'Test Files 1 passed (1) / Tests 3 passed (3)', file count confirmed as the expected 1 (no --project flag used). Probe DELETED afterwards; worktree verified clean. RED-BEFORE-GREEN on the blocker, both legs in one run: [A] today ButtonSchema.safeParse of the card's own historical payload (recovered from git at bca58068) => success=false, issue {'code':'invalid_type','expected':'function','path':['onClick'],'message':'Invalid input: expected function, received object'}; [B] the same payload against ButtonSchema.omit({onClick:true}) — the retirement, simulated — => success=TRUE, and the parsed output still carries onClick={'action':'toast','variant':'error','title':'Error','description':'Something went wrong.'}, i.e. passthrough preserves the value it no longer judges. Independently corroborated: onClick is a member of SDUI_DOM_PASS_THROUGH_KEYS (packages/core/src/utils/dom-props.ts:88-99, read directly, not inherited from #6498). CORPUS SWEEP (AnyComponentSchema.safeParse over every root-`type` JSON in examples/schema-catalog/src/schemas): 428 docs, 355 green, 73 red, toast/sonner reds = 0, and reds whose failure is `expected: function` = 0 — the 73 are a different, pre-existing defect family (text / sidebar / carousel / button-group), untouched and not this card's. No ablation was run: nothing was mutated, so there is no restore leg to prove.", "mcp_calls": "2 — one targeted search_issues (dedupe for the item-5 card) and one issue_write create, plus one issue_write update to restore a stripped footer and this comment; every read of #6124 / #6182 / #4453 / #5127 / PR #6498 went through the zero-config unauthenticated REST channel, which answers 200 for repo-scoped reads on this public repo. Channel switch declared: repo-scoped REST works, but /search/issues answers 403 for this seat, so dedupe went through MCP as the sanctioned fallback (3 results, all closed, none on the door — a non-empty result set, so no separate control word was needed).", "open_questions": [ { "question": "Q1 (blocking, applies to ALL 28 keys). Retiring a handler key from the mirror under BaseSchema.passthrough() turns today's loud rejection into a silent accept that KEEPS the bad value and forwards it to the DOM. Measured: reject -> accept, value preserved. The ruling's ADR-0049 precedent is driver.zod.ts, which is safe there precisely because those schemas are never JSON-authored and nothing passes unknown keys through; neither condition holds here. How should the retirement be made LOUD, as the triage wording ('响亮移除') requires?", "options": [ "A. Retire the keys AND make the affected mirrors refuse a retired handler key explicitly (a named refusal arm rather than deletion), so an authored onClick is rejected with a message naming the supported spelling. Cost: touches the same 9 mirrors twice and needs a refusal vocabulary that does not exist yet; strongest on the AI-error axis — the only option under which the validator still speaks.", "B. Retire the keys and pair it with resolving #5250 (BaseSchema.passthrough accepts undeclared keys) so removal is loud by default. Cost: #5250 is a much larger blast radius and is its own open decision; couples this card to it.", "C. Retire the keys as ruled and accept the silent-accept window as a known regression, tracked by a follow-up. Cost: measurably worsens the exact authoring hazard this card was filed about — an author writing onClick as an object goes from a clear parse error to no error plus an uncaught runtime throw.", "D. Do nothing to the mirrors. Cost: keeps declared != authorable, which the four-axis analysis called the one unacceptable option." ], "recommendation": "A. It is the only option that satisfies the axis the maintainer made decisive — making AI-authored metadata hard to get wrong — because it is the only one where the author still gets told. C is refused on that axis by measurement, not by taste: it is strictly worse than today for the shape the card names. B is correct in direction but should not be smuggled in under this card; if the maintainer prefers it, this card should be marked blocked-by #5250 instead. Note A is a NEW capability (a refusal arm), i.e. beyond the ruled shape, which is why this is escalated rather than implemented." }, { "question": "Q2 (scope-extension premise is false for 3 of the 11 z.string() rows). The extension retires the string rows on the stated basis that the expression-string dialect 'is measurably dropped at runtime (#4453)'. For three rows that is factually wrong: the authored string is READ and DISPATCHED as a CustomEvent name — ViewSwitcherSchema.onViewChange (packages/plugin-view/src/ViewSwitcher.tsx:249-255), FilterUISchema.onChange (FilterUI.tsx:99-105), SortUISchema.onChange (SortUI.tsx:93-99), each 'new CustomEvent(schema.<key>, {detail: ...})' on window. These are neither the expression dialect #6182 refused nor a declarative object, and they are live JSON-authorable capabilities. Retiring them deletes working behaviour.", "options": [ "A. Exclude these 3 from the sweep and keep them mirrored as z.string(), re-describing them as event-NAME strings rather than 'callback'/'expression' so the next census does not re-bucket them wrongly. Cost: ~3 describe() edits; keeps a real capability and removes the mislabel that caused the misclassification.", "B. Retire them with the other 8, deleting a working authored capability. Cost: a silent feature removal justified by a premise that measurement contradicts.", "C. Treat the window-CustomEvent channel as itself undesirable and retire it deliberately as a capability decision. Cost: a genuine product call about an escape hatch, well beyond this card." ], "recommendation": "A, and note that the mislabel is the root cause: #6182's census bucketed by zod type, which cannot tell 'handler expression' from 'event name'. The other 8 string rows (app onClick, blocks onInstall/onPreview/onSave/onCancel, reports onSave/onCancel, views onBack) have NO read anywhere and are unaffected by this objection." }, { "question": "Q3 (the ruled union arm cannot be instantiated at all). The ruling fixes the ActionDef union boundary 'by the measurements'. The measurements fix it EMPTY: no handler key anywhere consumes a declarative object; schema.events — the ActionDef channel AGENTS.md section 4 documents — is consumed nowhere in the repo; and ActionRunner's runnable vocabulary (script|url|modal|flow|api|form|navigation) has no dispatcher a handler-key object could reach. #6182's landed ruling states 'the authorable form for actions is the declarative ActionDef object', which is true as an intent but is implemented by NO key. Declaring the arm anyway would declare a capability the runtime does not honour — the exact thing the triage comment forbade.", "options": [ "A. Accept that the sweep is 100% retirement and no union arm is added; the 'widen' half of the ruling is recorded as inapplicable-on-measurement. Cost: none beyond Q1.", "B. Build the missing capability — make some handler key actually consume an ActionDef object and dispatch it — then widen the mirror to match. Cost: a new renderer capability plus an ActionRunner dispatcher; a materially larger card and explicitly outside this dispatch.", "C. Fork the widen half to a separate card so the retirement half can proceed alone once Q1 is answered." ], "recommendation": "A or C, never B under this card. B is real work with real value — it is what would make #6182's 'authorable form' statement true — but it is a capability build, and both this dispatch and the startup-scope axis say a dev does not start one on a sweep card." }, { "question": "Q4 (three keys cannot be filed after retirement). cell, custom and validate are not on[A-Z]-shaped, so RuntimeOnlyDeclared refuses them (assertionRuntimeOnlyIsCallbackShapedOnly, which zod-mirror-parity.test.ts calls 'the load-bearing half'), while UnmirroredDeclared refuses callback-shaped keys and records 'ordinary mirroring debt' — a remedy #6152's ruling rejects for function-only keys. cell is genuinely consumed as a called function (plugin-grid/src/VirtualGrid.tsx:153 'column.cell(value, row)', ObjectGrid.tsx:3948), so it is a real runtime-only face that the ledger vocabulary cannot express.", "options": [ "A. Relax the shape pin deliberately to admit named non-on* runtime-only keys, as the file itself says must happen by RULING and never by quiet refiling. Cost: a governed change to a heavily-pinned test; needs the maintainer.", "B. Leave cell/custom/validate mirrored and retire only the 25 on* keys. Cost: leaves 3 keys in the state the card calls the defect; internally inconsistent but small.", "C. File the three in UnmirroredDeclared anyway. Cost: records a remedy the ruling rejects — refused." ], "recommendation": "A if the sweep proceeds at all, B as the honest partial. C is not acceptable: it would make the ledger lie about what it holds, which is the one thing its own pins exist to prevent." } ], "out_of_scope_findings": [ "filed as #6658: the designer save door (packages/react/src/hooks/useSchemaPersistence.ts:58-61) JSON.stringify's the schema with no callable guard, so a function-valued handler key is silently dropped and reload yields a schema that lost it with no error — this is the ruling's item-5 rider measurement, which the ruling directs to file its own card rather than fix here. Dedupe: 3 results, all closed, none on this door.", "not filed, reported for PM dedup rather than filed blind: 73 of 428 schema-catalog root-`type` documents fail AnyComponentSchema today for reasons unrelated to this card (0 of them for `expected: function`) — concentrated in components-basic-text, components-basic-sidebar, components-complex-carousel and components-basic-button-group. This looks like an existing family (#5127's structure-key-less blind spot is the likely reason they are unreported) and I did not want to spend a second dedupe search to guess at it.", "observation, not filed: the card's own census command `grep -hoE \"^\\s*[A-Za-z]+: z\\.function\\(\"` is sound, but #6182's `rg 'on[A-Z][A-Za-z]*:\\s*z\\.(function|string|any)'` is UNANCHORED and matches mid-identifier — buttonLabel, actionUrl, conversationId, locationField, actionName and descriptionField all contain an on[A-Z] substring. It over-reports. The anchored recount still reproduces 28/11/3, so no published number moves; worth fixing in place if #6182's close condition is re-run on the merged ref, since that command is the stated close condition." ] }Generated by Claude Code
Generated by Claude Code
⛔ ESCALATE — the ruled sweep is NOT executable as ruled. No code was the right answer.
domain:uiexecution seat, PM sessionsession_01CRJge11jso9TpXRWFt1Z49. The dev stopped and reported, wrote zero files, and left the branch with no commits. I am not overriding that, and I am not asking it to try again.pm:dispatched→needs-user-decision.⛔ This is not a re-opening of the direction. Option A stands. What measurement refutes is the execution shape, on four independent counts — and the first one would make the product worse on the axis the maintainer called decisive.
⭐ 1. The blocker: under
passthrough(), "retire the key" means SILENTLY ACCEPT itMeasured, both directions, on the card's own historical payload recovered from git:
leg result today — ButtonSchema.safeParse({onClick: {action:'toast',…}})success: false,{code:'invalid_type', expected:'function', path:['onClick']}the retirement, simulated — same payload against ButtonSchema.omit({onClick:true})success: TRUE, and the parsed output still carriesonClick: {action:'toast',…}BaseSchemais.passthrough(), so removing a key does not refuse it — it stops judging it and keeps it. And independently corroborated by reading the source rather than inheriting it:onClickis inSDUI_DOM_PASS_THROUGH_KEYS(packages/core/src/utils/dom-props.ts:88-99), so the preserved object reaches the DOM listener slot and throws at click.⇒ Executing the retirement branch converts a clear parse error into no error plus an uncaught runtime throw. The triage wording was 「响亮移除」 — loud removal. Under passthrough the removal is silent. That is strictly worse than today for exactly the authoring hazard this card was filed about.
⚠️ And this is the same rule that bit #6355 an hour ago, in its third face this week. There, deleting a key would have let the retired spelling parse green while the renderer stopped reading it; the fix was a?: nevertombstone rather than a deletion. Here it is the same mechanism with a worse tail, because the preserved value is then forwarded to the DOM. Underpassthrough()/ an index signature, absence is not refusal — the refusal has to be declared.The ruling's ADR-0049 precedent is
driver.zod.ts. That precedent is safe there precisely because those schemas are never JSON-authored and nothing passes unknown keys through. Neither condition holds here.2. The ruled union arm cannot be instantiated — its boundary lands EMPTY
The ruling fixes the
ActionDefunion boundary "by the measurements". The measurements fix it at nothing: zero of the 42 distinct keys is consumed as a declarative object. Every measured consumption is a function call, a React-prop forward, or an authored string used as aCustomEventname.schema.events— the ActionDef channel AGENTS.md §4 documents — is consumed nowhere in the repo, andActionRunner's runnable vocabulary (script|url|modal|flow|api|form|navigation) has no dispatcher a handler-key object could reach.⇒ #6182's ruled statement "the authorable form for actions is the declarative
ActionDefobject" is true as intent and implemented by no key. Declaring the arm anyway would declare a capability the runtime does not honour — the exact thing this card's own triage forbade.3. The ruling's proof obligation is already discharged, elsewhere, and cannot be re-earned here
Item 4 made the 14 silently-invalid corpus files going green the proof the contract now tells authors the truth. PR #6498 merged 2026-08-26 — one day after the ruling — and rewrote all 14 toast/sonner fixtures. Corpus reds attributable to this card's defect are now 0. So no PR under this card can produce that flip; the evidence the ruling asked for no longer exists to be produced.
(Corpus sweep for context: 428 root-
typedocs, 355 green, 73 red, toast/sonner reds = 0, reds withexpected: function= 0. The 73 are a different pre-existing family — text / sidebar / carousel / button-group — untouched and not this card's.)⭐ 4. Three of the 11 scope-extension rows are FALSE POSITIVES — retiring them deletes working behaviour
The extension retires the
z.string()rows on the basis that the expression-string dialect "is measurably dropped at runtime (#4453)". For three rows that is factually wrong — the authored string is read and dispatched as aCustomEventname:ViewSwitcherSchema.onViewChange—plugin-view/src/ViewSwitcher.tsx:249-255FilterUISchema.onChange—FilterUI.tsx:99-105SortUISchema.onChange—SortUI.tsx:93-99
each
new CustomEvent(schema.<key>, { detail: … })onwindow. These are neither the expression dialect #6182 refused nor a declarative object — they are live, JSON-authorable capabilities.⚠️ Root cause, and it is worth fixing at the source: #6182's census bucketed by ZOD TYPE, which cannot distinguish "handler expression" from "event name". The other 8 string rows have no read anywhere and are unaffected.⚠️ Bonus defect: #6182's close condition is an unanchored patternrg 'on[A-Z][A-Za-z]*:\s*z\.(function|string|any)'matches mid-identifier —buttonLabel,actionUrl,conversationId,locationField,actionName,descriptionFieldall contain anon[A-Z]substring. It over-reports. The anchored recount still reproduces 28 / 11 / 3, so no published number moves — but that command is #6182's stated close condition, so it should be fixed in place before anyone runs it on a merged ref.The census itself reproduced exactly
On merge-base
813bf8323: 28 distinctz.function()keys / 60 call sites / 9 files; 11on*z.string(); 3on*z.any(). The card's numbers hold.What needs a ruling (four questions, in dependency order)
- How is the retirement made LOUD? A named refusal arm so an authored
onClickis rejected with a message naming the supported spelling (a new capability, beyond the ruled shape); or couple this card to finding: JSON doc snippets are checked by nothing — the ts/tsx gate cannot see them, andBaseSchema.passthrough()makesobjectui validateaccept arbitrary undeclared keys #5250 (BaseSchema.passthroughaccepts undeclared keys) and mark it blocked on that; or accept the silent-accept window as a tracked regression. The dev recommends the refusal arm and refuses the third on measurement, not taste. ⛔ It escalated rather than building a new capability under a sweep dispatch — correct. - The three
CustomEvent-name rows — exclude and re-describe them as event-name strings so the next census does not re-bucket them wrongly (recommended), or retire them anyway and delete a working capability. - The widen half — record it inapplicable-on-measurement (100% retirement, no union arm), or fork it to its own card. ⛔ Not "build the missing dispatcher" under this card.
cell/custom/validateare noton[A-Z]-shaped, soRuntimeOnlyDeclaredrefuses them whileUnmirroredDeclaredrefuses callback-shaped keys — the ledger vocabulary cannot express a genuine non-on*runtime-only face. (cellis consumed as a called function:plugin-grid/src/VirtualGrid.tsx:153,ObjectGrid.tsx:3948.) Relaxing that pin is a governed change the file itself says must happen by ruling and never by quiet refiling.
Filed, per the ruling's item 5
#6658 — the designer save door (
packages/react/src/hooks/useSchemaPersistence.ts:58-61)JSON.stringifys the schema with no callable guard, so a function-valued handler key is silently dropped and reload yields a schema that lost it with no error. That is the rider measurement the ruling directed to file its own card rather than fix here. Deduped first.⇒ #6182 stays blocked on this card, and its close condition should be re-stated with the anchored pattern.
Generated by Claude Code
21 remaining items
Correction to the claim above (same seat, same session): the serial-constraints line was meant to read "the
packages/types/src/**serial queue opened when PR #7240 (#7087) landed asc93b4d5f3(07:32Z)". The dev is dispatched againstorigin/mainat or after that sha.
Generated by Claude Code
os-dev-report
{ "issue": 6124, "status": "done", "branch": "claude/issue-6124-on-handler-keys-refusal-arm", "pr": "https://github.com/objectstack-ai/objectui/pull/7339", "premise_still_valid": true, "summary": "Executed the 2026-08-30 ruling (Q1 A, Q2 A+C, Q3 A, Q4 B) on origin/main c93b4d5f3. All 58 on* z.function() sites (26 distinct keys, 8 mirror files; 62 = 58 + the four non-on* sites, which stay by Q4 B) are now named refusal arms in the #5099 z.custom + guidance shape via a new handlerKeyRefusal(key, disposition, label) helper in zod/tombstone.zod.ts: the message names the key, says why JSON cannot author it, and points at the node-type spelling PR #6498 established; the same string is the describe() metadata. The arm refuses live functions too. The ruling's stop condition was measured and did NOT fire: SchemaRenderer validates through @object-ui/core's structural validateSchema, and the only zod safeParse doors are the CLI validators and the exported validateSchema/safeValidateSchema, none fed a function-bearing object. TS face measured per key: SchemaRenderer spreads every non-metadata schema key as a React prop (SchemaRenderer.tsx:1576), so 36 keys whose value reaches a renderer (read off schema.*, called as props.onX, or spread onto a Radix root / DOM listener slot) keep their function type; 22 keys nothing reads get the ?: never tombstone. Parity ledger: 28 pairs re-stated in KnownDrift (35 keys; MenuItemSchema.onClick is a lazy union in EXCLUSIONS), header counts 12 to 36 entries, 17 to 52 keys, 146 to 122. Premise nuance: the card's 28/60 and the ruling's 29/61 are both stale (renderCellEditor landed with #7241; the population is 62/30 with 58/26 on*); the '14 corpus files' half is dead since PR #6498, as the ruling records. Assignee field was empty at pickup (PM-side state), worked as dispatched.", "census": "58 sites = 36 runtime slots + 22 retired. RUNTIME SLOT (TS keeps function; mirror refuses by name): KanbanSchema.onCardMove/.onCardClick (plugin-kanban forwards schema.*), CalendarViewSchema.onViewChange (pickHostCallbacks off spread props), FilterBuilderSchema.onChange (props.onChange called), ChatbotSchema.onError/.onSend (forwarded into useObjectChat), DataTableSchema.onRowEdit/.onRowDelete/.onSelectionChange/.onColumnsReorder (data-table.tsx calls schema.*), AccordionSchema.onValueChange, CollapsibleSchema.onOpenChange, ToggleGroupSchema.onValueChange (spread onto Radix roots), ButtonSchema.onClick (toFormControlDomProps forwards it to the DOM; onClick is on SDUI_DOM_PASS_THROUGH_KEYS), InputSchema/TextareaSchema/SelectSchema/CheckboxSchema/FileUploadSchema/DatePickerSchema/InputOTPSchema.onChange (renderers call props.onChange), FormSchema.onSubmit/.onChange/.onCancel (form.tsx destructures off schema and calls them), CodeEditorSchema.onChange (onChange ?? schema.onChange), CardSchema.onClick (props.onClick, spread onto Card), TabsSchema.onValueChange (tabsProps spread after own handler), PaginationSchema.onPageChange (props.onPageChange called), DialogSchema/AlertDialogSchema/SheetSchema/DrawerSchema/PopoverSchema/HoverCardSchema/DropdownMenuSchema.onOpenChange (spread onto Radix/vaul roots), MenuItemSchema.onClick (item.onClick?.() in dropdown/context/menubar). RETIRED (TS ?: never; mirror refuses by name): KanbanSchema.onColumnAdd/.onCardAdd, CarouselSchema.onSlideChange, ChatbotSchema.onSendMessage, AlertSchema.onDismiss, ListItem.onClick, TreeViewSchema.onSelectChange/.onExpandChange, ToastSchema.onDismiss, RadioGroupSchema/SwitchSchema/ToggleSchema/SliderSchema/ComboboxSchema.onChange (whitelist drops), CalendarSchema.onChange (DayPicker uses onSelect), CommandSchema.onChange (lands on cmdk root div as a DOM change listener, not the declared contract), InputOTPSchema.onComplete, BreadcrumbItem.onClick, SidebarSchema.onCollapsedChange, ButtonGroupButton.onClick, AlertDialogSchema.onConfirm/.onCancel. Out of scope by ruling: cell, custom, validate, renderCellEditor (stay z.function, pinned by name); EventHandlersSchema (#6910); the three z.string() CustomEvent-name keys (PR #6899). Full table with file:line per row is in the PR body.", "files_changed": "packages/types/src/zod/{complex,data-display,disclosure,feedback,form,layout,navigation,overlay}.zod.ts (58 sites), packages/types/src/zod/tombstone.zod.ts (handlerKeyRefusal + HandlerKeyDisposition), packages/types/src/{complex,data-display,disclosure,feedback,form,layout,navigation,overlay}.ts (58 twins: 36 JSDoc runtime-slot notes, 22 ?: never tombstones), packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts (new), packages/types/src/__tests__/zod-mirror-parity.test.ts (KnownDrift: 4 entries extended, 24 added, header counts), packages/types/src/__tests__/menu-item-union.test.ts and chatbot-authoring-face-keys.test.ts (function-value cases restated), examples/schema-catalog/test/component-fixture-declared-keys.test.ts (counter-probe message restated, verdict unchanged), .changeset/6124-handler-keys-json-refusal.md (@object-ui/types minor).", "tests": "All heavy runs via bash /home/user/objectstack/scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=dev-6124; verdicts quoted from each tool's own lines. RED-FIRST on the unmodified tree c93b4d5f3 (pin file only): vitest 'Tests 178 failed | 62 passed (240)' (census found 58 sites; authored object refused with invalid_type not custom; live functions parsed GREEN), tsc -p tsconfig.test.json: 22 x TS2344 on the RetiredIsNever lines exactly as predicted (plus one TS2740 from my own typing of the lazy MenuItemSchema mirror, fixed). UNION ON THE FINAL COMMIT 11fd53500 (post-commit run): pnpm --filter @object-ui/types run type-check, echoed 'tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json', type-check-exit=0; pnpm exec vitest run packages/types/ examples/schema-catalog/test/component-fixture-declared-keys.test.ts --maxWorkers=2 -> 'Test Files 88 passed (88)' / 'Tests 1455 passed (1455)'; pnpm --filter @object-ui/types build -> 'dist completeness: 1 package(s) complete (118 emitted files verified)'; downstream pnpm --filter @object-ui/core run type-check against the rebuilt dist (core's only workspace dep is @object-ui/types) exit 0; tsc -p tsconfig.test.json --listFiles lists handler-keys-json-refusal-6124.test.ts (544 files). ABLATION (after commit, trap EXIT INT TERM, absolute paths): ButtonSchema.onClick reverted to z.function().optional().describe('Click handler'); on-disk proof 'arm-before=1 mut-before=0 -> arm-after=0 mut-after=1', git diff --stat 1 insertion 1 deletion; no build needed and none run, the pin imports ../zod/form.zod from SOURCE; result 'Tests 6 failed | 234 passed (240)' = census (1 site), Button declared/describe, Button object (invalid_type), Button function (GREEN), wording, arm-vs-tombstone - exactly the Button rows; restore by git checkout HEAD -- ABS_PATH, git diff HEAD empty (exit 0), git hash-object == git rev-parse HEAD:path == 23a63abe30d17838c6dd4e8d445e573e28a424b6, git status clean. GATES (unlocked check:*): check-changeset-presence '21 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'; check-changeset-no-major 'No changeset declares a major bump'; check:control-bytes 'OK (scanned 6023 tracked text file(s); skipped 85 binary)'; check:spec-symbols 'spec symbol derivation: 1333 files scanned against 4959 spec export names'; check:doc-types 'Every documented component type is registered'; check:vi-mock-inherit / check:vi-mock-specifiers OK. LINT narrowed and measured: eslint --no-inline-config --format json over the 22 staged .ts files: population from eslint's json = 22 files, 0 errors, 54 warnings, all @typescript-eslint/no-explicit-any and each warned file's merge-base blob lints to the identical E/W count (complex.ts 0/8, data-display.ts 0/30, form.ts 0/10, complex.zod.ts 0/2, data-display.zod.ts 0/1, navigation.zod.ts 0/2, overlay.zod.ts 0/1) so pre-existing; eslint.config.js has no projectService/parserOptions.project, so the diff cannot move an untouched file's verdict; repo-wide pnpm lint left to CI. DOWNSTREAM TYPE-CHECK narrowed and declared: the only breaking TS change is the 22 ?: never members; a read census over packages/*/src, apps/*/src, examples, scripts for files naming both an affected interface and its retired key found 3 (AppHeader.tsx builds BreadcrumbItem[] without onClick; ObjectCalendar.tsx declares its own local CalendarSchema; chatbot renderer.tsx intersects ChatbotSchema with onSend/onClear and its onSendMessage is the Chatbot component prop), none assigns a retired key on the affected type; @object-ui/core type-checked for real; the full turbo type-check over every consumer needs the whole repo built and exceeds the foreground cap on this shared box - CI runs it. NOT MEASURED, stated as such: no browser run; the DOM-tail behaviour is inherited from the 2026-08-30 report's measurement and not re-run.", "deviations": "1) Dispatch order said the ledger models #5099 like its own rows; measured, #5099's pattern.value has no ledger row because both faces agree (RegExp), so the runtime-slot keys are recorded as deliberate KnownDrift entries (z.input undefined vs TS function type) with a class note, following the file's PageNodeSchema.pageType precedent; retired keys do not drift. 2) Three existing tests were restated (verdicts unchanged, messages moved): the corpus counter-probe at component-fixture-declared-keys.test.ts:139 asserted zod's bare 'expected function, received object'; menu-item-union and chatbot-authoring-face-keys parsed a live function green. 3) Full downstream turbo type-check replaced by a read census + core type-check (declared above). 4) REST channel gated for this session (403 GitHub access is not enabled for this session, both authed and unauthenticated), so writes went through MCP; the card body came from the public-page payload but it carried only 15 of 65 timeline items, so the remaining comments were read with one MCP issue_read. 5) PR body footer carries both the standard attribution line and the session-URL footer; commit trailers as dispatched.", "mcp_calls": "8 - issue_read get_comments on #6124 (the public-page payload carried only 15 of 65 timeline items); create_pull_request (draft #7339); pull_request_read get (body + label read-back: body intact after the sanitizer, labels package: types / examples / tests); issue_write update (label union adding needs:contract-review); issue_read get_labels on 7339 (FAILED: that route resolves issues only, counted anyway); pull_request_read get (label read-back); add_issue_comment (this report); issue_read get_comments page 4 (report read-back to the tail). Channel declared: repo-scoped REST answered 403 'GitHub access is not enabled for this session' with and without the token (rate_limit 15000/h = credentials alive, session-gated), so every GitHub write went through MCP; the card body came from the public-page payload; branch/commit facts through git; no search call was needed (nothing filed).", "open_questions": [], "out_of_scope_findings": [ "NOT filed, handed to the PM (docs are outside the dispatched file surface; the ruling's scope names no docs work): three doc pages teach retired keys as authorable props and will drift from the tombstones once this lands - content/docs/api/schema-reference.md:926-927 (onColumnAdd, onCardAdd listed as function), content/docs/components/data-display/tree-view.mdx:46-47 (onSelectChange, onExpandChange), content/docs/components/form/input-otp.mdx:36 (onComplete). Candidate sub-issue of #6124 or a review-time rider on this PR, PM's call.", "NOT filed, observation for the PM: CommandSchema.onChange is retired here, but a host function placed on that key still lands on cmdk's root div through SchemaRenderer's prop spread and fires as a DOM change listener with a SyntheticEvent - a leak of the general spread hazard, not of this card; same shape (React unknown-event warnings, nothing attached) for Alert.onDismiss, Carousel.onSlideChange, TreeView.onSelectChange/onExpandChange, Sidebar.onCollapsedChange. If the spread of undeclared function values is ever gated, these are the measured cases.", "Re-confirmed, not re-filed: the 71 pre-existing corpus reds (0 for expected:function) and #6182's unanchored close-condition pattern are already on the PM's record from the 2026-08-30 report; nothing new measured here." ] }Generated by Claude Code
Generated by Claude Code
⚖️ Contract review PASS — PR #7339 (in-seat,
domain:uiexecution seat; served tier read byget_session:last_served_model = claude-fable-5-1, at or above theCONTRACT_REVIEW_TIERfloor)Reviewed under the maintainer's 2026-08-31 ruling that Clause-② cards are built at
fableand reviewed in-seat. Sessionsession_01NRRumy89BYdW9ogbcdHTho.Ruling conformance, verified by content on the fetched branch (head
11fd53500, one commit, both trailer lines, no model identifiers, 23 files, +1210/−132):- Q2 → A with C. Anchored
on*: z.function(sites inpackages/types/src/zod/*.ts: 62 namedz.function(sites onorigin/main→ 4 at the head, and the four survivors are exactlycell,custom,renderCellEditor,validate(Q4 → B).handlerKeyRefusal(appears 58 times across the eight mirrors (59 with its definition). The helper (tombstone.zod.ts:108-122) isz.custom<never>(() => false, { error: guidance }).optional().describe(guidance)— always refuses (a live function included, per 「拒绝臂对函数值一并拒绝」), absent key accepted, message names the key, states the disposition (RUNTIME SLOT vs RETIRED) and points at the node-type spelling ({ "type": "toast" }/action:button) — Q1's option C. Same string feeds the parse issue and.describe(). 22?: nevertombstones added on the TS twins (diff count 22), each with the ADR-0049 deprecation JSDoc (feedback.ts:148-154sampled); runtime-slot keys keep their function type with a JSDoc naming the consumer (form.ts:57-61sampled). - Q1 → A / Q3 → A. No declarative-object arm and no hooks-ref arm anywhere in the diff.
- EventHandlersSchema untouched (
base.zod.ts:394unchanged; retire(types):EventHandlersSchema— 公开导出的z.record(z.string(), z.function()),每个值 JSON 不可作者、无任何组合消费、census 与 parity 台账双双结构性不可见(ADR-0049 enforce-or-remove) #6910's). - Stop condition (a legitimate
safeParsepath for function values): measured, not fired.packages/react/src/SchemaRenderer.tsx:84validates through@object-ui/core'svalidateSchema(packages/core/src/validation/schema-validator.ts), andpackages/core/srcimports no zod — the structural validator never reads a handler key. The remainingsafeParsedoors are the CLI validators and the exportedvalidateSchema/safeValidateSchema, none fed a function-bearing object.
Accept-set delta, stated where it must be. The changeset (
@object-ui/types: minor,majorrefused by this repo) says plainly: a live function value that parsed green on the zod mirror is now refused; the 22 tombstoned keys are listed by name astscerrors for anyone assigning them; JSON authors were already refused and now get a named message. That is the whole delta, and it matches the diff.Pins.
handler-keys-json-refusal-6124.test.ts(240 cases): anchored census (0on*sites; the four non-on*pinned by name; 58 ledgered = 36 + 22 with no key filed twice), per-site object refusal withcode: 'custom'and the guidance wording, live-function refusal, the passthrough counter-probe (deletion simulated → green with the object kept — the ruling's ⛔ 不裸删 held as evidence), arm ≠ tombstone, and type-levelRetiredIsNever× 22 /KeepsFunction× 36 compiled bytsconfig.test.json. Red-first quoted (178 failed | 62 passed, 22 × TS2344), ablation onButtonSchema.onClickred on exactly the Button rows with hash-verified restore.zod-mirror-parityledger re-stated (36 entries,52 keys,122pairs with no entry — read in the file header).check-governed-merges.mjs --teston the 23 paths → NOT governed.Accepted deviations. The
#5099precedent has no ledger row (both faces agree there), so runtime-slot keys are deliberateKnownDriftentries with a class note — correct. Three existing tests restated for the new message, verdicts unchanged. Downstream type-check narrowed to a read census plus@object-ui/coreagainst the rebuilt dist; the full consumer sweep is CI's, and a red there is this PR's to fix.Disposition (清标即落地):
needs:contract-reviewcleared on this card and on PR #7339 in the same step (read-modify-write, read back). The PR goes ready + auto-merge (SQUASH) onceType Check, the four test shards,Lint,Doc Snippet Type CheckandREADME Export Checkarecompleted: successon11fd53500. Follow-ups filed by the seat: a docs sub-issue for the three pages that teach retired keys (schema-reference.md:926-927,tree-view.mdx:46-47,input-otp.mdx:36), blocked on this card; the measured DOM-listener leak cases (CommandSchema.onChangeon cmdk's root and the retired keys that still rideSchemaRenderer's spread) recorded on #5632's burn-down. #6182 returns topm:queuewhen this card closes, with its close condition restated in the anchored form.
Generated by Claude Code
- Q2 → A with C. Anchored
Landed. PR #7339 merged through the queue at 09:04Z (queue build ref
pr-7339-e75f4c986…, confirmed positively before the merge; Clause-② contract review PASSed in-seat before the flip, carriers cleared on card and PR by read-modify-write with read-back).Verified by content on
origin/mainaftergit fetch(tip2956d7af8at verification time), not by PR head sha:packages/types/src/zod/tombstone.zod.tsdeclareshandlerKeyRefusal(1 hit);packages/types/src/zod/form.zod.tscalls it at 21 sites.- The anchored census
^\s*on[A-Z][A-Za-z]*: z\.function\(overpackages/types/src/zod/*.tsreturns 0 sites. - Control: the four non-
on*z.function()keys the ruling keeps (Q4 → B) are still exactlycell,custom,renderCellEditor,validate— one site each. packages/types/src/__tests__/handler-keys-json-refusal-6124.test.tsis onmain.
Card closed by the PR's
Fixesline;pm:dispatchedstripped in the same round (labels nowdomain:ui,priority:p1). Downstream in this round: #6182 (blocked on this card) gets its close condition re-run on the merged ref; #7340 (docs half, blocked on this card) is re-checked on the merged ref and returned to the queue per itsUnlock-action:line.
Generated by Claude Code
- added a commit that references this issue
on Sep 2, 2026 - added a commit that references this issue
on Sep 27, 2026 - added 3 commits that reference this issue
on Sep 28, 2026 - added a commit that references this issue
on Oct 7, 2026
Filed unassigned by the
domain:uiexecution seat while measuring objectui#5392 (sessionsession_01CSoz9uGhaaSgiq3hshtN7L). Out of scope for that card, which is a measurement of zod→JSON Schema fidelity; this is a separate defect its instrument surfaced.Dedupe search run before filing: #5250 (
BaseSchema.passthrough()accepts undeclared keys) is the opposite direction and does not cover this; #5927 / #5853 / #6058 are the mirror-vs-TypeScript-declaration drift family, which is a different axis — this is mirror-vs-JSON-authorability. No existing card namesz.function().What was measured
@object-ui/typesis the protocol layer for a JSON-authored UI vocabulary. Its zod mirrors declare 28 distinct keys asz.function():Command (run in
packages/types/src/zod/):60 call sites across 9 files (
grep -oF 'z.function(' *.ts | wc -l→ 60;grep -lF→ base, complex, data-display, disclosure, feedback, form, layout, navigation, overlay).No JSON value satisfies
z.function()— measured, with a positive control in the same run:Why it matters: 14 in-repo corpus files author these keys and fail
Validating every root-
typeJSON file in the repo againstAnyComponentSchema.safeParsefinds 14 files that are rejected today, all for this reason. Example,examples/schema-catalog/src/schemas/components-feedback-toast/destructive.json:{"type":"button","label":"Destructive Toast","variant":"destructive", "onClick":{"action":"toast","variant":"destructive","title":"Error","description":"Something went wrong."}}The file is authoring
onClickas a declarative action object, which is the only thing JSON can express. The mirror asks for a JS function. The affected files are thecomponents-feedback-toast/andcomponents-feedback-sonner/catalogs.And they are invisible
All 14 sit in objectui#5127's structure-key-less set — they carry no
body/children/className/… at the root, soobjectui checknever judges them. They are invalid and unreported, which is why this has stood.The question this needs ruled
Two directions, and this is a contract question rather than a mechanical fix:
z.function()isdeclared != enforceableon a JSON surface and the keys should be removed or demoted to a runtime-only (non-authorable) face under ADR-0049 enforce-or-remove.onClick: {action: …}is the intended authored spelling (which the corpus and the renderer's own action plumbing suggest), the mirror should declare that shape, and the 14 corpus files become valid.⛔ Not fixed here — picking one changes a published contract. Filed for triage.
Related: #5250, #5927, #5853, #6058, #5127, #5392.