Skip to content

Commit 1bc22b3

Browse files
os-justinclaude
andauthored
fix(pm): every --pair run states the inputs it judged from (#18681)
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: ```text ----- 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: 25d2042 (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**): ```text ----- 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: 25d2042 (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: ```text ----- 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: 25d2042 (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//issues/18443 -> HTTP 200 #3 (i) token /repos//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//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: 1344eb5 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`: ```text HEAD blob 25d2042 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 25d2042 (HEAD 25d2042) 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 #18448's body independently cites as "the exact blob the 2026-09-13 readings were taken from". - The governing claim on card #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 #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-`$?`: ```text 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 --ran` ⇒ **34 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.com/claude-code) https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0111212 commit 1bc22b3

1 file changed

Lines changed: 755 additions & 28 deletions

File tree

0 commit comments

Comments
 (0)