Skip to content

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

@claude

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.widgets is sorted into zod-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, safeParse returns green, and tsc refuses it.

The divergence is not a key. It is a whole union arm.

face declaration
Zod mirror packages/types/src/zod/complex.zod.ts:887 — widgets: z.array(z.union([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema])). The first arm (:749) is BaseSchema.extend({ type: z.enum(DASHBOARD_COMPONENT_WIDGET_TYPES) }) — a passthrough component node.
TypeScript 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:1539 and again at complex.zod.ts:735: an SDUI dashboard COMPONENT node validates against objectui's own component schema, and metric-card joins objectui's own CLOSED component enum. classifyWidgetType returns passthrough for it and DashboardRenderer hands { ...widget } to SchemaRenderer. So the arm is deliberate, live, and documented — on the Zod side only.

Measured on a2e10cf96, both faces

Runtime — ACCEPT. DashboardComponentSchema.safeParse on the shape packages/plugin-dashboard/README.md teaches at :48, :178 and :275:

{ type: 'dashboard', widgets: [ { type: 'metric-card', title: 'T', value: '1',
    icon: 'users', trend: 'up', trendValue: '+1%', description: 'd' } ] }
=> ACCEPT, every authored key preserved in the parsed output

The same document rendered through the shipped DashboardRenderer paints title, value, trend, trendValue and description. Both halves of the ruling hold.

TypeScript — REFUSED. The same literal, annotated and compiled --strict against the built dist/:

error TS2561: Object literal may only specify known properties, but 'value' does not
              exist in type 'DashboardWidgetSchema'. Did you mean to write 'values'?

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:

widgets is SCHEMA-NODE.

