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 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     : 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 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:

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 正文;事后要改的由本席代写)。 两处:

  • applyMigrationChain → applyMetaMigrations(2 处) —— 前者在树上不存在;
    真函数是 packages/spec/src/migrations/chain.ts:68,CLI 调它,根 api-surface 导出它。
    同一处错名也写进了 ADR-0087 语义条目、并经 gen:migration-registry 复制进
    registry.ts:6765 —— 那段文本会被 os migrate meta 在协议 18 打印出来,
    所以读者照着 grep 会一无所获。已随 3b15ca1254 修正(条目 + 重生成,⛔ 未手改 registry.ts)。
    ⭐ 这一条由达档隔离契约复核判出(记录见下方 PASS/FAIL 评论),⛔ 不是本席自己看出来的。
  • Clause-②: no (narrowing) → yes (widening) —— 该行只定路由,⛔ 非终审:
    章程原文「只定是否必过席内契约复核的保守方向」,机械地板「新导出符号…恒 yes」。
    本 diff 在 api-surface/ui.json 上净增一个导出符号
    (checkDashboardWidgetMetricMeasureArity,+1 / 移除 0,本席对着 merge-base 72dd95fa5a 实测),
    ⇒ 地板落在 yes。认领侧早已是 yes (widening),正文与 changeset 两个载体落后于它;
    changeset 已随 881db1280d 对齐,并在行内写明两条轴(接受集收窄、公开面扩大),
    免得 CHANGELOG 读成「本改动放宽了行为」。

⚠️ 破坏性未受影响:check-adr-0087-registration 仍读作 breaking,
经 **BREAKING** 横幅与摘要里的 !;它失去的 clause-②-narrowing 信号从来不是唯一载体
(实测 [BREAKING+bang],exit 0)。

⏱️ 再正一次(席位):yes (widening) → yes (narrowing)。 上一版本席以为「收窄行为 + 扩大公开面」在这套两态词表里没有正确拼法,于是取了 widening 并写了一段话解释「它不是那个意思」。那个前提是错的:readClause2Line 认 yes (narrowing),而check-adr-0087-registration 的自测逐字命名了这个形状 ——「the narrowing arm beside a yes value — a diff that widens AND narrows」。⇒ 值仍是 yes(机械地板:新导出符号),但臂改回 narrowing,clause-②-narrowing 信号随之回到 ADR-0087 门禁(实测 [BREAKING+bang+clause-②-narrowing],exit 0)。⭐ 这一条由第二次达档复核在 ③ 里作为边界旗标提出,⛔ 不是 FAIL;本席自己验过词表才动手。⚠️ 顺带一提 no (widening) 读作 malformed —— 臂不是自由的:no 只配 narrowing,yes 两者皆可。


Generated by Claude Code

