Skip to content

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 into
mainfrom
claude/issue-17300-widening-tells-consult-removed
Sep 10, 2026
Merged

fix(pm): stop check-widening-tells reading the retirement ledger's own rows as an accept-set gain#17488
os-litant merged 1 commit into
mainfrom
claude/issue-17300-widening-tells-consult-removed

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

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 written
because an accept set shrank. Every retirement appends to RETIRED_KEYS_BY_MAJOR /
RETIRED_DEFS_BY_MAJOR and to a chain step's conversionIds, so the mechanical clause-② axis
read 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/main falsified half of the dispatch's
premise, 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 net
member/key delta, landed 2026-09-09T18:12:54Z). Running the pre-#17216 script — fetched at
68adebe99b — reproduces every recorded reading exactly; running origin/main at 65ad77d5
clears all four. Only the T2 instance still refused.

PR recorded on the card pre-#17216 script origin/main 65ad77d5 this branch
#17298 (T2 — retirement ledger) T2 x3, exit 4 5 tells (T1 x1, T2 x4), exit 4 T2 x3, exit 4 0 tells, exit 0
#17342 (describe-only edit) T1 x1, exit 4 T1 x1, exit 4 0 tells, exit 0 0 tells, exit 0
#17439 (z.unknown() narrowed) T1 x2, exit 4 T1 x2, exit 4 0 tells, exit 0 0 tells, exit 0
#17463 (regex byte-identical) T1 x1, exit 4 T1 x1, exit 4 0 tells, exit 0 0 tells, exit 0
#17473 (schema byte-identical) T1 x1, exit 4 T1 x1, exit 4 0 tells, exit 0 0 tells, exit 0

Heads are the ones the comments named and still current (52bc5eaf1c, b19b41eeec,
af7c089823, 024d964aab); both input paths agree (the API --files listing and the
three-dot --diff document). ⇒ No T1 code change is in this PR. What the T1 half gets
instead 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-maintained
step18.conversionIds array, outside every os-generated marker — a position-keyed predicate
covers two rows of three and leaves --pair at exit 4 on every retirement that registers a
conversion, 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:

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 typed
by hand between the markers with no entry file still fires (and check:migration-registry
reports it independently); a genuinely new member of a genuinely closed set in the ledger file
still fires with its own file:line.

⚠️ Two deliberate departures, stated rather than left to be found. (1) This is the first
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-removed reads 0 occurrences in src/conversions/registry.ts in any
checkout of main (positive control turso-config-timeout-to-timeout-ms reads 1). A seat's
worktree is not the diff's head, and resolving there answers about the wrong commit in the
direction that keeps the false positive.

  • 实际业务需求 — the real scenario is a seat running --pair N before enqueue, on a diff it
    did 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.
  • 项目长远合理性 — contract-first: the diff is the artefact under judgement, so the evidence
    belongs in it. A tree read makes the verdict depend on an input nobody declared.
  • 防 AI 写错 — a tree read fails silently toward "no licence", so the gate keeps refusing a
    correct declaration and teaches the seat to flip Clause-②: no to yes — writing a widening
    that does not exist into the governance ledger. The diff read fails loudly instead.
  • 创业阶段不扩散需求 — no new input, no new flag, no filesystem surface: the reading uses the
    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.

  • 实际业务需求 — the measured population is retirement PRs, and every one of them carries the
    entry file that mints the licence; the shape is drawn from the kit as it is actually used.
  • 项目长远合理性 — the licence rides on the declared generator contract (entries/README.md),
    so it moves when that contract moves rather than drifting behind it.
  • 防 AI 写错 — a region exclusion is exactly the "宽容恰是 AI 批量犯错被掩盖的温床" shape: 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.
  • 创业阶段不扩散需求 — three declared rows with an existence guard, no new configuration.

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.

  • 实际业务需求 — contrived today, but it is the only ordering that cannot lose a real member.
  • 项目长远合理性 / 防 AI 写错 — "a matcher that stops reporting a real widening is worse than
    one that over-reports" is this file's own standing rule; the ordering obeys it.
  • 创业阶段不扩散需求 — one line of placement, no mechanism.

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"
.

  • 实际业务需求 — the card's five instances are all cleared by the count; no measured instance
    needs identity.
  • 项目长远合理性 — overturning a landed ruling with its own pinned cost is not this card's to
    do, and it would make the gate stricter across the fleet, which is the opposite of the
    filed defect.
  • 防 AI 写错 / 创业阶段不扩散需求 — the existing instruments a rename must move
    (check:api-surface, check:authorable-surface, the ADR-0087 registries) already cover the
    quiet 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-testorigin/main 65ad77d5: 218 cases
    pass
    , exit 0. This branch: 245 cases pass, exit 0.
  • +27 cases in one new battery, declared in the roster at its exact floor:
    '#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-object against the HEAD blob and refused to read a result unless the
hash differed; each carried trap 'git checkout HEAD -- PATH' EXIT INT TERM with absolute
paths; each restore was proved by an empty git diff HEAD and a blob hash equal to
c03772b0e8c10d6be9ccaa4946320f5b1c223de3.

leg mutation anchor hits before/after result
predicate disabled the licence consult guarded by false 1 / 0, injected marker 1 6 of 245 fail — every one a decline case; all 12 firing controls stay green; PR #17298 returns to exit 4, 3 tells
predicate over-permissive licensed.has(value) dropped, so every ledger row declines 1 / 0, injected 1 9 of 245 fail — every one a firing control
enabled (HEAD) none 245 pass, exit 0

⇒ 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

Gate families — derived from the FINAL diff, all run, reconciled

  • node scripts/pm/dispatch-gates.mjs --commands (no paths; three-dot, merge base 65ad77d5a,
    1 path) — 32 families, exit 0.
  • All 32 run; 32 of 32 exit 0, no exceptions.
  • 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/main fa23d698); the family list did not move.
  • Named in the dispatch and green: 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 json on the final commit 56d3ab6c: 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.
  • File list taken three-dot: 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 assumed
scripts/pm/** is on the declared fast track, and the measurement agrees: 0 non-private
packages list a top-level scripts entry in files[], against a positive control of 23
packages that ship dist. Nothing published moves. The label is applied on this PR.

Acceptance notes

Clause-②: no — internal PM tooling under scripts/pm/**; no accept set, no published surface
and no authorable key moves, and the only behaviour change makes a mechanical checker stop
reporting a narrowing as a widening.


Generated by Claude Code

…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>
@os-litant os-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 10, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review September 10, 2026 17:41
@os-litant
os-litant enabled auto-merge September 10, 2026 17:41
@os-litant
os-litant added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit e7e883c Sep 10, 2026
36 checks passed
@os-litant
os-litant deleted the claude/issue-17300-widening-tells-consult-removed branch September 10, 2026 18:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants