Repository navigation
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
Description
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 byobjectui 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
actionUrlgets 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:
.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, andmetric-card-style component nodes in the widget slot (whose props are legitimately not widget keys — see theBaseSchemarouting schema-catalog: all 9plugin-dashboardexamples are refused by the spec'sDashboardSchema— the AI few-shot corpus teaches the pre-ADR-0021 widget shape #4600 introduced) would need to be checked first..passthrough()— keep undeclared keys instead of deleting them. Strictly better than silent loss (nothing is destroyed) and consistent withBaseSchema, which is already passthrough. But it still cannot report a stale key, so it does not close the hole.- 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
- A gauge widget's
options.inverthas no observable effect, and nothing can say so — it is an undeclared key ridingDashboardWidgetOptionsSchema.passthrough()#5709 (closed) — the same class one layer down, onDashboardWidgetOptionsSchema.passthrough(): an undeclaredoptions.invertwith no observable effect and nothing able to say so. - finding(types):
TableColumn.typedisagrees three ways — interface declares 8 literals, zod mirror accepts any string, renderer's live read set handles values outside the union via anas anycast #5853 —TableColumn.typedisagreeing three ways; sibling declared-vs-enforced drift in the same package.
Activity
os-zhuang commentedon Aug 24, 2026
Triage (label repair): filed bare —
finding+domain:uiapplied (types package silent-drop observation; awaiting first-touch grading in queue order).Generated by Claude Code
claude commentedon Aug 25, 2026
Concentrated triage round: →
needs-user-decision, Bug; relabelleddomain:ui→domain:spec(contract face of the three-stream split — the accept/reject semantics of a published@object-ui/typescontract). 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
os-steve commentedon Aug 25, 2026
Maintainer ruling (2026-08-25, in-session batch adjudication, batch 2; verbatim: 「其他同意」 — accepted as recommended): Route 1, two-step —
DashboardWidgetSchemagoes.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). Lanedomain:spec, type Bug. Clause-②: yes (accept/reject behaviour change) — contract-review tier at dispatch.Generated by Claude Code
claude commentedon Aug 31, 2026
分诊解锁 ·
pm:blocked→pm:queue—— 阻塞已过期本轮「objectui 停放面」复核:本卡的
Blocked-by:目标已全部关闭,阻塞比阻塞方活得久。Blocked-by:目标objectstack-ai/objectui#6150⇒ 声明的前置条件已兑现 ⇒ 解除,回
pm:queue。check-half-states.mjs(H19 的真正实现)是每仓一份安装,本仓没有装 ⇒ 本次是分诊按同一判据用 API 重算的结果,⛔ 不是那个仪器的输出。若将来本仓装上 sweep 且结论不一致,以 sweep 为准。目标状态是逐个直读的,不是推断的。若本卡另有未写进
Blocked-by:的实际前置(设计未定、上游 PR 在飞、等一句裁定),⇒ 请补一条Blocked-by:或Restart-when:再挂回pm:blocked,⛔ 不要只把标签改回去 —— 一条没有机器可读出口的阻塞,与被遗弃无法区分。pm:on-hold67 ·pm:blocked44,其中 26 张 on-hold 没有可点火的Restart-when:。本卡不在那 26 张内。Generated by Claude Code
os-warren commentedon Aug 31, 2026
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
os-warren commentedon Aug 31, 2026
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
os-warren commentedon Aug 31, 2026
席内 contract review:PASS — PR #7033(head
7718ccc),依 objectstack#13795 新流程(维护者 2026-08-31 裁定)执行,provenance 即该裁定。契约判断清单(逐项):
.strict()= 裁定 Route 1 第二步,前置 finding(types): 13 top-level schema keys that component renderers genuinely READ are declared by no shipped type — measured across all 76content/docs/componentspages #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 信封仍过 ✓。@object-ui/typesminor + Breaking for authored metadata 叙述,受影响(pre-ADR-0021 四键)/不受影响(metric-card 组件节点)两节 + 语料预检零增拒记录——与 accept-set narrowing 先例定级一致 ✓。放行:剥
needs:contract-review(双载体)→ ready → auto-merge;MERGED 后卡经 Fixes 关闭剥残留。Generated by Claude Code