…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 62bce5c297d3907b29515ff6943b26b01342608d → packageMentionDocs.

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 dashboard → DashboardSchema; 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; bogusProp → unrecognized_keys; values: 'x' → values:invalid_type; missing id → id: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

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… make a near miss audible (objectstack-ai#18756)

Fixes objectstack-ai#18680

Clause-②: no

## The defect

`CLAIM_COMMENT_MARKER` and `RELEASE_COMMENT_MARKER` anchor the bare word
at line start and tolerate only a leading blockquote, so a line written
in the decorated spelling a seat uses when it bolds the directive —
`**Release:** …` — begins with an asterisk and the record reads as
ABSENT. Nothing goes red; the ownership rows (H2, H47, H66, H67) simply
read a different history than the thread carries, which is the silent
direction. objectstack-ai#10102 made exactly this judgement for the other directive
family (`Blocked-by:` / `Restart-when:`) and never for these two
markers; PR objectstack-ai#18678 landed H67 declaring the loss on its own row rather
than widening, because widening moves three landed rows' populations and
is its own card. This is that card.

## Before-readings (reproduced offline, on the fetched specimen)

The live specimen is objectstack#16529 comment **5691473966**
(os-try-charles, 2026-09-16T03:11:44Z), fetched through the REST proxy
and never retyped. ⚠️ One correction to the card's prose: the
`**Release:**` line is the **45th** line of that comment, not its first
— both markers are `m`-flagged and scan every line, so line position was
never what hid the record. The asterisks were.

| probe | `RELEASE_COMMENT_MARKER` (bare) |
|:--|:--|
| the specimen's whole comment body | `false` |
| the specimen's release line alone | `false` |
| the SAME line with `**Release:**` replaced by `Release:` (control) |
`true` |
| `**Claim:** …` / `` `Release:` … `` / `__Release:__ …` / `## Release:
…` / `- Release: …` | all `false` |

`undecorateProseLine` — the objectstack-ai#10102 function — was measured rather than
assumed: its body is a single `replace` over a character class holding
exactly a backtick and an asterisk. It removes backticks and asterisks
and **nothing else** — not underscores, not a heading hash. That
measurement is what decides which spellings this repair reaches and
which become near misses.

Consumers of the two exported constants, all found by grep across
`scripts/`: nine call sites inside `check-half-states.mjs` itself (H2,
`governingClaim`, `latestClaimComment`, H34's guard, `h44ArtefactShape`,
`h46ClaimNamesBranch`, `latestMarkedComment` — which is H47's, H49's,
H50's, H53's and H67's shared resolver — `SEAT_SIGNATURE_FORMS`, and
`h66ReleaseVerdict`), each fed a raw comment body or one raw line; and
one cross-file importer, `scripts/pm/check-clause2-carriers.mjs`, which
feeds `CLAIM_COMMENT_MARKER` a raw comment body. That importer is
**outside this card's file surface and is deliberately unaffected**: the
constants keep their bare semantics.

## The ONE place decoration is handled

`markerMatches(marker, text)`, declared beside the two markers. It tries
the bare reading FIRST and short-circuits, then re-tests against the
same `undecorateProseLine` the `Blocked-by:` family uses. All nine
in-file call sites now go through it; ⛔ no regex was widened, ⛔ no
second stripper exists, and the two constants still describe the bare
directive (the cases that pin them still assert on them directly, and
they stay green).

Two properties fall out:

- **Strictly additive, by construction rather than by inspection.** The
bare test short-circuits, so ⛔ no body that read before can stop
reading. Measured on 770 live comments below: 0 regressions.
- **It refuses to undecorate through a markdown LIST ITEM.** The shared
stripper takes every asterisk, so a `* Claim:` bullet would become a
directive while H20's pinned `- Claim:` stays refused. A list marker is
followed by whitespace and a decoration is not; that is the whole
discriminator, and it is pinned both ways.

## The near-miss vocabulary — a line that looks like a marker makes a
sound

Widening alone leaves the same silence one decoration further out, which
is the triage's second half (comment 5716952460).
`OWNERSHIP_MARKER_NEAR_MISS_FORMS` is a frozen, named roster in the
register of objectstack-ai#18560's `SCHEMA_PROPERTY_FORMS`; each member carries its
own `example` fixture, and the roster is asserted EQUAL to a frozen list
of ids, so a form added without a fixture reds and a form silently
dropped reds.

| id | what | example |
|:--|:--|:--|
| `heading` | the directive written as a markdown heading | `## Release:
…` |
| `list-item` | the directive written as a markdown list item | `-
Release: …` |
| `underscore-emphasis` | emphasised with underscores, which the shared
stripper does not remove | `__Release:__ …` |
| `inflected-word` | a spelling the marker's vocabulary does not carry |
`Released: …` |
| `separator` | the canonical word with a separator that is not the
canonical colon | `Release — …` |

`ownershipMarkerNearMisses(commentRows)` is the reader; it buys nothing
(the sweep hands it threads other rows already paid for), files no
finding and proposes no state. It reports on an **unconditional summary
clause** (`Ownership-marker near misses: …`) naming the card, the
comment id and the offending prefix, capped at five named entries with
the remainder counted. A line the reading DOES read is ⛔ never a near
miss — the two are complements by construction, so a future widening
shrinks this census automatically.

H67's declared loss is retired in the same edit, on its row and in its
summary clause: both said the decorated line was invisible, and that is
no longer true.

## Pins

New battery **`H2/H47/H66 decorated ownership marker`**, 98 cases,
pinned at 94. The roster floor rose 4 to 5.

- the fetched objectstack-ai#16529 specimen: refused by the bare marker, READ through
`markerMatches`; the derived bare spelling of the same line matches both
ways (control); a release is still not a claim; the record reads from
any line of the body
- the rows: H2 goes clean on a decorated claim (with the no-claim
control still firing); `latestMarkedComment` locates a decorated
release; H66 reads it on the canonical leg with destination `pm:queue`,
quoted undecorated
- what the stripper measures: backticked and bold-italic directives
read; `__Release:__` does NOT, and is a near miss instead
- the firing controls of a widening, inside the same battery: seven bare
spellings still read, prose containing the word still does not,
`Released:` is still MALFORMED, the fullwidth colon still does not match
(the 2026-08-11 ruling is untouched), a dash-written claim is still
H34's row
- the bullet guard: an asterisk bullet, a hyphen bullet, an ordered `1.`
marker and a blockquoted bullet all refused; the whitespace
discriminator pinned as a pair
- the vocabulary: every member driven against its own fixture — not read
by either marker, reports naming its own form, with the comment id and
the offending prefix — plus the counterfactual roster-equality pin,
frozen-ness, distinct ids, and ⛔ no `g` and ⛔ no `m` flag
- the summary clause: counts, named entries, the cap clause,
unconditional rendering, render order, the forwarding contract, and ⛔ no
`undefined`
- **the PR objectstack-ai#18678 pin, EDITED and not deleted**: `H67 ⚠️ loss: a
DECORATED **Release:** line does not stand the row down` becomes `H67 ⚠️
loss CLOSED: … now STANDS THE ROW DOWN, as the bare one always did`, and
its companion flips from "the row DECLARES that blind spot" to "the row
no longer DECLARES a blind spot it no longer has". Its two control cases
are untouched and still green, because they assert on the marker
CONSTANT — which this PR does not change.

Self-test: **4782 cases / 4 batteries becomes 4881 cases / 5 batteries**
(98 battery cases, plus one case the per-anchor summary-clause coverage
loop registers for the new clause automatically).

## Ablation

Revert the one call that routes the markers through the undecorated line
(delete the undecorated leg of `markerMatches`, leaving the bare test
alone), from the committed state, with an EXIT/INT/TERM trap restoring
by absolute path.

- on-disk proof before reading any result: injected marker present 1
time, deleted text `const undecorated = raw` present 0 times, HEAD blob
`44aed794…` vs mutated blob `b4ab48a2…` (different, so ⛔ not a no-op)
- result: **17 red, 4864 green**. Every red is a case about the new
reading — the four objectstack-ai#16529 cases, H2/H47/H66 on a decorated record, the
five decoration cases, the two bullet-discriminator cases, and the
flipped H67 pin. Every bare-spelling control, every near-miss vocabulary
case, the `Released:` / fullwidth / prose negatives and the
marker-constant pins stayed green.
- restore verified by hash (`44aed794…` again) AND by an empty `git diff
HEAD` — ⛔ not by an exit code

## Live-board delta — report-only, ⛔ no state write of any kind

Two full sweeps, `node scripts/pm/check-half-states.mjs` against
`objectstack-ai/objectstack`: BEFORE on a detached worktree at
`62bce5c29` (17:53Z to 18:01Z), AFTER on this branch (18:01Z to 18:09Z).

**H2 / H47 / H66 verdicts: identical.** H2 fired on objectstack-ai#13597 and objectstack-ai#15638 in
both; H47 and H66 listed nothing in either. Eight rows differ between
the two runs (H14 objectstack-ai#18617, H38 objectstack-ai#7623, H52 objectstack-ai#18617 dropped; H1 objectstack-ai#18709, H19
objectstack-ai#18734, H36 objectstack-ai#18414/objectstack-ai#18720/objectstack-ai#18741 appeared) and every one is board churn
in the eight minutes between them — none reads an ownership marker.

**objectstack-ai#16529 specifically** still lists on H67 in both, and the reason has
nothing to do with the marker: its newest merge is now PR objectstack-ai#18678 (merged
2026-09-17), which is NEWER than the 2026-09-16 release record, so
"nobody has looked since the delivery landed" is a correct reading. Its
row text did change — the declared loss is gone.

Because a sweep only judges threads it bought, the zero above
understates the reading. So the same question was asked directly, over
the 286 open `pm:queue` / `pm:dispatched` cards and their 770 comments:

- **NEWLY READ as an ownership record: 4, on 4 cards; REGRESSIONS: 0.**
- objectstack-ai#16529 comment 5691473966 — `**Release:** …` (the card's own specimen)
  - objectstack-ai#17852 comment 5700605769 — a backticked `Release:`
  - objectstack-ai#15468 comment 5549954050 — `**Claim:** …`
  - #14026 comment 5486688759 — a backticked `Claim:`
- **Near misses over the same population: 2** — objectstack-ai#16233 comment
5704218834 and objectstack-ai#18143 comment 5707808263, both `### Release:` (form
`heading`). Over the sweep's wider 187-thread corpus the clause named
three: objectstack-ai#18336 comment 5693290763 (`list-item`), objectstack-ai#15768 comment
5556889538 (`heading`), objectstack-ai#6736 comment 5235658231 (`separator`).

