Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

finding(types): DashboardWidgetSchema SILENTLY DROPS every undeclared widget key — the file's own docstring names this failure mode, and it still applies to the keys nothing declares #6002

Closed

Description

@yinlianghui

Found while implementing #4600 (the dashboard catalog gate). Deliberately not fixed there: the honest fix is a published-contract decision with blast radius past that card's fence.

What was measured

On claude/issue-4600-schema-catalog-dashboard-gate at a54b8bca4, parsing a dashboard node with @object-ui/types' own DashboardComponentSchema:

input   { type: 'metric', title: 'x', zzcanary: 1, categoryField: 'stage', aggregate: 'sum' }
parsed  { type: 'metric', title: 'x' }
verdict ACCEPT

DashboardWidgetSchema is a plain z.object(), so undeclared keys are stripped, not refused. Three of the five keys above vanish and the parse reports success.

This is the same failure the file's own docstring already records, one level out — zod/complex.zod.ts, on the derivation that replaced the hand copy:

The hand copy this replaces declared 10 of the spec's 22 keys, and because a z.object() strips unknown keys, the other 12 were dropped without a word by objectui validate

That fix restored the 12 keys the spec declares. It did not change what happens to keys no contract declares — which is still "dropped without a word".

Why it matters

object / categoryField / valueField / aggregate — the retired pre-ADR-0021 inline analytics shape that #4600 was opened about — are exactly such keys. Under the current schema a widget carrying all four validates clean. The keys are simply deleted. So the contract cannot tell an author, a designer, or a publish-time check that the document says something the platform stopped honouring; the renderer's LEGACY_RETIRED_WIDGET_SCHEMA placeholder is the only surface that ever mentions it, and only at render time.

Compare actionUrl / actionType / actionIcon / aria / responsive: those are REFUSED by name, with a tombstone message pointing at the surviving home. report-chart-query-spec-parity.test.ts states the principle in those words:

Not "strips" — refuses. That distinction is the whole point of ADR-0049 enforce-or-remove: a stale dashboard carrying actionUrl gets told where the affordance moved instead of silently losing it.

The asymmetry is the finding: a key the spec retired is refused loudly, while a key that was never declared at all is dropped in silence.

Scope note — what #4600 did instead

#4600 closed the type vocabulary (an unbounded z.string() → a closed enum), which gives the catalog gate real teeth on type. For the key surface it added a catalog-local assertion instead of changing the shared schema: the gate routes each widget to the schema that owns it and fails if any authored key is dropped. That protects the few-shot corpus but protects nothing else in the repo.

Ablation on that branch, for evidence the detection works: injecting object + categoryField + aggregate into a real catalog entry turns the gate red naming all three; restoring turns it green (21/21).

The decision this needs

Three routes, none of them mechanical:

  1. .strict() — refuse undeclared keys. Matches the ADR-0049 principle above and is the only option that makes a stale key loud everywhere. Blast radius unmeasured: every stored dashboard, the designer's emit path, and metric-card-style component nodes in the widget slot (whose props are legitimately not widget keys — see the BaseSchema routing schema-catalog: all 9 plugin-dashboard examples are refused by the spec's DashboardSchema — the AI few-shot corpus teaches the pre-ADR-0021 widget shape #4600 introduced) would need to be checked first.
  2. .passthrough() — keep undeclared keys instead of deleting them. Strictly better than silent loss (nothing is destroyed) and consistent with BaseSchema, which is already passthrough. But it still cannot report a stale key, so it does not close the hole.
  3. Leave as-is, and document that objectui's widget validation is lossy for undeclared keys — with the catalog gate as the only place the loss is detected.

Route 1 looks right on the "declared = enforced" and "make AI-authored metadata hard to get wrong" axes, and route 2 is a cheap strict improvement over the status quo. Both want a measurement of the stored/designer population before anyone commits.

