Repository navigation
fix(scripts): route check:single-claim-paths through the session proxy so a seat can run it - #18935
Merged
os-steve merged 2 commits intoSep 18, 2026
Merged
Conversation
Every real run of this gate reads a PR's file list over the network. Node's fetch does not read HTTPS_PROXY, so in an agent container the request left without its credential and GitHub answered 401 — a reading with three plausible causes and none of them the true one. A seat could not pre-run the gate its own PR is judged by, and recorded NOT MEASURED instead. The decision is imported from the one source the sweeps already share (proxyRearmPlan), so there is one spelling of "go through the proxy" rather than a fourth copy of it. What is local is the guard variable: sharing a sibling's would let that sibling's re-exec suppress this one's. The hand-off happens only on the judging path, forwards the child's exit status verbatim so the 0/1/2 contract is unchanged, and never re-arms when no proxy is configured — so the gate behaves exactly as before on an Actions runner. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
The ordering assertion alone was vacuous in the one direction that matters: with the call site deleted, the last occurrence of the performer's name is its own declaration, which sits above the collection and satisfied the comparison while no hand-off remained in the file. Count the call sites too. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 18, 2026
os-steve
marked this pull request as ready for review
September 18, 2026 06:56
os-steve
deleted the
claude/issue-18314-single-claim-paths-proxy-reexec
branch
September 18, 2026 07:25
This was referenced Sep 18, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Sep 28, 2026
…a failed board read exits PREREQUISITE NOT MET, and the self-test handshake is set by the verdict it certifies (objectstack-ai#18986) Fixes objectstack-ai#18940 Clause-②: no Two defects in one file, both of the same class — `scripts/check-single-claim-paths.mjs` against the contracts it declares about itself. One commit each, so each is readable alone. No other file is edited. ## A. A transport failure exited 1, and this file defines 1 as the accusation The live path read a PR's file list with nothing catching a throw. `githubApi` throws on any non-ok response and `fetch` throws on a dead socket, so a 404, a dead credential or a dropped connection escaped as an unhandled rejection and node exited **1** — which this file's own exit register defines as "judged, an earlier open PR already claims a listed path". An accusation, about a board the run never read, against an author who did nothing. The file already forbade exactly that, in its own header: "a mis-wired gate must not read as an accusation, because it would be red on every PR at once for something no author did." So this is the file keeping its own rule, not a new policy. **The probe, run in an agent container. The card carried it unrun; this is the first run of it.** Before, at the base commit `26c73fb4e`: ``` $ PR_NUMBER=99999999 GITHUB_REPOSITORY=objectstack-ai/objectstack node scripts/check-single-claim-paths.mjs info re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it. file:///home/user/objectstack-issue-18940/scripts/check-single-claim-paths.mjs:543 if (!response.ok) throw new Error(`GitHub API ${response.status} for ${path}`); ^ Error: GitHub API 404 for /repos/objectstack-ai/objectstack/pulls/99999999/files?per_page=100&page=1 at async collect (.../check-single-claim-paths.mjs:502:16) at async .../check-single-claim-paths.mjs:901:66 Node.js v22.22.2 exit=1 == EXIT_CONFLICT: "an earlier open PR already claims a listed path" ``` After: ``` $ PR_NUMBER=99999999 GITHUB_REPOSITORY=objectstack-ai/objectstack node scripts/check-single-claim-paths.mjs info re-exec with --use-env-proxy: HTTPS_PROXY is set and node's fetch does not read it. X check:single-claim-paths: PREREQUISITE NOT MET - the board was not read - GitHub API 404 for /repos/objectstack-ai/objectstack/pulls/99999999/files?per_page=100&page=1 - NOT a verdict, not a clean tree. Nothing below this line is a reading. This run never learned which paths any pull request claims, so it says nothing about whether one is claimed twice, and it accuses no author of anything. Exit 3 rather than 1: that code means an EARLIER open PR already claims a listed path, and a failed read is not evidence of any such PR. Exit 3 rather than 0 too - a board that could not be read is not a clean board. Usual causes, in the order worth checking: the token cannot read this repository, the PR number resolves to no pull request, or the request never left the container [...] exit=3 == EXIT_PREREQUISITE_NOT_MET ``` Live reverse control, same tree, a PR number that really resolves — unchanged: ``` $ PR_NUMBER=18935 GITHUB_REPOSITORY=objectstack-ai/objectstack node scripts/check-single-claim-paths.mjs OK check:single-claim-paths: PR objectstack-ai#18935 modifies none of the 1 declared at-most-one-writer path(s), so there is nothing to serialise. exit=0 ``` Shape of the change: - The **read only** is wrapped. `judge` stays outside the `try` on purpose: it is pure and cannot throw on a transport, and a catch spanning it would relabel a real crash in the verdict layer as "the board was not read" — the same lie in the other direction. - The failure becomes a refusal through a pure handler, `boardNotReadRefusal(error)`, returning `{ exit, lines }` exactly as `judge` does. That is what lets the self-test drive the arm with no process exit and no network. The single `process.exit` stays at the dispatch, where every other verdict's exit already lives. - The code is `EXIT_PREREQUISITE_NOT_MET`, **imported** from the fleet's shared register, the way `scripts/check-issue-citations.mjs` and `scripts/pm/post-stamped.mjs` already spell it, so the wording family is one phrase across the tree: "PREREQUISITE NOT MET — the board was not read". - It stays **non-zero**. A board that could not be read is not a clean board, so the CI consumer (`.github/workflows/single-claim-path-guard.yml`) is still red on it. That is the right answer, and 0 would have been the anti-pattern the header names. - The header's exit register gains code 3 plus the paragraph saying why 1 could not be left to node's unhandled-rejection status. **The CI consumer needs no change, measured.** Its only comment about an exit code, at `single-claim-path-guard.yml:94-96`, names 2 for one specific condition (`PR_NUMBER` absent) and does not enumerate the register, so there is nothing there to extend. The `run:` line is unchanged and any non-zero exit is a red job. ## B. The self-test handshake was set before the block that produces the verdict `selfTestReachedVerdict = true` sat on the line directly above `return (async () => {` — the block that runs every assertion, evaluates the battery floor and prints the verdict. The flag was therefore true before a single case had run, so an early return or a throw anywhere inside that block left it true and the dispatch's `if (!selfTestReachedVerdict)` branch was unreachable. The one sentence the flag exists to make possible could never be said by this gate. AGENTS.md, verbatim: "Set the flag as the self-test's last statement, after its success line prints" and "SAY the self-test never reached its verdict. An exit code is not a handshake." The assignment is now that last statement. The failing arm deliberately gets none: it calls `process.exit(1)` on the next instruction, so no reader survives to consult the flag — the handshake is for the SILENT ways out, which is exactly what the new position catches. ### The negative control — red, then green, both legs on committed files The instrument is `scripts/ablation-replace.mjs`, so the mutation and the restore are both proven against the disk rather than against an exit code. The injected mutation is identical in both legs: an early `return;` as the first statement **inside** the async verdict block. **Leg 1 — the fixed file (this branch's HEAD):** ``` ablation-replace: anchor x1 -> x0 ablation-replace: blob 8850395 -> 8387e64acd8bacddc7a8b07b4cd991414edbe216 ablation-replace: ok mutation landed: anchor 1 -> 0, blob 8850395 -> 8387e64acd8b ablation-replace: running: node scripts/check-single-claim-paths.mjs --self-test X check-single-claim-paths self-test: selfTest() returned without reaching its verdict, so no success line was printed. Exiting 0 here would report a self-test that never finished as a self-test that passed. ablation-replace: command exited 1 ablation-replace: blob after restore 8850395 ablation-replace: blob at HEAD 8850395 ablation-replace: git diff HEAD empty ablation-replace: ok restored: blob == HEAD (8850395) and `git diff HEAD` is empty ``` **Leg 2 — the same injection on the pre-fix file (commit `d4b1f2817`, i.e. after A and before B), restored into the tree with `git restore --source=... --worktree` so the index is untouched:** ``` ablation-replace: anchor x1 -> x0 ablation-replace: blob 2bedc9c -> e5b5d1c042e6735f8e40eb874e3adcdca2f2429e ablation-replace: ok mutation landed: anchor 1 -> 0, blob 2bedc9c -> e5b5d1c042e6 ablation-replace: running: node scripts/check-single-claim-paths.mjs --self-test ablation-replace: command exited 0 ablation-replace: blob after restore 8850395 ablation-replace: blob at HEAD 8850395 ablation-replace: git diff HEAD empty ablation-replace: ok restored: blob == HEAD (8850395) and `git diff HEAD` is empty ``` Leg 2 printed **zero bytes** from the self-test and exited **0**. That is the defect, executed: the runner had nothing to say, and "every case held" and "no case ran" were the same output. This is why the triage asked for a RED negative control — without it, fixed and unfixed look identical in the gate's own output on a healthy run. `git status --porcelain` is empty after both legs, and the working blob equals the HEAD blob. ## Self-test counts | | batteries | cases | |:--|--:|--:| | base `26c73fb4e` | 8 | 74 | | this branch | 9 | 93 | `SELF_TEST_BATTERY_FLOOR` rises 8 to 9 with the roster, because leaving it at 8 would let the new battery be deleted without a red — which is what the roster comment already says about deletion. The measured base is 74 cases, not the 83 the dispatch carried; the 74 is also exactly the sum of the eight declared floors, so the floors sat at the live counts before this change and still do. The 19 new cases pin the code and the words, drive a throwing fake api through `collect` into the same handler, keep a healthy api reaching a real verdict as the reverse control, and pin structurally that the handler is reached after the LAST network read — the live one — so deleting the live catch reds instead of being satisfied by the self-test's own calls to the handler. No existing verdict moved: the CLEAN / CONFLICT / NOT WIRED pins are byte-identical and the three older constants keep their values, asserted as a new case (the four codes are four distinct values). ## Census — who else carries this ordering bug The card left this unmeasured and asked for it. Measured on this branch, and **not fixed here**. Population: 105 files under `scripts/` that both declare `let selfTestReachedVerdict = false` and read it at the dispatch. Two independent mechanical criteria, which agree on the same 4 candidates: (1) a success line printed after the last flag assignment; (2) an assertion-registering call (`registerCase`, `batteryFloorFailures`, `checkSelfTestFloor`) after the last flag assignment. All 4 then read by hand, because the mechanical signal cannot tell "the verdict is decided at the dispatch" apart from "assertions still run after the flag". | file | flag line | what follows the flag | verdict | |:--|--:|:--|:--| | `scripts/check-single-claim-paths.mjs` | 807 (pre-fix) | the entire async block it returns, opened at 808: every assertion, the floor, the verdict print | **ORDERING BUG** — fixed here | | `scripts/check-osv-exemptions.mjs` | 647 | nothing inside `selfTest()`; the flag is its last statement. The floor call at 675 is at the DISPATCH, after the flag is read at 657 | holds | | `scripts/typecheck-configs.mjs` | 350 | same shape: flag last in `selfTest()`, floor at 377 in the dispatch, flag read at 357 | holds | | `scripts/check-durability-degradation-log-level.mjs` | 5588 and 5592 | nothing — set adjacent to each of its two verdict lines, green and red. The sinks at 6718 and 6851 belong to a SECOND self-test carrying its OWN flag, `readSeamsReachedVerdict` (5609) | holds | The other 101 set the flag as the last statement of the function whose verdict it certifies. **So this is one card, not a class** — which also answers the card's own question about whether the B half should have been promoted to a census card. It should not. Worth recording for the next reader: `docs/audits/2026-09-self-test-shape-census.md` scores this very file **HELD** (its row, line 321). That is correct for the axis it measured — its probe injects `return;` as the first statement of the dispatched function, which lands *above* the flag assignment and therefore does leave the flag false. The ordering axis is a different injection point, inside the block, and the published census is blind to it by construction. Neither reading contradicts the other. ## Changeset `skip-changeset`, measured rather than assumed. Nothing published moves: 70 non-private packages declare `files[]` and every one ships `dist`, `README.md` and `CHANGELOG.md` only — none ships repo-root `scripts/`. The only reference to this script outside `scripts/` is the private root manifest's own `check:single-claim-paths` line. The positive control for the method is that same scan finding 70 populated `files[]` arrays, so an empty result is a reading and not a silent miss. ## Verification - `pnpm check:single-claim-paths` (the self-test) — pass, 93 cases, 9 batteries. - The 29 gate commands derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from this worktree, against the real change set (1 path, three-dot semantics vs merge base `26c73fb4e`). Every exit code captured redirect-then-`$?`, never through a pipe. **All 29 exited 0**, the slowest being `pnpm check:pm-dispatch-gates` at 745s (1848 self-test cases) under contention with a sibling worktree running the same gate. Reconciled with `dispatch-gates.mjs --ran`, each line recorded as `command :: exit N`: 29 derived, 29 run, 0 UNRUN, 0 NOT-MEASURED as a DERIVED zero rather than a claimed one, and none of the 29 exited 3. - Control-byte self-scan over the changed file (`grep -naP` over the C0 range plus DEL): no hits. - The derivation's own NOT MEASURED rows are left as it reports them, including the one for this very script's live invocation, whose argv takes a value only inside a CI run. ## Reader test A transport failure in CI reads PREREQUISITE NOT MET and a red job, never "an earlier PR claims your path". A self-test that dies mid-block is reported as never reaching its verdict. ## Acceptance notes Observations from this file, noted and deliberately not filed and not fixed — none is a reproducible defect, a declared-contract violation or a metadata-authoring trap: - The exit register's prose in the header is now four codes long and reads as a list; if a fifth ever arrives, the register would be better as a table. Style, no behaviour. - `MAX_PAGES` is 30 in both loops with one comment explaining the file-list ceiling; the open-PR loop borrows the same constant for a different endpoint's ceiling. Nothing observable follows from it today at this repo's open-PR count. --- _Generated by [Claude Code](https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #18314
Clause-②: no
check:single-claim-pathscould not be run outside CI. It reads a PR's file list over the network, node'sfetchdoes not readHTTPS_PROXY, and in an agent container the request therefore left without its credential and GitHub answered401. A seat could not pre-run the gate its own PR would be judged by, and correctly recorded NOT MEASURED where a reading was one flag away.Premise re-verified before anything was written. The card's repro is from
53dd5aaaon 2026-09-15 andmainhas moved. On625db0e(2026-09-18, this container) it still reproduces exactly:What changed
One file,
scripts/check-single-claim-paths.mjs, +117 / -1 across two commits:PROXY_FLAG,PROXY_REARM_GUARDandproxyRearmPlanfromscripts/pm/check-half-states.mjs, and re-execs this process with--use-env-proxywhen a proxy is configured and this run is not already routed through it;OS_SINGLE_CLAIM_PATHS_PROXY_REARMED, and presents it to the imported plan through a small pureproxyPlanEnv();0/1/2contract is unchanged;Nothing else is touched. The donor files named on the card stayed read-only.
The design fork the card leaves open is already settled in the tree
The card offers "lift the re-exec from
scripts/pm/check-clause2-carriers.mjsunchanged", and the real fork behind that is a fourth verbatim copy versus extracting a shared helper. Measured on625db0e, neither is what the tree wants, because the helper already exists:proxyRearmPlanis an exported function with 13 importers in this repo — the card's "the fix exists three times" table counts performers, not the decision;scripts/pm/:scripts/check-issue-citations.mjstakes it as./pm/check-half-states.mjs, which is exactly this file's position in the tree;scripts/pm/check-expected-skips.mjspins the preference as a self-test case of its own —structural: the proxy plan is imported, not restated.So this PR imports the decision and lifts only the roughly twenty-line performer, which has to stay per-script for the reason in the next section.
The bootstrapping constraint is not in the way: an ESM
importis resolved and evaluated before this module's body runs, and the hand-off is decided at the dispatch, before the first network read. Import cost measured at 96 ms wall, node startup included.Why the guard variable is local, and why that is not cosmetic
proxyRearmPlanreads one env name to decide "already re-armed once this run". Four sibling instruments hand itenv: process.envunchanged, so they ask about the name the PLAN owns rather than their own — and a sibling's guard then suppresses their re-exec. Measured here, read-only, same box and same minute:That is the "the credential died" false story of comment 5715705458, produced mechanically by one inherited variable. This file therefore maps its OWN guard onto the name the plan reads, and pins both halves: its own guard stops its own loop, and a sibling's guard does not stop it.
Evidence
Every exit code captured before any pipe, into a file. Tree at
7c3c674, clean.exit 0+ the clean verdict, banner on stderrnode scripts/check-single-claim-paths.mjs --self-testexit 0, 74 cases pass (65 before)PR_NUMBER/ noGITHUB_REPOSITORY/ emptyGITHUB_TOKENexit 2each, byte-identical text, no re-exec attempted401,exit 1— the guard is what fires itexit 0— no cross-suppressionPR_NUMBER=99999999, a 404 in the child)exit 1reaches the caller through the hand-offnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands7c3c674exit 0dispatch-gates --ranreconciliationpnpm lint— the repo-wide scan, not a narrowingexit 0, 68 s, at7c3c674Ablation — both legs, on-disk mutation proved, restore proved
Through
scripts/ablation-replace.mjs, which asserts the anchor count fell, the blob hash moved, and that the restore leftgit diff HEADempty.anchor 1 -> 0, blob 79d8c8299c28 -> bf456081441a, then✗ structural: the hand-off is CALLED exactly once, and decided BEFORE the first network read (got [1,true], want [2,true])—1 of 74 case(s) failed, and only that one.GitHub API 401 for /repos/objectstack-ai/objectstack/pulls/18313/files..., the exact pre-fix failure.Both legs restored with
blob == HEADand an emptygit diff HEAD.⭐ Planning leg 1 is what produced the second commit. The first version of that pin compared only positions, and under the mutation the last occurrence of the performer's name became its own declaration — which sits above the collection and satisfied the comparison with no hand-off left in the file at all. The ablation reported
truefor a deleted call. Counting the call sites is the repair; thegot [1,true]above is the strengthened pin failing as it should.Changeset:
skip-changesetNothing published moves. Measured rather than assumed: across the 70 published packages in this workspace, 217
files[]entries were scanned and zero name ascriptspath;files[]is package-relative and no package root lives underscripts/, so a repo-root script cannot be shipped by any of them. The root manifest isprivate: true. Positive control on the scan: the same pass finds all 70 packages shipping adistentry, so it can see entries when they are there.Acceptance notes
Found while measuring, deliberately not fixed here — out of this card's scope, reported for filing rather than folded in.
1, which its own header defines as an accusation.PR_NUMBER=99999999 GITHUB_REPOSITORY=objectstack-ai/objectstack node scripts/check-single-claim-paths.mjsends in an unhandled rejection andexit 1, and the header says1 judged, an earlier open PR already claims a listed pathand that "a mis-wired gate must not read as an accusation, because it would be red on every PR at once for something no author did". Unchanged by this PR in both directions, and a new exit code is a contract change, not an internal parameter.selfTestReachedVerdict = trueexecutes synchronously, before the async block that prints the verdict, so an earlyreturninside that block leaves the flag set — the exact#13798hole the flag exists to close, and the opposite of the rule in AGENTS.md ("Set the flag as the self-test's last statement, after its success line prints").check-clause2-carriers,post-stamped,board-snapshot,label-write): each declares a local guard name nothing reads, and asks the plan about the imported one. Donor files are read-only on this card and [finding] check-clause2-carriers' correction remedy prints a HARDCODED comment id in the imperative — copying it as printed silently voids the correction, in the one field that carries the whole fix #18376 is already open against one of them, so this is a report, not an edit.Noted, not filed: the card's suggested donor,
scripts/pm/check-clause2-carriers.mjs, returns 4 for the card's own grep pattern, not the 3-of-4 shape the card's table implies; the counts in the dispatch forcheck-prerelease-pin-watch,check-required-contextsandcheck-override-consistencywere not re-derived because none of those files is the donor. Carrier: whoever re-measures that table next; there may be none.Generated by Claude Code