Repository navigation
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 |
… 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>
…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>
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