And the counterfactual the rows themselves cannot show, offering the
SAME live thread to both readings — **3 of 6 cards change**:

| card | H47 | H66 |
|:--|:--|:--|
| objectstack-ai#16529 | FIRES becomes **clean** (the release now answers the claim) |
none becomes **`pm:queue`** |
| objectstack-ai#17852 | FIRES becomes **clean** | none becomes **the maintainer** |
| objectstack-ai#15468 | clean becomes **FIRES** (a bolded claim nobody has answered)
| unchanged |
| #14026, objectstack-ai#16233, objectstack-ai#18143 | unchanged | unchanged |

⛔ Nothing was written to any card, PR or label from either sweep, and ⛔
no verdict here is a proposal about any of those cards.

## Gates

Derived from this worktree with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (⛔ no hand-fed path list;
change set: `scripts/pm/check-half-states.mjs`, one path). 38 families,
every one run, exit code captured by redirect-then-`$?` before any pipe,
reconciled with `--ran`.

**37 of 38 exited 0**, with the two qualifications named below. Notably:
`check:pm-half-states` 0 · `check:nul-bytes` 0 ·
`check:closing-target-claim` 0 · `check:commit-card-trailers` 0 ·
`check:self-test-wired` 0 · `check:scripts-symbol-anchors` 0 ·
`check:declaration-mirrors` 0 · `check:whole-set-label-write` 0 ·
`check:changeset-no-major` 0 · `check:pm-governed-queue-guard` 0 ·
`check:cross-package-test-inputs` 0 · `check:parse-guard` 0.

