Skip to content

Commit 09e16a5

Browse files
os-billclaude
andauthored
feat(spec)!: a metric-family dashboard widget declares exactly ONE measure — narrow DashboardWidgetSchema.values for the metric/kpi/gauge/solid-gauge/bullet family (objectui#8894 ruling D) (#18720)
Fixes #17779 Clause-②: yes (narrowing) Executes maintainer ruling **D** on objectui#8894 (decision batch #119 item 4, 2026-09-12 「同意」) under the standing rule 「协议不正确的应该先修改协议。」 — judge the protocol wrong: a metric-family widget takes exactly one measure. The direction was not re-opened here. ## What changed `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; the dataset query 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#8887's sub-caption made the tile honest about dropping them; it did not make the document legal. - `checkDashboardWidgetMetricMeasureArity` — a new exported object-level check, chained onto the same door by identifier, refusing more than one measure on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` and on a widget that declares no `type` (it defaults to `metric`, and the message says so rather than claiming the author wrote it). One `custom` issue at `values`, naming the widget's `id`, the count, and the authored `type`, and prescribing one measure per tile — "make N tiles for N measures" — plus the visuals that DO render several numbers. - **Exactly one is a conjunction**: 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 check owns the upper bound. - `.changeset/17779-...` — `minor`, **BREAKING** banner, ADR-0087 disposition `registered dashboard-widget-metric-family-multi-measure-refused`. - `packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts` — one new entry file, plus the `gen:migration-registry` lap. No other file in that directory was touched and nothing was hand-edited inside the generated regions of `registry.ts`. - The `values` doc string now states the arity rule it enforces, so the generated reference page stops saying only "at least one". ## The three questions the dispatch asked, answered by measurement ### 1. `superRefine`, not a per-type union arm — because a union destroys every other diagnostic on this door Eight widget bodies through `z.union([metricArm, otherArm])` versus one more `.superRefine` on the strict object, measured on this tree: | body | union arms | the spelling shipped | |---|---|---| | `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 `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 | | `metric` + 3 measures | `too_big` at `values` | the curated `custom` refusal at `values` | 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 already records it 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 entire apparatus objectui needed **because** `ViewMetadataSchema` is a union. A second union here would commission that apparatus again to buy a refusal the object-level form gives for free. Second datum, measured: zod 4.4.3 throws `Cannot overwrite keys on object schemas containing refinements. Use .safeExtend() instead` on a plain `.extend()` that redeclares a key, so the arms cannot even be built from the existing door without `.safeExtend()` or a duplicated declaration. ### 2. `major` does collide with `check-changeset-no-major` — so the changeset is `minor` The guard is **armed**: there is no `.changeset/pre.json`, so the RC exemption does not apply, and the only other route is the `allow-major` PR label whose own error text says "a whole-stack major release is genuinely intended" — false for this PR. Its header states the convention: every publishable package is in the Changesets `fixed` group, so one `major` promotes all ~70 packages; during the launch window a breaking change ships `minor` and **breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the bump level**. `pr-automation.yml`'s "WHICH LEVEL" prose says the same in the place the author reads it. So the card's "major changeset" is satisfied as `minor` + `**BREAKING**` + `registered ...`, and `check-adr-0087-registration --base origin/main` reads the changeset back as `[BREAKING+bang+clause-②-narrowing] registered dashboard-widget-metric-family-multi-measure-refused`. ### 3. The migration entry's acceptance criteria, re-derived from what the code refuses Not a restatement of the card. Two things the card's wording implies that the machinery does **not** do, both measured and both written into the entry: - **The TODO cannot name your dropped measures.** `applyMetaMigrations` maps `step.semantic` straight onto the result (`chain.ts`) with no per-document interpolation and no filtering by whether the stack even carries the shape, and `SemanticMigration` has only static string fields. `os migrate meta` therefore prints the entry's prose, not a list. The **refusal** is what names them, per widget, on the re-parse — so the entry tells the author to drive the fix off `os build`, not off the migrate output. - **Splitting into N tiles is not attempted**, as the card says — and the entry states why in the registry's own terms: N tiles need N ids and N boxes on a 12-column grid, which is a layout fact about a dashboard the registry has never seen. The rest of `acceptanceCriteria` is the measured accept/refuse matrix: which door refuses (publish, not objectui's `.shape`-mirror editor), the empty-array carve-out, the aborting `invalid_value` on an unknown `type`, the un-reachable "does this measure exist in the dataset", and the fact that `.omit()` / `.pick()` / `.partial()` already threw before this change. ## Controls **LIT** — a legal single-measure metric tile parses **identically before and after**, and the non-metric families are untouched. Sixteen bodies through `DashboardWidgetSchema.safeParse`, before and after the change: | body | before | after | |---|---|---| | `metric` + 1 measure | ACCEPT, `values: ["amount_sum"]` | ACCEPT, `values: ["amount_sum"]` | | `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` + 2–3 measures | ACCEPT (all five) | REFUSE `values:custom` (all five) | | no `type` + 3 measures | ACCEPT, `type: "metric"` | REFUSE `values:custom` | | `bar` / `line` / `table` / `pivot` / `funnel` + 3 measures | ACCEPT | ACCEPT (unchanged) | | `metric` + `values: []` | REFUSE `values:too_small` | REFUSE `values:too_small` (one issue, not two) | | `type: 'ziggurat'` + 3 measures | REFUSE `type:invalid_value` | REFUSE `type:invalid_value` (alone) | | `metric` + 3 measures + `bogusProp` | REFUSE `unrecognized_keys` | REFUSE `unrecognized_keys` | The whole taxonomy is covered by a pin that asserts the metric family plus the fifteen others **is** `ChartTypeSchema.options`, so a new chart type cannot land uncovered by either list. **DARK** — things that must read **0**, with paths and counts: - `.min(1)` **array** keys in `packages/spec/src/ui/dashboard.zod.ts` other than `values`: **0**. The file has exactly two `.min(1)` code sites at the branch point — `values` (line 706) and `dashboard.columns` (line 1151, `z.number().int().min(1).max(24)`, a number bound, not an array). The latter is byte-identical after the change; every other new `.min(1)` occurrence in the file is inside a docblock. - `ReportSchema.values` (`packages/spec/src/ui/report.zod.ts`, lines 237 and 314) is a separate declaration, `optional()`, with no `.min(1)` and no arity check, and its `type` enum (`tabular` / `summary` / `matrix` / `joined`) contains **0** metric-family members. Untouched, and not the same defect. - Fleet census over every tracked `.ts` / `.tsx` / `.json` / `.mdx` / `.md` / `.yaml` **at the branch point** `72dd95fa5a`: **187** brace-local literals carrying a `values: [...]`, **39** of them on a metric-family `type` (both lit controls), and **0** of those carrying more than one measure. Nothing in the monorepo moves. On this branch the same scan reads 205 / 49 / **7**, and all seven are the fixtures this PR added. - `check:authorable-surface` is green with no regeneration: **0** authorable keys move. `check:api-surface` reports `0 breaking (removed/narrowed), 1 added` — the new exported check. ## Verification Red before green, with the mutation proved on disk and the restore hash-verified: ``` HEAD blob : 30c6d78 worktree blob : 30c6d78 (at HEAD before the mutation) anchor occurrences BEFORE: 1 AFTER: 0 injected line: 1 mutated blob : 90548649227c3971f16b7dc85b02e1bab8155f96 (differs -> the edit really landed) RED vitest exit=1 17 failed | 205 passed (222) restored blob : 30c6d78 git diff HEAD on the path: empty GREEN vitest exit=0 222 passed (222) ``` The mutation removed only the `.superRefine(checkDashboardWidgetMetricMeasureArity)` attachment, leaving the function declared — so the 17 reds are the door's behaviour, not a compile failure. The script carried a `trap ... EXIT INT TERM` restore against an absolute `git rev-parse --show-toplevel` path, restored with `git checkout HEAD -- path` (never a bare `git checkout --`), and proved the restore by blob hash **and** an empty `git diff HEAD`. - `pnpm --filter @objectstack/spec test` — **486 files / 13933 tests passed**, exit 0. - `pnpm --filter @objectstack/spec typecheck` — exit 0 (`check:scripts-typecheck` and `check:test-typecheck` included; the test-layer ledger held at 54 files / 259 errors / 144 pinned signatures, shrink-only). - `pnpm --filter @objectstack/spec check:generated` — **all 15 generated artifacts up to date**, exit 0, after regenerating exactly the three it proved stale (`api-surface/`, `export-origins/`, `content/docs/references/**`). - Changeset gates: `check-adr-0087-registration --base origin/main` exit 0 (+ `--self-test`, 384 assertions), `check-changeset-no-major --base origin/main` exit 0, `check-empty-changeset --base origin/main` exit 0. - `pnpm check:nul-bytes` exit 0 (8812 text files, no raw control bytes), plus `check:widget-option-census`, `check:liveness`, `check:exported-any`, `check:dual-source-exports`, `check:entry-nameability`, `check:empty-state`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage`, `check:merge-driver`, `check:pm-widening-tells`, `check:spec-docblock-symbol-anchors`, `check:dts-closure`, `check:published-files`, `check:spec-parsed-alias`, `check:page-declaration-shape`, `check:corpus-claim-drift`, `check:skill-examples`, `check:docs-transcript-drift`, `check:doc-formula-expressions`, `check:variant-docs`, `check:llms-txt`, `check:yaml-examples`, `check:objectui-pin-citations`, and the ten doc gates the regenerated `.mdx` newly derives — every one exit 0. - **Repo-wide lint, not a narrowing**: `node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format json` at `ea17ab8491`, 81s — **6822 files linted, 0 errors, 0 warnings**, exit 0. ## Migration-entry adjacency — checked, not assumed `packages/spec/src/migrations/entries/` is one file per entry and the entries README records the measured #8344 table: two in-flight registrations merge clean **unless** their ids are adjacent in sort order or both are the first entry of a new major. Enumerated the `18.*` semantic directory and every open PR's file list on 2026-09-17: - In-flight ADDED semantic registrations: `ui-list-view-groupbyfield-padded-refused` (#18695), `structured-region-body-pause-and-end-refused` (#18688), `evaluated-expression-slots-source-required` (#18638), `manifest-id-reverse-domain-required` (#18319). (#18420 modifies an existing entry, which is not an insertion.) - This entry's immediate neighbours in the sorted set are `dashboard-header-modal-target-page-only` and `dashboard-widget-stage-order-non-funnel-refused` — **both already landed on `main`**, neither in flight — and it is not the first entry of major 18. Neither ejection row applies. The seat's expectation about #18695 held, and was verified rather than assumed. The two projections the README names came back **byte-identical, and that is correct rather than a skipped step**: `build-spec-changes.ts` and `build-upgrade-guide.ts` both loop `for (major = MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++)`, and `PROTOCOL_MAJOR` is **17** while this entry registers under **18**. Both were regenerated anyway and `check:spec-changes` / `check:upgrade-guide` are green. ## Acceptance notes Noted, not filed — neither is a reproducible defect, a contract violation, or a metadata-authoring trap: - **zod 4.4.3 refuses `.extend()` that overwrites a key on a refined object** ("Use `.safeExtend()` instead"), measured here while probing the union spelling. It is a trap for the next author who mirrors or re-arms this door — recorded in the new check's docblock and in the migration entry, which is where that author looks. Successor: whoever lands objectui#8894's half, which must re-attach this export onto a `.shape` mirror. - **An ADR-0087 semantic entry cannot name per-document values.** `applyMetaMigrations` emits `step.semantic` unconditionally and `SemanticMigration` carries only static strings, so a card instruction of the form "emit a structured TODO naming X" is unsatisfiable as literally written — the refusal message is the only per-document channel. Recorded in this entry's `acceptanceCriteria`. Successor: the next card that writes that instruction. ## Downstream, not in this PR Card item 3 (objectui's contract twins gain the refusal pin; the runtime warning becomes the door refusal) is the objectui half and objectui#8894 is `pm:blocked` on this card. Nothing in `../objectui` was touched. Until that package imports and chains `checkDashboardWidgetMetricMeasureArity`, its `.shape`-mirror editor keeps accepting three measures on a `metric` and the author meets this refusal at publish — stated in the check's docblock and in the migration entry rather than left implied. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ --- > ⏱️ **席位代改正文(dev 只写一次,⛔ 不 PATCH 正文;事后要改的由本席代写)。** 两处: > > - **`applyMigrationChain` → `applyMetaMigrations`(2 处)** —— 前者在树上**不存在**; > 真函数是 `packages/spec/src/migrations/chain.ts:68`,CLI 调它,根 api-surface 导出它。 > 同一处错名也写进了 ADR-0087 语义条目、并经 `gen:migration-registry` 复制进 > `registry.ts:6765` —— 那段文本会被 `os migrate meta` 在协议 18 打印出来, > 所以读者照着 grep 会一无所获。已随 `3b15ca1254` 修正(条目 + 重生成,⛔ 未手改 registry.ts)。 > ⭐ 这一条由**达档隔离契约复核**判出(记录见下方 PASS/FAIL 评论),⛔ 不是本席自己看出来的。 > - **`Clause-②: no (narrowing)` → `yes (widening)`** —— 该行**只定路由**,⛔ 非终审: > 章程原文「只定是否必过席内契约复核的保守方向」,机械地板「新导出符号…恒 `yes`」。 > 本 diff 在 `api-surface/ui.json` 上**净增一个导出符号** > (`checkDashboardWidgetMetricMeasureArity`,+1 / 移除 0,本席对着 merge-base `72dd95fa5a` 实测), > ⇒ 地板落在 `yes`。认领侧早已是 `yes (widening)`,正文与 changeset 两个载体**落后于它**; > changeset 已随 `881db1280d` 对齐,并在行内写明两条轴(接受集**收窄**、公开面**扩大**), > 免得 CHANGELOG 读成「本改动放宽了行为」。 > > ⚠️ **破坏性未受影响**:`check-adr-0087-registration` 仍读作 breaking, > 经 `**BREAKING**` 横幅与摘要里的 `!`;它失去的 `clause-②-narrowing` 信号从来不是唯一载体 > (实测 `[BREAKING+bang]`,exit 0)。 > > ⏱️ **再正一次(席位):`yes (widening)` → `yes (narrowing)`。** 上一版本席以为「收窄行为 + 扩大公开面」在这套两态词表里没有正确拼法,于是取了 `widening` 并写了一段话解释「它不是那个意思」。**那个前提是错的**:`readClause2Line` 认 `yes (narrowing)`,而`check-adr-0087-registration` 的自测逐字命名了这个形状 ——「the `narrowing` arm beside a `yes` value — a diff that **widens AND narrows**」。⇒ 值仍是 `yes`(机械地板:新导出符号),但**臂**改回 `narrowing`,`clause-②-narrowing` 信号随之回到 ADR-0087 门禁(实测 `[BREAKING+bang+clause-②-narrowing]`,exit 0)。⭐ 这一条由第二次达档复核在 ③ 里作为**边界旗标**提出,⛔ 不是 FAIL;本席自己验过词表才动手。⚠️ 顺带一提 `no (widening)` 读作 **malformed** —— 臂不是自由的:`no` 只配 `narrowing`,`yes` 两者皆可。 --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a84f61a commit 09e16a5

9 files changed

Lines changed: 638 additions & 9 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
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)
6+
7+
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.
8+
9+
<!-- adr-0087: registered dashboard-widget-metric-family-multi-measure-refused -->
10+
11+
**BREAKING** accept-set narrowing at `dashboard.widgets[].values`, shipped as
12+
`minor` under this repo's launch-window convention for breaking changes
13+
(`check-changeset-no-major` refuses `major` outright while the window is open, so
14+
breaking-ness is carried by this banner and by the ADR-0087 disposition above,
15+
never by the bump level). The mechanical prescription is registered under
16+
protocol major 18 as `dashboard-widget-metric-family-multi-measure-refused`.
17+
18+
**What was wrong.** `DashboardWidgetSchema.values` was
19+
`z.array(z.string()).min(1)` with **no upper bound on any widget type**, so a
20+
`metric` tile could declare three measures. Measured on this tree before the
21+
change: `{ type: 'metric', values: ['a','b','c'] }` returned `success: true`,
22+
and so did `kpi`, `gauge`, `solid-gauge` and `bullet`, with `bogusProp` refused
23+
by name on the same call as the lit control. The dataset query then **selected
24+
and computed all three** and the tile rendered `values[0]` — the other two were
25+
queried and dropped on the floor (objectui#7293 defect 1). objectui PR #8887
26+
landed a sub-caption that says so, which makes the tile honest about dropping
27+
them; it does not make the document legal.
28+
29+
The maintainer ruled **D** on objectui#8894 (decision batch #119 item 4,
30+
2026-09-12 「同意」) under the standing rule 「协议不正确的应该先修改协议。」 —
31+
judge the protocol wrong rather than invent display semantics for `values[1..]`.
32+
A metric tile answers one number; `ChartTypeSchema` groups these five under
33+
*"Performance (single value)"* in its own words. Several numbers is a different
34+
visual, not a variant of this one.
35+
36+
### Write N tiles for N measures
37+
38+
| wrote | write instead |
39+
|---|---|
40+
| `{ id: 'sales', type: 'metric', values: ['amount_sum', 'count'] }` | `{ id: 'sales', type: 'metric', values: ['amount_sum'] }` **and** `{ id: 'sales_count', type: 'metric', values: ['count'] }` |
41+
| 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 |
42+
43+
Splitting is not done for you and no conversion could do it: N tiles need N ids
44+
and N boxes on a 12-column grid, which is a layout decision about a dashboard
45+
the registry has never seen. The refusal lands at `widgets[N].values` with one
46+
`custom` issue naming the widget's `id`, the number of measures it declared and
47+
the authored `type`, and prescribing one measure per tile.
48+
49+
**Exactly one is a conjunction, not one rule.** The field's own `.min(1)` still
50+
owns the empty array (`too_small`, unchanged, and the new check deliberately
51+
adds no second issue there); the new upper bound is
52+
`checkDashboardWidgetMetricMeasureArity`, exported so objectui's `.shape` mirror
53+
can re-attach it. A widget that declares no `type` is refused too — `type`
54+
defaults to `metric` and zod applies defaults before object-level checks — and
55+
the message says so rather than claiming the author wrote it.
56+
57+
**Nothing else moves.** All fifteen other `ChartTypeSchema` members — `bar`,
58+
`horizontal-bar`, `column`, `line`, `area`, `pie`, `donut`, `funnel`, `scatter`,
59+
`treemap`, `sankey`, `combo`, `radar`, `table`, `pivot` — keep accepting three
60+
measures, byte for byte; `ReportSchema.values` is a separate declaration and is
61+
untouched; and `dashboard.zod.ts` has no other `.min(1)` **array** key at all
62+
(its one other `.min(1)` is `dashboard.columns`, a number bound, unchanged).
63+
Fleet census over every tracked `.ts` / `.tsx` / `.json` / `.mdx` / `.md` /
64+
`.yaml` at the branch point: **187** brace-local literals carrying a
65+
`values: [...]`, **39** of them on a metric-family `type`, and **0** of those
66+
carrying more than one measure. Both counts are lit controls on the scan.

