From 32c2f5623915230d7f85c0d15c85a094259c771e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:13:17 +0000 Subject: [PATCH 1/6] feat(spec)!: a metric-family dashboard widget declares exactly one measure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DashboardWidgetSchema.values` was `z.array(z.string()).min(1)` with no upper bound on every widget type, so a `metric` tile could declare three measures: the query ran all three and the tile rendered `values[0]`. objectui#8894 decision batch #119 item 4 took option D — judge the protocol wrong. `checkDashboardWidgetMetricMeasureArity` refuses more than one measure on the metric family (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`, and the `metric` default a typeless widget resolves to), at `values`, naming the widget and prescribing one tile per measure. Every other widget type is untouched. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- packages/spec/src/ui/dashboard.test.ts | 137 ++++++++++++++ packages/spec/src/ui/dashboard.zod.ts | 177 +++++++++++++++++- .../object-refinement-check-exports.test.ts | 79 +++++++- 3 files changed, 386 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 31d7dc2adef..29f0f9a06c5 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -16,6 +16,7 @@ import { DATE_RANGE_DEFAULT_RANGES, DashboardWidgetOptionsSchema, checkDashboardWidgetStageOrder, + checkDashboardWidgetMetricMeasureArity, } from './dashboard.zod'; import * as ui from './index'; import { readFileSync } from 'node:fs'; @@ -1049,3 +1050,139 @@ describe('DashboardWidgetOptions.stageOrder — the ADR-0049 type gate', () => { expect(paths).toContain('widgets.0.options.stageOrder'); }); }); + +/** + * [#17779] objectui#8894 ruling D — a metric-family widget declares EXACTLY one + * measure. + * + * The maintainer took **D** on objectui#8894 (decision batch #119 item 4, + * 2026-09-12 「同意」): judge the protocol wrong rather than invent display + * semantics for `values[1..]`. Before this, `values` was + * `z.array(z.string()).min(1)` with no upper bound on EVERY widget type, so a + * `metric` tile could declare three measures, the query ran all three, and the + * tile rendered `values[0]`. + * + * "Exactly one" is the CONJUNCTION of two rules and the tests below read both: + * the field's own `.min(1)` (0 measures → `too_small`) and + * `checkDashboardWidgetMetricMeasureArity` (>1 on the family → `custom` at + * `values`). + */ +describe('[#17779] DashboardWidgetSchema — the metric family takes exactly one measure', () => { + const widget = (over: Record) => ({ ...WIDGET_BASE, ...over }); + const refusal = (value: Record) => { + const r = DashboardWidgetSchema.safeParse(value); + expect(r.success).toBe(false); + const issues = r.success ? [] : r.error.issues; + expect(issues).toHaveLength(1); + return issues[0]!; + }; + + // The family, read off the refusal rather than re-listed as a literal: the + // message interpolates the authored type, so a member silently dropped from + // the set would fail HERE rather than in a list that agrees with itself. + const FAMILY = ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet'] as const; + + it.each(FAMILY)('refuses two measures on a `%s` tile, at `values`', (type) => { + const issue = refusal(widget({ type, values: ['amount_sum', 'count'] })); + expect(issue.code).toBe('custom'); + expect(issue.path.join('.')).toBe('values'); + expect(issue.message).toContain(`\`type: '${type}'\``); + }); + + it.each(FAMILY)('accepts ONE measure on a `%s` tile — the legal single-value card', (type) => { + expect(DashboardWidgetSchema.safeParse(widget({ type, values: ['amount_sum'] })).success).toBe(true); + }); + + it('names the widget, the count, and the ruling\'s own prescription', () => { + const issue = refusal(widget({ id: 'pipeline_total', type: 'metric', values: ['a', 'b', 'c'] })); + // "The refusal names the widget and says one measure per tile, 'make N + // tiles for N measures'" — the card's acceptance sentence, as an assertion. + expect(issue.message).toContain('`pipeline_total`'); + expect(issue.message).toContain('declares 3 measures'); + expect(issue.message).toContain('one measure per tile'); + expect(issue.message).toContain('make N tiles for N measures'); + // …and it names the visuals that DO render several numbers, so "I really + // want three" has an answer that is not "delete two". + expect(issue.message).toContain("`type: 'table'`"); + }); + + it('a widget that declares NO type is refused too — `type` defaults to `metric`', () => { + const issue = refusal(widget({ values: ['a', 'b'] })); + expect(issue.path.join('.')).toBe('values'); + expect(issue.message).toContain("`type: 'metric'`"); + // …and says so, because the gate cannot tell the two apart. + expect(issue.message).toContain('declares no `type` at all'); + }); + + it.each(['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie', 'donut', 'funnel', + 'scatter', 'treemap', 'sankey', 'combo', 'radar', 'table', 'pivot'] as const)( + 'leaves `%s` — every NON-metric type — accepting three measures, unmoved', + (type) => { + expect(DashboardWidgetSchema.safeParse(widget({ type, values: ['a', 'b', 'c'] })).success).toBe(true); + }, + ); + + it('covers the whole taxonomy — the metric family plus the others IS `ChartTypeSchema`', () => { + // Guards the two `it.each` lists above against a new chart type landing in + // the enum and being covered by neither. + const OTHERS = ['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie', 'donut', 'funnel', + 'scatter', 'treemap', 'sankey', 'combo', 'radar', 'table', 'pivot']; + expect([...FAMILY, ...OTHERS].sort()).toEqual([...ChartTypeSchema.options].sort()); + }); + + it('the EMPTY array keeps the field\'s own verdict — not a second `custom` issue', () => { + // "Exactly one" is `.min(1)` AND this check; the check returns on 0 so the + // author reads one refusal about an empty tile, not two. + const issue = refusal(widget({ type: 'metric', values: [] })); + expect(issue.code).toBe('too_small'); + expect(issue.path.join('.')).toBe('values'); + }); + + it('a `type` outside the enum reports the TYPE refusal alone, not both', () => { + // Same zod behaviour the stage-order gate pins: `invalid_value` on the enum + // aborts, so object-level checks are skipped for that input. + const issue = refusal(widget({ type: 'ziggurat', values: ['a', 'b'] })); + expect(issue.code).toBe('invalid_value'); + expect(issue.path.join('.')).toBe('type'); + }); + + it('does NOT reach whether the one measure exists in the dataset', () => { + // A fact about the dataset, not about the widget — unreachable from here, + // stated as a pin rather than left implied. + expect(DashboardWidgetSchema.safeParse(widget({ type: 'metric', values: ['no_such_measure'] })).success) + .toBe(true); + }); + + it('the rule the door runs is the EXPORT, attached by identifier — no inline copy', () => { + const src = readFileSync(new URL('./dashboard.zod.ts', import.meta.url), 'utf8'); + expect(src).toContain('export function checkDashboardWidgetMetricMeasureArity('); + expect(src.match(/^\s*(export )?function checkDashboardWidgetMetricMeasureArity\b/gm)).toHaveLength(1); + expect(src.match(/^[ \t]*\.superRefine\(checkDashboardWidgetMetricMeasureArity\)/gm)).toHaveLength(1); + }); + + it('`@objectstack/spec/ui` ships the same function object', () => { + expect((ui as Record).checkDashboardWidgetMetricMeasureArity) + .toBe(checkDashboardWidgetMetricMeasureArity); + expect(checkDashboardWidgetMetricMeasureArity.length).toBe(2); + }); + + it('the gate travels with the widget through `DashboardSchema.widgets[]`', () => { + const r = DashboardSchema.safeParse({ + name: 'sales_dashboard', + label: 'Sales', + widgets: [widget({ type: 'kpi', values: ['a', 'b'] })], + }); + expect(r.success).toBe(false); + const paths = (r.success ? [] : r.error.issues).map((i) => i.path.join('.')); + expect(paths).toContain('widgets.0.values'); + }); + + it('the shipped `values` doc string states the arity rule it enforces', () => { + // declared = documented: the `.describe()` an author reads in the generated + // reference cannot still say only "at least one". + const described = (DashboardWidgetSchema as unknown as { shape: Record }) + .shape.values.description ?? ''; + expect(described).toContain('exactly one'); + for (const type of FAMILY) expect(described).toContain(type); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 4c681c99842..30c6d785e79 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -518,6 +518,162 @@ export function checkDashboardWidgetStageOrder( }); } +/** + * The widget `type`s that render exactly ONE number — the metric FAMILY. + * + * Read off `ChartTypeSchema`'s own "Performance (single value)" group, which + * is the taxonomy's word for the same set: `metric`/`kpi` render a number and + * `gauge`/`solid-gauge`/`bullet` "render a value today and gain a dial when a + * gauge renderer lands". A dial is still one value; nothing in the group has a + * second mark to put a second measure on. + * + * Declared here beside the check rather than exported from `chart.zod.ts`: the + * taxonomy groups by RENDERER FAMILY in a comment, and a comment is not a set. + * Widening it later (a real gauge that draws a target band, say) is a one-line + * edit here plus a relaxation of this rule — the direction that costs an author + * nothing. + */ +const SINGLE_MEASURE_WIDGET_TYPES = ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet'] as const; + +/** + * objectui#8894 ruling D — a metric-family widget declares EXACTLY ONE measure. + * + * ## What was wrong + * + * `values` is `z.array(z.string()).min(1)` with no upper bound, so a `metric` + * tile could declare three measures. All three were selected, the analytics + * query ran all three, and the tile rendered `values[0]`: the other two were + * queried and thrown away. That is the declared≠delivered shape ADR-0049 exists + * to end, and it had been kept alive by a runtime warning — objectui#8887 + * landed a sub-caption saying the extra measures are not rendered, which makes + * the tile HONEST about dropping them without making the document legal. + * + * The maintainer's standing ruling on this class is 「协议不正确的应该先修改协议。」 + * and objectui#8894 decision batch #119 item 4 (2026-09-12) took option **D** + * on this instance: judge the protocol wrong. A single-value card is one + * measure on every mainstream dashboard product; several numbers is a different + * visual, not a variant of this one. + * + * ## Why an object-level check and not a per-`type` union arm — MEASURED + * + * The card left the spelling to this seat. Both spellings refuse the same + * document; they differ in what the author is told about EVERY OTHER mistake. + * Measured on this tree, eight widget bodies through + * `z.union([metricArm, otherArm])` (arms built with `.safeExtend()`, since zod + * 4.4.3 throws `Cannot overwrite keys on object schemas containing refinements` + * on a plain `.extend()` that redeclares a key) versus one more `.superRefine` + * on this strict object: + * + * | body | union arms | this spelling | + * |---|---|---| + * | `bogusProp` on a widget | `(root) invalid_union: Invalid input` | the strict-object refusal, naming the key + the history sentence | + * | `categoryField`/`valueField` | `(root) invalid_union: Invalid input` | the {@link WIDGET_GUIDANCE_SETS} ADR-0021 prescription | + * | `titel` | `(root) invalid_union: Invalid input` | `Did you mean \`titel\` → \`title\`?` | + * | `type: 'ziggurat'` | `(root) invalid_union: Invalid input` | `invalid_value` at `type`, listing all twenty | + * + * Four of eight bodies lose their whole diagnostic to one bare `Invalid input`. + * That is not a new observation on this file — the `compareTo` docblock above + * records the same measurement for the same reason (#5014: "a union collapses + * into one bare `Invalid input` on the wire … A plain strict object's errors + * reach the author"), and `view-union-diagnostics.test.ts` is the whole + * apparatus objectui needed because `ViewMetadataSchema` IS a union. Adding a + * second union to this file would be commissioning that apparatus again to buy + * a refusal the object-level form gives for free. + * + * So: one more check on the same door, attached by identifier, exactly as + * {@link checkDashboardWidgetStageOrder} is. + * + * ## What the refusal says + * + * It names the widget (its `id` and its `type`), states the rule in the ruling's + * own words — one measure per tile, make N tiles for N measures — and names the + * shapes that DO render several numbers, so "I really do want three" has an + * answer that is not "delete two". + * + * ## What this check deliberately does NOT reach + * + * Five shapes, named so the gate is not read as complete: + * + * 1. **The EMPTY array.** `values: []` is refused by the field's own `.min(1)` + * with `too_small`, and this check returns on it rather than adding a + * second issue about a tile with no measure at all. "Exactly one" is the + * CONJUNCTION of that `.min(1)` and this upper bound, not this check alone + * — a mirror that re-attaches this export onto a shape whose `values` + * carries no `.min(1)` gets the upper bound only. + * 2. **A widget that declares no `type`.** `type` carries + * `.default(WIDGET_TYPE_DEFAULT)`, which is `metric` — a member of this + * family — and zod applies defaults BEFORE object-level checks, so an + * omitted `type` arrives here as `metric` and is refused like an authored + * one. The verdict is right either way; the message carries an extra + * sentence in that ambiguous case rather than claiming the author wrote it. + * 3. **A `type` outside `ChartTypeSchema`.** zod treats that `invalid_value` + * as aborting and skips every object-level check for the input, so + * `type: 'ziggurat'` plus four measures reports the type refusal alone. + * 4. **Whether the measures EXIST in the bound dataset.** Still a fact about + * the dataset, not about the widget, and unreachable from this schema — a + * tile naming one measure nobody declared parses exactly as before. + * 5. **objectui's CLIENT-SIDE authoring door**, a `.shape` mirror that runs no + * object-level check of this schema's: at the `.objectui-sha` pin, + * `@object-ui/types` builds its own `DashboardWidgetSchema` from + * `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()` + * and re-attaches none of this file's exported checks. Until it imports and + * chains this one, the dashboard EDITOR keeps accepting three measures on a + * `metric` and the author meets the refusal at PUBLISH. That mirror also + * redeclares `type` with no default, so a typeless widget reaches a + * re-attached check as `undefined`; this function defaults it itself for + * exactly that caller. + */ +export function checkDashboardWidgetMetricMeasureArity( + widget: { id?: unknown; type?: unknown; values?: unknown }, + ctx: z.RefinementCtx, +): void { + const values = widget.values; + // Not an array, or empty, or already the one measure the family takes: the + // field's own `z.array(z.string()).min(1)` owns both of the first two + // verdicts and says them better (`too_small` at `values`), and the third is + // the legal document. See non-coverage 1. + if (!Array.isArray(values) || values.length <= 1) return; + + // `?? WIDGET_TYPE_DEFAULT` is UNREACHABLE through this schema's own door — + // zod applies `type`'s default before object-level checks. It is here for the + // mirror that re-attaches this export onto a shape whose `type` carries no + // default (non-coverage 5), so the export never refuses LESS than the door it + // is exported from; `object-refinement-check-exports.test.ts` pins that + // equivalence on the raw fixture. + const type = widget.type ?? WIDGET_TYPE_DEFAULT; + if (typeof type !== 'string') return; + if (!(SINGLE_MEASURE_WIDGET_TYPES as readonly string[]).includes(type)) return; + + // Same ambiguity the stage-order check carries, and the same repair: a widget + // that declared NO type arrives here as `metric` and cannot be told apart + // from one that wrote it, so the extra sentence is added only in that case. + const defaultedTypeNote = type === WIDGET_TYPE_DEFAULT + ? ' (`' + WIDGET_TYPE_DEFAULT + '` is also what a widget that declares no `type` at all ' + + 'resolves to — if you meant a chart, the `type` key is missing rather than wrong.)' + : ''; + const widgetName = typeof widget.id === 'string' && widget.id.length > 0 + ? '`' + widget.id + '`' + : 'this widget'; + + ctx.addIssue({ + code: 'custom', + path: ['values'], + message: + 'Widget ' + widgetName + ' declares ' + values.length + ' measures on `type: ' + + `'${type}'` + + '`, and a metric-family widget (' + + SINGLE_MEASURE_WIDGET_TYPES.map((t) => '`' + t + '`').join(' / ') + + ') renders exactly ONE number: one measure per tile, so make N tiles for N ' + + 'measures. Every measure after `values[0]` was queried and then dropped on the ' + + 'floor by the renderer — keep the one this tile is for, and give each of the ' + + 'others its own widget with its own `id` (and `layout`, if you pin positions). ' + + 'If you meant several numbers in ONE widget, that is a different visual: ' + + "`type: 'table'` renders a row of measures, and the chart families " + + "(`bar` / `line` / `area` / `combo`) render one mark per measure." + + defaultedTypeNote, + }); +} + /** * Dashboard Widget Schema * A single component on the dashboard grid. @@ -702,8 +858,19 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ dataset: SnakeCaseIdentifierSchema.describe('Dataset name to bind (ADR-0021)').meta({ title: 'Dataset' }), /** Dimension names (from the dataset) for X / group / split. */ dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split').meta({ title: 'Dimensions' }), - /** Measure names (from the dataset) for the value axis. */ - values: z.array(z.string()).min(1).describe('Measure names — Y (at least one)').meta({ title: 'Values' }), + /** + * Measure names (from the dataset) for the value axis. + * + * At least one, always. For the METRIC FAMILY — `metric` / `kpi` / `gauge` / + * `solid-gauge` / `bullet`, and the `metric` default a widget with no `type` + * resolves to — exactly one: those types render a single number and dropped + * every measure after `values[0]` on the floor, so the second one is now a + * parse error rather than a queried-and-discarded column + * ({@link checkDashboardWidgetMetricMeasureArity}). + */ + values: z.array(z.string()).min(1) + .describe('Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family)') + .meta({ title: 'Values' }), /** * Layout Position (React-Grid-Layout style) @@ -853,7 +1020,11 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ // ADR-0049 enforce-or-remove on `options.stageOrder`. Attached by identifier // rather than inlined, the way `GlobalFilterSchema` attaches its own check: // the exported function IS the rule this door runs. - .superRefine(checkDashboardWidgetStageOrder)); + .superRefine(checkDashboardWidgetStageOrder) + // objectui#8894 ruling D — the metric FAMILY takes exactly one measure. Same + // idiom, same reason: `values`'s arity is decided by its sibling `type` one + // level up, so the rule has to run where both keys are in scope. + .superRefine(checkDashboardWidgetMetricMeasureArity)); /** * Dashboard date-range presets — the named windows a dashboard date filter may diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index 74928175c22..b244a1f6a0b 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -61,6 +61,7 @@ import { checkGlobalFilterDateDefaultValue, DashboardWidgetSchema, checkDashboardWidgetStageOrder, + checkDashboardWidgetMetricMeasureArity, } from './dashboard.zod'; import * as ui from './index'; @@ -272,6 +273,67 @@ const stageOrderFixtures: Fixture[] = [ { label: 'a non-funnel with no `options` at all', value: { ...WIDGET, type: 'horizontal-bar' }, refusesAt: [] }, ]; +/** + * objectui#8894 ruling D — the metric FAMILY takes exactly one measure. + * + * Shape-valid like every fixture here, and deliberately carrying NO + * `options.stageOrder`: the two checks on this door must be separable, and leg + * 2's bijection is only discriminating if each export's issue vector over the + * union matrix is its own. + * + * ⛔ No `values: []` fixture: the empty array is refused by the field's own + * `.min(1)` (`too_small`, not `custom`), so it is not shape-valid and `runParse` + * refuses to judge it — the accompanying pin lives in `dashboard.test.ts`, where + * the field-level verdict can be read directly. + */ +const metricMeasureArityFixtures: Fixture[] = [ + { + label: 'three measures on a `metric` tile', + value: { ...WIDGET, type: 'metric', values: ['amount_sum', 'count', 'avg_days'] }, + refusesAt: ['values'], + }, + { + label: 'two measures on a `kpi` — the message interpolates, the family does not', + value: { ...WIDGET, type: 'kpi', values: ['amount_sum', 'count'] }, + refusesAt: ['values'], + }, + { + label: 'two measures on a `gauge`', + value: { ...WIDGET, type: 'gauge', values: ['amount_sum', 'count'] }, + refusesAt: ['values'], + }, + { + label: 'two measures on a `solid-gauge`', + value: { ...WIDGET, type: 'solid-gauge', values: ['amount_sum', 'count'] }, + refusesAt: ['values'], + }, + { + label: 'two measures on a `bullet`', + value: { ...WIDGET, type: 'bullet', values: ['amount_sum', 'count'] }, + refusesAt: ['values'], + }, + { + label: 'two measures on a widget that declares NO type (the `metric` default)', + value: { ...WIDGET, values: ['amount_sum', 'count'] }, + refusesAt: ['values'], + }, + { + label: 'ONE measure on a `metric` — the legal single-value tile', + value: { ...WIDGET, type: 'metric', values: ['amount_sum'] }, + refusesAt: [], + }, + { + label: 'three measures on a `bar` — the non-metric families are untouched', + value: { ...WIDGET, type: 'bar', values: ['amount_sum', 'count', 'avg_days'] }, + refusesAt: [], + }, + { + label: 'three measures on a `table` — likewise', + value: { ...WIDGET, type: 'table', values: ['amount_sum', 'count', 'avg_days'] }, + refusesAt: [], + }, +]; + // --------------------------------------------------------------------------- // The population — every mirrored spec object that carries an object-level check // --------------------------------------------------------------------------- @@ -312,7 +374,14 @@ const MIRRORED: MirroredSchema[] = [ { name: 'DashboardWidgetSchema', schema: DashboardWidgetSchema, - exports: [{ name: 'checkDashboardWidgetStageOrder', check: checkDashboardWidgetStageOrder, fixtures: stageOrderFixtures }], + exports: [ + { name: 'checkDashboardWidgetStageOrder', check: checkDashboardWidgetStageOrder, fixtures: stageOrderFixtures }, + { + name: 'checkDashboardWidgetMetricMeasureArity', + check: checkDashboardWidgetMetricMeasureArity, + fixtures: metricMeasureArityFixtures, + }, + ], cleanFixtures: [{ ...WIDGET, type: 'horizontal-bar' }], }, ]; @@ -444,9 +513,11 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () // --------------------------------------------------------------------------- describe('`./index` (the `@objectstack/spec/ui` surface) exports the same function objects', () => { - // NOT the full export list: `checkDashboardWidgetStageOrder` is catalogued in - // `MIRRORED` above (legs 1-2) and carries its own legs 3-4 — barrel identity - // and attached-by-identifier — beside the schema it guards, in + // NOT the full export list: the two widget checks — + // `checkDashboardWidgetStageOrder` and + // `checkDashboardWidgetMetricMeasureArity` — are catalogued in `MIRRORED` + // above (legs 1-2) and carry their own legs 3-4 — barrel identity and + // attached-by-identifier — beside the schema they guard, in // `dashboard.test.ts`. Read this `it.each` as the rows that live here, not as // an enumeration of every exported refinement. it.each([ From 04cc60412cb6367a28ab042b25bc334122e07550 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:17:01 +0000 Subject: [PATCH 2/6] feat(spec): register the metric-family measure-arity narrowing (ADR-0087, protocol 18) One new `semantic/` entry file plus the `gen:migration-registry` lap it owes. `spec-changes.json` and `docs/protocol-upgrade-guide.md` come back byte-identical by construction: both project majors from the support floor up to `PROTOCOL_MAJOR` (17), and this entry registers under 18. The changeset ships `minor`, not `major`: `check-changeset-no-major` refuses a `major` outright while the launch window is open, so a breaking narrowing carries its breaking-ness in the **BREAKING** banner and the ADR-0087 disposition instead. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- ...-dashboard-metric-family-single-measure.md | 66 +++++++++++++ ...get-metric-family-multi-measure-refused.ts | 93 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 89 ++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 .changeset/17779-dashboard-metric-family-single-measure.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts diff --git a/.changeset/17779-dashboard-metric-family-single-measure.md b/.changeset/17779-dashboard-metric-family-single-measure.md new file mode 100644 index 00000000000..2bfef4538b0 --- /dev/null +++ b/.changeset/17779-dashboard-metric-family-single-measure.md @@ -0,0 +1,66 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: a metric-family dashboard widget declares exactly ONE measure — `values` is bounded above on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` (#17779; objectui#8894 ruling D, decision batch #119 item 4) + +Clause-②: no (narrowing) + + + +**BREAKING** accept-set narrowing at `dashboard.widgets[].values`, shipped as +`minor` under this repo's launch-window convention for breaking changes +(`check-changeset-no-major` refuses `major` outright while the window is open, so +breaking-ness is carried by this banner and by the ADR-0087 disposition above, +never by the bump level). The mechanical prescription is registered under +protocol major 18 as `dashboard-widget-metric-family-multi-measure-refused`. + +**What was wrong.** `DashboardWidgetSchema.values` was +`z.array(z.string()).min(1)` with **no upper bound on any widget type**, so a +`metric` tile could declare three measures. Measured on this tree before the +change: `{ type: 'metric', values: ['a','b','c'] }` returned `success: true`, +and so did `kpi`, `gauge`, `solid-gauge` and `bullet`, with `bogusProp` refused +by name on the same call as the lit control. The dataset query then **selected +and computed all three** and the tile rendered `values[0]` — the other two were +queried and dropped on the floor (objectui#7293 defect 1). objectui PR #8887 +landed a sub-caption that says so, which makes the tile honest about dropping +them; it does not make the document legal. + +The maintainer ruled **D** on objectui#8894 (decision batch #119 item 4, +2026-09-12 「同意」) under the standing rule 「协议不正确的应该先修改协议。」 — +judge the protocol wrong rather than invent display semantics for `values[1..]`. +A metric tile answers one number; `ChartTypeSchema` groups these five under +*"Performance (single value)"* in its own words. Several numbers is a different +visual, not a variant of this one. + +### Write N tiles for N measures + +| wrote | write instead | +|---|---| +| `{ id: 'sales', type: 'metric', values: ['amount_sum', 'count'] }` | `{ id: 'sales', type: 'metric', values: ['amount_sum'] }` **and** `{ id: 'sales_count', type: 'metric', values: ['count'] }` | +| several numbers wanted in ONE widget | a different visual: `type: 'table'` renders a row of measures, and `bar` / `line` / `area` / `combo` render one mark per measure — all keep the unbounded `values` they have always had | + +Splitting is not done for you and no conversion could do it: N tiles need N ids +and N boxes on a 12-column grid, which is a layout decision about a dashboard +the registry has never seen. The refusal lands at `widgets[N].values` with one +`custom` issue naming the widget's `id`, the number of measures it declared and +the authored `type`, and prescribing one measure per tile. + +**Exactly one is a conjunction, not one rule.** The field's own `.min(1)` still +owns the empty array (`too_small`, unchanged, and the new check deliberately +adds no second issue there); the new upper bound is +`checkDashboardWidgetMetricMeasureArity`, exported so objectui's `.shape` mirror +can re-attach it. A widget that declares no `type` is refused too — `type` +defaults to `metric` and zod applies defaults before object-level checks — and +the message says so rather than claiming the author wrote it. + +**Nothing else moves.** All fifteen other `ChartTypeSchema` members — `bar`, +`horizontal-bar`, `column`, `line`, `area`, `pie`, `donut`, `funnel`, `scatter`, +`treemap`, `sankey`, `combo`, `radar`, `table`, `pivot` — keep accepting three +measures, byte for byte; `ReportSchema.values` is a separate declaration and is +untouched; and `dashboard.zod.ts` has no other `.min(1)` **array** key at all +(its one other `.min(1)` is `dashboard.columns`, a number bound, unchanged). +Fleet census over every tracked `.ts` / `.tsx` / `.json` / `.mdx` / `.md` / +`.yaml` at the branch point: **187** brace-local literals carrying a +`values: [...]`, **39** of them on a metric-family `type`, and **0** of those +carrying more than one measure. Both counts are lit controls on the scan. diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts new file mode 100644 index 00000000000..499ee0a5f2e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'dashboard-widget-metric-family-multi-measure-refused', + surface: 'dashboard widget measure arity — `dashboard.widgets[].values` ' + + '(`DashboardWidgetSchema.values`) on a widget whose `type` is one of the metric ' + + 'FAMILY (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`), INCLUDING a widget ' + + 'that declares no `type` at all and so resolves to the `metric` default', + replacement: 'ONE measure per tile. Keep the measure the tile is actually for — in ' + + 'practice `values[0]`, which is the only one that has ever rendered — and give each ' + + 'of the others its OWN widget: a new `id`, the same `dataset`, that one measure in ' + + '`values`, and its own `layout` if the dashboard pins grid positions. ⛔ The ' + + 'migration does not do this for you and no conversion could: N tiles need N ids and ' + + 'N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the ' + + 'registry has never seen. If several numbers in ONE widget is what was meant, that ' + + 'is a different visual and the arity rule is not in its way: `type: \'table\'` ' + + 'renders a row of measures, and the chart families (`bar` / `line` / `area` / ' + + '`combo`) render one mark per measure — all of them keep the unbounded `values` ' + + 'they have always had.', + reason: + 'objectui#8894 ruling D (decision batch #119 item 4, 2026-09-12 「同意」) on the ' + + 'maintainer\'s standing rule 「协议不正确的应该先修改协议。」 — judge the protocol ' + + 'wrong rather than invent display semantics for `values[1..]`. Measured on ' + + 'objectui#7293 defect 1: `values` was `z.array(z.string()).min(1)` with NO upper ' + + 'bound on every widget type, so a `metric` tile could declare three measures; the ' + + 'dataset query selected and computed all three, and the tile rendered `values[0]`. ' + + 'The other two were queried and dropped on the floor — the declared≠delivered shape ' + + 'ADR-0049 exists to end, kept alive by a runtime warning rather than closed. ' + + 'objectui PR #8887 (merged) added the sub-caption, and the seat\'s second half made ' + + 'the tile SAY that the extra measures are not rendered: that makes the tile honest ' + + 'about dropping them, it does not make the document legal. A metric tile answers ONE ' + + 'number — that is what the family means on every mainstream dashboard product, and ' + + '`ChartTypeSchema` groups these five under "Performance (single value)" in its own ' + + 'words. Several numbers is a DIFFERENT visual, not a variant of this one, so the ' + + 'repair is an accept-set narrowing and not a renderer feature. ⛔ NOT the other arm ' + + '(`objectstack-ai/duly#109`\'s wish for several numbers on one tile): under this ' + + 'ruling that is a request for a different widget type, and it stays reachable ' + + 'through `table` / the chart families, which this narrowing does not touch. ' + + 'Ships at once, no deprecation window: there is no window in which a queried-and-' + + 'discarded measure does anything. Widening later (a real gauge renderer that draws ' + + 'a target band, say) costs an author nothing and needs no second migration — a ' + + 'narrowing that is later relaxed is free, while leaving the key unbounded costs ' + + 'them a tile that silently drops what they declared.', + acceptanceCriteria: + '⚠️ WHICH DOOR: this refusal is the PUBLISH door\'s, not the editor\'s. Every stored ' + + 'dashboard carrying more than one measure on a metric-family widget is refused the ' + + 'next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the ' + + 'metadata publish path, and any server-side door that parses the spec schema — with ' + + 'ONE `custom` issue at `widgets[N].values` naming the widget\'s `id`, the number of ' + + 'measures it declared, and the authored `type`. It is NOT refused by objectui\'s ' + + 'client-side authoring door: `@object-ui/types` builds its own ' + + '`DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, ' + + '…).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping ' + + 'every object-level check, so until that package imports and chains ' + + '`checkDashboardWidgetMetricMeasureArity` the dashboard EDITOR keeps accepting three ' + + 'measures on a `metric` and the author meets the refusal later, at publish. ⇒ Do not ' + + 'read a green editor as a clean dashboard; re-parse through the spec. ' + + '⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose ' + + 'emitted once per hop — `applyMigrationChain` maps `step.semantic` straight onto the ' + + 'result with no per-document interpolation and no filtering by whether the stack ' + + 'even carries the shape — so `os migrate meta` prints THIS paragraph, not a list of ' + + 'your dropped measures. The refusal is what names them, per widget, on the re-parse. ' + + 'Drive the fix off `os build`, not off the migrate output. ' + + 'WHAT IS REFUSED, exactly: two or more `values` members on `metric`, `kpi`, `gauge`, ' + + '`solid-gauge` or `bullet`, and on a widget that declares no `type` (it resolves to ' + + '`metric`, and the message says so rather than claiming you wrote it). ' + + 'WHAT IS NOT, so this is not read as complete: a single-measure tile of any of those ' + + 'five types parses byte-identically to before; all fifteen OTHER members of ' + + '`ChartTypeSchema` — `bar`, `horizontal-bar`, `column`, `line`, `area`, `pie`, ' + + '`donut`, `funnel`, `scatter`, `treemap`, `sankey`, `combo`, `radar`, `table`, ' + + '`pivot` — keep accepting three measures, unmoved; an EMPTY `values` keeps the ' + + 'field\'s own `too_small` from `.min(1)` and gains no second issue ("exactly one" is ' + + 'the conjunction of that lower bound and this upper one, so a mirror re-attaching ' + + 'this export onto a shape without `.min(1)` gets the upper bound only); a widget ' + + 'whose `type` is outside `ChartTypeSchema` reports the TYPE refusal ALONE (zod treats ' + + 'that `invalid_value` as aborting and skips object-level checks), so the arity ' + + 'refusal arrives on the next parse and the two are never seen together; and whether ' + + 'the surviving measure EXISTS in the bound dataset is still unreachable from this ' + + 'schema — a tile naming one measure nobody declared parses exactly as it did before. ' + + 'Nothing new is broken for consumers that DERIVE this schema: ' + + '`.omit()`/`.pick()`/`.partial()` already threw on it before this change, because it ' + + 'already carried `checkDashboardWidgetStageOrder`; `.extend()` is unaffected — except ' + + 'that zod 4.4.3 refuses an `.extend()` which OVERWRITES a key on a refined object ' + + '("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` ' + + 'instead"), which was already true here and is why a per-`type` union arm was not the ' + + 'spelling chosen. VERIFY by re-parsing each dashboard and reading the widget count: ' + + 'a dashboard that had one three-measure `metric` tile should end with three ' + + 'single-measure tiles and the same three numbers on screen — check the rendered grid ' + + 'afterwards, because the two new tiles are numbers the dashboard was ALREADY paying ' + + 'to compute and had never shown.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3d54818d5d9..4435ca07b17 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6706,6 +6706,95 @@ const step18: MigrationStep = { + '`.` target instead. Clicking each converted button opens the intended ' + 'page or form rather than a refusal dialog.', }, + { + id: 'dashboard-widget-metric-family-multi-measure-refused', + surface: 'dashboard widget measure arity — `dashboard.widgets[].values` ' + + '(`DashboardWidgetSchema.values`) on a widget whose `type` is one of the metric ' + + 'FAMILY (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`), INCLUDING a widget ' + + 'that declares no `type` at all and so resolves to the `metric` default', + replacement: 'ONE measure per tile. Keep the measure the tile is actually for — in ' + + 'practice `values[0]`, which is the only one that has ever rendered — and give each ' + + 'of the others its OWN widget: a new `id`, the same `dataset`, that one measure in ' + + '`values`, and its own `layout` if the dashboard pins grid positions. ⛔ The ' + + 'migration does not do this for you and no conversion could: N tiles need N ids and ' + + 'N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the ' + + 'registry has never seen. If several numbers in ONE widget is what was meant, that ' + + 'is a different visual and the arity rule is not in its way: `type: \'table\'` ' + + 'renders a row of measures, and the chart families (`bar` / `line` / `area` / ' + + '`combo`) render one mark per measure — all of them keep the unbounded `values` ' + + 'they have always had.', + reason: + 'objectui#8894 ruling D (decision batch #119 item 4, 2026-09-12 「同意」) on the ' + + 'maintainer\'s standing rule 「协议不正确的应该先修改协议。」 — judge the protocol ' + + 'wrong rather than invent display semantics for `values[1..]`. Measured on ' + + 'objectui#7293 defect 1: `values` was `z.array(z.string()).min(1)` with NO upper ' + + 'bound on every widget type, so a `metric` tile could declare three measures; the ' + + 'dataset query selected and computed all three, and the tile rendered `values[0]`. ' + + 'The other two were queried and dropped on the floor — the declared≠delivered shape ' + + 'ADR-0049 exists to end, kept alive by a runtime warning rather than closed. ' + + 'objectui PR #8887 (merged) added the sub-caption, and the seat\'s second half made ' + + 'the tile SAY that the extra measures are not rendered: that makes the tile honest ' + + 'about dropping them, it does not make the document legal. A metric tile answers ONE ' + + 'number — that is what the family means on every mainstream dashboard product, and ' + + '`ChartTypeSchema` groups these five under "Performance (single value)" in its own ' + + 'words. Several numbers is a DIFFERENT visual, not a variant of this one, so the ' + + 'repair is an accept-set narrowing and not a renderer feature. ⛔ NOT the other arm ' + + '(`objectstack-ai/duly#109`\'s wish for several numbers on one tile): under this ' + + 'ruling that is a request for a different widget type, and it stays reachable ' + + 'through `table` / the chart families, which this narrowing does not touch. ' + + 'Ships at once, no deprecation window: there is no window in which a queried-and-' + + 'discarded measure does anything. Widening later (a real gauge renderer that draws ' + + 'a target band, say) costs an author nothing and needs no second migration — a ' + + 'narrowing that is later relaxed is free, while leaving the key unbounded costs ' + + 'them a tile that silently drops what they declared.', + acceptanceCriteria: + '⚠️ WHICH DOOR: this refusal is the PUBLISH door\'s, not the editor\'s. Every stored ' + + 'dashboard carrying more than one measure on a metric-family widget is refused the ' + + 'next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the ' + + 'metadata publish path, and any server-side door that parses the spec schema — with ' + + 'ONE `custom` issue at `widgets[N].values` naming the widget\'s `id`, the number of ' + + 'measures it declared, and the authored `type`. It is NOT refused by objectui\'s ' + + 'client-side authoring door: `@object-ui/types` builds its own ' + + '`DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, ' + + '…).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping ' + + 'every object-level check, so until that package imports and chains ' + + '`checkDashboardWidgetMetricMeasureArity` the dashboard EDITOR keeps accepting three ' + + 'measures on a `metric` and the author meets the refusal later, at publish. ⇒ Do not ' + + 'read a green editor as a clean dashboard; re-parse through the spec. ' + + '⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose ' + + 'emitted once per hop — `applyMigrationChain` maps `step.semantic` straight onto the ' + + 'result with no per-document interpolation and no filtering by whether the stack ' + + 'even carries the shape — so `os migrate meta` prints THIS paragraph, not a list of ' + + 'your dropped measures. The refusal is what names them, per widget, on the re-parse. ' + + 'Drive the fix off `os build`, not off the migrate output. ' + + 'WHAT IS REFUSED, exactly: two or more `values` members on `metric`, `kpi`, `gauge`, ' + + '`solid-gauge` or `bullet`, and on a widget that declares no `type` (it resolves to ' + + '`metric`, and the message says so rather than claiming you wrote it). ' + + 'WHAT IS NOT, so this is not read as complete: a single-measure tile of any of those ' + + 'five types parses byte-identically to before; all fifteen OTHER members of ' + + '`ChartTypeSchema` — `bar`, `horizontal-bar`, `column`, `line`, `area`, `pie`, ' + + '`donut`, `funnel`, `scatter`, `treemap`, `sankey`, `combo`, `radar`, `table`, ' + + '`pivot` — keep accepting three measures, unmoved; an EMPTY `values` keeps the ' + + 'field\'s own `too_small` from `.min(1)` and gains no second issue ("exactly one" is ' + + 'the conjunction of that lower bound and this upper one, so a mirror re-attaching ' + + 'this export onto a shape without `.min(1)` gets the upper bound only); a widget ' + + 'whose `type` is outside `ChartTypeSchema` reports the TYPE refusal ALONE (zod treats ' + + 'that `invalid_value` as aborting and skips object-level checks), so the arity ' + + 'refusal arrives on the next parse and the two are never seen together; and whether ' + + 'the surviving measure EXISTS in the bound dataset is still unreachable from this ' + + 'schema — a tile naming one measure nobody declared parses exactly as it did before. ' + + 'Nothing new is broken for consumers that DERIVE this schema: ' + + '`.omit()`/`.pick()`/`.partial()` already threw on it before this change, because it ' + + 'already carried `checkDashboardWidgetStageOrder`; `.extend()` is unaffected — except ' + + 'that zod 4.4.3 refuses an `.extend()` which OVERWRITES a key on a refined object ' + + '("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` ' + + 'instead"), which was already true here and is why a per-`type` union arm was not the ' + + 'spelling chosen. VERIFY by re-parsing each dashboard and reading the widget count: ' + + 'a dashboard that had one three-measure `metric` tile should end with three ' + + 'single-measure tiles and the same three numbers on screen — check the rendered grid ' + + 'afterwards, because the two new tiles are numbers the dashboard was ALREADY paying ' + + 'to compute and had never shown.', + }, { id: 'dashboard-widget-stage-order-non-funnel-refused', surface: 'dashboard widget stage order — `dashboard.widgets[].options.stageOrder` ' From ea17ab8491d97c457013d7fc1148db39f23682af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:21:41 +0000 Subject: [PATCH 3/6] chore(spec): regenerate the three artifacts the narrowing proved stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:generated` named exactly three: `api-surface/` (+1 added, 0 breaking — the new exported refinement check), `export-origins/ui.json` (same symbol), and `content/docs/references/ui/dashboard.mdx` (the `values` doc string now states the arity rule it enforces). The other twelve were already current, including `authorable-surface/` — no authorable key moves here. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- content/docs/references/ui/dashboard.mdx | 4 ++-- packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 516af1cd5ef..46a0757c6c7 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -76,7 +76,7 @@ const result = DashboardSchema.parse(data); | **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) | | **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) | | **dimensions** | `string[]` | optional | Dimension names — X/group/split | -| **values** | `string[]` | ✅ | Measure names — Y (at least one) | +| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family) | | **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) | | **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record` | optional | Widget specific configuration | | **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | @@ -181,7 +181,7 @@ Dashboard header action | **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) | | **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) | | **dimensions** | `string[]` | optional | Dimension names — X/group/split | -| **values** | `string[]` | ✅ | Measure names — Y (at least one) | +| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family) | | **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) | | **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record` | optional | Widget specific configuration | | **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index d1068f2496b..6da166101d4 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -443,6 +443,7 @@ "chartAggregateCategoryKey (function)", "chartAggregateResultKeys (function)", "chartAggregateValueKey (function)", + "checkDashboardWidgetMetricMeasureArity (function)", "checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index fbc7b023e10..444f188cc74 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -429,6 +429,7 @@ "chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)", "chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)", "chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)", + "checkDashboardWidgetMetricMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetMetricMeasureArity (function)", "checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)", From 3b15ca1254837b9648e46d0168b0e7510b57dace Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 17:12:56 +0000 Subject: [PATCH 4/6] docs(spec): name the real chain entrypoint in the ADR-0087 entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The semantic entry for the metric-family refusal told readers that `applyMigrationChain` maps `step.semantic` onto the result. No such symbol exists in the tree; the function is `applyMetaMigrations` (packages/spec/src/migrations/chain.ts:68), which is what the CLI calls and what the root api-surface exports. The behaviour the sentence describes is correct — chain.ts:102 maps `step.semantic` straight through with no per-document interpolation — so only the identifier moves. It matters because this text ships in the migration ledger and is printed by `os migrate meta` at protocol 18, where a reader who greps the name finds nothing. registry.ts is regenerated by `gen:migration-registry`, never edited by hand. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 --- .../18.dashboard-widget-metric-family-multi-measure-refused.ts | 2 +- packages/spec/src/migrations/registry.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts index 499ee0a5f2e..8e12a7c227e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts @@ -58,7 +58,7 @@ export const entry: SemanticMigration = { + 'measures on a `metric` and the author meets the refusal later, at publish. ⇒ Do not ' + 'read a green editor as a clean dashboard; re-parse through the spec. ' + '⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose ' - + 'emitted once per hop — `applyMigrationChain` maps `step.semantic` straight onto the ' + + 'emitted once per hop — `applyMetaMigrations` maps `step.semantic` straight onto the ' + 'result with no per-document interpolation and no filtering by whether the stack ' + 'even carries the shape — so `os migrate meta` prints THIS paragraph, not a list of ' + 'your dropped measures. The refusal is what names them, per widget, on the re-parse. ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4435ca07b17..a224e977e89 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6762,7 +6762,7 @@ const step18: MigrationStep = { + 'measures on a `metric` and the author meets the refusal later, at publish. ⇒ Do not ' + 'read a green editor as a clean dashboard; re-parse through the spec. ' + '⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose ' - + 'emitted once per hop — `applyMigrationChain` maps `step.semantic` straight onto the ' + + 'emitted once per hop — `applyMetaMigrations` maps `step.semantic` straight onto the ' + 'result with no per-document interpolation and no filtering by whether the stack ' + 'even carries the shape — so `os migrate meta` prints THIS paragraph, not a list of ' + 'your dropped measures. The refusal is what names them, per widget, on the re-parse. ' From 881db1280d4d0f27f58387a68bb8125ef9289c78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 17:22:19 +0000 Subject: [PATCH 5/6] =?UTF-8?q?docs(spec):=20declare=20clause-=E2=91=A1=20?= =?UTF-8?q?`yes`=20on=20the=20changeset,=20with=20both=20axes=20named?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset declared `Clause-②: no (narrowing)`. The accept-set direction it names is true, but that is not what the line decides: per the contract-review charter the line only routes — "只定是否必过席内契约复核的保守方向,⛔ 非终审" — and its mechanical floor is "新导出符号...恒 `yes`". This diff adds one exported symbol to the published surface (`checkDashboardWidgetMetricMeasureArity`, +1 in api-surface/ui.json, 0 removed), so the routing answer is `yes` and the seat's claim already reads `yes (widening)`. Two of the three carriers disagreed with it; this aligns the changeset and names both axes so the CHANGELOG line does not read as a claim that the change widens behaviour. Breaking-ness is unaffected: check-adr-0087-registration still reads the entry as breaking through the **BREAKING** banner and the `!` in the summary, and the `clause-②-narrowing` signal it loses was never the only carrier. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 --- .changeset/17779-dashboard-metric-family-single-measure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/17779-dashboard-metric-family-single-measure.md b/.changeset/17779-dashboard-metric-family-single-measure.md index 2bfef4538b0..80e94d63d38 100644 --- a/.changeset/17779-dashboard-metric-family-single-measure.md +++ b/.changeset/17779-dashboard-metric-family-single-measure.md @@ -4,7 +4,7 @@ feat(spec)!: a metric-family dashboard widget declares exactly ONE measure — `values` is bounded above on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` (#17779; objectui#8894 ruling D, decision batch #119 item 4) -Clause-②: no (narrowing) +Clause-②: yes (widening) — routing, not direction. The accept set NARROWS (that is this change). What makes the line `yes` is the other axis: the published surface GAINS one exported symbol, `checkDashboardWidgetMetricMeasureArity`, and a new exported symbol is the mechanical floor for in-seat contract review. Breaking-ness is carried by the **BREAKING** banner and the ADR-0087 disposition above, not by this line. From ee0f6eaced59b3610d60c2695fbf7774b3f02bc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 17:40:27 +0000 Subject: [PATCH 6/6] docs(spec): use the `yes (narrowing)` arm, the spelling for a diff that does both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset declared `yes (widening)`. `yes` is right — a new exported symbol is the mechanical floor — but the arm was not: this diff narrows the accept set. The vocabulary already has the spelling for this. `readClause2Line` accepts `yes (narrowing)`, and check-adr-0087-registration's own self-test names the case verbatim: "the `narrowing` arm beside a `yes` value — a diff that widens AND narrows". The earlier wording needed a paragraph explaining why `widening` did not mean what it says; the correct arm needs none, and it restores the `clause-②-narrowing` signal the gate reads. `no (widening)` stays malformed, so the arms are not free: `no` takes only `narrowing`, while `yes` takes either. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 --- .changeset/17779-dashboard-metric-family-single-measure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/17779-dashboard-metric-family-single-measure.md b/.changeset/17779-dashboard-metric-family-single-measure.md index 80e94d63d38..8ef1aa54336 100644 --- a/.changeset/17779-dashboard-metric-family-single-measure.md +++ b/.changeset/17779-dashboard-metric-family-single-measure.md @@ -4,7 +4,7 @@ feat(spec)!: a metric-family dashboard widget declares exactly ONE measure — `values` is bounded above on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` (#17779; objectui#8894 ruling D, decision batch #119 item 4) -Clause-②: yes (widening) — routing, not direction. The accept set NARROWS (that is this change). What makes the line `yes` is the other axis: the published surface GAINS one exported symbol, `checkDashboardWidgetMetricMeasureArity`, and a new exported symbol is the mechanical floor for in-seat contract review. Breaking-ness is carried by the **BREAKING** banner and the ADR-0087 disposition above, not by this line. +Clause-②: yes (narrowing) — this diff BOTH narrows and widens, which is the shape this arm exists for. The accept set NARROWS (that is the change). What makes the value `yes` is the other axis: the published surface GAINS one exported symbol, `checkDashboardWidgetMetricMeasureArity`, and a new exported symbol is the mechanical floor for in-seat contract review.