⚠️ `pnpm check:nul-bytes` exited **1 on the first pass** and was right
to: an editing tool had materialised a backslash-u-0001 escape (written
out in words here for the same reason) into a real 0x01 byte in the
near-miss dedupe key — the exact slip that gate exists for. Fixed by
writing the escape text (byte-identical at runtime), re-run green, and
the rule's own `grep -naP` control-byte self-scan over the file returns
nothing.

⚠️ `pnpm check:pm-dispatch-gates` is the one family whose self-test runs
longer than this container's foreground ceiling: a first attempt reached
1768 green cases and was killed by the timeout wrapper at 560s (exit 124
= no verdict reached, which is NOT MEASURED and ⛔ not a red). It was
re-run detached; its verdict is reported in this card's `os-dev-report`
comment rather than guessed here. ⛔ Its diff-relevant half is unaffected
either way — this PR touches neither `dispatch-gates.mjs` nor its
fixtures.

Repo-wide `pnpm lint` (`eslint . --no-inline-config`): **exit 0**, as PR
objectstack-ai#18654 did.

## Not in scope, read and left alone

- **objectstack-ai#18664** (`ISSUE_BODY_LIMIT`, queued behind this card on this file)
— read, ⛔ not touched.
- **H67's own row logic** beyond the pin flip and the two sentences that
declared a loss which no longer exists.
- **The objectui copy of this file** — byte-pinned, re-synced only
through objectui#9395, ⛔ never hand-mirrored.
- **How a verdict is written to a card** — unchanged; this file still
writes nothing.

## Acceptance notes

- H66's summary clause still carries the dated reading 「Measured
2026-09-16 over 29 threads on two boards, the canonical `Release:` line
appeared ZERO times」. It names its date and its boards, so it stays true
as written, but it was taken with the bare reader and this PR changes
what a re-measure would find. Noted, not filed — the sentence is a dated
measurement, not a live claim. Who would meet it: the next author of
H66's buy-order or clause.
- `check-clause2-carriers.mjs` reads `CLAIM_COMMENT_MARKER` against a
raw comment body and therefore still cannot see a decorated claim. That
is correct for this card's file surface (the constant is unchanged) and
is a reading about that file, not a defect in this one. Noted, not
filed; the seat decides whether that gate wants the same reading. Who
would meet it: whoever next touches that gate's claim leg.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ading context (objectstack-ai#18804)

Fixes objectstack-ai#18721

Clause-②: no

## The defect

`scripts/pm/check-widening-tells.mjs` read `+ ctx: z.RefinementCtx,` —
the second parameter of an exported object-level refinement, this repo's
own prescribed `objectstack-ai#16489` signature — as "a new key on a Zod object
schema", although objectstack-ai#17618's parameter decline for exactly that line
already existed and was pinned. Every PR that adds an object-level
refusal therefore earned a free T1 and a C5 / exit 4 on `--pair`, and
the cheapest remedy that clears it — re-declaring `Clause-②: yes` — is
the wrong one: such a diff tightens the accept set, it does not widen
it. The instrument read the tightening direction as the widening one,
which is the inverse of what clause 2 exists to catch.

## The two before-readings, re-derived rather than inherited

Both taken on this worktree's base, `origin/main` `7572329069` (the card
measured `94b3f37be`; the defect is unchanged on the newer tip).

FALSE POSITIVE — the card's own probe, rebuilt from git:

```
git diff 72dd95f..09e16a5 -- packages/spec/src/ui/dashboard.zod.ts > d.patch   # 202 lines, ONE hunk
node scripts/pm/check-widening-tells.mjs --declaration no --diff d.patch
-> exit 4
   T1 packages/spec/src/ui/dashboard.zod.ts:628 - a new key on a Zod object schema
   + ctx: z.RefinementCtx,
```

TRUE-POSITIVE CONTROL — same matcher, same run, same real file path,
built as a real `git diff` in the worktree and then restored with `git
checkout HEAD -- packages/spec/src/ui/dashboard.zod.ts`:

```
+  brandNewAuthorableKey: z.string().optional(),     # added to DashboardWidgetSchema
node scripts/pm/check-widening-tells.mjs --declaration no --diff control.patch
-> exit 4
   T1 packages/spec/src/ui/dashboard.zod.ts:701 - a new key on a Zod object schema
```

The matcher fires on a real new key. So the first reading is a FALSE
POSITIVE, not a dead instrument. The filing card's own first control
read 0 and was its own mis-build (a synthetic path off the declared
surface is judged by nothing); this one sits on the path the probe was
taken from.

## The WHY, measured — and the line that proves it

`enclosingDelimiter` walks from the first line of the line's own hunk
and abandoned the walk, answering `null`, the first time a closer
arrived with an empty stack. `null` is what both callers read as "keep
the tell firing".

A real hunk opens on CONTEXT lines. This one's are the tail of the
previous declaration. Instrumented over the real patch, the new-side
reading is:

```
lines index of ctx: 110  newFile index: 110  new-file line: 628  hunk: 0
inParameterList    -> false
enclosingDelimiter -> null
hunk start newFile index: 0
  newFile[0] kind=context text="  });"        #  THE PROVING LINE: two closers, no opener above them
  newFile[1] kind=context text="}"
  newFile[2] kind=context text=""
function head newFile index: 108 "export function checkDashboardWidgetMetricMeasureArity("
enclosingDelimiter from a window starting at the head -> {"opener":"(","head":"export function checkDashboardWidgetMetricMeasureArity"}
```

The `)` on patch line 6 — the hunk's FIRST line, a context line —
underflows a stack that has seen no opener, and the reading was over 108
lines before the hunk reached the `export function ...(` head it went on
to show. Neither the 202-line hunk length, nor the object-literal type
on the first parameter (`widget: { id?: unknown; ... },`, whose braces
close on their own line), nor the distance to the head is the cause: the
single branch is the underflow `return null`. The three-line synthetic
the existing pin drives carries no context line at all, so that pin
stayed green through every real diff it was written to protect.

## The repair — route A's shape, at that branch

An underflow now DROPS the closer and the walk continues:

```js
if (ch === ')' || ch === ']' || ch === '}') {
  // objectstack-ai#18721 - UNDERFLOW: this closes an opener the hunk never showed. Drop it
  // and keep walking. ...
  if (stack.length === 0) continue;
  stack.pop();
}
```

The argument is a stack one, and it is why this does not loosen the `no`
criterion: everything a hunk opens is strictly INSIDE everything it did
not show, so the shown stack is a SUFFIX of the real one and its top —
whenever it has one — IS the innermost open delimiter, whatever sits
below. An empty shown stack still answers `null`, so the reading stays
positive-evidence-only: the answer is always an opener this hunk showed,
never one inferred from a closer.

Not route B. A `z.RefinementCtx` type-name exception is walked past by
one differently-named parameter type, and it would leave the same branch
broken for every other parameter shape.

`SCHEMA_PROPERTY_FORMS` is untouched and still 10 rows. objectstack-ai#18560 / PR
objectstack-ai#18700 and objectstack-ai#18702 / PR objectstack-ai#18750 are the false-NEGATIVE direction on this
same matcher; their rows, fixtures and batteries are untouched, and
their headers' words are the ones this round's header section uses.

## The pins — one battery, both directions

New battery `objectstack-ai#18721 - a hunk's LEADING CONTEXT is not a reason to
abandon the parameter reading`, 14 cases, registered in the roster at
14:

- THE FINDING: PR objectstack-ai#18720's own hunk, reduced only as far as the failing
branch requires (the leading context that closes the previous
declaration, the function head, the object-literal-typed first
parameter, the `ctx` line) at the line the card reported — T1 silent,
and the whole verdict CLEAN.
- the line number is asserted from the fixture itself, so the fixture is
the probe and not merely a shape like it.
- the object-literal type on the first parameter is pinned as NOT the
confusing element.
- TRUE-POSITIVE CONTROL on the same file: `+ brandNewAuthorableKey:
z.string().optional(),` FIRES, at
`packages/spec/src/ui/dashboard.zod.ts:701`.
- a new key behind the SAME underflowing context still tells (no opener
shown, so no positive evidence).
- a real key added AFTER the parameter list closes still tells,
underflowing context and all, and the row reported is the shape member.
- the branch itself: an opener shown after an underflow is the answer;
an underflow with no opener after it is still `null`; the drop does not
leak past the parameter list's own close; no reading crosses a hunk
boundary.
- the OLD side: objectstack-ai#17618 reads the same decline on the removed side, so a
removed parameter behind leading context now buys no objectstack-ai#16943 budget — and
a genuine key added in the same block, which that phantom budget used to
pay for, FIRES. One repair, one false positive closed and one false
negative with it.