‎content/docs/references/ui/dashboard.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const result = DashboardSchema.parse(data);
7676
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) |
7777
| **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) |
7878
| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
79-
| **values** | `string[]` | ✅ | Measure names — Y (at least one) |
79+
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family) |
8080
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
8181
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
8282
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
@@ -181,7 +181,7 @@ Dashboard header action
181181
| **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) |
182182
| **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) |
183183
| **dimensions** | `string[]` | optional | Dimension names — X/group/split |
184-
| **values** | `string[]` | ✅ | Measure names — Y (at least one) |
184+
| **values** | `string[]` | ✅ | Measure names — Y (at least one; exactly one on the metric/kpi/gauge/solid-gauge/bullet family) |
185185
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
186186
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
187187
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |

‎packages/spec/api-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@
443443
"chartAggregateCategoryKey (function)",
444444
"chartAggregateResultKeys (function)",
445445
"chartAggregateValueKey (function)",
446+
"checkDashboardWidgetMetricMeasureArity (function)",
446447
"checkDashboardWidgetStageOrder (function)",
447448
"checkGlobalFilterDateDefaultValue (function)",
448449
"checkListViewCalendarVisualization (function)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@
429429
"chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)",
430430
"chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)",
431431
"chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)",
432+
"checkDashboardWidgetMetricMeasureArity": "src/ui/dashboard.zod.ts#checkDashboardWidgetMetricMeasureArity (function)",
432433
"checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)",
433434
"checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)",
434435
"checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'dashboard-widget-metric-family-multi-measure-refused',
7+
surface: 'dashboard widget measure arity — `dashboard.widgets[].values` '
8+
+ '(`DashboardWidgetSchema.values`) on a widget whose `type` is one of the metric '
9+
+ 'FAMILY (`metric` / `kpi` / `gauge` / `solid-gauge` / `bullet`), INCLUDING a widget '
10+
+ 'that declares no `type` at all and so resolves to the `metric` default',
11+
replacement: 'ONE measure per tile. Keep the measure the tile is actually for — in '
12+
+ 'practice `values[0]`, which is the only one that has ever rendered — and give each '
13+
+ 'of the others its OWN widget: a new `id`, the same `dataset`, that one measure in '
14+
+ '`values`, and its own `layout` if the dashboard pins grid positions. ⛔ The '
15+
+ 'migration does not do this for you and no conversion could: N tiles need N ids and '
16+
+ 'N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the '
17+
+ 'registry has never seen. If several numbers in ONE widget is what was meant, that '
18+
+ 'is a different visual and the arity rule is not in its way: `type: \'table\'` '
19+
+ 'renders a row of measures, and the chart families (`bar` / `line` / `area` / '
20+
+ '`combo`) render one mark per measure — all of them keep the unbounded `values` '
21+
+ 'they have always had.',
22+
reason:
23+
'objectui#8894 ruling D (decision batch #119 item 4, 2026-09-12 「同意」) on the '
24+
+ 'maintainer\'s standing rule 「协议不正确的应该先修改协议。」 — judge the protocol '
25+
+ 'wrong rather than invent display semantics for `values[1..]`. Measured on '
26+
+ 'objectui#7293 defect 1: `values` was `z.array(z.string()).min(1)` with NO upper '
27+
+ 'bound on every widget type, so a `metric` tile could declare three measures; the '
28+
+ 'dataset query selected and computed all three, and the tile rendered `values[0]`. '
29+
+ 'The other two were queried and dropped on the floor — the declared≠delivered shape '
30+
+ 'ADR-0049 exists to end, kept alive by a runtime warning rather than closed. '
31+
+ 'objectui PR #8887 (merged) added the sub-caption, and the seat\'s second half made '
32+
+ 'the tile SAY that the extra measures are not rendered: that makes the tile honest '
33+
+ 'about dropping them, it does not make the document legal. A metric tile answers ONE '
34+
+ 'number — that is what the family means on every mainstream dashboard product, and '
35+
+ '`ChartTypeSchema` groups these five under "Performance (single value)" in its own '
36+
+ 'words. Several numbers is a DIFFERENT visual, not a variant of this one, so the '
37+
+ 'repair is an accept-set narrowing and not a renderer feature. ⛔ NOT the other arm '
38+
+ '(`objectstack-ai/duly#109`\'s wish for several numbers on one tile): under this '
39+
+ 'ruling that is a request for a different widget type, and it stays reachable '
40+
+ 'through `table` / the chart families, which this narrowing does not touch. '
41+
+ 'Ships at once, no deprecation window: there is no window in which a queried-and-'
42+
+ 'discarded measure does anything. Widening later (a real gauge renderer that draws '
43+
+ 'a target band, say) costs an author nothing and needs no second migration — a '
44+
+ 'narrowing that is later relaxed is free, while leaving the key unbounded costs '
45+
+ 'them a tile that silently drops what they declared.',
46+
acceptanceCriteria:
47+
'⚠️ WHICH DOOR: this refusal is the PUBLISH door\'s, not the editor\'s. Every stored '
48+
+ 'dashboard carrying more than one measure on a metric-family widget is refused the '
49+
+ 'next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the '
50+
+ 'metadata publish path, and any server-side door that parses the spec schema — with '
51+
+ 'ONE `custom` issue at `widgets[N].values` naming the widget\'s `id`, the number of '
52+
+ 'measures it declared, and the authored `type`. It is NOT refused by objectui\'s '
53+
+ 'client-side authoring door: `@object-ui/types` builds its own '
54+
+ '`DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, '
55+
+ '…).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping '
56+
+ 'every object-level check, so until that package imports and chains '
57+
+ '`checkDashboardWidgetMetricMeasureArity` the dashboard EDITOR keeps accepting three '
58+
+ 'measures on a `metric` and the author meets the refusal later, at publish. ⇒ Do not '
59+
+ 'read a green editor as a clean dashboard; re-parse through the spec. '
60+
+ '⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a `SemanticMigration` is static prose '
61+
+ 'emitted once per hop — `applyMetaMigrations` maps `step.semantic` straight onto the '
62+
+ 'result with no per-document interpolation and no filtering by whether the stack '
63+
+ 'even carries the shape — so `os migrate meta` prints THIS paragraph, not a list of '
64+
+ 'your dropped measures. The refusal is what names them, per widget, on the re-parse. '
65+
+ 'Drive the fix off `os build`, not off the migrate output. '
66+
+ 'WHAT IS REFUSED, exactly: two or more `values` members on `metric`, `kpi`, `gauge`, '
67+
+ '`solid-gauge` or `bullet`, and on a widget that declares no `type` (it resolves to '
68+
+ '`metric`, and the message says so rather than claiming you wrote it). '
69+
+ 'WHAT IS NOT, so this is not read as complete: a single-measure tile of any of those '
70+
+ 'five types parses byte-identically to before; all fifteen OTHER members of '
71+
+ '`ChartTypeSchema` — `bar`, `horizontal-bar`, `column`, `line`, `area`, `pie`, '
72+
+ '`donut`, `funnel`, `scatter`, `treemap`, `sankey`, `combo`, `radar`, `table`, '
73+
+ '`pivot` — keep accepting three measures, unmoved; an EMPTY `values` keeps the '
74+
+ 'field\'s own `too_small` from `.min(1)` and gains no second issue ("exactly one" is '
75+
+ 'the conjunction of that lower bound and this upper one, so a mirror re-attaching '
76+
+ 'this export onto a shape without `.min(1)` gets the upper bound only); a widget '
77+
+ 'whose `type` is outside `ChartTypeSchema` reports the TYPE refusal ALONE (zod treats '
78+
+ 'that `invalid_value` as aborting and skips object-level checks), so the arity '
79+
+ 'refusal arrives on the next parse and the two are never seen together; and whether '
80+
+ 'the surviving measure EXISTS in the bound dataset is still unreachable from this '
81+
+ 'schema — a tile naming one measure nobody declared parses exactly as it did before. '
82+
+ 'Nothing new is broken for consumers that DERIVE this schema: '
83+
+ '`.omit()`/`.pick()`/`.partial()` already threw on it before this change, because it '
84+
+ 'already carried `checkDashboardWidgetStageOrder`; `.extend()` is unaffected — except '
85+
+ 'that zod 4.4.3 refuses an `.extend()` which OVERWRITES a key on a refined object '
86+
+ '("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` '
87+
+ 'instead"), which was already true here and is why a per-`type` union arm was not the '
88+
+ 'spelling chosen. VERIFY by re-parsing each dashboard and reading the widget count: '
89+
+ 'a dashboard that had one three-measure `metric` tile should end with three '
90+
+ 'single-measure tiles and the same three numbers on screen — check the rendered grid '
91+
+ 'afterwards, because the two new tiles are numbers the dashboard was ALREADY paying '
92+
+ 'to compute and had never shown.',
93+
};

0 commit comments

Comments
 (0)