Skip to content
66 changes: 66 additions & 0 deletions .changeset/17779-dashboard-metric-family-single-measure.md
Original file line number Diff line number Diff line change
@@ -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-②: 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.

<!-- adr-0087: registered dashboard-widget-metric-family-multi-measure-refused -->

**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.
4 changes: 2 additions & 2 deletions content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` | optional | Widget specific configuration |
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
Expand Down Expand Up @@ -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<string, any>` | optional | Widget specific configuration |
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@
"chartAggregateCategoryKey (function)",
"chartAggregateResultKeys (function)",
"chartAggregateValueKey (function)",
"checkDashboardWidgetMetricMeasureArity (function)",
"checkDashboardWidgetStageOrder (function)",
"checkGlobalFilterDateDefaultValue (function)",
"checkListViewCalendarVisualization (function)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 — `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. '
+ '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.',
};
Loading
Loading