fix(pm): stop check-widening-tells reading the retirement ledger's own rows as an accept-set gain - #17488
Merged
os-litant merged 1 commit intoSep 10, 2026
Conversation
…n rows as an accept-set gain T2 is "the accept set gains a VALUE", and the ADR-0087 retirement ledger is a list of values written BECAUSE an accept set shrank. Every retirement adds rows to `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` and to a step's `conversionIds`, so the mechanical clause-② axis read adverse on the one change class whose direction is unambiguously narrowing: PR #17298 exits 4 with three T2 rows against a declaration that is correct. The repair is a LICENCE, minted only by an added line on one of two declared input surfaces and checked by exact string identity: - `export const entry = '<row>';` under `packages/spec/src/migrations/entries/**` — the generator's own per-entry input (#7297), the exact string `gen:migration-registry` emits into the table; and - `id: '<id>',` added to `packages/spec/src/conversions/registry.ts` — the D2 registration a hand-maintained `conversionIds` row refers to. Neither a region nor a file is excluded. One of PR #17298's three rows sits in the hand-maintained `conversionIds` array, outside every `<os-generated …>` marker, so a position-keyed predicate covers two rows of three. A lookup in the local tree was measured and is wrong rather than merely expensive: a retirement registers its conversion in the same PR, so `view-page-mount-removed` reads 0 occurrences in any checkout of main against a positive control reading 1. The licence is read AFTER #16943's replacement budget, so a block that removes a member and adds both a tombstone and a genuine member still reports the genuine one. Self-test 218 -> 245 cases, 27 of them a new battery whose firing half comes first: the same row with no licence, a licence for a neighbouring row, a licence spent on the wrong file, a genuine member beside a licensed one, and the ordering against the budget. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 10, 2026
os-litant
marked this pull request as ready for review
September 10, 2026 17:41
os-litant
enabled auto-merge
September 10, 2026 17:41
os-litant
deleted the
claude/issue-17300-widening-tells-consult-removed
branch
September 10, 2026 18:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17300
T2 is "a new member of a closed set — the accept set gains a VALUE", and the ADR-0087
retirement ledger (
packages/spec/src/migrations/registry.ts) is a list of values writtenbecause an accept set shrank. Every retirement appends to
RETIRED_KEYS_BY_MAJOR/RETIRED_DEFS_BY_MAJORand to a chain step'sconversionIds, so the mechanical clause-② axisread adverse on the one change class whose direction is unambiguously narrowing.
The population, and what it actually reads today
The card carries five PRs. ⭐ Measuring them on
origin/mainfalsified half of the dispatch'spremise, and the falsification is itself measured rather than argued: the four T1 instances
were recorded against a seat checkout that predated PR #17216 (
0e45ee8cfe, the #16943 netmember/key delta, landed 2026-09-09T18:12:54Z). Running the pre-#17216 script — fetched at
68adebe99b— reproduces every recorded reading exactly; runningorigin/mainat65ad77d5clears all four. Only the T2 instance still refused.
origin/main65ad77d5z.unknown()narrowed)Heads are the ones the comments named and still current (
52bc5eaf1c,b19b41eeec,af7c089823,024d964aab); both input paths agree (the API--fileslisting and thethree-dot
--diffdocument). ⇒ No T1 code change is in this PR. What the T1 half getsinstead is four pinned regression cases built from those exact diffs, so the clearance #17216
bought cannot silently regress — the card's acceptance criterion for T1 is "no longer fires",
and it is met, so re-deciding the arm would be reopening a landed ruling with its own pinned
cost.
The predicate, and why it is not a hole
⛔ No region is excluded and ⛔ no file is excluded. Neither could have worked anyway: of PR
#17298's three rows, one (
'view-page-mount-removed') sits in the hand-maintainedstep18.conversionIdsarray, outside everyos-generatedmarker — a position-keyed predicatecovers two rows of three and leaves
--pairat exit 4 on every retirement that registers aconversion, which a D2 retirement does by definition.
What lands is a licence, minted only by an added line on one of two declared input surfaces
and matched by exact string identity:
export const entry = 'ui/ListView:pageName';underpackages/spec/src/migrations/entries/**— the generator's own per-entry input (Split
migrations/registry.ts's two append tables into per-entry files (registry half of #6957's ruling) #7297), and the exact stringgen:migration-registryemits into the table; andid: 'view-page-mount-removed',added topackages/spec/src/conversions/registry.ts— theD2 registration a
conversionIdsrow refers to.The sensitivity guarantee is the identity, not a shape. An entry for
'a/B:c'buys nothing for'a/B:d'; a licence buys nothing for the same string added to any other file; a row typedby hand between the markers with no entry file still fires (and
check:migration-registryreports it independently); a genuinely new member of a genuinely closed set in the ledger file
still fires with its own file:line.
reading in the file whose evidence spans files rather than a hunk — the generator's input
and its output are two files by construction (#7297), so a hunk-local reader cannot see the
input however carefully written. The evidence is still positive, still carried by the document
being judged, and still absent by default. (2) A regeneration that lands separately from the
entry file it emits carries no licence in its own diff and still tells. That is the loud
direction and it is the right one.
Four-axis reasoning
Judgement 1 — resolve the conversion id against the local tree, or against the diff?
(Zone 2(c) asked for this to be measured.) ⭐ Measured: the tree read is not merely expensive,
it is wrong for the whole population. A retirement registers its conversion in the same PR,
so
view-page-mount-removedreads 0 occurrences insrc/conversions/registry.tsin anycheckout of
main(positive controlturso-config-timeout-to-timeout-msreads 1). A seat'sworktree is not the diff's head, and resolving there answers about the wrong commit in the
direction that keeps the false positive.
--pair Nbefore enqueue, on a diff itdid not author, from a checkout of unknown age. The tree read serves only the case where the
conversion already landed, which is the case that is not the problem.
belongs in it. A tree read makes the verdict depend on an input nobody declared.
correct declaration and teaches the seat to flip
Clause-②: notoyes— writing a wideningthat does not exist into the governance ledger. The diff read fails loudly instead.
document the CLI is already handed.
Judgement 2 — region/position predicate, or exact-string licence? Position was the card's
first shape and its own ⛔ Correction retired it. Beyond covering 2 of 3 rows, a region
predicate blinds T2 to anything a generator emits there, which is the hole the triage grading
named.
entry file that mints the licence; the shape is drawn from the kit as it is actually used.
entries/README.md),so it moves when that contract moves rather than drifting behind it.
would accept anything written inside the markers. Exact string identity cannot be widened by
accident, and the ablation below shows widening it reds nine cases.
Judgement 3 — read the licence before or after #16943's replacement budget? After.
The ordering decides a real case: a block that removes a member and adds both a licensed
tombstone and a genuine member. Read first, the licence would decline free and leave the budget
for the genuine member to spend — buying exactly the silence this file refuses. Read last, the
tombstone spends the removal and the genuine member fires. Pinned as a case.
one that over-reports" is this file's own standing rule; the ordering obeys it.
Judgement 4 — refine T1's budget from a count to key identity? ⛔ Not taken. #16943
deliberately chose net-delta arithmetic over identity and pinned the cost it buys (a one-for-one
rename declines) as an explicit self-test case that says "this case exists so the cost is read,
not discovered".
needs identity.
do, and it would make the gate stricter across the fleet, which is the opposite of the
filed defect.
(
check:api-surface,check:authorable-surface, the ADR-0087 registries) already cover thequiet direction; adding a second reader of the same question is the drift this family punishes.
Recorded in Acceptance notes rather than filed, because it is declared, documented behaviour and
not a defect.
Verification
Every exit code captured before any pipe (
cmd > log 2>&1; EXIT=$?).Self-test — the case count moves and the new cases are pinned
node scripts/pm/check-widening-tells.mjs --self-test—origin/main65ad77d5: 218 casespass, exit 0. This branch: 245 cases pass, exit 0.
'#17300 — the retirement ledger is a record of REMOVALS, not a set that gained a value': 27.12 firing controls, 7 declines, 4 T1 regression pins, 4 declared-row/existence assertions.
Ablation — both legs, each with an on-disk mutation proof and a restore leg
Every leg ran
git hash-objectagainst theHEADblob and refused to read a result unless thehash differed; each carried
trap 'git checkout HEAD -- PATH' EXIT INT TERMwith absolutepaths; each restore was proved by an empty
git diff HEADand a blob hash equal toc03772b0e8c10d6be9ccaa4946320f5b1c223de3.falselicensed.has(value)dropped, so every ledger row declines⇒ The predicate is bracketed on both sides: switching it off reds the declines, widening it
reds the fires. It is not a change that can only suppress.
The acceptance population, both input paths
--declaration no --filesand--declaration no --diffon all five PRs: before/after tableabove. After: exit 0 on all five, zero tells. PR feat(spec)!: retire the
type: 'page'list-view mount and itspageNamebinding #17298's verdict names9 judged against a declared surface, soregistry.tswas read rather than skipped.Gate families — derived from the FINAL diff, all run, reconciled
node scripts/pm/dispatch-gates.mjs --commands(no paths; three-dot, merge base65ad77d5a,1 path) — 32 families, exit 0.
node scripts/pm/dispatch-gates.mjs --ran RANFILE (the recorded list, each line ending in ' :: exit CODE')— exit 0, verdict line:✓ dispatch-gates --ran: 32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED zero — all 32 recorded an exit code and none of them is 3).Re-derived after
git fetch origin main(origin/mainfa23d698); the family list did not move.pnpm check:pm-widening-tells(exit 0),pnpm check:pm-dispatch-gates(exit 0),pnpm check:nul-bytes(exit 0).Lint — the union, not a narrowing
npx eslint . --no-inline-config --format jsonon the final commit56d3ab6c: exit 0,6568 files linted, 0 errors, 0 warnings, 89s wall. Population read from eslint's own
config, count read from the JSON. No narrowing was claimed, so no narrowing needs defending.
npx eslint --no-inline-config scripts/pm/check-widening-tells.mjs: exit 0, 1 file, 0/0.Path face
node scripts/pm/check-governed-merges.mjs --test scripts/pm/check-widening-tells.mjs— exit 0:✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list.git diff --name-only origin/main...HEAD= one path.Bytes
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'over the changed file: no match (exit 1).Changeset —
skip-changeset, measured not assumedscripts/pm/**is on the declared fast track, and the measurement agrees: 0 non-privatepackages list a top-level
scriptsentry infiles[], against a positive control of 23packages that ship
dist. Nothing published moves. The label is applied on this PR.Acceptance notes
noted, not filed:T1's replacement budget pays by count per kind, not by key identity, soa block that removes key
alphaand adds an unrelated new keybetadeclines. This is thedocumented "quiet direction" [finding] check-clause2-carriers T2 fires on a replaced string property value as "a new member of a closed set", and the C5 row it raises cannot be cleared except by declaring Clause-② yes on a change that does not widen #16943 chose deliberately and pinned as its own self-test case,
with the instruments that still catch it named there — declared behaviour, not a defect, and
overturning it would make the gate stricter across the fleet. Successor: whichever card next
revisits [finding] check-clause2-carriers T2 fires on a replaced string property value as "a new member of a closed set", and the C5 row it raises cannot be cleared except by declaring Clause-② yes on a change that does not widen #16943's arithmetic; none is queued, so 承接者:无 for now.
noted, not filed:the card's own header count is now slightly stale — it reads "T2 x3, andfive instances across two tells", while on today's
origin/mainfour of the five no longerreproduce. The card is being closed by this PR, so there is nothing to correct forward.
noted, not filed:check-widening-tells.mjsis 2073 lines with a ~350-line docblock; theheader is load-bearing (every narrowing states the quiet direction it buys) and no reader
complained, so this is an observation, not a finding.
Clause-②: no — internal PM tooling under
scripts/pm/**; no accept set, no published surfaceand no authorable key moves, and the only behaviour change makes a mechanical checker stop
reporting a narrowing as a widening.
Generated by Claude Code