Related

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 24, 2026
  2. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Triage (label repair): filed bare — finding + domain:ui applied (types package silent-drop observation; awaiting first-touch grading in queue order).


    Generated by Claude Code

  3. claude commented on Aug 25, 2026

    @claude
    Contributor

    Concentrated triage round: → needs-user-decision, Bug; relabelled domain:ui → domain:spec (contract face of the three-stream split — the accept/reject semantics of a published @object-ui/types contract). The file's own docstring names silent key-stripping as the failure mode, and it still applies to every undeclared widget key.

    ① 项目长远合理性:strip 语义让 dashboard 契约永远无法响亮拒绝,与全平台 declared=enforced 方向相反 —— 本文件的 docstring 自己就把 strip 记为上一次事故的机制。
    ② 实际业务拉动:作者写的 widget 键(实测 categoryField/aggregate)被静默剥掉,五键进三键出还 ACCEPT —— 图表空白或退化且无任何报错,作者只能瞎猜。
    ③ 防 AI 犯错:静默剥键正是 AI 批量生成 dashboard 页面时错误被掩盖的温床;strict + unrecognized_keys 让错键在编写期响亮爆炸。
    ④ 创业阶段不扩散:strict 化不新增声明面,但有一个前置债 —— #6150 已记录 13 个 renderer 真实消费而 schema 未声明的键:先补齐真实消费键的声明,再 strict,否则合法页面被拒。
    推荐:A = 两步走:先并 #6150 补齐真实消费键声明,再 .strict() 响亮拒绝;B = 直接 strict(会拒今天合法的页面,不可行);C = 维持 strip。回一个字母即可。
    置信缺口:存量 dashboard 语料中未声明键的使用广度未盘点 —— A 的迁移面大小未测,裁 A 后第一步应是语料 sweep。


    Generated by Claude Code

  4. added
    domain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lane
    and removed
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 25, 2026
  5. added theissue type on Aug 25, 2026
  6. os-steve commented on Aug 25, 2026

    @os-steve
    Collaborator

    Maintainer ruling (2026-08-25, in-session batch adjudication, batch 2; verbatim: 「其他同意」 — accepted as recommended): Route 1, two-step — DashboardWidgetSchema goes .strict(), but only AFTER the genuinely-consumed undeclared keys have been declared.

    Blocked-by: #6150

    Sequencing ruled with it: #6150 (the declaration catch-up, already queued) lands first; then this card flips .strict() with the blast-radius measurement the body names as prerequisite — stored dashboard population, designer emit paths, widget-slot component nodes — done at claim time, not assumed. If measurement finds live documents that would start refusing for keys #6150 did not declare, that is a fork back to triage per key, not a reason to quietly widen. Route 2 (.passthrough()) rejected as the endpoint (detection gap unfilled); Route 3 rejected — the silence is no longer an accepted property.

    State transition: needs-user-decision → pm:blocked (unlock scan returns it on #6150's close; re-verify on the merged ref). Lane domain:spec, type Bug. Clause-②: yes (accept/reject behaviour change) — contract-review tier at dispatch.


    Generated by Claude Code

  7. claude commented on Aug 31, 2026

    @claude
    Contributor

    分诊解锁 · pm:blocked → pm:queue —— 阻塞已过期

    本轮「objectui 停放面」复核:本卡的 Blocked-by: 目标已全部关闭,阻塞比阻塞方活得久。

    Blocked-by: 目标 直读结果
    objectstack-ai/objectui#6150 closed

    ⇒ 声明的前置条件已兑现 ⇒ 解除,回 pm:queue。

    ⚠️ 关于本次读数的来源,如实声明

    check-half-states.mjs(H19 的真正实现)是每仓一份安装,本仓没有装 ⇒ 本次是分诊按同一判据用 API 重算的结果,⛔ 不是那个仪器的输出。若将来本仓装上 sweep 且结论不一致,以 sweep 为准。目标状态是逐个直读的,不是推断的。

    ⚠️ 本席只解除了「机器可读的那条阻塞线」

    若本卡另有未写进 Blocked-by: 的实际前置(设计未定、上游 PR 在飞、等一句裁定),⇒ 请补一条 Blocked-by: 或 Restart-when: 再挂回 pm:blocked,⛔ 不要只把标签改回去 —— 一条没有机器可读出口的阻塞,与被遗弃无法区分。

    ⚠️ 本仓停放面同批读数:pm:on-hold 67 · pm:blocked 44,其中 26 张 on-hold 没有可点火的 Restart-when:。本卡不在那 26 张内。


    Generated by Claude Code

  8. self-assigned this
    on Aug 31, 2026
  9. os-warren commented on Aug 31, 2026

    @os-warren
    Collaborator

    Claim: session_01PBjwYLS6BciTQW3c9xQiD2 · branch claude/issue-6002-dashboard-widget-strict · seat domain:spec (os-warren)

    按 2026-08-25 维护者裁定(comment 5406212152)执行:Route 1 两步走的第二步——#6150(声明补齐)已关闭,在合并 ref 上复核其兑现后 DashboardWidgetSchema 转 .strict()。Clause-②: yes ⇒ fable 派发;PR 创建时 draft + needs:contract-review 双载体停靠,按 2026-08-31 维护者复核改制裁定(objectstack#13795)走席内 contract review 后放行,不再等外部评审链。

    认领时爆炸半径实测(裁定原文点名,⛔ 不得假设):存量 dashboard 语料、designer emit 路径、widget 槽内 component 节点(BaseSchema 路由的 metric-card 类 props 非 widget 键)。实测发现 #6150 未声明的活文档键 ⇒ 逐键回分诊,⛔ 不悄悄放宽。


    Generated by Claude Code

  10. os-warren commented on Aug 31, 2026

    @os-warren
    Collaborator

    os-dev-report

    {
      "issue": 6002,
      "status": "done",
      "branch": "claude/issue-6002-dashboard-widget-strict",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7033",
      "premise_still_valid": true,
      "summary": "Step 0: #6150 closed via PR #6945 (squash 2c45966, ancestor of branch base f7850ee); all 13 recorded renderer-consumed keys verified declared key-by-key on that ref - premise holds (none of the 13 are dashboard-widget keys, so family-1 measurement carried the live-doc question). Step 1 (measured before change): live-corpus strict-refusal delta is EMPTY - 575 JSON files + all dashboard-bearing doc fences = 10 dashboards / 30 widgets (11 component nodes), zero newly refused, so no per-key fork back to triage; all designer emit paths (metadata-admin DashboardPreview/DashboardWidgetInspector, plugin-designer DashboardEditor, plugin-dashboard WidgetConfigPanel with sanitizeDraftForType scrub) emit declared keys only; bare strict WOULD have refused the 11 ruled-legal metric-card component nodes at document level (value/icon/trend/trendValue), so the 2026-08-14 ruling's BaseSchema routing moved into the shared DashboardComponentSchema widget slot as a union gated on the closed component-type enum (not exported, structurally unreachable for spec-family widgets). Step 2: DashboardWidgetSchema now .strict() - the card's five-in/three-out ACCEPT probe refuses naming zzcanary/categoryField/aggregate; tombstones (actionUrl family) keep their own removal messages; BaseSchema passthrough, #5709 options layer, and #4600 type enum untouched. One observable message-shape change: document-level refusals now wrap in invalid_union with the named issues nested per arm (gate counter-probe regexes updated order-insensitively). PR parked draft + needs:contract-review on both carriers per Clause-2, release via objectstack#13795 in-seat flow.",
      "tests": "Final head 7718ccc, all runs re-executed at that sha. vitest (repo root, path-filtered): packages/types/ + examples/schema-catalog/ + packages/cli/ = 112 files / 3113 tests passed ('Test Files 112 passed', 'Tests 3113 passed'); packages/plugin-dashboard/ + packages/plugin-designer/ = 99 files / 901 tests passed. @object-ui/types type-check (tsc noEmit + examples + test tsconfigs) exit 0 with script name echoed; tsc --listFiles counts the new pin file in the test tsconfig closure (1 hit). Gates: check-changeset-presence PASS (verdict line: '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'), check-changeset-no-major PASS, check:control-bytes PASS (5826 files), check:spec-symbols PASS (0 untriaged collisions); eslint on the 3 changed files: 0 errors / 2 pre-existing warnings on untouched lines. Declared narrowings owned by CI: full pnpm lint and the full cross-package pnpm test farm not run locally; the targeted set covers every suite that runtime-parses these schemas (enumerated by grep over parse call sites). Ablation: fix committed first; mutation = remove .strict(), confirmed on disk by anchor grep count 1 to 0; predicted direction observed exactly - the 4 refusal pins red, the 5 positive/routing pins green, vitest exit 1; restore via git checkout HEAD then verified by git diff HEAD empty AND git hash-object equal to the HEAD blob (f92423f982...). No dist build sits in the ablation loop: the suites resolve the subject through the vitest root alias to packages/types/src (and the pin file imports the source module relatively), so both legs measured the mutated/restored source directly.",
      "mcp_calls": "9 - bulk reads went through the zero-quota page-payload channel (issue bodies, comments, PR label read-back via data-name grep); MCP was used for the 2 truncated-timeline comments, 2 label writes with read-merge-write, PR create, PR body read-back, label read-backs, and this report comment + its read-back. Channel declaration: the repo-scoped REST probe returned 403 'GitHub access is not enabled for this session' (session gate closed), so label writes used the MCP whole-set replace with pre-read union and compare read-back instead of the additive REST endpoint; issue #6002 label union verified intact (pm:dispatched, domain:spec, needs:contract-review), PR label verified via page payload data-name (2 hits).",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT filed (per dispatch, handing to PM): packages/plugin-dashboard/README.md fence 8 documents a dashboard whose widgets use type 'card' with a nested body key - that type is outside the closed widget vocabulary and the example is REFUSED today (measured: DashboardWidgetTypeSchema.safeParse('card') fails; pre-existing since #4600, not a delta of this PR). Docs-drift card wanted: rewrite the example onto legal widget forms (component node or a chart family).",
        "Observation, no card needed unless PM disagrees: apps/console preview-samples dashboard sample is already ledgered KNOWN_STALE in preview-samples-spec-valid.test.ts against the spec schema (value/format/chart) - existing ledger covers it; recorded here only because the corpus sweep touched it."
      ]
    }

    Generated by Claude Code

  11. os-warren commented on Aug 31, 2026

    @os-warren
    Collaborator

    席内 contract review:PASS — PR #7033(head 7718ccc),依 objectstack#13795 新流程(维护者 2026-08-31 裁定)执行,provenance 即该裁定。

    契约判断清单(逐项):

    • ① derived judgments:(a) .strict() = 裁定 Route 1 第二步,前置 finding(types): 13 top-level schema keys that component renderers genuinely READ are declared by no shipped type — measured across all 76 content/docs/components pages #6150 已在合并 ref 逐键复核 ✓;(b) DashboardWidgetSlotComponentSchema(BaseSchema.extend + 闭集组件枚举,未导出)把 2026-08-14 objectstack#8593 裁定的组件节点路由从 catalog-local 移进共享 widget 槽——枚举独占匹配 + 两个 NOT-A-HATCH 文档级 pin 证明它不构成绕过 strict 的舱口,非新作者面 ✓;(c) tombstone 键保持 z.never() 声明成员、专属消息不降级 ✓;(d) invalid_union 消息形状变化仅诊断层,counter-probe 正则序不敏感更新 ✓;legacy component 信封仍过 ✓。
    • ② semver:@object-ui/types minor + Breaking for authored metadata 叙述,受影响(pre-ADR-0021 四键)/不受影响(metric-card 组件节点)两节 + 语料预检零增拒记录——与 accept-set narrowing 先例定级一致 ✓。
    • ③ 边界旗:两条 out-of-scope——README fence 8 教被拒 widget 形状(pre-existing)⇒ 本席立 docs-drift 卡;preview-samples 已有 KNOWN_STALE 台账 ⇒ 同意不立卡。
    • 独立性判定:无真分叉、dev 无 open_questions(slot 路由是具名裁定的机械实施且 pin 非空洞)⇒ 按新流程不起隔离复核子代理,记档。
    • 爆炸半径(裁定点名前置):存量 575 JSON + 全 doc fences = 零新拒 ⇒ 逐键回分诊条件未触发 ✓;designer 四条 emit 路径全声明键 ✓;ablation 精确(4 拒绝 pin 红/5 阳性 pin 绿,哈希证明)✓。

    放行:剥 needs:contract-review(双载体)→ ready → auto-merge;MERGED 后卡经 Fixes 关闭剥残留。


    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

Labels

domain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lane

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions