Repository navigation
Commit 09e16a5
feat(spec)!: a metric-family dashboard widget declares exactly ONE measure — narrow
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 读成「本改动放宽了行为」。
>
> DashboardWidgetSchema.values for the metric/kpi/gauge/solid-gauge/bullet family (objectui#8894 ruling D) (#18720)1 parent a84f61a commit 09e16a5
9 files changed
Lines changed: 638 additions & 9 deletions
File tree
- .changeset
- content/docs/references/ui
- packages/spec
- api-surface
- export-origins
- src
- migrations
- entries/semantic
- ui
Lines changed: 66 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
76 | 76 | | |
77 | 77 | | |
78 | 78 | | |
79 | | - | |
| 79 | + | |
80 | 80 | | |
81 | 81 | | |
82 | 82 | | |
| |||
181 | 181 | | |
182 | 182 | | |
183 | 183 | | |
184 | | - | |
| 184 | + | |
185 | 185 | | |
186 | 186 | | |
187 | 187 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
443 | 443 | | |
444 | 444 | | |
445 | 445 | | |
| 446 | + | |
446 | 447 | | |
447 | 448 | | |
448 | 449 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
429 | 429 | | |
430 | 430 | | |
431 | 431 | | |
| 432 | + | |
432 | 433 | | |
433 | 434 | | |
434 | 435 | | |
| |||
Lines changed: 93 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
0 commit comments