That is true of the SECOND arm — DashboardWidgetSchema.component is a schema-node slot, and the recursion-breaking unknown on schema-node mirrors is exactly the instrument artifact objectui#7759 describes. It is not true of the FIRST arm, which is a concrete BaseSchema.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 DashboardWidgetSchema with value / icon / trend / trendValue. Both declarations say so in terms, for the same reason (complex.ts:1553, complex.zod.ts:653): those are MetricCard's registry inputs, and a member of DASHBOARD_COMPONENT_WIDGET_TYPES is validated as a component node against passthrough BaseSchema, 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

  • objectui#7035 / PR docs(plugin-dashboard): README's chart example taught a rejected type: 'card' widget #7951 — the docs card this was measured under. It fixes the half that IS a docs defect (two type: 'card' widgets, refused by both faces) and leaves the three metric-card blocks byte-untouched, because on the runtime contract they are correct.
  • objectui#7759 — the WiderThanDeclared disposition lane, and the classification this card questions.
  • objectui#4600 — closed the widget type enum.
  • objectstack#8593 — the 2026-08-14 ruling that admits the component node.

Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    domain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lane
    on Sep 6, 2026
  2. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊:domain:spec / bug + finding / needs-user-decision / priority:p2

    ⛔ needs-user-decision 不与 pm:* 并存。domain:spec @ objectui(标签自述:fix lands on packages/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 对这个键的判词是三个词——widgets is 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 DashboardWidgetSchema with value / icon / trend / trendValue. Both declarations say so in terms, for the same reason (complex.ts:1553, complex.zod.ts:653): those are MetricCard's registry inputs, and a member of DASHBOARD_COMPONENT_WIDGET_TYPES is validated as a component node against passthrough BaseSchema, 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

  3. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    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:1733 widgets becomes a two-arm union matching complex.zod.ts:887: a component-node arm (BaseSchema-shaped node whose type is a member of DASHBOARD_COMPONENT_WIDGET_TYPES) plus DashboardWidgetSchema. That is the shape objectstack#8593 (2026-08-14) ruled legal and the zod side already carries; the README's three metric-card blocks then annotate and compile. ⛔ DashboardWidgetSchema is not widened with value / icon / trend / trendValue — both declarations say why (complex.ts:1553, complex.zod.ts:653). Widening a published TS accept set: needs:contract-review on the PR, @object-ui/types changeset (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

  4. self-assigned this
    on Sep 7, 2026
  5. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    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 (the DashboardComponentSchema.widgets member and whatever arm type it needs), one new issue-numbered type-level pin, one .changeset/ entry. ⛔ packages/types/src/zod/complex.zod.ts is 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: #7735 and #6950 (dispatched in this same batch — file surfaces measured disjoint, neither touches complex.ts) · #6939 (pm:dispatched, groups 2–6 queued, none in flight; its claim 5521422355 enumerates overlay / data-display / kanban / filter-builder / chart / object-map mirrors and zod-mirror-parity.test.ts, ⛔ not complex.ts) · #8252 is the census this card's ruling ordered downstream and is deliberately held behind this PR · no other in-flight claim on packages/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:1733 widgets becomes a two-arm union matching complex.zod.ts:887: a component-node arm (BaseSchema-shaped node whose type is a member of DASHBOARD_COMPONENT_WIDGET_TYPES) plus DashboardWidgetSchema. That is the shape objectstack#8593 (2026-08-14) ruled legal and the zod side already carries; the README's three metric-card blocks then annotate and compile. ⛔ DashboardWidgetSchema is not widened with value / icon / trend / trendValue — both declarations say why (complex.ts:1553, complex.zod.ts:653). Widening a published TS accept set: needs:contract-review on the PR, @object-ui/types changeset (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 = fc32921 at 2026-09-07T08:14Z:

    the card / the ruling says actually on origin/main
    complex.ts:1733 complex.ts:1897 ⚠️ drift +164
    complex.zod.ts:887 complex.zod.ts:943 ⚠️ drift +56
    complex.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 safeParse ACCEPT and the five tsc --strict TS2561s). 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 whose type is a member of DASHBOARD_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.md teaches 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.mjs cannot 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 own package.json at fc32921 — 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 #7952 on the first line, target main. Merge origin/main before opening. needs:contract-review is 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 newest Claim: 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 the os-dev-report marker before returning.


    Generated with Claude Code

    https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX


    Generated by Claude Code

  6. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    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

  7. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    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 (BaseSchema with type: string + [key: string]: any; an all-optional, index-signature-free DashboardWidgetSchema; the PR's arm) under --strict on 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

    1. DashboardComponentSchema.widgets: DashboardWidgetSchema[] → Array<DashboardWidgetSlotComponentSchema | DashboardWidgetSchema>. This is the ruled shape exactly — a component-node arm plus DashboardWidgetSchema — and it matches the zod twin complex.zod.ts:943 z.array(z.union([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema])) including arm order (component arm first). Correct.
    2. New interface DashboardWidgetSlotComponentSchema extends BaseSchema { type: DashboardComponentWidgetType } — one-to-one twin of zod :805 BaseSchema.extend({ type: z.enum(DASHBOARD_COMPONENT_WIDGET_TYPES) }). The type field is closed by reference: DashboardComponentWidgetType = (typeof DASHBOARD_COMPONENT_WIDGET_TYPES)[number] (origin/main complex.ts:1735), and the pin asserts Equal<Slot['type'], DashboardComponentWidgetType>. A member added to the const widens both faces together; there is no copy to drift. Correct.
    3. DashboardWidgetSchema body untouched — no value / icon / trend / trendValue. Re-driven: const w: DashboardWidgetSchema = { type: 'metric-card', value: '1' } is still TS2561 on both compilers; the pin's @ts-expect-error turns into TS2578 if anyone follows the compiler's Did you mean 'values'? suggestion. Correct — the forbidden repair was not made.
    4. packages/types/src/zod/complex.zod.ts is not in the diff (5 files; zod absent). The runtime accept set is unchanged. Correct.
    5. Intended widening, TS face: { type: 'metric-card', title, value, trend, trendValue } in widgets[] 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.
    6. Entailed widening, TS face: { type: 'metric-card', someProp: 1 } compiles — the passthrough the ruling admits; zod keeps the key. Correct.
    7. Still refused on both faces (no hatch): { type: 'bar', bogus: 1 } → TS2353 (the literal is discriminated by type, so the passthrough arm never applies); { type: 'not-a-component', value } → TS2322. Re-measured on both compilers; pinned. Correct.
    8. The corner — { id, component: {…}, bogus: 1 } with no type. 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; a type-less envelope has none, so the check falls back to "known in any constituent", and a [key: string]: any arm 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 if tsc ever closes the corner; never @ts-expect-error it). Not a hole dressed up as a limitation. Accepted as the cost of the ruled shape.
    9. New public export DashboardWidgetSlotComponentSchema (module export in complex.ts plus barrel line index.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.
    10. Consumer-visible effect: an unannotated element read of any key other than type / id (w.layout, w.options, …) now resolves to any through the arm's index signature (was DashboardWidgetLayout | undefined). Measured. w.type and w.id reads are unchanged; the union array is still assignable to DashboardWidgetSchema[]; (w: DashboardWidgetSchema) callbacks compile unchanged; DashboardWidgetSchema[] still assigns into widgets. A checking-strength loss, not a compile break. Disclosed in the changeset with the remedy. See ②.
    11. 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.
    12. Wording defect, not a contract change: head complex.ts:1723 and README :498 call the component arm "the second arm", while complex.ts:1937 says "component arm first" — and the declaration and zod both put it first. Patch nit.

    ② Semver grading — @object-ui/types: minor is correct

    • Repo convention (AGENTS.md → 版本号策略): a changeset never declares major (fixed group of 39, enforced by scripts/check-changeset-no-major.mjs, "Changeset Bump Policy" green); even a breaking change is scored minor with the break stated in the body. So minor is 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 any degradation 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

    1. 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 (head complex.ts:1896–1899) claim the export is compiler-forced. Measured false on TS 5.9.3 and 6.0.3 with declaration: true, composite: true, isolatedModules: true mirroring packages/types/tsconfig.json: a non-exported same-module interface referenced by an exported interface emits into dist/complex.d.ts as a local interface … {} followed by export {}, exit 0; and a consumer importing only DashboardComponentSchema from the barrel annotates the document and writes the arm shape with no name, exit 0. So the barrel line — and the module-level export itself — 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.
    2. packages/plugin-dashboard/README.md — accepted; forced in part. The prose at origin/main:479–484 ("a metric-card node 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 the kpi example follow from the export decision (item 1). The six metric-card blocks are byte-untouched (hunks at :426, :466, :479 only) — "do not edit the README to fit the type" holds. Carries the "second arm" nit at :498.
    3. README :131 declare 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.
    4. zod-mirror-parity.test.ts WiderThanDeclared note now true of the whole key — accepted, deferred to types: re-judge the 27 SCHEMA-NODE-bucketed keys of zod-mirror-parity per UNION ARM — a per-key verdict over per-arm facts let DashboardComponentSchema.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.
    5. finding(docs): content/docs/api/schema-reference.md's dashboard table teaches colSpan / rowSpan / body as widget keys — all three refused BY NAME by the strict DashboardWidgetSchema — and refreshInterval in milliseconds where the renderer multiplies by 1000 #8290 (schema-reference.md colSpan / rowSpan / body / refreshInterval) — accepted as out of scope, filed rather than ridden.
    6. Channel deviation (REST 403 → one MCP search before filing finding(docs): content/docs/api/schema-reference.md's dashboard table teaches colSpan / rowSpan / body as widget keys — all three refused BY NAME by the strict DashboardWidgetSchema — and refreshInterval in milliseconds where the renderer multiplies by 1000 #8290) — accepted, procedural.
    7. 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.
    8. 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:

    1. 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.
    2. complex.ts:1723 and README :498: "the second arm" → the component arm, first in the declaration and in zod; align with :1937.
    3. 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

  8. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    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

  9. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    Clause-② in-seat contract re-review — PR #8296, patch round, head 197e248 (parent 297b014)

    Judged against the diff 297b014..197e248 itself (2 files, +18/−10), not the author's description of it. Head 197e248 has the single parent 297b014, 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

    1. 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 exported DashboardComponentSchema emits into dist/complex.d.ts as 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 matches complex.zod.ts:800–802 verbatim 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, local interface at dist/complex.d.ts:1749, zero mentions in dist/index.d.ts, restore proven by blob hash, rebuild from restored source exit 0), and this seat's, on TypeScript 6.0.3 with declaration + composite + isolatedModules mirroring packages/types/tsconfig.json (local interface in complex.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.
    2. "second arm" wording fixed — done at both sites. complex.ts:1723–1724 now reads "the component arm — first, as in the Zod twin — of DashboardComponentSchema.widgets"; README :498–499 now reads "the component arm of DashboardComponentSchema['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 with complex.zod.ts:943. Accepted.
    3. 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.ts contains 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.
    • DashboardWidgetSchema body byte-identical between 297b014 and 197e248.
    • git diff --stat 297b014 197e248 -- packages/types/src/index.ts .changeset packages/types/src/__tests__ packages/types/src/zod is empty: barrel line, changeset (text and level), envelope-corner pin, zod all untouched; zod also still identical to origin/main.
    • README hunks against origin/main are still only :426 / :466 / :479 — the six metric-card blocks 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 with type closed by reference to DASHBOARD_COMPONENT_WIDGET_TYPES; DashboardWidgetSchema not widened; zod untouched), @object-ui/types: minor is 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_progress on 197e248 at 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

  10. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #8296 at head 197e248, domain:spec @ objectui seat, R1, 2026-09-07T11:09Z

    Reviewed 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 into complex.d.ts as 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 build exits 0 with zero TS4033, dist/index.d.ts does 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 197e248

    item reading
    PR shape draft, base main, first line Fixes #7952; whole body scanned, no closing keyword beside another open issue number
    Head pinning PR head.sha = 197e248546c524fb94698a0eef908c56755a2552; the re-review judged 197e248; the check-suite event carried the same sha. Three-way match
    CI 32 check runs, all completed: 29 success + 3 skipped. Zero failures. Required jobs read individually: Lint → success 10:58:22Z, Type Check → success 11:00:23Z
    Patch scope 2 files, complex.ts + plugin-dashboard/README.md, comment-and-prose only. The re-review confirmed mechanically that no non-comment line in complex.ts changed and that the union, arm interface, by-reference type closure, DashboardWidgetSchema, barrel line, changeset and pin are byte-untouched
    Clause-② gate PASS (5569569634) after FAIL (5568304348), both from a context-isolated contract-review-tier subagent

    Spot checks I took myself

    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) and packages/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.ts plus 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 8296 cannot answer for this repository: the script lives only in objectstack and resolves PR numbers there. Its --pair-json route 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-review from this card and PR #8296, flipping ready, enqueueing.


    Generated with Claude Code

    https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX


    Generated by Claude Code

  11. removed their assignment
    on Sep 7, 2026
  12. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    MERGED — PR #8296 landed as c842594 on main, 2026-09-07T11:32Z

    Verified on the tree, not on the merge notification: DashboardWidgetSlotComponentSchema is present in packages/types/src/complex.ts on origin/main (5 occurrences) and exported from the barrel at packages/types/src/index.ts:327.

    pm:dispatched stripped and the assignee cleared in the same stroke as this note. domain:spec, bug, finding and priority:p2 stay — 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 on main (InjectedComponentInput at :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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lanefindingpriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions