Skip to content

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

Merged
os-bill merged 6 commits into
mainfrom
claude/issue-17779-metric-family-single-measure
Sep 17, 2026
Merged

os-bill merged 6 commits into
mainfrom
claude/issue-17779-metric-family-single-measure

Conversation

@os-bill

@os-bill os-bill commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

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 titeltitle?"
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     : 30c6d785e7971edbf15cb962639493f352bfde2c
worktree blob : 30c6d785e7971edbf15cb962639493f352bfde2c   (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 : 30c6d785e7971edbf15cb962639493f352bfde2c   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 test486 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:generatedall 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:

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


⏱️ 席位代改正文(dev 只写一次,⛔ 不 PATCH 正文;事后要改的由本席代写)。 两处:

  • applyMigrationChainapplyMetaMigrations(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 并写了一段话解释「它不是那个意思」。那个前提是错的:readClause2Lineyes (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

…asure

`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 <noreply@anthropic.com>
…087, 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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 4 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/export-origins/ui.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/analytics.mdx (via DashboardWidgetSchema (symbol, a top-level const))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via DashboardWidgetSchema (symbol, a top-level const))
  • content/docs/releases/v16.mdx (via DashboardWidgetSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/export-origins/ui.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 62bce5c297d3907b29515ff6943b26b01342608dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from caf901444e9f96d56e9b9d654bd259a0b3536a79 — the merge of head ee0f6eaced59b3610d60c2695fbf7774b3f02bc9 into base 62bce5c297d3907b29515ff6943b26b01342608d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin caf901444e9f96d56e9b9d654bd259a0b3536a79 && git checkout caf901444e9f96d56e9b9d654bd259a0b3536a79
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 62bce5c297d3907b29515ff6943b26b01342608d ee0f6eaced59b3610d60c2695fbf7774b3f02bc9 && git checkout -B drift-repro 62bce5c297d3907b29515ff6943b26b01342608d && git merge --no-ff ee0f6eaced59b3610d60c2695fbf7774b3f02bc9

node scripts/docs-audit/affected-docs.mjs --json 62bce5c297d3907b29515ff6943b26b01342608d

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 62bce5c297d3907b29515ff6943b26b01342608d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-bill commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 86/86 CONTRACT_REVIEW_TIER
Head-sha: ea17ab8491d97c457013d7fc1148db39f23682af

Every reading below is taken on a detached worktree at the head sha (merge-base with main = 72dd95fa5a, the branch point the PR body names), with deps installed offline in that tree. Dispatch orders and PM-seat comments on the card and the PR were not read; the ruling was read from the card body and from the ruling record on objectui#8894 (comment 5643392537: maintainer 「同意」 to batch 1A·2A·3A·4D·5A, D = judge the protocol wrong for the metric family).

① Derived judgments

Accept-set changes, row by row (my own probe, DashboardWidgetSchema.safeParse, every one of ChartTypeSchema.options × 1/2/3 measures, plus edge bodies; 20 options confirmed):

row before (card's measurement) at head (measured) judgment
metric / kpi / gauge / solid-gauge / bullet + 2 or 3 measures ACCEPT REFUSE, exactly one issue, values:custom (10/10 bodies) correct — the ruled family, nothing more
no type + 2 measures; type: undefined + 2 ACCEPT (defaults to metric) REFUSE values:custom, message carries the extra "declares no type at all" sentence correct — type: ChartTypeSchema.default('metric') (dashboard.zod.ts:703), defaults apply before object-level checks, and the message does not claim the author wrote metric
the 15 other members (bar horizontal-bar column line area pie donut funnel scatter treemap sankey combo radar table pivot) + 1/2/3 ACCEPT ACCEPT (45/45) untouched, as ruled
family + 1 measure ACCEPT ACCEPT (5/5 + typeless) the legal tile is unchanged
metric + [] REFUSE too_small REFUSE too_small, one issue the lower bound stays the field's; the new check adds no second issue
metric + ['a','a']; metric + 2 + dimensions ACCEPT REFUSE values:custom arity is counted, not de-duplicated; fine under the ruling
type: 'ziggurat' + 2 REFUSE type:invalid_value REFUSE type:invalid_value alone zod's aborting enum issue skips object checks — matches the docblock's non-coverage 3
metric + 2 + bogusProp REFUSE unrecognized_keys REFUSE unrecognized_keys alone strict-object refusal is aborting; the arity issue arrives on the next parse
DashboardSchema.widgets[] carrying a bullet + 2 ACCEPT REFUSE at widgets.0.values the gate travels with the widget

Firing controls: the PR's two test files pass at head (2 files, 222 tests); detaching only the .superRefine(checkDashboardWidgetMetricMeasureArity) line (blob 30c6d785…fc804b84…, attachment count 1 → 0) turns 17 red / 205 green, and restoring by git checkout HEAD -- brings the blob back to 30c6d785… with an empty git diff HEAD — the same 17/205 the PR reports.

Scope of the refusal. Correctly the five family types plus the typeless default; every other type is byte-for-byte accepted. Corpus: my own census over every tracked .ts/.tsx/.json/.mdx/.md/.yaml/.yml at head (8376 files, 332 values: [...] string-array literals) finds 101 metric-family literals with one measure (lit control) and 9 with more than one — all 9 are this PR's own fixtures (changeset ×2, dashboard.test.ts ×2, object-refinement-check-exports.test.ts ×5); typeless multi-measure literals: 10, none widget-shaped (no dataset + title/layout/id sibling; four are this PR's own widget({...}) fixtures). So nothing pre-existing in the monorepo moves. The method differs from the PR's (which reads 187/39/0), the zero agrees, and both carry a lit control. Production stored dashboards: NOT MEASURED — unreachable from here, and the card names this gap itself.

Public-surface changes, row by row: packages/spec/api-surface/ui.json +1 checkDashboardWidgetMetricMeasureArity (function), export-origins/ui.json +1 (same symbol, src/ui/dashboard.zod.ts), 0 removed, 0 narrowed. pnpm --filter @objectstack/spec check:generated after a full build at head: all 15 artifacts current (api-surface, export-origins, authorable-surface, docs among them), so no authorable key moved and the two surface rows above are the whole delta.

Shape of the export. a (widget, ctx) function returning void, attached by identifier as the second .superRefine beside checkDashboardWidgetStageOrder (dashboard.zod.ts:1023–1027), catalogued in object-refinement-check-exports.test.ts MIRRORED with its own fixtures, and reachable as the same function object on @objectstack/spec/ui. Exporting is warranted, not optional: at the pinned objectui 53ded82bf7, @object-ui/types builds its DashboardWidgetSchema from specFieldsExcept(SpecDashboardWidgetSchema.shape, ['id','type']).extend({... type: DashboardWidgetTypeSchema.optional() ...}).strict() (complex.zod.ts:627) — a .shape spread that drops every object-level check and redeclares type with no default — and it re-attaches none of this file's exported checks today (grep at the pin: 0 hits for any of the four). Card item 3 (objectui's contract twins gain the refusal) is therefore only reachable through an export, and the function's ?? WIDGET_TYPE_DEFAULT fallback exists for exactly that typeless mirror (raw call, no type, 2 measures → 1 issue at values; bar → 0). Module-private would have left objectui with a copy, which is the drift the pin test exists to refuse. .omit()/.pick()/.partial() throw at head; they already threw at the base because checkDashboardWidgetStageOrder was already attached; .extend() with a new key works, .extend() overwriting a key throws the zod 4.4.3 .safeExtend() message — the entry's claims about derivation hold.

Does the message say what to do? Yes, measured text: it names the widget id (or "this widget"), the count, the authored type, states "one measure per tile, so make N tiles for N measures", tells the author to keep the one the tile is for and give each other measure its own widget with its own id (and layout), and names the visuals that do render several numbers (table, bar/line/area/combo). The "row of measures" claim for table is fair to the pinned renderer (DatasetWidget's table branch maps one column per values member).

② Semver level

Read from the changeset itself (.changeset/17779-dashboard-metric-family-single-measure.md), not the PR body: frontmatter "@objectstack/spec": minor; body carries feat(spec)!:, the BREAKING banner, Clause-②: no (narrowing), exactly one marker adr-0087: registered dashboard-widget-metric-family-multi-measure-refused, and a FROM → TO table with the one-line fix. Consistent with what the diff does: an accept-set narrowing plus one added export.

The card and the ruling record say major; the changeset says minor. That is the repo's mechanized convention, not the author's choice: scripts/check-changeset-no-major.mjs (header: lockstep fixed group of ~70 packages, breaking changes ship minor until GA, breaking-ness carried by the banner + the ADR-0087 disposition), .changeset/pre.json absent at head so the guard is armed, and pr-automation.yml "WHICH LEVEL" records the maintainer ruling (2026-09-04, batch #35). Gates run in the worktree against the merge base: check-adr-0087-registration --base 72dd95fa5a exit 0 reading [BREAKING+bang+clause-②-narrowing] registered … (new here); check-changeset-no-major exit 0; check-empty-changeset exit 0; build-migration-registry --self-test --check exit 0 (223 semantic entries, regions current).

The migration entry versus what the code refuses. surface: dashboard.widgets[].values on the five family types, "INCLUDING a widget that declares no type" — matches the probe exactly. replacement: keep the measure the tile is for, one new widget per other measure, the migration does not split — matches; a stored multi-measure tile is refused at the next parse through the registry door (metadata-type-schemas.ts:112 maps dashboardDashboardSchema; metadata-protocol/src/protocol.ts save-time validation and metadata-diagnostics.ts resolve schemas through getMetadataTypeSchema), not silently truncated — the entry says exactly that. reason: the ruling, the defect, and why "several numbers" is a different visual — matches the ruling record. acceptanceCriteria: the refuse/accept matrix (family + typeless refused; 15 others unmoved; empty array keeps too_small; unknown type aborts alone; measure existence unreachable; derivation behaviour) — every row re-measured above and correct.

One factual error in the entry, and it is the ground for the verdict. The acceptanceCriteria text (entry file line 61, and its generated copy in registry.ts:6765) states "applyMigrationChain maps step.semantic straight onto the result". No such symbol exists: git grep applyMigrationChain over the whole tree returns only those two lines; the function that does this is applyMetaMigrations (packages/spec/src/migrations/chain.ts:69, exported on the root api-surface, called by cli/src/commands/migrate/meta.ts), and grep for it fires across chain.ts, apply.ts, the CLI and both api-surface/export-origins — so the zero on the wrong name is not a dead probe. The behaviour claimed is true (chain.ts:102 step.semantic.map(...), no per-document interpolation, no filtering); the identifier is wrong. This sentence ships inside the @objectstack/spec migration ledger and will be printed by os migrate meta and projected into the upgrade guide once PROTOCOL_MAJOR reaches 18. A reader who greps the named function to check the claim finds nothing. Fix: rename to applyMetaMigrations in the entry file and run gen:migration-registry (registry regions are generated; hand edits there are refused). The PR body repeats the wrong name, so it is a misremembered symbol, not a typo in one place.

③ Boundary flags

  • "Structured TODO naming the dropped measures" (card item 2, ruling item 1) — answered: not deliverable by the mechanism, and the entry does not promise it. SemanticMigration (types.ts:30–41) has only static string fields; applyMetaMigrations emits every step.semantic once per hop with no per-document data. The other channel, a D2 conversion, does carry per-document from/to/path detail (chain.ts:90–98) — but ADR-0087 D2 is "losslessly mappable changes only" (0087 line 139; "Semantic changes are excluded", 152–153), and dropping measures is lossy, so a truncating conversion is the wrong ledger and the semantic route is right. The entry says the TODO cannot name measures and points the author at os build — that is the honest statement of the limit, modulo the wrong function name above.
  • major on the card vs minor shipped — answered in ② (mechanized convention with a written GA end condition); no re-ruling needed.
  • Dimensionless non-family widgets — escalate, not this PR's. At the pinned objectui, isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0 (DatasetWidget.tsx:423): a bar with no dimensions and three measures is still accepted by the spec at head (my probe) and still rendered as a tile of values[0]. The card's defect paragraph mentions "any dimensionless widget the renderer treats as a tile", but the card's title, its item 1 and the ruling record scope the narrowing to the family, so this PR is right not to widen the refusal on its own. The ruling record's item 2, though, says the "measures not rendered" caption path "becomes unreachable and is removed" once the spec lands — that is false for this residue while objectui routes dimensionless widgets to the tile. Successor: objectui#8894's remaining half must decide the dimensionless case (renderer heuristic or a second narrowing under its own Clause-②), not remove the caption path on the assumption it is unreachable.
  • objectui pin bump — the pinned spec-ui-schema-reexports.test.ts resolves candidates with candidate in SpecUI rather than enumerating every export, so the new export does not break the pin; Console Pin Gate was skipped on this diff and nothing is removed, so nothing at the pin can lose an import. The .shape spread continues to work with a second refinement (measured through the PR's own mirror tests). The pinned sha predates objectui#8887/feat(spec): stamp-only tenancy.organizationField — read-neutral organization declaration for audit stamping (#8778) #8905 (no sub-caption node, no warning at the pin), which is objectui's lane.
  • Clause-② card-side carrier — NOT MEASURED. The changeset and the PR body both carry Clause-②: no (narrowing); the card's claim comment is a seat comment I did not read, and check-clause2-carriers is not run by any workflow at head (grep 0), so the pairing is unverified by this review.
  • Duplicate carding/search/* is unreachable through this proxy, so whether another card already covers the dimensionless residue is not verifiable here; it is stated as an escalation, not asserted as a duplicate.
  • CI at review time: required contexts TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard all success; Lint & Repo Gates still in_progress when this record was written. No governed path in the diff (9 files, 0 hits on the governed register). PR is draft.

Implemented-by: claude/issue-17779-metric-family-single-measure
Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3

VERDICT: FAIL

Single ground: the shipped migration-ledger entry names a function that does not exist (applyMigrationChain; the real one is applyMetaMigrations), in the one field consumers are told to read to understand why the TODO cannot name their measures. Everything else in ①–③ passes as measured; the fix is one identifier in 18.dashboard-widget-metric-family-multi-measure-refused.ts plus the gen:migration-registry lap, after which a re-review is a one-line delta.


Generated by Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
…amed

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3

os-bill commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 98/98 CONTRACT_REVIEW_TIER
Head-sha: 881db1280d4d0f27f58387a68bb8125ef9289c78

Isolated at-tier contract review of PR #18720 against card #17779 and objectui#8894 ruling D (decision batch #119 item 4, 2026-09-12 「同意」, read first-hand on objectui#8894 as the recorded ruling: 「协议不正确的应该先修改协议。」— the metric family takes exactly one measure; major, ADR-0087 entry, keep values[0], structured TODO naming the dropped measures). Inputs: the card body, that ruling, and the PR's own body, diff, changeset and tests. Every number below is my own reading in a detached worktree at 881db1280d (merge-base with origin/main = 72dd95fa5a), never the PR's.

① Derived judgments

Accept-set changes, measured with a 16-body probe through DashboardWidgetSchema.safeParse at the head (blob 30c6d785e7) and again with the merge-base dashboard.zod.ts (blob 4c681c9984) swapped in, restore hash-verified and git diff HEAD empty afterwards:

  1. Narrowing on the metric family — CORRECT per ruling D. metric/kpi/gauge/solid-gauge/bullet with 2–3 measures: ACCEPT at the merge-base (the defect), REFUSE at the head with exactly one custom issue at values. The family is exactly the ruling's five, read off ChartTypeSchema's "Performance (single value)" group.
  2. A widget with no type is refused too — CORRECT. type carries .default('metric') and zod applies it before object-level checks; the merge-base accepted values: ['a','b','c'] with type: 'metric' in the parsed output, the head refuses it and the message says the type was defaulted rather than authored. The exported function also defaults type itself for a mirror that redeclares type with no default.
  3. Nothing else moves — CORRECT. bar/line/table/pivot/funnel + 3 measures ACCEPT identically before and after; metric + 1 measure parses to the same output before and after; the PR's pin that the five plus the fifteen others IS ChartTypeSchema.options (20) holds.
  4. "Exactly one" is a conjunction — CORRECT. values: [] reads too_small alone before and after (one issue, not two); type: 'ziggurat' reads invalid_value alone (zod aborts before object-level checks); an unknown key reads unrecognized_keys as before; a single measure that no dataset declares still parses (unreachable from this schema, stated as a pin).
  5. The refusal text meets the card's acceptance sentence. It names the widget (pipeline_total), the count ("declares 3 measures"), the authored type, "one measure per tile", "make N tiles for N measures", and the visuals that do render several numbers (table, bar/line/area/combo). It names the COUNT of dropped measures, not their names — carried to ③ F2.
  6. Public surface widens by exactly one export — CORRECT and needed. checkDashboardWidgetMetricMeasureArity(widget, ctx) from @objectstack/spec/ui, attached to the door by identifier (same idiom as checkDashboardWidgetStageOrder), api-surface/ui.json +1 / 0 removed, export-origins +1. The reason it must be exported is measured in the sibling: at the pinned objectui sha 53ded82bf7, packages/types/src/zod/complex.zod.ts:627 builds its DashboardWidgetSchema from specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict() and re-attaches no object-level check, so the editor will keep accepting three measures until it chains this export (③ F3).
  7. Mutation control fired. Detaching only the .superRefine(checkDashboardWidgetMetricMeasureArity) attachment (function left declared; mutated blob 3b503fe4cb) turns 17 of the 222 tests in dashboard.test.ts + object-refinement-check-exports.test.ts red; restored blob = HEAD blob. Pristine head: 222/222 green; full @objectstack/spec suite: 486 files / 13933 tests passed, exit 0 (my own run).
  8. ADR-0087 entry — CORRECT within what the machinery can do. One semantic entry dashboard-widget-metric-family-multi-measure-refused under protocol 18 beside its 130-odd 18.* siblings; gen:migration-registry re-run is a byte-identical no-op (git diff --stat empty), so registry.ts was not hand-edited. The entry names the real chain entrypoint applyMetaMigrations (chain.ts:68); the non-existent name appears 0 times in packages/spec/src, .changeset and content/docs. The ruling's "keep values[0]" is carried as the entry's replacement prose; a D2 conversion that truncates to values[0] is excluded by ADR-0087's own rule (D2 is lossless only, and dropping measures is lossy), and a SemanticMigration is static prose that applyMetaMigrations maps through unchanged (chain.ts:102), so a per-document TODO is unreachable — the entry says so instead of implying it.
  9. Fleet at the merge-base — 0 in-repo documents break. My own brace-local census over tracked .ts/.tsx/.json/.mdx/.md/.yaml/.yml at 72dd95fa5a: 104 values: [...] literals on a metric-family type, 0 with more than one measure (lit control: the same scan at the head reads 9, all of them this PR's own fixtures and changeset table, so the instrument fires). YAML metric-family type: lines at the merge-base: 0. Programmatic widget construction outside tests (platform-objects system_overview dashboard, cli explain) is single-measure.
  10. Generated artifacts current. check:generated after a fresh build: all 15 up to date, worktree clean; tsc --noEmit on the package: exit 0; the values doc string and content/docs/references/ui/dashboard.mdx state the arity rule the door enforces.

② Semver level

Changeset .changeset/17779-dashboard-metric-family-single-measure.md: @objectstack/spec: minor, feat(spec)! bang, **BREAKING** banner, ADR-0087 marker registered dashboard-widget-metric-family-multi-measure-refused. Measured in the worktree: check-adr-0087-registration --base origin/main reads it as [BREAKING+bang] registered … exit 0; check-changeset-no-major exit 0; check-empty-changeset exit 0. The ruling and card say major; the repo's launch-window rule (header of scripts/check-changeset-no-major.mjs, no .changeset/pre.json present, allow-major reserved for a whole-stack major) forbids a major bump and makes the banner plus the ADR-0087 disposition the breaking-ness carriers. minor + banner + disposition is therefore the conformant rendering of the ruling's major, and the PR body's own level claim matches the changeset. Consistent.

③ Boundary flags

  • F1 — Clause-② arm under-declares the direction (non-blocking, declaration hygiene). The card prescribed Clause-②: no (narrowing); the claim, PR body and changeset all carry yes (widening) (check-clause2-carriers --pair 18720: exit 0, carriers agree, head 881db1280d). yes is right by the mechanical floor — a new exported symbol is always yes. The ARM is not: this diff narrows the accept set, AGENTS.md says "(narrowing) is BREAKING", and check-adr-0087-registration's own self-test names this exact shape as "the narrowing arm beside a yes value — a diff that widens AND narrows: Clause-②: yes (narrowing)". Nothing mechanical is lost (the gate still reads BREAKING through the banner and the bang), so this is escalated, not failed: the owning seat should re-spell all three carriers to yes (narrowing); a declaration overturned by review is not a seat fault.
  • F2 — The refusal names the count, not the dropped measures (non-blocking follow-up). Ruling item 1 asks for a TODO "naming the dropped measures"; the migration machinery cannot do that per document (① 8), and the refusal is the only per-document channel — it currently says "declares 3 measures" and leaves the author to read values[1..] off their own source. Interpolating the names after values[0] into the message is a one-line improvement; recommended as a follow-up, not a defect against the card's stated acceptance sentence.
  • F3 — objectui half not in this PR, by design. Card item 3 (contract twins gain the refusal pin; the runtime warning becomes the door refusal) is objectui#8894, pm:blocked on this card. Until @object-ui/types imports and chains checkDashboardWidgetMetricMeasureArity, the editor accepts three measures and the author meets the refusal at publish; the PR states this in the check's docblock and the entry. Escalated to that successor.
  • F4 — Stored dashboards: NOT MEASURED. The card's carried gap (how many stored dashboards declare more than one measure on a metric tile) stays unmeasured here; no stored corpus was in reach. The in-repo corpus reads 0 (① 9). Any such stored row is refused at its next spec parse rather than converted, which is what the ruling ordered.
  • F5 — CI at the head: NOT MEASURED to completion. At read time every completed check run was success or skipped and none had failed; Test Core 1–6, Dogfood 2/3 and 3/3, Lint & Repo Gates and two Type Check jobs were still in progress. The landing seat's precondition ③ is theirs to read.
  • Refused inputs. The PR body ends in an appended seat note (blockquote headed 席位代改正文); I stopped reading it once identified and used none of its conclusions. The pr-18720/ scratch folder held files not written this round (review-comment.md, build.log, census2.mjs, install.log); none were read. No card or PR comment was read; the only comment read anywhere was the recorded ruling on objectui#8894.

Implemented-by: claude/issue-17779-metric-family-single-measure
Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3

VERDICT: PASS


Generated by Claude Code

…at does both

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3

os-bill commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 111/111 CONTRACT_REVIEW_TIER
Head-sha: ee0f6eaced59b3610d60c2695fbf7774b3f02bc9

Isolated at-tier contract review of #18720 against card #17779 and the governing ruling (objectui#8894 comment 5643392537 — decision batch #119 item 4, 2026-09-12 「同意」 to D: the protocol is judged wrong for the metric family; DashboardWidgetSchema.values becomes exactly one measure there, with an ADR-0087 entry keeping values[0]). Inputs read: the card body, that ruling, the PR body up to its appended seat note, the diff, changeset, tests and CI check runs. ⛔ Not read and not used: any project-manager seat comment on the card or the PR (dispatch order, claim, seat notes, prior contract-review records). The API payload of the PR body carried the appended seat-note blockquote at its tail; it was not used as evidence. Every number below was measured first-hand in a detached worktree at this head (merge-base with main measured as 72dd95fa5a), never taken from the PR's own account of itself.

① Derived judgments

  • Accept set — NARROWS, exactly where the ruling says and nowhere else. Correct. Thirty-seven widget bodies through DashboardWidgetSchema.safeParse at merge-base vs head: every metric-family type (metric / kpi / gauge / solid-gauge / bullet) with 2 or 3 measures flips ACCEPT → REFUSE with ONE custom issue at values (10 of 10 bodies); a widget with no type and 3 measures flips the same way (the metric default, and the message says so); every family member with 1 measure is ACCEPT on both trees with byte-identical parse output (JSON.stringify equal); all 15 non-family members of ChartTypeSchema with 3 measures stay ACCEPT (15 of 15); through DashboardSchema the issue lands at widgets.0.values. Dark controls identical before and after, one issue each: values: []values:too_small; type: 'ziggurat'type:invalid_value alone; bogusPropunrecognized_keys; values: 'x'values:invalid_type; missing idid:invalid_type. ChartTypeSchema.options measured at 20 = the 5-member family + the 15 the test enumerates, and the PR pins that equality.
  • Refusal text — meets the card's sentence. Measured message names the widget id, the count, the authored type, "one measure per tile", "make N tiles for N measures", and the visuals that do render several numbers. It names the count, ⛔ not the dropped measure names — see ③.
  • Public surface — WIDENS by exactly one exported symbol. Correct and declared. git diff 72dd95fa5a..ee0f6eaced -- packages/spec/api-surface/ = + checkDashboardWidgetMetricMeasureArity (function), 0 removed; export-origins/ui.json +1 matching row; authorable-surface/ delta = 0 lines (no authorable key moves — the arity rule is an object-level check, not a new key). The export is attached to the door by identifier (.superRefine(checkDashboardWidgetMetricMeasureArity), 1 occurrence) and @objectstack/spec/ui ships the same function object.
  • Spelling — object-level superRefine, not a per-type union arm. Correct for this door. The strict-object diagnostics (unrecognized_keys naming the key, invalid_value at type) survive unchanged in the probe above; a union would have collapsed them, as the file's own compareTo docblock already records.
  • ADR-0087 semantic entry — registered, and its prose names identifiers that exist. New file entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts, regenerated into registry.ts under step 18 (check:migration-registry ✓). Every identifier the entry's prose names was grepped at this head: applyMetaMigrations (chain.ts:68, and chain.ts:102 does map step.semantic straight onto MigrationTodo[]), SemanticMigration (five static string fields only — so the entry's "the TODO cannot name your measures" is true of the machinery), checkDashboardWidgetMetricMeasureArity, checkDashboardWidgetStageOrder, ChartTypeSchema (and its literal "Performance (single value)" grouping), WIDGET_TYPE_DEFAULT = 'metric', CLI build / lint / migrate meta commands, ADR-0049 and ADR-0087 files. The phantom applyMigrationChain reads 0 hits at this head. The objectui claim was checked at the pinned .objectui-sha 53ded82bf7: packages/types/src/zod/complex.zod.ts:627 is specFieldsExcept(SpecDashboardWidgetSchema.shape, ['id','type']).extend({… type: DashboardWidgetTypeSchema.optional() …}).strict() — a .shape mirror, type redeclared with no default, and 0 hits for either exported check — exactly as the entry and the docblock state.
  • Protocol-major bookkeeping — consistent. PROTOCOL_VERSION is 17.0.0; the entry registers under 18; build-spec-changes.ts:96 and build-upgrade-guide.ts:60 loop to PROTOCOL_MAJOR, so spec-changes.json and the upgrade guide are unchanged by construction (check:spec-changes ✓, check:upgrade-guide ✓). The sorted 18.* semantic set puts this entry between dashboard-header-modal-target-page-only and dashboard-widget-stage-order-non-funnel-refused, both already on main.
  • Clause-② declaration — yes (narrowing) on both carriers, and BOTH halves are true of this diff. Live readClause2Line on the PR body's line 2 and on the changeset's line reads { value: 'yes', arm: 'narrowing' } for each. yes is the mechanical floor (a new exported symbol, measured above); narrowing is the accept-set direction (measured above); the vocabulary in check-clause2-carriers.mjs names this exact combination ("a diff that widens one surface and narrows another"). node scripts/pm/check-clause2-carriers.mjs --pair 18720 exit 0 — "readable in the fixed spelling and both carriers agree" (only that summary row was read).
  • Discriminating power — measured, not trusted. Ablation in the review worktree: detached only the .superRefine(checkDashboardWidgetMetricMeasureArity) attachment (anchor count 1 → 0, blob 30c6d785…fc804b84…, function still declared) → the two test files go RED with exactly 17 named failures (9 in dashboard.test.ts, 8 in object-refinement-check-exports.test.ts), exit 1; restored with git checkout HEAD --, blob back to 30c6d785…, git diff HEAD empty; GREEN again at 222 passed (222).
  • Generated artifacts and gates at this head: pnpm --filter @objectstack/spec build exit 0; check:generated ✓ all 15 artifacts up to date on the clean head tree (a first run read api-surface/ stale only because my own two probe files were inside packages/spec/src when dist was built; rebuilt on the restored tree it clears — an instrument artefact, not the PR); check-changeset-no-major --base 72dd95fa5a ✓ (no major; self-test 299 assertions ✓); check-adr-0087-registration --base 72dd95fa5a ✓ reading [BREAKING+bang+clause-②-narrowing] registered dashboard-widget-metric-family-multi-measure-refused (self-test 384 ✓); eslint over the five changed source files exit 0; pnpm --filter @objectstack/spec typecheck exit 0 (tsc --noEmit, check:scripts-typecheck, check:test-typecheck; the test-layer ledger held at 54 files / 259 errors / 144 pinned signatures). Governed surfaces: the nine-file list touches none.
  • Scope held. Nothing in ../objectui is touched; the card's item 3 (contract twins gain the refusal pin; the runtime warning becomes the door refusal) is objectui#8894's half and stays pm:blocked on this card, as the card itself says. ReportSchema.values is a separate declaration and is untouched.

② Semver level

@objectstack/spec: minor with a **BREAKING** banner, a ! in the summary, and the ADR-0087 disposition registered dashboard-widget-metric-family-multi-measure-refused. Consistent with the declaration and with the tree's own rule. The card and the ruling say major; the tree forbids it: scripts/check-changeset-no-major.mjs is armed (.changeset/pre.json ABSENT at this head, so no RC exemption; the only bypass is the allow-major label for a genuine whole-stack major, which this is not), and its header states the launch-window convention — a breaking change ships minor and breaking-ness is carried by the banner plus the ADR-0087 disposition, never by the bump level. Clause-②: yes takes at least minor ✓; the (narrowing) arm is BREAKING and the changeset says so on every carrier the gate reads ✓. Judged: minor here is the tree's spelling of the ruling's major, not a deviation from it.

③ Boundary flags

  • Card item 2 "structured TODO naming the dropped measures" — delivered as far as the machinery allows, and the gap is declared, not hidden. SemanticMigration is static prose and applyMetaMigrations emits it once per hop with no interpolation (both measured), so no entry can name per-document measures; the entry says so and redirects the author to the refusal. The refusal names the widget, the count and the type — it does ⛔ not list the dropped measure names themselves. Not a contract defect (the information is in the author's own document and the refusal points at it), but the entry's sentence "the refusal is what names them" is looser than the measured message. Follow-up, out of this PR's contract: have the refusal enumerate values[1..] by name so that sentence is literally true. Escalated, not failed.
  • CI at this head (read at review time, 17:53Z): required contexts Build Core, Governed Surface Queue Guard, Dogfood Regression Gate (aggregate and all three shards) and Temporal Conformance (live PG + MySQL) are success, as are Type Check · source gates / debt ledger / consumer gates, Check Changeset, Spec property liveness and every claim/single-writer guard; Lint & Repo Gates, Type Check · workspace and Test Core (1, 2, 3, 5, 6/6) were still in_progress (4/6 success), 0 failures — landing waits on their success per Multi-agent discipline §7; this verdict does not depend on them.
  • NOT MEASURED here: the PR's repo-wide eslint (6822 files) and full spec suite (13933 tests) — I ran the five changed source files and the two changed test files; the fleet census of values: [...] literals (187 / 39 / 0) — no stored multi-measure metric tile was searched for independently; the .extend() / .safeExtend() behaviour of zod 4.4.3 — the docblock's claim was not re-probed.
  • Independence pair: the diff was produced by a mode:subagent dev on the branch below under the same seat session that adopts this verdict; the pair is branch vs session, the designed form for that mode.

Implemented-by: claude/issue-17779-metric-family-single-measure
Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants