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
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: 86/86 Every reading below is taken on a detached worktree at the head sha (merge-base with ① Derived judgmentsAccept-set changes, row by row (my own probe,
Firing controls: the PR's two test files pass at head (2 files, 222 tests); detaching only the Scope of the refusal. Correctly the five family types plus the typeless default; every other Public-surface changes, row by row: Shape of the export. a Does the message say what to do? Yes, measured text: it names the widget id (or "this widget"), the count, the authored ② Semver levelRead from the changeset itself ( The card and the ruling record say The migration entry versus what the code refuses. One factual error in the entry, and it is the ground for the verdict. The ③ Boundary flags
Implemented-by: VERDICT: FAIL Single ground: the shipped migration-ledger entry names a function that does not exist ( 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
Contract reviewServed-tier: 98/98 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; ① Derived judgmentsAccept-set changes, measured with a 16-body probe through
② Semver levelChangeset ③ Boundary flags
Implemented-by: 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
Contract reviewServed-tier: 111/111 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; ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
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.valueswasz.array(z.string()).min(1)with no upper bound on any widget type, so ametrictile could declare three measures; the dataset query selected and computed all three and the tile renderedvalues[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 onmetric/kpi/gauge/solid-gauge/bulletand on a widget that declares notype(it defaults tometric, and the message says so rather than claiming the author wrote it). Onecustomissue atvalues, naming the widget'sid, the count, and the authoredtype, and prescribing one measure per tile — "make N tiles for N measures" — plus the visuals that DO render several numbers..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 dispositionregistered 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 thegen:migration-registrylap. No other file in that directory was touched and nothing was hand-edited inside the generated regions ofregistry.ts.valuesdoc 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 doorEight widget bodies through
z.union([metricArm, otherArm])versus one more.superRefineon the strict object, measured on this tree:bogusPropon a widget(root) invalid_union: Invalid inputcategoryField/valueField(root) invalid_union: Invalid inputWIDGET_GUIDANCE_SETSADR-0021 prescriptiontitel(root) invalid_union: Invalid inputtitel→title?"type: 'ziggurat'(root) invalid_union: Invalid inputinvalid_valueattype, listing all twentymetric+ 3 measurestoo_bigatvaluescustomrefusal atvaluesFour of eight bodies lose their whole diagnostic to one bare
Invalid input. That is not a new observation on this file: thecompareTodocblock already records it for the same reason (#5014 — "a union collapses into one bareInvalid inputon the wire … A plain strict object's errors reach the author"), andview-union-diagnostics.test.tsis the entire apparatus objectui needed becauseViewMetadataSchemais 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 throwsCannot overwrite keys on object schemas containing refinements. Use .safeExtend() insteadon 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.
majordoes collide withcheck-changeset-no-major— so the changeset isminorThe guard is armed: there is no
.changeset/pre.json, so the RC exemption does not apply, and the only other route is theallow-majorPR 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 Changesetsfixedgroup, so onemajorpromotes all ~70 packages; during the launch window a breaking change shipsminorand 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 asminor+**BREAKING**+registered ..., andcheck-adr-0087-registration --base origin/mainreads 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:
applyMetaMigrationsmapsstep.semanticstraight onto the result (chain.ts) with no per-document interpolation and no filtering by whether the stack even carries the shape, andSemanticMigrationhas only static string fields.os migrate metatherefore 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 offos build, not off the migrate output.The rest of
acceptanceCriteriais the measured accept/refuse matrix: which door refuses (publish, not objectui's.shape-mirror editor), the empty-array carve-out, the abortinginvalid_valueon an unknowntype, 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:metric+ 1 measurevalues: ["amount_sum"]values: ["amount_sum"]metric/kpi/gauge/solid-gauge/bullet+ 2–3 measuresvalues:custom(all five)type+ 3 measurestype: "metric"values:custombar/line/table/pivot/funnel+ 3 measuresmetric+values: []values:too_smallvalues:too_small(one issue, not two)type: 'ziggurat'+ 3 measurestype:invalid_valuetype:invalid_value(alone)metric+ 3 measures +bogusPropunrecognized_keysunrecognized_keysThe 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 inpackages/spec/src/ui/dashboard.zod.tsother thanvalues: 0. The file has exactly two.min(1)code sites at the branch point —values(line 706) anddashboard.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 itstypeenum (tabular/summary/matrix/joined) contains 0 metric-family members. Untouched, and not the same defect..ts/.tsx/.json/.mdx/.md/.yamlat the branch point72dd95fa5a: 187 brace-local literals carrying avalues: [...], 39 of them on a metric-familytype(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-surfaceis green with no regeneration: 0 authorable keys move.check:api-surfacereports0 breaking (removed/narrowed), 1 added— the new exported check.Verification
Red before green, with the mutation proved on disk and the restore hash-verified:
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 atrap ... EXIT INT TERMrestore against an absolutegit rev-parse --show-toplevelpath, restored withgit checkout HEAD -- path(never a baregit checkout --), and proved the restore by blob hash and an emptygit diff HEAD.pnpm --filter @objectstack/spec test— 486 files / 13933 tests passed, exit 0.pnpm --filter @objectstack/spec typecheck— exit 0 (check:scripts-typecheckandcheck:test-typecheckincluded; 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/**).check-adr-0087-registration --base origin/mainexit 0 (+--self-test, 384 assertions),check-changeset-no-major --base origin/mainexit 0,check-empty-changeset --base origin/mainexit 0.pnpm check:nul-bytesexit 0 (8812 text files, no raw control bytes), pluscheck: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.mdxnewly derives — every one exit 0.node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format jsonatea17ab8491, 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 the18.*semantic directory and every open PR's file list on 2026-09-17:ui-list-view-groupbyfield-padded-refused(fix(spec)!: refuse a paddedgroupByFieldon kanban, gantt and timeline instead of handing the renderer a lookup that always misses #18695),structured-region-body-pause-and-end-refused(feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node #18688),evaluated-expression-slots-source-required(feat(spec)!: every engine-evaluated expression slot requires a non-blanksource#18638),manifest-id-reverse-domain-required(feat(spec)!: manifest.id enforces the reverse-domain rule its registry face already had #18319). (feat(spec,types,triggers)!:groupruns package-authored scheduled work without a declaration, owning each run's writes per record #18420 modifies an existing entry, which is not an insertion.)dashboard-header-modal-target-page-onlyanddashboard-widget-stage-order-non-funnel-refused— both already landed onmain, neither in flight — and it is not the first entry of major 18. Neither ejection row applies. The seat's expectation about fix(spec)!: refuse a paddedgroupByFieldon kanban, gantt and timeline instead of handing the renderer a lookup that always misses #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.tsandbuild-upgrade-guide.tsboth loopfor (major = MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++), andPROTOCOL_MAJORis 17 while this entry registers under 18. Both were regenerated anyway andcheck:spec-changes/check:upgrade-guideare green.Acceptance notes
Noted, not filed — neither is a reproducible defect, a contract violation, or a metadata-authoring trap:
.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.shapemirror.applyMetaMigrationsemitsstep.semanticunconditionally andSemanticMigrationcarries 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'sacceptanceCriteria. 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:blockedon this card. Nothing in../objectuiwas touched. Until that package imports and chainscheckDashboardWidgetMetricMeasureArity, its.shape-mirror editor keeps accepting three measures on ametricand 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
Generated by Claude Code