From b988bec9a45dc2f927279ca3827b92fa2e982d37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:59:10 +0000 Subject: [PATCH 1/2] feat(types)!: DashboardWidgetSchema refuses undeclared keys (.strict()) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route 1 step 2 of the maintainer ruling on objectui#6002: after #6150 declared the genuinely-consumed keys, the widget schema flips from strip-in-silence to loud refusal — zod `unrecognized_keys` names every offending key. The widget-slot component route (objectstack#8593 ruling: metric-card props belong to passthrough BaseSchema) moves into the shared DashboardComponentSchema widget slot as a union, so ruled-legal component nodes keep parsing whole with props intact. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- .../plugin-dashboard-component-schema.test.ts | 31 ++- .../dashboard-widget-strict-6002.test.ts | 197 ++++++++++++++++++ packages/types/src/zod/complex.zod.ts | 70 ++++++- 3 files changed, 283 insertions(+), 15 deletions(-) create mode 100644 packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts diff --git a/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts b/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts index 3ca4096be1..3ec4ee5341 100644 --- a/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts +++ b/examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts @@ -62,8 +62,12 @@ * COMPONENT node, so its body is `BaseSchema`'s (passthrough: `value` / * `icon` / `trend` / `trendValue` are `plugin-dashboard`'s registry * `inputs`, not widget keys), while every other widget is the spec-derived - * `DashboardWidgetSchema`'s. Check 3 is what catches a stale or - * mis-layered key that check 1 would strip in silence. + * `DashboardWidgetSchema`'s. When this gate was written check 3 caught the + * stale or mis-layered keys check 1 stripped in silence; since + * objectui#6002 made the widget schema `.strict()` (and moved this same + * routing into the shared `DashboardComponentSchema` widget slot), those + * keys refuse loudly in checks 1 AND 3, and the dropped-key detector + * remains as the regression floor. * * ## Deliberately NOT catalog-wide * @@ -141,6 +145,12 @@ function auditWidget(widget: Widget, where: string): string[] { // schema that owns its body is objectui's own `BaseSchema` — the ruling's // "objectui's own component schema" — which is passthrough and therefore // keeps the component's props. Everything else is a spec-derived widget. + // + // Since objectui#6002 the spec-derived arm is `.strict()`, so an undeclared + // key on that arm surfaces as a REFUSAL from the parse below (loud, key + // named) rather than reaching the dropped-key detector. The detector stays: + // it is the regression floor if strictness is ever loosened back toward the + // strip regime — the same defence-in-depth stance check 2 takes for `type`. const isComponentNode = typeof type === 'string' && COMPONENT_WIDGET_TYPES.has(type); const owner = isComponentNode ? BaseSchema : DashboardWidgetSchema; const ownerName = isComponentNode ? 'BaseSchema (objectui component node)' : 'DashboardWidgetSchema'; @@ -288,13 +298,16 @@ describe('counter-probe — the gate refuses deliberately malformed entries', () 'check 2 — a widget type nothing registers (the OBJUI-001 red panel at runtime)', (w) => { w.type = 'metrci-card'; }, // Both halves must fire: the shared schema's own enum message AND the - // catalog's restatement of it. - /Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/, + // catalog's restatement of it. Order-insensitive lookaheads since + // objectui#6002: the doc-level parse (check 1) now reports the widget + // slot's union as a bare `Invalid input`, so the enum message reaches + // the report through check 3's routed parse, AFTER the restatement. + /^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/, ], [ 'check 2 — a chart family the spec retired', (w) => { w.type = 'heatmap'; }, - /Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/, + /^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/, ], [ 'check 2 — a widget naming no component at all', @@ -302,14 +315,18 @@ describe('counter-probe — the gate refuses deliberately malformed entries', () /no `type` and no `component` envelope/, ], [ + // Pre-#6002 these two were caught by the dropped-key detector + // (`silently DROPS authored key(s)`); the strict widget schema now + // refuses them upstream of it, naming every key (objectui#6002). 'check 3 — the pre-ADR-0021 inline analytics keys this card was opened about', (w) => { w.object = 'opportunity'; w.categoryField = 'stage'; w.aggregate = 'sum'; }, - /silently DROPS authored key\(s\).*`object`.*`categoryField`.*`aggregate`/, + /refused by DashboardWidgetSchema[\s\S]*Unrecognized keys[\s\S]*"object"[\s\S]*"categoryField"[\s\S]*"aggregate"/, ], [ 'check 3 — a single mis-layered renderer setting left at widget top level', (w) => { w.dateGranularity = 'month'; }, - /silently DROPS authored key\(s\) `dateGranularity`/, + // zod prints the singular `Unrecognized key:` for a single offender. + /refused by DashboardWidgetSchema[\s\S]*Unrecognized keys?[\s\S]*"dateGranularity"/, ], [ 'check 1 — a retired key the spec tombstoned, refused rather than dropped', diff --git a/packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts b/packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts new file mode 100644 index 0000000000..89ff0fd1dc --- /dev/null +++ b/packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts @@ -0,0 +1,197 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6002 — `DashboardWidgetSchema` is `.strict()`: undeclared widget + * keys are REFUSED BY NAME, never silently stripped. + * + * The failure mode being closed is the one the schema's own docstring records + * from the previous incident (the hand copy dropping 12 spec keys "without a + * word"): a plain `z.object()` deletes whatever it does not declare and then + * reports success. Measured on the #4600 branch and re-measured here as the + * red control: a widget carrying `zzcanary` / `categoryField` / `aggregate` + * parsed five-keys-in, three-keys-out, verdict ACCEPT. + * + * Maintainer ruling 2026-08-25 (objectui#6002, Route 1 two-step): #6150 + * declares the genuinely consumed keys FIRST, then this schema flips strict. + * Both faces of the flip are pinned here: + * + * 1. undeclared keys refuse loudly (`unrecognized_keys`, every key named); + * 2. the legal surface is untouched — every declared key still parses AND + * survives (strict must not turn into a narrowing of the accepted set + * beyond key membership); + * 3. the widget-slot COMPONENT route (2026-08-14 ruling, objectstack#8593) + * is unhurt: a `metric-card` node's props are component inputs, not + * widget keys, and the slot routes it to passthrough `BaseSchema` + * before the strict schema is consulted; + * 4. the routing arm is NOT a hatch: a spec-family widget with stray keys + * is refused even at the document level, where the union could + * otherwise have offered the passthrough arm as an escape. + * + * Tombstone precedence (`actionUrl` et al. keep their own removal messages + * under strict) is pinned next door in `report-chart-query-spec-parity.test.ts` + * — the drift guard this schema names; the catalog-side routing gate is + * `examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { BaseSchema } from '../zod/base.zod.js'; +import { + DashboardComponentSchema, + DashboardWidgetSchema, +} from '../zod/complex.zod.js'; +import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js'; + +describe('DashboardWidgetSchema.strict() — undeclared keys refuse by name (objectui#6002)', () => { + it('the measured five-in/three-out ACCEPT is now a refusal naming all three keys', () => { + // The exact probe from the card body. Before #6002: ACCEPT with + // `zzcanary`/`categoryField`/`aggregate` deleted in silence. + const result = DashboardWidgetSchema.safeParse({ + type: 'metric', + title: 'x', + zzcanary: 1, + categoryField: 'stage', + aggregate: 'sum', + }); + expect(result.success, 'undeclared keys must refuse, not strip').toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue, 'the refusal must be zod `unrecognized_keys`').toBeDefined(); + // Every offending key is named — the author is told exactly what the + // contract does not know, not just that "something" is wrong. + expect((issue as { keys?: string[] }).keys).toEqual( + expect.arrayContaining(['zzcanary', 'categoryField', 'aggregate']), + ); + }); + + it('the retired pre-ADR-0021 inline analytics shape refuses as a set', () => { + // `object`/`categoryField`/`valueField`/`aggregate` — the very keys #4600 + // was opened about. Under the strip regime a widget carrying all four + // validated clean; the renderer's LEGACY_RETIRED_WIDGET_SCHEMA placeholder + // was the only surface that ever mentioned them, and only at render time. + const result = DashboardWidgetSchema.safeParse({ + id: 'w1', + type: 'bar', + object: 'opportunity', + categoryField: 'stage', + valueField: 'amount', + aggregate: 'sum', + }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect((issue as { keys?: string[] })?.keys).toEqual( + expect.arrayContaining(['object', 'categoryField', 'valueField', 'aggregate']), + ); + }); + + it('POSITIVE — a widget authoring the declared surface parses green and loses nothing', () => { + // Same fixture family as the parity suite's "stops stripping" case, plus + // `options` (the single-value form the README documents). Key-for-key + // survival is the other half of strict: refusal of the undeclared set must + // not come with any change to what the declared set keeps. + const widget = { + id: 'w1', + type: 'bar', + title: 'Pipeline', + description: 'Pipeline by stage', + colorVariant: 'blue', + requiresObject: 'opportunity', + requiresService: 'analytics', + suppressWarnings: ['no-data'], + dataset: 'pipeline', + dimensions: ['stage'], + values: ['amount'], + layout: { x: 0, y: 0, w: 6, h: 4 }, + filterBindings: { dateRange: 'closed_at' }, + options: { value: '1,234' }, + }; + const result = DashboardWidgetSchema.safeParse(widget); + expect(result.success, 'the declared surface must stay green').toBe(true); + if (!result.success) return; + for (const key of Object.keys(widget)) { + expect(result.data, `declared key \`${key}\` must survive the parse`).toHaveProperty(key); + } + }); + + it('the legacy `component` envelope (no `type`) still parses', () => { + const result = DashboardWidgetSchema.safeParse({ + id: 'w_legacy', + component: { type: 'metric-card', title: 'Revenue', value: '$1' }, + layout: { x: 0, y: 0, w: 3, h: 2 }, + }); + expect(result.success).toBe(true); + }); +}); + +describe('the widget-slot component route is unhurt (objectstack#8593 ruling)', () => { + // A real component node as the catalog authors it: registry `inputs` + // (`value`/`icon`/`trend`/`trendValue`) that are NOT widget keys and MUST + // NOT be refused by the strict widget schema. + const metricCard = { + type: 'metric-card', + title: 'Total Sales', + value: '$123,456', + icon: 'users', + trend: 'up', + trendValue: '+12%', + description: 'vs last month', + }; + + it('DASHBOARD_COMPONENT_WIDGET_TYPES is the closed set the route keys on', () => { + // Non-vacuity: an emptied component enum would make every case below + // unreachable while staying green. + expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0); + }); + + it('a dashboard document holding a component node parses whole, props kept', () => { + const result = DashboardComponentSchema.safeParse({ + type: 'dashboard', + widgets: [metricCard], + }); + expect(result.success, 'the ruled-legal component node must not refuse').toBe(true); + if (!result.success) return; + const kept = result.data.widgets[0] as Record; + for (const key of Object.keys(metricCard)) { + expect(kept, `component prop \`${key}\` must survive — it is a component input, not a widget key`).toHaveProperty(key); + } + }); + + it('the component arm agrees with BaseSchema — the ruling\'s named owner', () => { + // The slot's passthrough behaviour is BaseSchema's, not a private third + // schema: whatever BaseSchema keeps, the slot keeps. + const viaBase = BaseSchema.parse(metricCard) as Record; + for (const key of Object.keys(metricCard)) expect(viaBase).toHaveProperty(key); + }); + + it('NOT A HATCH — a spec-family widget with stray keys refuses at document level too', () => { + // The union offers the passthrough arm ONLY to component-enum types; a + // `metric` widget with an undeclared key must not slip through it. + const result = DashboardComponentSchema.safeParse({ + type: 'dashboard', + widgets: [{ type: 'metric', title: 'x', zzcanary: 1 }], + }); + expect(result.success, 'the passthrough arm must not admit spec-family widgets').toBe(false); + if (result.success) return; + // The union nests each arm's issues; the strict arm's refusal must be in + // there, naming the key. + const flat = JSON.stringify(result.error.issues); + expect(flat).toContain('unrecognized_keys'); + expect(flat).toContain('zzcanary'); + }); + + it('NOT A HATCH — undeclared keys refuse even when the widget also carries component-ish props', () => { + // A widget that fails the component arm (type outside the component enum) + // and carries keys the strict arm refuses: both arms red, document red. + const result = DashboardComponentSchema.safeParse({ + type: 'dashboard', + widgets: [{ type: 'bar', value: '$1', trend: 'up' }], + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index cfd30915ed..f92423f982 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -373,14 +373,16 @@ export const DashboardWidgetLayoutSchema = z.object({ * built on it would have passed by validating nothing. * * ⚠️ A member of `DASHBOARD_COMPONENT_WIDGET_TYPES` is a component node, and - * this schema is NOT the schema for its body: the object below strips undeclared - * keys, so `metric-card`'s own props (`value`, `icon`, `trend`, `trendValue` — - * registry `inputs`, not widget keys) do not survive a parse here. They are - * kept by objectui's own passthrough component schema, `BaseSchema`, which is - * the schema the ruling names for a component node. The standing gate + * this schema is NOT the schema for its body: `metric-card`'s own props + * (`value`, `icon`, `trend`, `trendValue` — registry `inputs`, not widget + * keys) belong to objectui's own passthrough component schema, `BaseSchema`, + * which is the schema the ruling names for a component node. Since + * objectui#6002 made {@link DashboardWidgetSchema} `.strict()`, that routing + * lives in `DashboardComponentSchema`'s widget slot itself (see + * {@link DashboardWidgetSlotComponentSchema}); the standing gate * (`examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts`) - * routes each widget to whichever of the two owns it and asserts neither loses - * an authored key. + * routes each widget to whichever of the two owns it and asserts a component + * node loses no authored key while a widget refuses undeclared ones. */ export const DashboardWidgetTypeSchema = z.enum([ ...SpecChartTypeSchema.options, @@ -414,6 +416,28 @@ export const DashboardWidgetTypeSchema = z.enum([ * - `component` — the legacy `{ id, component: , layout }` envelope, * which the spec has no room for. Migration to the shorthand form is deferred. * + * ## `.strict()` — undeclared keys are REFUSED, not stripped (objectui#6002) + * + * Until #6002 this was a plain `z.object()`, and the docstring above records + * what that meant once already: keys outside the declared set were dropped + * WITHOUT A WORD. Measured on the #4600 branch, a widget carrying the retired + * pre-ADR-0021 inline analytics keys (`object` / `categoryField` / `aggregate`) + * parsed five-keys-in, three-keys-out, verdict ACCEPT — so the contract could + * never tell an author, a designer, or a publish-time check that the document + * says something the platform stopped honouring. Maintainer ruling 2026-08-25 + * (objectui#6002, Route 1 two-step): after #6150 declared the genuinely + * consumed keys, undeclared widget keys refuse loudly — zod's + * `unrecognized_keys` issue names every offending key. The spec's own + * tombstones (`actionUrl` / `actionType` / `actionIcon` / `aria` / + * `responsive`) stay DECLARED `z.never()` members, so they keep their specific + * removal messages rather than degrading to a generic unknown-key error. + * + * ⚠️ This schema owns spec-family widgets only. A component node in the widget + * slot (`type: 'metric-card'`) is routed to passthrough `BaseSchema` by + * {@link DashboardWidgetSlotComponentSchema} before this schema is consulted — + * its props are component inputs, not widget keys, and MUST NOT be refused + * here (2026-08-14 ruling, objectstack#8593). + * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema.shape, [ @@ -424,6 +448,30 @@ export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema. type: DashboardWidgetTypeSchema.optional() .describe('Widget visualization type — the spec families plus objectui\'s closed `list`/`custom` and `metric-card` extensions'), component: SchemaNodeSchema.optional().describe('Widget Component (legacy format)'), +}).strict(); + +/** + * A COMPONENT node sitting directly in a dashboard's widget slot — the + * `metric-card` extension the 2026-08-14 ruling (objectstack#8593) admits: + * + * > An SDUI dashboard COMPONENT node validates against objectui's OWN + * > component schema; [...] `metric-card` joins objectui's own CLOSED + * > component enum as an explicitly allowed objectui extension. + * + * Its body is `BaseSchema` (passthrough): `value` / `icon` / `trend` / + * `trendValue` are the component's registry `inputs`, not widget keys, and a + * `.strict()` {@link DashboardWidgetSchema} must never see them. The `type` + * override is what makes this arm reachable ONLY for component nodes: for + * every other widget (any spec family, `list`/`custom`, the legacy + * `component` envelope with no `type` at all) the required closed enum fails + * fast and the union falls through to the strict widget schema — so this arm + * cannot become a passthrough hatch around #6002's refusal. Deliberately NOT + * exported: the routing is an internal property of the widget slot, not new + * authoring surface. + */ +const DashboardWidgetSlotComponentSchema = BaseSchema.extend({ + type: z.enum(DASHBOARD_COMPONENT_WIDGET_TYPES) + .describe('objectui component type legal in a widget slot (closed set)'), }); /** @@ -554,7 +602,13 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh type: z.literal('dashboard'), columns: z.number().optional().describe('Number of columns'), gap: z.number().optional().describe('Grid gap'), - widgets: z.array(DashboardWidgetSchema).describe('Dashboard widgets'), + // Routed slot (objectui#6002): a component node (`metric-card`) is owned by + // passthrough BaseSchema per the 2026-08-14 ruling; every other widget is + // the `.strict()` spec-derived schema. Component arm first — it matches + // exclusively on the closed component-type enum, so a spec-family widget + // can never be captured by it. + widgets: z.array(z.union([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema])) + .describe('Dashboard widgets'), globalFilters: z.array(GlobalFilterSchema).optional().describe('Dashboard-level filters'), dateRange: z.object({ field: z.string().optional(), From 7718ccc7fd9500501d11fe66f8654c3dc0d70d41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:09:53 +0000 Subject: [PATCH 2/2] chore(changeset): declare the DashboardWidgetSchema strict flip (minor, breaking narrative) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- .changeset/6002-dashboard-widget-strict.md | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .changeset/6002-dashboard-widget-strict.md diff --git a/.changeset/6002-dashboard-widget-strict.md b/.changeset/6002-dashboard-widget-strict.md new file mode 100644 index 0000000000..22737b1e9d --- /dev/null +++ b/.changeset/6002-dashboard-widget-strict.md @@ -0,0 +1,36 @@ +--- +'@object-ui/types': minor +--- + +**Breaking for authored metadata:** `DashboardWidgetSchema` (the zod validator +in `@object-ui/types/zod`) is now `.strict()` — an undeclared widget key +REFUSES the parse with zod's `unrecognized_keys` issue naming every offending +key, instead of being silently deleted. + +Before this change the schema was a plain `z.object()`: a widget carrying +`zzcanary` / `categoryField` / `aggregate` parsed five-keys-in, three-keys-out, +verdict ACCEPT — the same "dropped without a word" failure the schema's own +docstring records from the pre-derivation hand copy, still live for every key +no contract declares (objectui#6002). Maintainer ruling 2026-08-25, Route 1 +two-step: objectui#6150 declared the 13 genuinely-consumed keys first (landed +as PR #6945), then this flip makes a stale or mistyped key loud everywhere the +contract is consulted (`objectui validate`, `safeValidateSchema`, the catalog +gate) instead of only inside one catalog test. + +**Who is affected — a widget authoring a key outside the declared surface:** +the retired pre-ADR-0021 inline analytics shape (`object` / `categoryField` / +`valueField` / `aggregate`) is the canonical case — it used to validate clean +with all four keys deleted; it now refuses with the keys named. The spec's +tombstoned keys (`actionUrl` / `actionType` / `actionIcon` / `aria` / +`responsive`) keep their specific removal messages — they are declared +`z.never()` members, so they do not degrade to a generic unknown-key error. + +**Not affected:** a `metric-card` COMPONENT node in a dashboard's widget slot. +Its props (`value` / `icon` / `trend` / `trendValue` — registry inputs, not +widget keys) stay legal: per the 2026-08-14 ruling (objectstack#8593) a +component node is owned by objectui's own passthrough `BaseSchema`, and +`DashboardComponentSchema`'s widget slot now routes component-enum types there +before the strict widget schema is consulted. The legacy +`{ id, component, layout }` envelope also still parses. The repo-wide corpus +preflight (575 JSON files, every dashboard-bearing doc fence, all designer +emit paths) measured **zero** newly-refused widgets.