fix(pm): bind H31's carrier pair through the closing keyword - #18243
Conversation
Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr Co-authored-by: Claude <noreply@anthropic.com>
H31 compares the two carriers of the `needs:contract-review` gate, and it took `prDeliversCard` -- the wide body-first/branch-fallback delivery relation -- as its population. That relation counts a `Part of #N` line, which declares MEMBERSHIP in what #N tracks rather than a delivery of it, so an epic tracker whose sub-PRs are gated correctly drew a row saying the gate was missing from the card half of a dual carrier. The row's remedy text is action-shaped: acting on it hangs the gate on a card nothing will ever close, where the stroke that clears gates never arrives. The comparison now runs over PRs bound by a closing keyword -- the binding that makes the landing reach the card, which is what the clearing stroke rides. The shared relation is untouched (its docblock forbids narrowing it), so H8, H31, H35 and H53 still agree about which PR delivers which card. Weaker bindings are DECLINED, never silently dropped: a weak-bound delivering PR whose carrier differs from the card's still produces a row that names both carriers and the binding it read, states that it declined, and prescribes no write. Agreement on a weak binding stays silent, as before. Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewing seat: ① Derived judgments
② Semver levelNot applicable — nothing published moves; ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Landing provenance — skills seat, session Generated by Claude Code |
… that has one (objectstack-ai#18251) Fixes objectstack-ai#18174 `check-clause2-carriers --pair` read a review of record's `Served-tier:` line only beside a gate clear, so `references/contract-review.md` :29's 「无此行不成裁决」 was enforced on one path of two. ## The defect, measured One spelling, two answers, same script, same day: | pair | gate hung? | the record's `Served-tier:` | `--pair` | |---|---|---|---| | objectstack PR objectstack-ai#18157 / card objectstack-ai#17991 (record 5661052272) | no — `Clause-②` never declared `yes` | a stamp control, then prose, then the constant — unreadable to this file's own reader | **0** | | objectui PR objectstack-ai#9486 / card objectstack-ai#9191 (record 5662548425) | hung and cleared | the same spelling | **4**, C7 | The seam: `reviewOfRecord` answered `not-owed` before reading a thread unless the pair was in C6's completed state (`needsRecordRead` → `gateBindingState(pair).state === 'completed'`), and `c7ServedTierBelow` returned `null` for anything but `found`. Measured on this branch's base `b3b43b6`: a non-gated pair carrying an unreadable record, one carrying the live shape, and one carrying no record at all were indistinguishable — `pairRows` empty for all three, `reviewOfRecord` `not-owed` for all three. ## The change - **`locateReviewOfRecord(pair)`** — the gate-independent locator: the same imported H51 heading / head-sha recognition, the same `Reviewed-by:` third fact, the same newest-governs choice, with no reference to the gate. No recognition moved. - **`reviewOfRecord(pair)`** — that locator under C6's population gate. C6's row, its 「not-owed」 scope, its shape sentence and its remedy are unmoved, and both rows still read ONE comment chosen once. - **C7** and the **C6-RECORD note** read the locator. C7's accept set (one token, the constant's NAME), its exactness, its refusal of a missing line and its remedy are untouched — widened in POPULATION only, in the direction its own rule text names. Its opening sentence is now gate-aware: it names a clear only where a clear rides on the record. The note's citation half stays C6's, because the act it names is C6's. - **`gather`'s `landingReads`** (was `readFiles`) buys the PR thread for every pair on the `--pair` path, where the ruling puts this reading (「清标前」); the sweep's population is unchanged, for the budget reason C5's identical split already records. **`locatedRecordUnjudged`** is that path's own objectstack-ai#4690 half, so an unread thread is UNJUDGED and never clean. - **Consequence pinned, not left to be discovered:** with the PR thread in hand, `verdictThreadRows` hands C4 a thread it previously saw only on completed pairs, so a verdict on a non-gated or still-hung pair is now judged for the independence pair it declares. That direction only adds verdicts to a reading whose newest-governs rule already lets a later independent verdict displace an older self-review. Pinned in both directions; the sweep's blindness there is restated as the limit that survives. ## Evidence **Reproduction on the real historical pair, replayed offline.** Both PRs are merged, so `--pair` cannot form the pair from the live board; the document carries the fetched `/pulls/18157`, `objectstack-ai/issues/17991`, both comment threads and the file list, and nothing else. - before (`origin/main` `b3b43b6`): `--pair 18157 --pair-json …` → exit **0** - after: exit **4** — C7 naming comment 5661052272, the unreadable line quoted back, and its stamp control read as NOT total (the 「回退证据」 half) **Live control**, PR objectstack-ai#18243 / card objectstack-ai#18229 — open, non-gated, `Clause-②: no`, record 5673963268 in the template's shape: exit **0** before and after. After, the run also prints the C6-RECORD note naming that record and saying the pair owes no clear (4 reads → 5). **The sweep is unmoved, deterministically:** the same document in sweep mode produces byte-identical `--json` output before and after. A live gated pair (objectstack-ai#18212) reads exit 4 with the same rows on both. **Self-test:** 629 → 658 cases, `pnpm check:pm-clause2-carriers` exit 0. A new battery (`objectstack-ai#18174: …`, 28 cases) carries the measured non-gated spelling beside the live one; the roster floor moves 22 → 23 and C7's own floor 42 → 43. Four of C7's population pins were re-triaged rather than left green for the wrong reason: each now spells a pair whose threads were READ and carry no record, instead of passing because no thread was ever fetched. **Reverse verification**, from the committed fix: `c7ServedTierBelow`'s locator call mutated back to `reviewOfRecord`. On-disk proof — injected marker grep = 1, deleted anchor grep = 0, blob hash `5a79776b` → `c4757e7a`. Self-test under the ablation: **5 of 658 red** (the measured pair, its two sentence pins, the still-hung pair and the 重挂 pair). Restored with `git checkout HEAD --`: `git diff HEAD` empty and the blob back at `5a79776b`. Direction: turns red, as predicted. **Gates**, union re-run at `7af15586` (`git rev-parse --short HEAD`), working tree clean: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths derives **35** families; all 35 ran in the foreground with exits captured by redirect, all **exit 0**; `--ran` reconciles **35 derived / 35 run / 0 NOT-MEASURED / 0 UNRUN**, every exit code recorded. Beyond the derivation: `pnpm lint` (repo-wide, `eslint . --no-inline-config`) exit 0, and the two siblings that import this module — `check-governed-queue-guard.mjs --self-test` (238 cases) and `check-half-states.mjs --self-test` (4042 cases) — exit 0. `skip-changeset`: `scripts/pm/**` is repo tooling that no package `files[]` ships. ## Acceptance notes - noted, not filed: in a SWEEP a record on a non-gated pair stays invisible, because the sweep buys no PR thread for it. That is a declared limit, written into this file's budget paragraph beside C5's identical one, not a defect — and 承接者: nobody today; it becomes a card only if a seat ever prices a per-pair sweep thread. - noted, not filed: an existing pre-fetched `--pair-json` document that omits the `comments` entry for the PR now reads UNJUDGED (exit 2) on a pair that owes no record, where it read 0 before. That is this change's own documented owing, restated in the doc-shape paragraph, not a separate finding. 承接者: the MCP-only seats that hand this file a document — the file's own refusal text names the key to add. - The C4 reach on the landing path widens as a consequence of the bought thread (see above). In scope, documented and pinned in both directions. --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18229
What changed
scripts/pm/check-half-states.mjsonly. H31 compares the two carriers of theneeds:contract-reviewgate, and it tookprDeliversCard— the widebody-first/branch-fallback delivery relation — as its population. That relation
counts a
Part of #Nline, which declares MEMBERSHIP in what #N tracks ratherthan a delivery of it, so an epic tracker whose sub-PRs are gated correctly drew
a row saying the gate was missing from the card half of a dual carrier. The
row's remedy text is action-shaped, so acting on it hangs the gate on a card
nothing will ever close — where the stroke that clears gates never arrives.
Three edits:
bindingClosesCard(pr, n)— a new exported predicate, one read ofdeliveryEvidence's existing grading, never a second keyword parser. Itanswers "does this PR CLOSE #n", which is the question the gate's clearing
stroke rides.
part-of,part-of-inlineandbranch-nameall answer false.bindingClosesCardaccepts. Those rows are byte-identical to before.carrier differs from the card's still produces a row, one that names both
carriers and the binding it read, states that it declined to judge, and
prescribes no write. A weak-bound PR whose carrier agrees produces nothing,
exactly as before.
TRACKING_ANCHOR_LABELis extracted so the ruling-anchor state has one spellingacross H13's exemption list and H31's new clause.
Why this seam, and how the shared invariant survives
The shared relation is NOT narrowed.
prDeliversCard's docblock forbids it(「⛔ Do not narrow it here to serve H8: that would make the live half invisible
to the rows that exist to see it」), and H8's open side, H35's sibling resolver,
claimDelivery, H53 and thecheck-clause2-carrierspairing all still read itwide. The narrowing is H31's own filter over the population that relation hands
it, so the rows still agree about which PR delivers which card and differ only
about which binding makes a CARRIER PAIR — a question only H31 asks. Both
docblocks that state the invariant were updated to say so rather than left to
imply the old thing.
Silently dropping the weak bindings would have been #4690 in this row's own
uniform: a split that was never judged renders identically to a board whose two
carriers agree, and this is the one row that can tell 「被剥」 from 「从未挂过」.
Hence the declined row, and hence its scope: it speaks only where the carriers
actually differ, because a standing row per tracker per sweep is the disease,
not the cure. The declined row is deliberately NOT marked
UNJUDGED_MARKER—that marker buys trim priority ahead of judged rows, and a decline must never
sort ahead of a real carrier split in the same
gateband.Measured on the live board
Anchor #9857's sweep at 2026-09-15T01:57Z, commit
b3b43b6, run 34919049964,carries exactly one H31 row: #14122 (
tracking), naming open PR #18212 (draft,via a
Part ofdeclaration). Replayed offline against the 9 open PRs and the 9open cards those PRs name (the exact H31 candidate set — a card no open PR names
can never reach the comparison):
trackingOne row changes, and it is the expected one. Every other delivering binding on
the live board is a closing keyword, so nothing else moves.
Tests
pnpm check:pm-half-states(=node scripts/pm/check-half-states.mjs --self-test): 4042 cases pass before, 4075 cases pass after, exit 0 —33 new cases.
The pin and the control both land, per the card's own remedies:
Part of-only tracker still produces a row (⛔ not a silent skip),the row says
DECLINES to judge, names the binding it read, explains why aruling anchor can never clear a gate, and prescribes NOTHING; it is asserted
to be neither the old action-shaped sentence nor a LOUD row nor an
UNJUDGED-ranked one.
with the unchanged action-shaped sentence.
without
trackingstill reports the split but without the ruling-anchorclause; an adjudicable split outranks a decline and names only the
closing-bound PR.
Ablation (one-off, restored; run from the committed fix). Mutating
bindingClosesCardback to the pre-fix wide population(
deliveryEvidence(pr, n) !== null) turns the self-test RED:The mutation is proved on disk by the blob hash moving off the HEAD blob and by
the two anchor occurrence counts flipping; the restore leg is proved by the blob
hash returning to the HEAD blob and by an empty
git diff HEAD, not by an exitcode. There is no build step and no
dist/for a repo-root.mjs, so therebuild half of the ablation preflight does not apply here.
Gates — derived with no paths, at head
e0a4efa:All 41 exited 0, each captured by redirect before any pipe. The gate script's
own wiring is inside that set:
pnpm check:pm-half-statesis the package.jsonstep lint.yml runs, and it is the script's
--self-test.Lint — a declared narrowing, not the farm.
eslint scripts/pm/check-half-states.mjs --no-inline-config --format jsonat heade0a4efa: 1 file linted, 0 errors, 0warnings, exit 0. The population is read from eslint's own config, not guessed
— an
ESLintinstance overgit ls-filesreportstracked=8674 in-scope=6755 ignored=1919 no-rules=0. Invariance: this repo runs oneeslint.config.mjswhich enables type-aware linting for no file at all (every
parserOptionsin itis exactly
{ ecmaVersion: 'latest', sourceType: 'module' }; zeroproject/projectServicehits; the config says so itself at itsQUERY_OPTIONS_TEST_GLOBSnote), and that config is not in this diff — so norule's verdict on a file this PR did not touch can move because of it. The
repo-wide
pnpm lintrun is CI's.skip-changeset:scripts/pm/**is PM tooling and ships in no package'sfiles[], so nothing published moves.Acceptance notes
scripts/pm/check-half-states.mjsis non-governed PM tooling, so this isin-seat review.
.github/workflows/half-state-patrol.ymland anchor [Half-state patrol] check-half-states live sweep — generated view (please pin) #9857 areuntouched: the workflow runs the script from
main, so the fix reaches theanchor at the first scheduled sweep after landing.
Part of #N— residual exit 4 with nothing wrong on the diff (#18213 / #14122) #18214 is the sibling shape one file over — the clause-② dual carrier thatscripts/pm/check-clause2-carriers.mjs --pairdemands on an epic trackerreached through a
Part ofline. It is out of scope here and remains open.bindingClosesCardis exported for it: that file already importsdeliveryEvidenceandprDeliversCardfrom this one, so the fix there is animport rather than a second copy of the predicate.
objectui(
scripts/pm/check-half-states.mjs) has already drifted from this copy(different md5 at
b3b43b6), while this file's header describes the pair ascopied VERBATIM. No gate can hold two repos equal, so the wording is an
aspiration rather than a claim — carrier: the next dev dispatched on a
cross-repo patrol card.
Generated by Claude Code