Skip to content

fix(pm): every --pair run states the inputs it judged from - #18681

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-18456-pair-inputs-stated
Sep 17, 2026
Merged

os-justin merged 1 commit into
mainfrom
claude/issue-18456-pair-inputs-stated

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #18456

Clause-②: no

scripts/pm/ sits outside every workspace package, and the root package is private, so no
files[] can ship this diff — skip-changeset.

The defect

check-clause2-carriers --pair is the landing pre-check every seat runs, and on one pair
(PR #17917 / card #17425) it answered 0 at 02:57Z, 4 at 03:04:09Z and 0 at 03:58:33Z on
2026-09-13 with an identical script blob
. Two explanations were ruled out with controls
(no comment on that thread was ever edited; the board is resolved from the environment, never
from the working directory), so the cause is still UNKNOWN — and the three runs could not be
compared, because not one of them had SAID what it read. The judging half is already
deterministic given a fixed document (--pair-json proves that); what was unpinned is what
document the live path builds
. This states it: every --pair run now closes with a fenced
clause2 input record block on stderr, with the same field roster on every exit, so two runs
that disagree are settled by diffing their two blocks — never by re-running until one side
wins. ⛔ No guess at the cause is dressed as a fix here: no predicate, no state, no row, no
count and no exit code reads one character of the record, and the judging half is untouched.

The record's field roster

Rendered from INPUT_RECORD_RUN_FIELDS and INPUT_RECORD_PAIR_FIELDS and from nowhere else,
so a field cannot silently disappear: a declared field this run could not fill renders with an
explicit token rather than vanishing, and a field the builder fills that the roster does not
declare is NAMED in the block (record.undeclared). Values too long for one line continue on
indented lines under their key.

half fields
run record.version · run.utc · run.mode · run.script.path · run.script.blob · run.script.bytes · run.node · board.repo · board.source · read.plan · read.api · read.token · read.served · read.pair-json · run.requests · pairs.derived
per pair (pair.N.) pr · card · derivation · head-sha · card-comments · card-comment-ids · card-comment-newest · pr-comments · pr-comment-ids · pr-comment-newest · claim.rule · claim.selected · claim.rejected · claim.clause2-line · pr-body.clause2-line

Four of them are worth naming for WHY they are there:

  • run.requests — every read the run issued, in order, with its channel, its exact path
    and its row count. A page asked for with per_page=100 that answers with exactly 100
    rows is the one shape a truncated read and a complete one share, and nothing printed it.
  • claim.rule + claim.selected + claim.rejected — the carrier, the rule that picked it
    and every candidate it did not pick, each with its reason. That separates "the two runs
    selected different comments" from "the two runs applied different rules".
  • claim.selected's body fingerprint (bytes + sha256:) — the field the measured 0/4/0
    actually needs. A misplaced verdict on that thread requires the governing claim to have
    carried no readable declaration while a superseded one did; same ids with a different verdict
    is only possible if the BYTES differed, and the ids were all anybody could see.
  • run.script.blob — git's blob hash of this file, beside the path it ran from. "The blob
    was identical on both sides" was a claim in the incident; it is now a printed fact any seat
    checks with git hash-object. On this PR's head it reads 25d204236aa8296644813109fa77541d6efe1644,
    which is exactly git rev-parse HEAD:scripts/pm/check-clause2-carriers.mjs.

The --json sweep carries the same record under inputs — the same record, ⛔ never a second
format.

The two live blocks the card names

--pair 17917 — the pair from the card. Both it and #18654 have since merged, so --pair
answers exit 2 on each today (the pair cannot be formed from a closed PR). ⭐ That is
precisely the class of exit the old code said the least about, and the block is now complete
on it:

----- clause2 input record v1 -----
record.version: 1
run.utc: 2026-09-17T14:10:26.210Z
run.mode: --pair 17917
run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs
run.script.blob: 25d204236aa8296644813109fa77541d6efe1644 (git blob sha1 — check it with `git hash-object` on the path above)
run.script.bytes: 513169
run.node: v22.22.2
board.repo: objectstack-ai/objectstack
board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered
read.plan: (i) token then (ii) token-less public read
read.api: https://api.github.com (REST, accept application/vnd.github+json)
read.token: present
read.served: token=1, public=0, pair-json=0
read.pair-json: (not named — this run read the network)
run.requests: 1 read(s), in the order they were issued
  #1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s))
pairs.derived: 0 pair(s)
record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument.
----- end clause2 input record -----

--pair 18654 — the pair this seat landed today, which answered 0 at 12:32Z and is likewise
merged now (exit 2):

----- clause2 input record v1 -----
record.version: 1
run.utc: 2026-09-17T14:10:27.163Z
run.mode: --pair 18654
run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs
run.script.blob: 25d204236aa8296644813109fa77541d6efe1644 (git blob sha1 — check it with `git hash-object` on the path above)
run.script.bytes: 513169
run.node: v22.22.2
board.repo: objectstack-ai/objectstack
board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered
read.plan: (i) token then (ii) token-less public read
read.api: https://api.github.com (REST, accept application/vnd.github+json)
read.token: present
read.served: token=1, public=0, pair-json=0
read.pair-json: (not named — this run read the network)
run.requests: 1 read(s), in the order they were issued
  #1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s))
pairs.derived: 0 pair(s)
record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument.
----- end clause2 input record -----

diff of those two blocks is four lines: run.utc and run.mode, twice. Same roster,
same order, same shape — which is the property the card asked for.

A live block on exit 0

--pair 18659 (open at the time of writing) — exit 0, the full pair half:

----- clause2 input record v1 -----
record.version: 1
run.utc: 2026-09-17T14:10:36.744Z
run.mode: --pair 18659
run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs
run.script.blob: 25d204236aa8296644813109fa77541d6efe1644 (git blob sha1 — check it with `git hash-object` on the path above)
run.script.bytes: 513169
run.node: v22.22.2
board.repo: objectstack-ai/objectstack
board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered
read.plan: (i) token then (ii) token-less public read
read.api: https://api.github.com (REST, accept application/vnd.github+json)
read.token: present
read.served: token=5, public=0, pair-json=0
read.pair-json: (not named — this run read the network)
run.requests: 5 read(s), in the order they were issued
  #1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s))
  #2 (i) token /repos/objectstack-ai/objectstack/issues/18443 -> HTTP 200
  #3 (i) token /repos/objectstack-ai/objectstack/issues/18443/comments?per_page=100 -> HTTP 200 (4 row(s))
  #4 (i) token /repos/objectstack-ai/objectstack/pulls/18659/files?per_page=100&page=1 -> HTTP 200 (1 row(s))
  #5 (i) token /repos/objectstack-ai/objectstack/issues/18659/comments?per_page=100 -> HTTP 200 (1 row(s))
pairs.derived: 1 pair(s)
pair.1.pr: 18659
pair.1.card: 18443
pair.1.derivation: `closing-keyword` (via a closing keyword) — body line: Fixes #18443
pair.1.head-sha: 1344eb515e3e68ec0099fb37cd4f101f6ebe65d1
pair.1.card-comments: 4 row(s)
pair.1.card-comment-ids: 5713976124,5714587497,5714873191,5715029659
pair.1.card-comment-newest: 5715029659 at 2026-09-17T13:19:56Z
pair.1.pr-comments: 1 row(s)
pair.1.pr-comment-ids: 5715030051
pair.1.pr-comment-newest: 5715030051 at 2026-09-17T13:19:57Z
pair.1.claim.rule: the GOVERNING claim — the NEWEST comment whose body carries a line beginning `Claim:`/`Claimed:` AND whose `Branch:` line parses at least one protocol-shaped branch (newest by `created_at`; an unreadable stamp or a tie falls back to thread order, later row wins). The pool is every claim comment sharing that `created_at`; when NO claim names a branch at all, every claim comment is the pool. ⛔ Not earliest, ⛔ not a session match, ⛔ not the one whose body mentions the key.
pair.1.claim.selected: 1 comment(s) in the pool
  5714587497 at 2026-09-17T12:46:45Z — 2159 bytes, sha256:795e1df6c9fd
pair.1.claim.rejected: none — every claim comment on this thread is in the pool
pair.1.claim.clause2-line: DECLARED `no` — Clause-②: no
pair.1.pr-body.clause2-line: DECLARED `no` — Clause-②: no ⚠️ stated as an INPUT only — ⛔ no row here judges the PR body; the declaration limb is judged from the card, and `check-changeset-no-major.mjs` is what reads this line.
record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument.
----- end clause2 input record -----

Pins

Battery #18456: the --pair input record — the same block on every exit, so two runs that
disagree can be diffed
, registered in SELF_TEST_BATTERIES with a floor of 38; 41
cases register. SELF_TEST_BATTERY_FLOOR raised 26 → 27 by exactly the one battery this adds.

What is pinned, in the card's own terms:

  • the record is present and complete on exit 0, on the exit-4 (MISPLACED) shape and on
    a refusal that formed no pair — all three key lists asserted equal;
  • the field roster cannot lose a field: a declared field that was never filled still renders
    (with INPUT_RECORD_UNSET), an empty record still carries every declared key, and a key
    outside the roster is named rather than printed in silence;
  • the selected-claim rule is stated, and it is the one constant claimCarrierSelection
    applies — so the printed rule cannot drift from the applied one;
  • a rejected candidate is named with its reason, and a thread with nothing rejected says so;
  • a --pair-json run names that read path as such and names the document;
  • the body fingerprint moves when only the bytes move while every id field stays identical —
    the measured shape, asserted directly;
  • gitBlobSha1 is pinned against two values git hash-object prints.

⛔ CONTROLS in the same battery: the block carries no verdict, no exit code and no finding row;
building it changes no reading; and the selection the block prints IS the pool cardDeclaration
judged (ONE derivation — cardDeclaration now calls claimCarrierSelection instead of deriving
the pool inline, so the record and the verdict cannot describe two different comments).

--self-test on this head: 786 cases pass, exit 0 (745 before; +41).

Ablation

From the committed tree, blob 25d204236aa8296644813109fa77541d6efe1644 (= this PR's head
blob), the pair half of the record removed on disk, mutation proved before the run, restore by
blob hash under a trap:

HEAD blob                25d204236aa8296644813109fa77541d6efe1644
before: removed-text count=1 (want 1); injected count=0 (want 0)
after : removed-text count=0 (want 0); injected count=1 (want 1)
mutated blob             e88355f70e8648f1e3d30147f0c82b7c3c157609
VERDICT ablation-mutated self-test exit=1       ← 14 cases red
  ✗ every declared PAIR field is present once per derived pair, prefixed by its index
  ✗ the SELECTION RULE is printed, not merely applied — two runs must be comparable on the rule too
  ✗ …and it is the one constant, so the printed rule cannot drift from the applied one
  ✗ the SELECTED carrier is named by id and by date
  ✗ ⭐ …with a BODY FINGERPRINT: the one field that tells "same ids, different bytes" apart
  ✗ ⭐ …and it MOVES when only the bytes move: same ids, same count, same newest, different verdict
  ✗ every REJECTED candidate is named, with the reason it is not the carrier
  ✗ …and a thread whose claims are all in the pool says THAT, rather than going quiet
  ✗ a claim that parses ZERO branches leaves NO carrier, and the block names that claim
  ✗ an UNREAD thread reads UNREAD, ⛔ never 0 rows
  ✗ the line READ from the carrier is stated — declared, near miss or nothing
  ✗ the PAIRING quotes the body line it was derived from
  ✗ …and the branch-name fallback names the head ref instead of quoting a line that does not exist
  ✗ the PR-BODY line is read and stated — ⛔ and stated as an INPUT, never as a limb
restored blob            25d204236aa8296644813109fa77541d6efe1644   (HEAD 25d204236aa8296644813109fa77541d6efe1644)
git diff HEAD --name-only: []
VERDICT ablation-restored self-test exit=0

Direction predicted before the run and observed: turns red. The module is run directly from
source by node scripts/pm/… — no build and no dist/ between the edit and the run, so the
on-disk proof is the whole preflight.

⚠️ A named gap, not a hidden one: the battery drives the builder and the renderer, and it
cannot see main's emission. An ablation that deleted the two lines in main's finally
would come back green. What covers emission is the three live blocks quoted above, taken on this
head across three different exits.

Candidate cause, unproven — ⛔ not fixed here

Two readings taken while wiring the record. Neither is acted on in this PR.

1. On the blob all three 2026-09-13 runs ran, exit 4 was the DETERMINISTIC answer for that
pair — so what is unexplained is the two 0s, not the 4.

  • The file's last change before those runs was a5ed18ced (2026-09-12T06:05:25Z, "a key-INITIAL
    clause-② line that QUOTES the spelling is not a declaration"); its next change was
    4e3a496ba at 2026-09-13T17:19:18Z, after all three runs. a5ed18ced's blob is
    aecbb2d86683eb908468fdacaac2ff53753f06ef — the same blob PR fix(pm): the clause-② correction remedy names THIS card's claim comment, never a specimen from another card #18448's body independently cites
    as "the exact blob the 2026-09-13 readings were taken from".
  • The governing claim on card ObjectPermissionSchema's retired allowRestore/allowPurge: only literal false parses (not a truthy/falsy split), and no post-parse guard can ever see either key #17425 at that moment was comment 5650083758
    (2026-09-13T01:57:23Z). Its line 3 opens Clause-②: no — and then quotes the spelling again
    inside the same line. Run first-hand against that historical blob's own
    readClause2Line
    : {"kind":"near-miss","reason":"describing"}, and cardDeclaration on a
    one-claim thread reads missing — ⛔ not a declaration. Today's copy reads it identically.
  • A --pair-json document assembled from the REAL thread as it stood at 03:04:09Z (its 9
    comments, both carriers' real label event streams) answers exit 4, MISPLACED on this PR's
    head, quoting the superseded Clause-②: yes and naming 5650083758 as the correction target —
    which is what the 03:04Z reviewer and the 02:53Z dev round both reported.
  • ⇒ The 4 is reproducible and mechanically explained. The 0s are not. ⭐ Exactly the difference
    the record's claim.selected fingerprint and claim.clause2-line would have shown, had the
    0-runs printed one.
  • ⚠️ Limits of this reading: the historical module was exercised for readClause2Line (self
    contained) and cardDeclaration (which imports today's sibling modules); the commit ordering
    is read from a shallow checkout, corroborated by fix(pm): the clause-② correction remedy names THIS card's claim comment, never a specimen from another card #18448's independent citation of the same blob.

2. The comment read — the one the declaration limb depends on — is the only read here with no
page discipline.
readCardComments issues ONE request, /issues/N/comments?per_page=100, with
no page= ladder and no short-read check. readCarrierEvents and readPullFiles both page to
exhaustion and answer null (UNJUDGED, never clean) when their cap is hit, for the reason their
own docblocks state. A card thread past 100 comments therefore loses its tail silently, and the
claim pool is built from whatever came back. Not the cause on #17425 (7 comments at 02:53Z, 16
today), but it is a live fail-open in this reading. The record makes it visible for the first
time: request #3 prints its row count, so a (100 row(s)) on a per_page=100 request is now
readable. ⛔ Not fixed here; the seat files or re-scopes.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
from the worktree with no hand-fed path list; re-derived after rebasing onto current main
(the derivation was STALE-TREE by 4 commits) — identical command list. All 34 run at head
f5773ce08, exit codes captured redirect-then-$?:

0 :: node scripts/check-adr-0087-registration.mjs --base origin/main
0 :: node scripts/check-adr-0087-registration.mjs --self-test
0 :: node scripts/check-changeset-no-major.mjs --base origin/main
0 :: node scripts/check-changeset-no-major.mjs --self-test
0 :: node scripts/check-ci-filter-parity.mjs
0 :: node scripts/check-closing-keyword-parity.mjs
0 :: node scripts/check-closing-keyword-parity.mjs --self-test
0 :: node scripts/check-comment-mask-corpus.mjs
0 :: node scripts/check-declaration-mirrors.mjs
0 :: node scripts/check-declaration-mirrors.mjs --self-test
0 :: node scripts/check-scripts-symbol-anchors.mjs
0 :: node scripts/check-scripts-symbol-anchors.mjs --self-test
0 :: node scripts/check-self-test-wired.mjs
0 :: node scripts/check-self-test-wired.mjs --self-test
0 :: node scripts/check-self-test-workflow-commands.mjs
0 :: node scripts/check-self-test-workflow-commands.mjs --self-test
0 :: node scripts/check-whole-set-label-write.mjs
0 :: node scripts/check-whole-set-label-write.mjs --self-test
0 :: node scripts/pm/bare-root-worklist.mjs --self-test
0 :: pnpm check:agent-test-spelling
0 :: pnpm check:bash32-floor
0 :: pnpm check:changeset-gate-self-tests
0 :: pnpm check:cli-command-ids
0 :: pnpm check:cross-package-test-inputs
0 :: pnpm check:driver-memory-census
0 :: pnpm check:entry-guard
0 :: pnpm check:nul-bytes
0 :: pnpm check:parse-guard
0 :: pnpm check:pm-clause2-carriers
0 :: pnpm check:pnpm-filter-targets
0 :: pnpm check:ratchet-remedy-authority
0 :: pnpm check:refd-timer-probe
0 :: pnpm check:watch-hint-literal
0 :: pnpm check:pm-dispatch-gates

Reconciled: dispatch-gates --ran34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN.

Repo-wide pnpm lint (eslint . --no-inline-config) at f5773ce08: exit 0.
grep -naP for control bytes over the changed file: no hits.

⛔ Outside these 34, as the derivation itself prints: 53 artifact-roster families, 11
wide-population families, 7 pending-changeset families, 1 path-scheduled CI job and the
always-runs tail. Their absence here is not a clearance.

Acceptance notes

Out of scope, noted and ⛔ not filed:

  • The read-path report and the input record now also print on the --pair-json usage
    refusals
    (a missing file, a non-JSON document, a board conflict), because everything past the
    board resolution moved inside one try/finally. One extra stderr line on those paths, in the
    direction the file's own header argues for. Carrier: whoever next edits main.
  • main's --pair value is parsed in two places now (once for run.mode, once for the pair
    itself). Both read the same argv through flagIndex; a reader may prefer one. Carrier: whoever
    next edits main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu


Generated by Claude Code

`check-clause2-carriers --pair` answered 0 / 4 / 0 on one pair with an
identical script blob, and nothing either run printed could settle which
of them had read what. The judging half is already deterministic given a
fixed document (`--pair-json` proves it); what was unpinned is what
document the LIVE path builds.

Every run past the board resolution now closes with a fenced
`clause2 input record` block on stderr — the board and which of the three
sources answered, the read path and every request it issued with its row
count, the pair and the evidence it was derived from, the comments read
by count/ids/newest, the claim comment SELECTED as the carrier with the
rule that selected it and every candidate it rejected with a reason, each
pooled claim's body fingerprint, the PR-body line, and this file's own
blob hash and path. The field roster is the same on exit 0, exit 4 and
every refusal, so two runs that disagree are diffed rather than re-run.

The `--json` sweep carries the same record under `inputs`. No verdict, row,
count or exit code reads any of it.

Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu
Co-authored-by: Claude <noreply@anthropic.com>
@os-justin os-justin added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 17, 2026 — with Claude
This was referenced Sep 17, 2026
@os-justin
os-justin marked this pull request as ready for review September 17, 2026 14:41
@os-justin
os-justin added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 1bc22b3 Sep 17, 2026
37 checks passed
@os-justin
os-justin deleted the claude/issue-18456-pair-inputs-stated branch September 17, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants