Repository navigation
finding(types): DashboardComponentSchema.widgets is a two-arm union in zod and a one-arm array in TypeScript — the ruled-legal metric-card component node parses green and tsc refuses it, and the parity ledger files the key as SCHEMA-NODE #7952
Description
Activity
- addedbugSomething isn't workingSomething isn't workingdomain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec laneobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lane
on Sep 6, 2026 分诊:
domain:spec/bug+finding/needs-user-decision/priority:p2⛔
needs-user-decision不与pm:*并存。domain:spec@ objectui(标签自述:fix lands onpackages/types, schema corpus or spec pin coupling)——两个面都在packages/types。落点核实(
origin/main)——两行逐字命中,行号精确packages/types/src/zod/complex.zod.ts:887 widgets: z.array(z.union([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema])) packages/types/src/complex.ts:1733 widgets: DashboardWidgetSchema[];⇒ zod 两臂,TypeScript 一臂。 卡片零错。
⚠️ 本席未复现卡片的两次驱动测量(safeParse的 ACCEPT、tsc --strict的 TS2561 × 5 处)。⛔ 不把行级核对当成对它们的复现继承下去。但那两次测量的形状是对的:它对同一份字面量、同一份 README 的三个块,分别过运行期与类型期,⇒ 「接受 / 拒绝」的不对称是被驱动出来的,不是从声明推的。
⭐ 本卡真正的价值在第二层,而卡片自己指了出来
⇒ The interesting half of this card is not the dashboard. It is that the SCHEMA-NODE bucket is a per-KEY verdict over a per-ARM fact, so a concrete gap can hide behind a schema-node sibling. objectui#7759 sorted 27 keys out of its lane on that verdict; whether any of the other 26 hides one the same way is the question this card opens and does not answer.
⇒
WiderThanDeclared对这个键的判词是三个词——widgetsis SCHEMA-NODE——而它对第二臂为真、对第一臂为假:第一臂是一个BaseSchema.extend({ type: z.enum(...) })的具体节点,里面没有任何 schema-node 递归。⇒ 一个臂里的仪器伪影,吸收了另一个臂里的真实分歧。⭐ 这正是本 lane 一整天在追的那个失败类的分类学变体:不是「一个不可能失败的读数」,而是**「一个粒度错配的判词,使一整类真分歧被合法地排除在处置车道之外」**。
⇒ 本席据此定 p2(见下),并强烈建议裁决者把「另外 26 个键要不要重判」一并纳入——⛔ 但本席不代为开那张普查卡(本轮纪律是先清完既有裸卡,且那需要它自己的总体)。
为什么定 p2
- 一个被维护者明文裁定为合法的编排形状,作者无法为它写出任何注解。 卡片给的判据是决定性的:"There is no annotation an author can write for a shape the platform accepts and the maintainer ruled legal." 而那个形状就写在
packages/plugin-dashboard/README.md的三个块里(:48/:178/:275),即已发布文档在教它。 - 两条已发布契约面互相矛盾:runtime 接受并正确渲染(title / value / trend / trendValue / description 全画出来),
tsc拒绝。 - 它同时暴露了一个影响 26 个其它键的分类缺陷(见上)。
⛔ 不到 p1:无运行期故障、无数据风险、无安全面——运行期是对的,坏的是类型面与它的账本分类。⛔ 不降 p3:作者按已发布文档写,然后被
tsc拒绝,且账本把这件事分类成了「不是分歧」。为什么是决定箱
卡片自陈:"Both spellings are a published-contract change and need a ruling, which is why this is a card and not a patch." ⛔ 本席不裁决(本会话
claude-opus-5,CONTRACT_REVIEW_TIER硬门要求 fable)。四facet
① 事实(已核)
complex.zod.ts:887是两臂 union(第一臂:749=BaseSchema.extend({ type: z.enum(DASHBOARD_COMPONENT_WIDGET_TYPES) }),一个 passthrough 组件节点);complex.ts:1733是一臂数组。第一臂由 2026-08-14 的维护者裁决(objectstack#8593)确立,并在complex.ts:1539与complex.zod.ts:735两处逐字引用;classifyWidgetType对metric-card返回passthrough,DashboardRenderer把{ ...widget }交给SchemaRenderer。⇒ 该臂是刻意的、活的、有文档的——只在 Zod 一侧。② 分叉 (a) 给 TypeScript 面补上第二个声明臂(与 Zod 对齐);(b) 从 Zod 面删掉第一臂(与 TypeScript 对齐)——
⚠️ 那等于推翻 objectstack#8593 的裁决;(c) 维持现状并把「这个形状类型上不可注解」写成已声明边界。③ 各支的代价 (a) 拓宽已发布的 TypeScript 接受集 ⇒ 人工地板,但它只是让类型面追上一条已生效的裁决;(b) 收窄运行期接受集 ⇒ 破坏性,且与维护者裁决冲突;(c) 零成本,但把「已发布文档教的形状不可类型化」固化为契约。
④ 需要裁决者提供的东西 一句话:objectstack#8593 允许的组件节点形状,是否应当在 TypeScript 面上可声明? 答「是」⇒ (a);答「否」⇒ 必须解释 README 的三个块该如何被作者书写。
⛔ 明确禁止的修法(卡片给出,本席加倍背书)
⛔ Do not widen
DashboardWidgetSchemawithvalue/icon/trend/trendValue. Both declarations say so in terms, for the same reason (complex.ts:1553,complex.zod.ts:653): those areMetricCard's registryinputs, and a member ofDASHBOARD_COMPONENT_WIDGET_TYPESis validated as a component node against passthroughBaseSchema, which is what keeps them. The shape the Zod side already uses — a second declared arm on the widget slot — is the shape the TypeScript side is missing.⇒ 这是最容易被误做的那一支(TS2561 的报错文本
Did you mean to write 'values'?正把人往那个方向引)。⛔ 承接席请先读那两处声明再动手。Refs:objectui#7035 / PR #7951(发现现场;它修了确属文档缺陷的那一半——两个
type: 'card'widget,两个面都拒——并逐字节不动那三个metric-card块,因为在运行期契约上它们是对的 ⭐ 正确的范围克制)· objectui#7759(WiderThanDeclared处置车道,及本卡质疑的那条分类)· objectui#4600(关闭 widget 类型枚举)· objectstack#8593(2026-08-14 的裁决)。
⛔ 本席为 triage 席位:不认领、不派单、不写码、不合并、不裁决 decision-box。
Generated by Claude Code
- 一个被维护者明文裁定为合法的编排形状,作者无法为它写出任何注解。 卡片给的判据是决定性的:"There is no annotation an author can write for a shape the platform accepts and the maintainer ruled legal." 而那个形状就写在
Ruling recorded — option (a): the TypeScript face gains the second union arm (director seat, decision batch #68, 2026-09-07)
Maintainer reply, verbatim: 「同意」 (all five batch #68 recommendations adopted).
Ruling.
packages/types/src/complex.ts:1733widgetsbecomes a two-arm union matchingcomplex.zod.ts:887: a component-node arm (BaseSchema-shaped node whosetypeis a member ofDASHBOARD_COMPONENT_WIDGET_TYPES) plusDashboardWidgetSchema. That is the shape objectstack#8593 (2026-08-14) ruled legal and the zod side already carries; the README's threemetric-cardblocks then annotate and compile. ⛔DashboardWidgetSchemais not widened withvalue/icon/trend/trendValue— both declarations say why (complex.ts:1553,complex.zod.ts:653). Widening a published TS accept set:needs:contract-reviewon the PR,@object-ui/typeschangeset (minor).The second half of this card — the SCHEMA-NODE bucket being a per-key verdict over per-arm facts — is ordered as a census: #8252 re-judges #7759's 27 keys per arm and changes the ledger's granularity so a concrete arm cannot hide behind a schema-node sibling.
Labels:
needs-user-decision→pm:queue. Ledger on objectstack#12708 (batch #68).
Generated by Claude Code
Claim: PM loop round R1
Session:session_01QtGhnU3WnnWyiWeYQhw2aX
Branch:claude/issue-7952-dashboard-widgets-union-arm
Worktree:objectui-issue-7952
Domain:domain:spec
File surface:packages/types/src/complex.ts(theDashboardComponentSchema.widgetsmember and whatever arm type it needs), one new issue-numbered type-level pin, one.changeset/entry. ⛔packages/types/src/zod/complex.zod.tsis NOT in surface — the zod side already carries the ruled shape and this card does not touch it (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: claude-fable-5-1(CONTRACT_REVIEW_TIER, mandatory under clause ② — see the tier reading below)
Clause-②: yes
Serial constraints cleared:#7735and#6950(dispatched in this same batch — file surfaces measured disjoint, neither touchescomplex.ts) ·#6939(pm:dispatched, groups 2–6 queued, none in flight; its claim 5521422355 enumeratesoverlay/data-display/ kanban / filter-builder / chart / object-map mirrors andzod-mirror-parity.test.ts, ⛔ notcomplex.ts) ·#8252is the census this card's ruling ordered downstream and is deliberately held behind this PR · no other in-flight claim onpackages/types/src/complex.ts
Adjudication — ⛔ not re-openable
Ruled by the director seat, decision batch #68, 2026-09-07 (comment 5565355483). Maintainer reply, verbatim: 「同意」.
Ruling.
packages/types/src/complex.ts:1733widgetsbecomes a two-arm union matchingcomplex.zod.ts:887: a component-node arm (BaseSchema-shaped node whosetypeis a member ofDASHBOARD_COMPONENT_WIDGET_TYPES) plusDashboardWidgetSchema. That is the shape objectstack#8593 (2026-08-14) ruled legal and the zod side already carries; the README's threemetric-cardblocks then annotate and compile. ⛔DashboardWidgetSchemais not widened withvalue/icon/trend/trendValue— both declarations say why (complex.ts:1553,complex.zod.ts:653). Widening a published TS accept set:needs:contract-reviewon the PR,@object-ui/typeschangeset (minor).⛔ The forbidden repair is the one the compiler actively recommends: TS2561 says
Did you mean to write 'values'?. Read both cited declarations before you touch anything.⚠️ PM reading — the card's line numbers are STALE. Re-derive every one.Measured on
origin/main=fc32921at 2026-09-07T08:14Z:the card / the ruling says actually on origin/maincomplex.ts:1733complex.ts:1897⚠️ drift +164complex.zod.ts:887complex.zod.ts:943⚠️ drift +56complex.ts:1553,complex.zod.ts:653(the "do not widen" docblocks)⛔ not re-derived by me you verify Both declarations are still in the state the ruling describes — one arm in TS, two arms in zod — so the premise is live. Only the coordinates moved. Take every path, line and list from the tree at the moment you work, ⛔ never from this brief, the card, or the ruling.
PM mechanism assumptions — measure these; you are encouraged to falsify them
- I have not reproduced the card's two driving measurements (the
safeParseACCEPT and the fivetsc --strictTS2561s). Triage said the same of itself. ⇒ Re-drive both before and after: an ACCEPT that was already failing, or a TS error that has since moved, changes what "fixed" means here. - The arm's spelling is yours to derive, not to copy. The ruling names the shape ("
BaseSchema-shaped node whosetypeis a member ofDASHBOARD_COMPONENT_WIDGET_TYPES"), not the TypeScript for it.complex.zod.ts:943's first arm (DashboardWidgetSlotComponentSchema) is the reference; whether the TS twin is best written as an interface, a&, or a discriminated member is an implementation judgement — state which you chose and why. - The acceptance test is the README, not the type.
packages/plugin-dashboard/README.mdteaches this shape at three blocks. The card says they are correct on the runtime contract and must annotate and compile after your change. If any of the three still refuses, the arm is wrong — ⛔ do not edit the README to fit the type.
Gates
⚠️ scripts/pm/dispatch-gates.mjscannot derive for this repo and says so rather than guessing: "REFUSING — asked for 'objectstack-ai/objectui', but this checkout is 'objectstack-ai/objectstack' … a sister repo's list is hand-derived from its own package manifest and its own workflow files." So the tier line above is a content judgement under clause ②, ⛔ not a path derivation, and the gate list below is hand-derived from objectui's ownpackage.jsonatfc32921— treat it as a lead, re-derive against your actual diff, and run what your change touches:pnpm --filter @object-ui/types type-check·lint·pnpm exec vitest run packages/types/·check:spec-symbols·check:doc-types·check:doc-snippets·check:doc-fences·check:readme-exports·check:control-bytes· changeset-presence.Capture each exit code to disk before reading any output, then reconcile.
Route
Draft PR,
Fixes #7952on the first line, targetmain. Mergeorigin/mainbefore opening.needs:contract-reviewis already on the card; put it on the PR too. ⛔ Do not enqueue, ⛔ do not flip ready, ⛔ do not auto-merge — a clause-② PR is reviewed in-seat first, and this seat does that, not you. ⛔ Do not leave a second claim: this comment is the identity for this card; check that the newestClaim:names your branch, and stop and report if it does not. The assignee field is set and is ⛔ not yours to change.Final report: machine-checkable fields only —
gates/line_budget/deviations/files_changed. ⛔ Do not restate the PR body's narrative. Post it to this card with theos-dev-reportmarker before returning.
Generated with Claude Code
https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX
Generated by Claude Code
- I have not reproduced the card's two driving measurements (the
os-dev-report
{ "issue": 7952, "status": "done", "branch": "claude/issue-7952-dashboard-widgets-union-arm", "pr": "https://github.com/objectstack-ai/objectui/pull/8296", "premise_still_valid": true, "summary": "DashboardComponentSchema.widgets is now an array of DashboardWidgetSlotComponentSchema | DashboardWidgetSchema (component arm first, as the zod twin); DashboardWidgetSlotComponentSchema (extends BaseSchema, type: DashboardComponentWidgetType) is a new @object-ui/types export; complex.zod.ts untouched; DashboardWidgetSchema not widened. README 'TypeScript Support' table and prose corrected; two-face pin added; @object-ui/types minor changeset. Premise re-driven on fc32921: 6 x TS2561 on the README's annotated dashboard blocks (the card's 5 plus the post-#7951 block at :216) with safeParse ACCEPT and all keys kept; after: 0 errors, runtime unchanged. Assignee was set at dispatch; newest Claim: names this branch; no second claim posted.", "tests": "final head 297b014 (merge of origin/main 3c6394c, scripts/ only). types type-check exit 0; vitest packages/types 138 files / 2633 tests exit 0; plugin-dashboard type-check exit 0 and plugin-designer type-check exit 0 after their closure builds; readme-dashboard-examples-spec-valid 7 passed; check:spec-symbols / doc-types / doc-fences / control-bytes / changeset-presence exit 0; eslint narrowed 3 files 0 errors 10 pre-existing warnings (population from eslint.config.js, non-type-aware, no cross-file custom rule). Ablation on 297b014 under the verify lock: one-arm member restored (grep 1 to 0 and 0 to 1), tsc -p tsconfig.test.json exit 2 with 4 errors all in the 7952 pin (:82 TS2561, :101 Equal, :165 and :184 TS2353); restore proved by blob hash b7571577 equal to HEAD and empty git diff HEAD; source-resolved (no dist leg). NOT run locally: check:doc-snippets (34-package dist prerequisite; narrowed stand-in: edited README block vs built types .d.ts exit 0), check:readme-exports (prerequisite not met, 508 unjudgeable self-imports; this README edit is a cross-package import), app-shell type-check (its widgets reads go through as-any casts).", "gates": { "types:type-check": "exit 0 @297b014", "vitest:packages/types": "138 files / 2633 tests, exit 0 @297b014", "plugin-dashboard:type-check": "exit 0 @297b014 (13-package closure built)", "plugin-designer:type-check": "exit 0 @297b014 (closure built)", "plugin-dashboard:readme-dashboard-examples-spec-valid": "7 passed", "check:spec-symbols": "exit 0", "check:doc-types": "exit 0", "check:doc-fences": "exit 0", "check:control-bytes": "exit 0 (6598 tracked files)", "check-changeset-presence": "exit 0 (1 changeset declared)", "lint(narrowed)": "3 files judged, 0 errors, 10 pre-existing warnings", "check:doc-snippets": "CI (not run locally; narrowed stand-in exit 0)", "check:readme-exports": "CI (prerequisite not met locally)", "app-shell:type-check": "CI (declared; reads via as-any casts)", "ablation": "4 reds, all in the pin; restore blob-hash proven", "pr": "draft #8296, base main, head 297b014, not readied / not enqueued / no auto-merge", "labels": "needs:contract-review via REST additive POST 200; read-back = documentation, package: types, plugin, tests, needs:contract-review (equals the union; nothing stripped)", "pr_body_readback": "9890 bytes, first line Fixes #7952, 0 tag-shaped fragments, footer intact", "ci": "in_progress" }, "line_budget": "n/a — no skills/** file touched", "deviations": [ "file surface +2 beyond the brief: packages/types/src/index.ts (barrel line for the new export — the barrel is an explicit list, and declaration emit needs an exported name) and packages/plugin-dashboard/README.md (TypeScript Support table row + prose made true of the two-arm slot; the metric-card blocks the brief names are byte-untouched)", "channel: REST search returned 403 on this seat, so one targeted MCP search_issues (non-empty result, no duplicate) preceded filing #8290", "check:doc-snippets, check:readme-exports and app-shell type-check declared to CI with the narrowing stated in tests", "ablation direction as predicted (red) but count 4, not the predicted 2: the passthrough-arm and envelope-corner literals also become TS2353 under one arm" ], "files_changed": [ "packages/types/src/complex.ts", "packages/types/src/index.ts", "packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts", "packages/plugin-dashboard/README.md", ".changeset/7952-dashboard-widgets-component-arm.md" ], "mcp_calls": "4 — search_issues 1, issue_write 1 (#8290), create_pull_request 1 (#8296), add_issue_comment 1 (this report)", "open_questions": [], "out_of_scope_findings": [ "filed as #8290: content/docs/api/schema-reference.md dashboard table teaches colSpan / rowSpan / body as widget keys (all refused by name) and refreshInterval in milliseconds where the renderer multiplies by 1000; its widgets type column is also one arm short after this PR", "noted, not filed: plugin-dashboard/README.md:131 still declares widgets as DashboardWidgetSchema[] in the Dashboard Schema block (valid TS; teaches spec-family usage)", "noted, not filed: zod-mirror-parity WiderThanDeclared note for widgets is now true of the whole key; rewording per arm is #8252's census" ] }Session
session_01QtGhnU3WnnWyiWeYQhw2aX— see the JSON above; attribution in prose on purpose.
Generated by Claude Code
Clause-② in-seat contract review — PR #8296 (head
297b014) against the batch #68 ruling (comment 5565355483)Independent, adversarial review: verdict derived from the PR diff and the ruling, not from the dispatch brief or the author's report. Tree facts read from
origin/main=3c6394c(the PR's merge-base). Every compiler claim below was re-measured, not inherited, on a reduced model of the two declarations (BaseSchemawithtype: string+[key: string]: any; an all-optional, index-signature-freeDashboardWidgetSchema; the PR's arm) under--stricton TypeScript 5.9.3 and 6.0.3 (the repo pins^6.0.3).① Derived judgments — every accept-set and public-surface change the diff produces
DashboardComponentSchema.widgets:DashboardWidgetSchema[]→Array<DashboardWidgetSlotComponentSchema | DashboardWidgetSchema>. This is the ruled shape exactly — a component-node arm plusDashboardWidgetSchema— and it matches the zod twincomplex.zod.ts:943z.array(z.union([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema]))including arm order (component arm first). Correct.- New interface
DashboardWidgetSlotComponentSchema extends BaseSchema { type: DashboardComponentWidgetType }— one-to-one twin of zod:805BaseSchema.extend({ type: z.enum(DASHBOARD_COMPONENT_WIDGET_TYPES) }). Thetypefield is closed by reference:DashboardComponentWidgetType = (typeof DASHBOARD_COMPONENT_WIDGET_TYPES)[number](origin/maincomplex.ts:1735), and the pin assertsEqual<Slot['type'], DashboardComponentWidgetType>. A member added to the const widens both faces together; there is no copy to drift. Correct. DashboardWidgetSchemabody untouched — novalue/icon/trend/trendValue. Re-driven:const w: DashboardWidgetSchema = { type: 'metric-card', value: '1' }is still TS2561 on both compilers; the pin's@ts-expect-errorturns into TS2578 if anyone follows the compiler'sDid you mean 'values'?suggestion. Correct — the forbidden repair was not made.packages/types/src/zod/complex.zod.tsis not in the diff (5 files; zod absent). The runtime accept set is unchanged. Correct.- Intended widening, TS face:
{ type: 'metric-card', title, value, trend, trendValue }inwidgets[]now compiles. Measured: refused (TS2561) on the one-arm declaration, accepted on the two-arm one — the card's measurement and the ruling's acceptance criterion, now agreeing; CI "Doc Snippet Type Check" is green on the README's annotated block. Correct. - Entailed widening, TS face:
{ type: 'metric-card', someProp: 1 }compiles — the passthrough the ruling admits; zod keeps the key. Correct. - Still refused on both faces (no hatch):
{ type: 'bar', bogus: 1 }→ TS2353 (the literal is discriminated bytype, so the passthrough arm never applies);{ type: 'not-a-component', value }→ TS2322. Re-measured on both compilers; pinned. Correct. - The corner —
{ id, component: {…}, bogus: 1 }with notype. Measured: TS2353 (refused) on the one-arm declaration; compiles on the two-arm one; zod refuses it by name (unrecognized_keys). My call is (a) — an unavoidable property of a TS union containing a passthrough arm, honestly recorded and correctly pinned. Mechanism: excess-property checking on a union first narrows by the discriminants present in the literal; atype-less envelope has none, so the check falls back to "known in any constituent", and a[key: string]: anyarm makes every key known. Closing it would require either dropping the index signature (contradicting the ruled "BaseSchema-shaped node") or a discriminant the legacy envelope by definition lacks. It is a real accept-set widening beyond the metric-card node — a literal that was refused before now compiles — and it is named as such in the changeset, in the member docblock, and pinned two-faced with the right instruction (delete the pin iftscever closes the corner; never@ts-expect-errorit). Not a hole dressed up as a limitation. Accepted as the cost of the ruled shape. - New public export
DashboardWidgetSlotComponentSchema(module export incomplex.tsplus barrel lineindex.ts:324). Not ordered by the ruling, and the reason the PR records for it is false (see ③ item 1). As surface it is additive, declared in the changeset and taught in the README; under lanes/spec.md, expanding the public face "however small" is clause ② — reviewable here. I accept the surface; I do not accept the recorded rationale. - Consumer-visible effect: an unannotated element read of any key other than
type/id(w.layout,w.options, …) now resolves toanythrough the arm's index signature (wasDashboardWidgetLayout | undefined). Measured.w.typeandw.idreads are unchanged; the union array is still assignable toDashboardWidgetSchema[];(w: DashboardWidgetSchema)callbacks compile unchanged;DashboardWidgetSchema[]still assigns intowidgets. A checking-strength loss, not a compile break. Disclosed in the changeset with the remedy. See ②. - Narrowing: none. Every change is additive on the TS face or docs/tests; the zod face is untouched. The action performed is the widening that was ruled, not a different contract action.
- Wording defect, not a contract change: head
complex.ts:1723and README:498call the component arm "the second arm", whilecomplex.ts:1937says "component arm first" — and the declaration and zod both put it first. Patch nit.
② Semver grading —
@object-ui/types: minoris correct- Repo convention (AGENTS.md → 版本号策略): a changeset never declares
major(fixed group of 39, enforced byscripts/check-changeset-no-major.mjs, "Changeset Bump Policy" green); even a breaking change is scoredminorwith the break stated in the body. Sominoris both the ceiling and the correct level under either reading of item 10. - On the merits there is no narrowing to hide: every consuming position that compiled before still compiles (items 10–11, measured). The
anydegradation on unannotated reads weakens checking; it does not break a consumer. The changeset's "Consumers" paragraph states it and the remedy. The new export is additive. - The changeset text needs no change. It correctly says the arm "is a new export" — it does not claim the export was forced; the source docblock and PR body do (③ item 1).
③ Boundary-flag disposition
packages/types/src/index.ts(+1 barrel line) — escalate the rationale, accept the surface. The PR body ("an exported member cannot reference a private type in the emitted.d.ts(TS4033)") and the arm's docblock (headcomplex.ts:1896–1899) claim the export is compiler-forced. Measured false on TS 5.9.3 and 6.0.3 withdeclaration: true,composite: true,isolatedModules: truemirroringpackages/types/tsconfig.json: a non-exported same-module interface referenced by an exported interface emits intodist/complex.d.tsas a localinterface … {}followed byexport {}, exit 0; and a consumer importing onlyDashboardComponentSchemafrom the barrel annotates the document and writes the arm shape with no name, exit 0. So the barrel line — and the module-levelexportitself — is a discretionary expansion of the public face, taken in the opposite direction to the zod twin's own docblock ("Deliberately NOT exported … not new authoring surface",complex.zod.ts:800–802). The surface is defensible (a name authors can annotate; the README teaches it; it is declared and graded) and I accept it. A false compiler fact recorded in shipped source as the reason is not acceptable in this repo — its own docblocks say a stale claim is what the next agent reads as the measurement.packages/plugin-dashboard/README.md— accepted; forced in part. The prose atorigin/main:479–484("ametric-cardnode is typed only where it appears as a component … not as a widget family") becomes false the moment the arm lands, so correcting it is truth-maintenance, not creep. The table row and thekpiexample follow from the export decision (item 1). The sixmetric-cardblocks are byte-untouched (hunks at:426,:466,:479only) — "do not edit the README to fit the type" holds. Carries the "second arm" nit at:498.- README
:131declare const widgets: DashboardWidgetSchema[], noted-not-filed — accepted: a usage declaration, not a declaration of the type; narrower-into-wider is valid and it teaches the spec family. zod-mirror-parity.test.tsWiderThanDeclarednote now true of the whole key — accepted, deferred to types: re-judge the 27 SCHEMA-NODE-bucketed keys ofzod-mirror-parityper UNION ARM — a per-key verdict over per-arm facts letDashboardComponentSchema.widgets' concrete gap hide behind a schema-node sibling (census ordered by the #7952 ruling) #8252, the census this ruling itself ordered; ledgers unchanged and green.- finding(docs):
content/docs/api/schema-reference.md's dashboard table teachescolSpan/rowSpan/bodyas widget keys — all three refused BY NAME by the strictDashboardWidgetSchema— andrefreshIntervalin milliseconds where the renderer multiplies by 1000 #8290 (schema-reference.mdcolSpan/rowSpan/body/refreshInterval) — accepted as out of scope, filed rather than ridden. - Channel deviation (REST 403 → one MCP search before filing finding(docs):
content/docs/api/schema-reference.md's dashboard table teachescolSpan/rowSpan/bodyas widget keys — all three refused BY NAME by the strictDashboardWidgetSchema— andrefreshIntervalin milliseconds where the renderer multiplies by 1000 #8290) — accepted, procedural. - Gates declared to CI — at review time: Type Check (covers app-shell), Lint, Doc Snippet Type Check, README Export Check, Changeset Bump Policy, Changeset Fixed Group Check, Build & E2E, Test shards 2/3/4 all green; Test shard 1/4 still running. Accepted; the queue step is not this seat's and waits on the shard regardless.
- Ablation 4 reds vs the predicted 2 — accepted; the two extras are the passthrough-arm and envelope literals becoming TS2353 under one arm, which is exactly what item 8 predicts.
Verdict: FAIL — a narrow patch round is owed. The ruled contract change itself (items 1–8, 11) is correct and is not to be re-litigated.
Required, one commit, nothing else:
packages/types/src/complex.ts, arm docblock (head:1896–1899): remove the TS4033 claim. State the true reason for exporting — a deliberate authoring-surface decision (a name authors can annotate, taught in the README) and an intentional asymmetry with the zod twin's non-exported const, whose docblock records why it stays private. If the author holds a measurement showing TS4033 in this package's actual build, post it on the PR instead; absent that, the claim would ship false in source.complex.ts:1723and README:498: "the second arm" → the component arm, first in the declaration and in zod; align with:1937.- PR body, "Spelling choice" bullet 2: the same correction, so the record does not say the export was forced.
No change to the union, the arm, the pin, the changeset level or text, or the barrel line. This seat added/removed no labels, did not flip the PR to ready, did not enqueue, did not enable auto-merge, approved nothing, pushed nothing.
Implemented-by: claude/issue-7952-dashboard-widgets-union-arm
Reviewed-by: session_01QtGhnU3WnnWyiWeYQhw2aX (contract-review-tier subagent)
Generated by Claude Code
os-dev-report
{ "issue": 7952, "status": "done", "round": "patch round after the Clause-② review (comment 5568304348)", "branch": "claude/issue-7952-dashboard-widgets-union-arm", "pr": "https://github.com/objectstack-ai/objectui/pull/8296", "head": "197e248", "premise_still_valid": true, "summary": "Review finding confirmed by my own measurement in this package's real build (tsc 6.0.3, declaration + composite, run through the verify lock with a restore trap): with the arm un-exported and the barrel line removed, `pnpm --filter @object-ui/types build` exits 0 with zero TS4033, `dist/complex.d.ts` carries the arm as a local `interface` and `dist/index.d.ts` does not mention it; both files restored with blob hashes equal to HEAD, dist rebuilt from the restored source. One commit, comment-and-prose only: the arm docblock now states the real reason (a deliberate authoring surface — a name an author can annotate the node with, taught in the README — an intentional asymmetry with the zod twin's non-exported const) and records that measurement; 'the second arm' corrected to the component arm, first in the declaration as in zod, at the closed-set docblock and the README; the ordinal 'gains the second arm' inside the same docblock reworded to 'gains the component arm'. PR body 'Spelling choice' bullet 2 corrected via PATCH (sent without the trailing footer block; read back verbatim, 0 tag-shaped fragments; the platform re-appended one bare footer). Union, arm order, arm interface, by-reference type closure, DashboardWidgetSchema, envelope-corner pin, changeset (text and level), barrel line: untouched.", "tests": "patched tree, later committed as 197e248 (parent 297b014). `pnpm --filter @object-ui/types type-check` exit 0 (TYPECHECK_EXIT captured to disk); `pnpm exec vitest run packages/types/` 138 files / 2633 tests, exit 0 (VITEST_EXIT captured to disk); check:control-bytes exit 0 (6598 tracked files); check-changeset-presence exit 0 (1 changeset declared). TS4033 probe under the lock: pre export-arm=1 barrel-line=1, post export-arm=0 local-arm=1 barrel-line=0, BUILD_EXIT_WITH_LOCAL_ARM=0, TS4033 lines 0, dist arm `interface` at complex.d.ts:1749, index.d.ts mentions 0; RESTORED blob hashes b7571577 / f48568d2 equal HEAD, git diff HEAD empty; rebuild from restored source exit 0, dist arm exported=1.", "gates": { "types:type-check": "exit 0 (patched tree = 197e248)", "vitest:packages/types": "138 files / 2633 tests, exit 0 (patched tree = 197e248)", "check:control-bytes": "exit 0", "check-changeset-presence": "exit 0", "ts4033-measurement": "false in this package's build: un-exported arm builds exit 0, local interface in dist, restore blob-hash proven", "pr": "draft #8296, head 197e248, base main; not readied / not enqueued / no auto-merge; labels untouched (read-back: documentation, package: types, plugin, tests, needs:contract-review)", "pr_body": "bullet 2 corrected via PATCH; stored body starts with the sent body verbatim; 0 tag-shaped fragments; one TS4033 mention left, inside the correction note", "ci": "in_progress on 197e248 (comment-and-prose diff; the 297b014 farm was green except test shard 1/4 still running at review time)" }, "line_budget": "n/a — no skills/** file touched", "deviations": [ "the required patch covers three sites; a fourth wording fix ('gains the second arm' → 'gains the component arm') sits inside the same arm docblock the review names, made so the docblock does not keep an ordinal that reads against 'component arm first' — same commit, no other content", "the PR body was corrected by REST PATCH before the commit was pushed (order only; both done)", "worktree re-created on the existing branch for this round and removed again after the push (no force); the shared checkout was never edited" ], "files_changed": [ "packages/types/src/complex.ts", "packages/plugin-dashboard/README.md" ], "mcp_calls": "5 total across both rounds — search_issues 1, issue_write 1 (#8290), create_pull_request 1 (#8296), add_issue_comment 2 (first report, this report); the PR-body PATCH and label/PR read-backs went over REST", "open_questions": [], "out_of_scope_findings": [] }Session
session_01QtGhnU3WnnWyiWeYQhw2aX— patch-round report; attribution in prose on purpose.
Generated by Claude Code
Clause-② in-seat contract re-review — PR #8296, patch round, head
197e248(parent297b014)Judged against the diff
297b014..197e248itself (2 files, +18/−10), not the author's description of it. Head197e248has the single parent297b014, the tree the first review (comment 5568304348) judged, so the first review's findings on the contract change carry over unchanged.The three required changes
- TS4033 claim removed, true reason stated — done, and to the right standard. The arm docblock (
packages/types/src/complex.ts, head:1897–1908) no longer says the compiler forces the export. The replacement does not merely stop saying what was false; it says what is true and how it was measured: "The compiler does not force it — measured in this package's own build (declaration+composite, TypeScript 6.0.3): a non-exported arm referenced from the exportedDashboardComponentSchemaemits intodist/complex.d.tsas a local interface, exit 0, and a barrel consumer still writes the node with no name. The export is an authoring-surface decision … taken deliberately in the opposite direction to the Zod arm, whose own docblock keeps that const private because its routing is an internal property of the slot." Every clause of that is true, and the characterisation of the zod twin's stance matchescomplex.zod.ts:800–802verbatim in substance. Two independent measurements now stand behind it: the author's, in this package's real build (report 5569540342: un-exported arm builds exit 0, zero TS4033, localinterfaceatdist/complex.d.ts:1749, zero mentions indist/index.d.ts, restore proven by blob hash, rebuild from restored source exit 0), and this seat's, on TypeScript 6.0.3 withdeclaration+composite+isolatedModulesmirroringpackages/types/tsconfig.json(local interface incomplex.d.ts, exit 0; barrel consumer writes the node with no name, exit 0). I could not repeat the real build cheaply from this container (no workspace install) and did not try; the two measurements agree on every point the docblock now asserts. Accepted. - "second arm" wording fixed — done at both sites.
complex.ts:1723–1724now reads "the component arm — first, as in the Zod twin — ofDashboardComponentSchema.widgets"; README:498–499now reads "the component arm ofDashboardComponentSchema['widgets'], first in the declaration as in the zod schema's two-arm slot". Both agree with the declaration, with:1937's "component arm first", and withcomplex.zod.ts:943. Accepted. - PR body "Spelling choice" bullet 2 — done. The bullet now states the export is "a deliberate authoring-surface decision, not a compiler requirement", records the real-build measurement, names the intentional asymmetry with the zod twin, and carries an explicit correction note ("the first version of this bullet claimed TS4033 forced the export — false, per the contract review"). The only remaining "TS4033" mention in the body is inside that correction note. Accepted.
The declared extra
"gains the second arm" → "gains the component arm" at
complex.ts:1895— inside the scope I set, not creep. Same docblock the review ordered edited, same defect (an arm ordinal that reads against "component arm first"), one word, comment-only, no contract effect. I had read that ordinal charitably in the first review; the author's stricter reading is the better one.Everything passed is untouched — confirmed mechanically
git diff 297b014 197e248 -- packages/types/src/complex.tscontains no non-comment line: every changed line is a*docblock line.- At
197e248:widgets: Array<DashboardWidgetSlotComponentSchema | DashboardWidgetSchema>(:1965, component arm first);export interface DashboardWidgetSlotComponentSchema extends BaseSchema { type: DashboardComponentWidgetType }(:1912–1915);DashboardComponentWidgetType = (typeof DASHBOARD_COMPONENT_WIDGET_TYPES)[number](:1737) — the by-reference closure. DashboardWidgetSchemabody byte-identical between297b014and197e248.git diff --stat 297b014 197e248 -- packages/types/src/index.ts .changeset packages/types/src/__tests__ packages/types/src/zodis empty: barrel line, changeset (text and level), envelope-corner pin, zod all untouched; zod also still identical toorigin/main.- README hunks against
origin/mainare still only:426/:466/:479— the sixmetric-cardblocks remain byte-untouched.
Verdict: PASS at head
197e248.The contract change is exactly what the batch #68 ruling ordered (two-arm union matching the zod twin, component arm first;
BaseSchema-shaped arm withtypeclosed by reference toDASHBOARD_COMPONENT_WIDGET_TYPES;DashboardWidgetSchemanot widened; zod untouched),@object-ui/types: minoris the correct grade, every boundary flag is dispositioned (first review), and the one thing that shipped false in source is now stated true with two measurements behind it.CI is
in_progresson197e248at the time of this comment. That is not this seat's input — the diff is comment-and-prose only, and convergence is the landing pre-check at enqueue time. This seat added/removed no labels, did not flip the PR to ready, did not enqueue, did not enable auto-merge, approved nothing, pushed nothing.Implemented-by: claude/issue-7952-dashboard-widgets-union-arm
Reviewed-by: session_01QtGhnU3WnnWyiWeYQhw2aX (contract-review-tier subagent)
Generated by Claude Code
- TS4033 claim removed, true reason stated — done, and to the right standard. The arm docblock (
ACCEPT — PR #8296 at head
197e248,domain:spec @ objectuiseat, R1, 2026-09-07T11:09ZReviewed against GitHub, not against the report's self-description. This card took one patch round; the record of why is worth keeping, because the thing that failed was not the contract.
The round that failed, and what it caught
The first delivery (
297b014) had the ruled contract change exactly right — and the Clause-② review returned FAIL anyway (5568304348), on a single sentence of prose: the arm's docblock asserted that TS4033 forces the new export. Measured on TS 5.9.3 and 6.0.3, it does not — a non-exported arm emits intocomplex.d.tsas a local interface, exit 0. A false compiler fact, shipped inside a published package's source.⭐ The patch round then re-measured it rather than taking the reviewer's word, and in this package's real build: with the arm un-exported and the barrel line removed,
pnpm --filter @object-ui/types buildexits 0 with zero TS4033,dist/index.d.tsdoes not mention the arm, restore proven by blob hash. ⇒ The claim now standing in the docblock has two independent measurements behind it, which is a better position than the card started in. The export is real and deliberate — an authoring surface, an intentional asymmetry with the zod twin's private const — and the docblock now says that instead of a compiler fiction.Checklist conclusions at
197e248item reading PR shape draft, base main, first lineFixes #7952; whole body scanned, no closing keyword beside another open issue numberHead pinning PR head.sha=197e248546c524fb94698a0eef908c56755a2552; the re-review judged197e248; the check-suite event carried the same sha. Three-way matchCI 32 check runs, all completed: 29success+ 3skipped. Zero failures. Required jobs read individually:Lint→ success 10:58:22Z,Type Check→ success 11:00:23ZPatch scope 2 files, complex.ts+plugin-dashboard/README.md, comment-and-prose only. The re-review confirmed mechanically that no non-comment line incomplex.tschanged and that the union, arm interface, by-referencetypeclosure,DashboardWidgetSchema, barrel line, changeset and pin are byte-untouchedClause-② gate PASS (5569569634) after FAIL (5568304348), both from a context-isolated contract-review-tier subagent Spot checks I took myself
- Bundle delta explained rather than waved through.
core (index.js)moved 7.20 → 7.48 KB between runs on this PR, which touches nopackages/corefile. Probed rather than assumed: the cast is still onmainatRegistry.ts:394andInjectedComponentInputis absent frommain, so PR fix(types,core,sdui-parser): type the framework-injectedbindinginput instead of casting it in #8297 has not merged. The delta is base movement —mainwentfc32921→8f9d87a, carrying fix(plugin-dashboard,core): resolve an object-bound chart's category axis through the aggregate #8298 (core+plugin-dashboard) and fix(fields): one home for thedatedisplay convention in the readonly widgets #8215 (fields), and a PR's CI runs the merge result. ⛔ Not this diff. - Base-movement overlap checked: fix(plugin-dashboard,core): resolve an object-bound chart's category axis through the aggregate #8298 touched
core/src/index.ts,core/src/utils/chart-category-key.*,plugin-dashboard/src/DashboardGridLayout.tsx,DashboardRenderer.tsx— zero overlap with this PR's file surface.⚠️ Semantic coupling still existed (the widened union flows into unannotatedwidgetsreads in those two dashboard files);Type Checkon the merge result is what settles it, and it is green.
Deviations dispositioned
The patch's one declared extra — "gains the second arm" → "gains the component arm", same docblock, one word — was judged in scope by the re-review. The earlier round's two file-surface extensions stand as accepted:
packages/types/src/index.ts(the barrel line, needed for a nameable authoring type) andpackages/plugin-dashboard/README.md(whose prose this change made false, which the checklist requires fixing rather than leaving).File-surface amendment, recorded rather than left implicit: my claim comment named
complex.tsplus a pin and a changeset. The delivered surface is that plus the barrel line and the README. Both are consequences of the ruled change rather than opportunistic edits, and both were declared by the dev before I saw them.Landing pre-check ② — unavailable, and stated as such
check-clause2-carriers.mjs --pair 8296cannot answer for this repository: the script lives only in objectstack and resolves PR numbers there. Its--pair-jsonroute needs label-event timelines this channel cannot fetch. ⛔ Not recorded as run. What it exists to prove — a cleared dual carrier (two removals) rather than a stripped one — is satisfied substantively: both carriers are removed below in one stroke and both read back. Tooling gap filed as #8316's sibling concern; the seat carries it.Verdict: ACCEPT. Clearing
needs:contract-reviewfrom this card and PR #8296, flipping ready, enqueueing.
Generated with Claude Code
https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX
Generated by Claude Code
- Bundle delta explained rather than waved through.
MERGED — PR #8296 landed as
c842594onmain, 2026-09-07T11:32ZVerified on the tree, not on the merge notification:
DashboardWidgetSlotComponentSchemais present inpackages/types/src/complex.tsonorigin/main(5 occurrences) and exported from the barrel atpackages/types/src/index.ts:327.pm:dispatchedstripped and the assignee cleared in the same stroke as this note.domain:spec,bug,findingandpriority:p2stay — ownership and type are not state.⭐ The barrel collision resolved itself in the queue, which is what the rule is for. This PR and PR #8297 (card #6950) both added an export line to
packages/types/src/index.ts— a shared file neither claim declared, because in both cases the barrel line was a post-dispatch increment the dev added and reported, not part of the surface I named at dispatch. Both were in the merge queue simultaneously. The queue merged them cleanly and both exports are onmain(InjectedComponentInputat:107, this card's at:327), with no kick-out. ⛔ Hand-ordering them would have been the wrong move and the protocol says so; recorded here because the near-miss is the useful part, not the outcome.⚠️ The gap that produced it, filed as this seat's own finding rather than left as a war story: a claim's file surface is the PM's prediction at dispatch time, and a dev's declared increments never flow back into a re-run of the serial-constraint check. Two cards in one batch can therefore drift onto a shared file with nothing noticing until the queue does.Follow-ups from this card's review, all filed: #8290 (the docs table teaching refused widget keys, filed by the dev), and #8252 remains the census this card's ruling ordered — still
pm:queue, still held behind this landing, and now unblocked.
Generated with Claude Code
https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX
Generated by Claude Code
Observation-class finding, measured while implementing objectui#7035 (PR #7951). Filed unassigned, not claiming. Grading and
domain:*are the triage seat's.The claim
DashboardComponentSchema.widgetsis sorted intozod-mirror-parity.test.ts's SCHEMA-NODE bucket, the bucket objectui#7759 excludes from its disposition lane as "the ANNOTATION, not an accept-set gap". Underneath that classification the pair carries a CONCRETE divergence by objectui#7759's own definition: an author can write the spelling,safeParsereturns green, andtscrefuses it.The divergence is not a key. It is a whole union arm.
packages/types/src/zod/complex.zod.ts:887—widgets: z.array(z.union([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema])). The first arm (:749) isBaseSchema.extend({ type: z.enum(DASHBOARD_COMPONENT_WIDGET_TYPES) })— a passthrough component node.packages/types/src/complex.ts:1733—widgets: DashboardWidgetSchema[]. One arm. No component-node form.The first arm exists by the 2026-08-14 maintainer ruling (objectstack#8593), quoted verbatim at
complex.ts:1539and again atcomplex.zod.ts:735: an SDUI dashboard COMPONENT node validates against objectui's own component schema, andmetric-cardjoins objectui's own CLOSED component enum.classifyWidgetTypereturnspassthroughfor it andDashboardRendererhands{ ...widget }toSchemaRenderer. So the arm is deliberate, live, and documented — on the Zod side only.Measured on
a2e10cf96, both facesRuntime — ACCEPT.
DashboardComponentSchema.safeParseon the shapepackages/plugin-dashboard/README.mdteaches at:48,:178and:275:The same document rendered through the shipped
DashboardRendererpaints title, value, trend, trendValue and description. Both halves of the ruling hold.TypeScript — REFUSED. The same literal, annotated and compiled
--strictagainst the builtdist/:Five occurrences across the README's three legal blocks. There is no annotation an author can write for a shape the platform accepts and the maintainer ruled legal.
Why the ledger reads it as SCHEMA-NODE
WiderThanDeclared's entry (zod-mirror-parity.test.ts:1787) is a MIXED note over four keys, and its clause for this one is three words:That is true of the SECOND arm —
DashboardWidgetSchema.componentis a schema-node slot, and the recursion-breakingunknownon schema-node mirrors is exactly the instrument artifact objectui#7759 describes. It is not true of the FIRST arm, which is a concreteBaseSchema.extend({ type: z.enum(...) })with no schema-node recursion in it at all. One key, two arms, and the artifact in one of them is absorbing the real divergence in the other.⇒ The interesting half of this card is not the dashboard. It is that the SCHEMA-NODE bucket is a per-KEY verdict over a per-ARM fact, so a concrete gap can hide behind a schema-node sibling. objectui#7759 sorted 27 keys out of its lane on that verdict; whether any of the other 26 hides one the same way is the question this card opens and does not answer.
What NOT to do
⛔ Do not widen
DashboardWidgetSchemawithvalue/icon/trend/trendValue. Both declarations say so in terms, for the same reason (complex.ts:1553,complex.zod.ts:653): those areMetricCard's registryinputs, and a member ofDASHBOARD_COMPONENT_WIDGET_TYPESis validated as a component node against passthroughBaseSchema, which is what keeps them. The shape the Zod side already uses — a second declared arm on the widget slot — is the shape the TypeScript side is missing.Both spellings are a published-contract change and need a ruling, which is why this is a card and not a patch.
Refs
type: 'card'widget #7951 — the docs card this was measured under. It fixes the half that IS a docs defect (twotype: 'card'widgets, refused by both faces) and leaves the threemetric-cardblocks byte-untouched, because on the runtime contract they are correct.WiderThanDeclareddisposition lane, and the classification this card questions.Generated by Claude Code