Skip to content

fix(pm): bind H31's carrier pair through the closing keyword - #18243

Merged
claude[bot] merged 2 commits into
mainfrom
claude/issue-18229-h31-closing-keyword-binding
Sep 15, 2026
Merged

claude[bot] merged 2 commits into
mainfrom
claude/issue-18229-h31-closing-keyword-binding

Conversation

@claude

@claude claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #18229

What changed

scripts/pm/check-half-states.mjs only. 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, so acting on it hangs the gate on a card
nothing will ever close — where the stroke that clears gates never arrives.

Three edits:

  1. bindingClosesCard(pr, n) — a new exported predicate, one read of
    deliveryEvidence's existing grading, never a second keyword parser. It
    answers "does this PR CLOSE #n", which is the question the gate's clearing
    stroke rides. part-of, part-of-inline and branch-name all answer false.
  2. H31's own population filter — the carrier comparison runs over the PRs
    bindingClosesCard accepts. Those rows are byte-identical to before.
  3. A DECLINED row for every other binding — a weak-bound delivering PR whose
    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_LABEL is extracted so the ruling-anchor state has one spelling
across 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 the check-clause2-carriers pairing all still read it
wide. 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 gate band.

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 of declaration). Replayed offline against the 9 open PRs and the 9
open cards those PRs name (the exact H31 candidate set — a card no open PR names
can never reach the comparison):

card tracking delivering evidence row before row after
#18122 no #18238 closing-keyword none none
#17502 no #18231 closing-keyword none none
#17598 no #18230 closing-keyword none none
#15410 no #18227 closing-keyword none none
#18202 no #18212 closing-keyword none none
#14122 yes #18212 part-of action-shaped finding DECLINED row
#17396 no #18198 closing-keyword none none
#17356 no #18131 closing-keyword none none
#17097 no #18096 closing-keyword none none

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

  • pin: the 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 a
    ruling 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.
  • control: a closing-keyword binding whose card lacks the gate — the row STANDS,
    with the unchanged action-shaped sentence.
  • plus: agreement on a weak binding stays clean both ways; a weak-bound card
    without tracking still reports the split but without the ruling-anchor
    clause; an adjudicable split outranks a decline and names only the
    closing-bound PR.

Ablation (one-off, restored; run from the committed fix). Mutating
bindingClosesCard back to the pre-fix wide population
(deliveryEvidence(pr, n) !== null) turns the self-test RED:

== anchor counts BEFORE ==   old-text occurrences: 1 / new-text occurrences: 0
mutation written
== anchor counts AFTER ==    old-text occurrences: 0 / new-text occurrences: 1
HEAD blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811
MUT  blob=906ac10ab7837ef5dc746f1bb3ccda8740e88c75
VERDICT ablation-exit=1
✗ check-half-states self-test: 11 of 4075 case(s) failed.
== restore leg ==
REST blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811
old-text occurrences after restore: 1 / new-text occurrences after restore: 0
git diff HEAD bytes: 0

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 exit
code. There is no build step and no dist/ for a repo-root .mjs, so the
rebuild half of the ablation preflight does not apply here.

Gates — derived with no paths, at head e0a4efa:

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  -> 41 command(s)
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE
  -> Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN.
     EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED
     count above is DERIVED from them.

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-states is the package.json
step 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 json at head e0a4efa: 1 file linted, 0 errors, 0
warnings
, exit 0. The population is read from eslint's own config, not guessed
— an ESLint instance over git ls-files reports tracked=8674 in-scope=6755 ignored=1919 no-rules=0. Invariance: this repo runs one eslint.config.mjs
which enables type-aware linting for no file at all (every parserOptions in it
is exactly { ecmaVersion: 'latest', sourceType: 'module' }; zero
project/projectService hits; the config says so itself at its
QUERY_OPTIONS_TEST_GLOBS note), and that config is not in this diff — so no
rule's verdict on a file this PR did not touch can move because of it. The
repo-wide pnpm lint run is CI's.

skip-changeset: scripts/pm/** is PM tooling and ships in no package's
files[], so nothing published moves.

Acceptance notes


Generated by Claude Code

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>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 15, 2026
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e0a4efa3b334c913a6d7e9cf059bdf2c9b8baee5

Reviewing seat: domain:skills, in-seat review at the contract-review tier — get_session read at 2026-09-15T02:54Z: configured_model = session_context.model = last_served_model, the constant's tier, no fallback notice this round. Card #18229, claim 5673630907, report 5673938684; the PR declares Clause-②: no and the card's claim declares the same.

① Derived judgments

  • Accept set / public surface: none moves — scripts/pm/check-half-states.mjs only (+214/−6 against merge-base b3b43b6), a repo-root PM gate with no package files[]; skip-changeset is the right form and is on the PR (read back with size/m).
  • The shared delivery relation prDeliversCard is NOT narrowed (pinned still-true on the tracker); H31 gains its own population filter bindingClosesCard(pr, n) reading deliveryEvidence's existing grading — part-of, part-of-inline and branch-name answer false. The H8 / H31 / H35 / H53 invariant survives: they still agree about which PR delivers which card and differ only about which binding makes a carrier pair, and both invariant docblocks say so now.
  • Weak-bound disagreements are DECLINED audibly, never dropped: the row names both carriers and the binding, says 「DECLINES to judge」, prescribes NOTHING and tells the reader not to hang the gate; a tracking card adds the ruling-anchor clause. Agreement on a weak binding stays silent, as before. The decline is deliberately not UNJUDGED_MARKER (trim priority), pinned.
  • Live-board replay (the dev's, read against anchor [Half-state patrol] check-half-states live sweep — generated view (please pin) #9857's sweep of 2026-09-15T01:57Z on b3b43b6): exactly one row changes — One artifact, N packages: let a release bundle carry co-owning packages so a product can be split into modules without renaming objects #14122's action-shaped finding becomes the declined row; the other eight candidates are closing-keyword bound and stay null.
  • Seat spot-check on the PR head in a detached worktree at e0a4efa: node scripts/pm/check-half-states.mjs --self-test4075 cases pass, exit 0 (the dev's before/after 4042 → 4075 holds); the diff's key hunks read as the report says (TRACKING_ANCHOR_LABEL shared with H13_EXEMPT_LABELS, the C3 pin and control both present). Ablation reported red (11 of 4075) with the on-disk mutation proof and the restore proved by git diff HEAD empty — accepted as reported; the direction (turns red) is the predicted one.

② Semver level

Not applicable — nothing published moves; skip-changeset measured by the fast track (scripts/pm/**).

③ Boundary flags

open_questions: none. Four declared deviations, each answered: (1) the decline fires for ANY non-closing binding whose carriers differ, with tracking as a clause rather than a gate — accepted, it closes the silent-drop half the card's own text warned about and changes no other live row; (2) branch-name bindings route to the declined channel and one self-test case was renamed in place with a second case pinning the sentence — accepted; (3) TRACKING_ANCHOR_LABEL extracted and H13_EXEMPT_LABELS pointed at it — one token, behaviour-identical, pinned — accepted; (4) not UNJUDGED_MARKER — accepted with the pin. Out-of-scope note (objectui's sibling copy has drifted, md5 differs at b3b43b6): recorded on the card's ACCEPT, no card — objectui#9395's open question (pin re-sync vs hand port) is where that port is decided. Cross-lane note: bindingClosesCard is exported and named for #18214 (domain:devx) in its docblock.

Implemented-by: claude/issue-18229-h31-closing-keyword-binding
Reviewed-by: session_01HZfg2AwVX191qCizp88gQr

VERDICT: PASS


Generated by Claude Code

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Landing provenance — skills seat, session session_01HZfg2AwVX191qCizp88gQr, 2026-09-15T03:18Z. In-seat landing (path face NOT governed: scripts/pm/check-half-states.mjs only): review of record 5673963268 (## Contract review PASS) names head e0a4efa3b334c913a6d7e9cf059bdf2c9b8baee5; ACCEPT 5673963485 on #18229; check-clause2-carriers --pair 18243 exit 0 re-read at 2026-09-15T03:18Z; on that head Lint & Repo Gates and TypeScript Type Check concluded success and every other check is success or skipped (37 runs, 0 failed, 0 running); mergeable_state clean. Ready through POST …/ccr/ready_for_review (200, read back draft: false), auto-merge armed through PUT …/ccr/auto_merge SQUASH (200, {"enabled":true,"merge_method":"squash"}); timeline reads ready_for_review 2026-09-15T03:18Z and added_to_merge_queue 2026-09-15T03:18Z; queue ref gh-readonly-queue/main/pr-18243-* present on origin. MERGED is confirmed later by two readings (the queue ref gone + git log origin/main carrying (#18243)), then the card's residue is stripped.


Generated by Claude Code

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

1 participant