objectstack-ai#17618's existing pin in the `objectstack-ai#18560` battery (`objectstack-ai#17618's parameter
decline is untouched by the wider vocabulary`) and its own battery's
underflow pin are byte-unchanged and green.

## Self-test

```
node scripts/pm/check-widening-tells.mjs --self-test
-> exit 0 :: 473 cases pass   (459 before this round, + the 14 new)
```

Every case that fires today keeps firing: the whole suite was run, no
pre-existing case changed its verdict, and the header section records
the direction in both halves.

## Ablation, from the committed fix

Reverted the branch on disk (`continue` back to `return null`), proved
the mutation landed by blob hash and by anchor counts, ran the suite,
restored under a `trap` and verified the restore by hash. There is no
build step and no `dist/` for a `scripts/pm/*.mjs` file, so the on-disk
proof is the hash plus the anchor counts.

```
HEAD blob      : 025f8e5
anchor counts  : removed-text 1 -> 0 ; injected-text 0 -> 1
mutated blob   : 9459620c63e6590b7e2c81a0c3a2a9a0cbb7020c
VERDICT --self-test under the ablation: exit 1   -> 6 of 473 cases failed
VERDICT probe under the ablation: exit 4         -> T1 back at dashboard.zod.ts:628
VERDICT true-positive control under the ablation: exit 4
restored blob  : 025f8e5  (== HEAD blob)
git diff HEAD  : 0 line(s)
```

All 6 failures are in the new `objectstack-ai#18721` battery and nothing pre-existing
reds:

```
  THE FINDING - PR objectstack-ai#18720's real hunk ... reads NO tell
  ...and the whole verdict is CLEAN ...
  CONTROL - a real key added AFTER the parameter list closes still tells, underflowing context and all
  an opener the hunk shows AFTER an underflow is the answer ...
  the OLD side moves too - a REMOVED parameter behind leading context ... buys no budget
  ...and the row that fires is the genuine new key the phantom budget used to pay for
```

An earlier ablation attempt was a NO-OP (`perl` with a double-escaped
pattern, anchor counts `1 -> 1`, blob unchanged): the script's own guard
refused it and exited non-zero rather than reporting a reading. The run
above is the one that landed.

## Gates

Derived from this worktree with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no hand-fed path list;
change set `scripts/pm/check-widening-tells.mjs`, 1 path vs merge base
`757232906`) — 29 families, all run, all exit 0. Reconciled with
`--ran`: `29 derived, 29 run, 0 NOT-MEASURED, 0 UNRUN` (a DERIVED zero —
every row carries its exit code).

```
node scripts/check-ci-filter-parity.mjs                          :: exit 0
node scripts/check-closing-keyword-parity.mjs                    :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test        :: exit 0
node scripts/check-comment-mask-corpus.mjs                       :: exit 0
node scripts/check-declaration-mirrors.mjs                       :: exit 0
node scripts/check-declaration-mirrors.mjs --self-test           :: exit 0
node scripts/check-scripts-symbol-anchors.mjs                    :: exit 0
node scripts/check-scripts-symbol-anchors.mjs --self-test        :: exit 0
node scripts/check-self-test-wired.mjs                           :: exit 0
node scripts/check-self-test-wired.mjs --self-test               :: exit 0
node scripts/check-self-test-workflow-commands.mjs               :: exit 0
node scripts/check-self-test-workflow-commands.mjs --self-test   :: exit 0
node scripts/check-whole-set-label-write.mjs                     :: exit 0
node scripts/check-whole-set-label-write.mjs --self-test         :: exit 0
node scripts/pm/bare-root-worklist.mjs --self-test               :: exit 0
pnpm check:agent-test-spelling                                   :: exit 0
pnpm check:bash32-floor                                          :: exit 0
pnpm check:cli-command-ids                                       :: exit 0
pnpm check:cross-package-test-inputs                             :: exit 0
pnpm check:driver-memory-census                                  :: exit 0
pnpm check:entry-guard                                           :: exit 0
pnpm check:nul-bytes                                             :: exit 0
pnpm check:parse-guard                                           :: exit 0
pnpm check:pm-dispatch-gates                                     :: exit 0   (detached, 742.1s, 1809 cases)
pnpm check:pm-widening-tells                                     :: exit 0
pnpm check:pnpm-filter-targets                                   :: exit 0
pnpm check:ratchet-remedy-authority                              :: exit 0
pnpm check:refd-timer-probe                                      :: exit 0
pnpm check:watch-hint-literal                                    :: exit 0
pnpm check:pm-clause2-carriers   (consumes this file's verdict)  :: exit 0   (838 cases)
pnpm lint   (repo-wide, eslint . --no-inline-config)             :: exit 0
```

`check-scripts-symbol-anchors` was the one red in the first sweep — the
header section cited `path:628`, and a line number is not an anchor
form. Rewritten to the symbol anchor
`packages/spec/src/ui/dashboard.zod.ts#checkDashboardWidgetMetricMeasureArity`;
green on re-run, and the line numbers that carry evidence stayed, in
prose.

`pnpm lint` is the repo-wide run at this PR's final commit, not a
narrowing.

No changeset: `scripts/pm/**` publishes nothing from any released
package — `skip-changeset`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants