You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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:
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.
#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).
.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.
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.
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-gateata54b8bca4, parsing a dashboard node with@object-ui/types' ownDashboardComponentSchema:DashboardWidgetSchemais a plainz.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: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'sLEGACY_RETIRED_WIDGET_SCHEMAplaceholder 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.tsstates the principle in those words: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
typevocabulary (an unboundedz.string()→ a closed enum), which gives the catalog gate real teeth ontype. 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+aggregateinto 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.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
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.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.