Skip to content

fix(scripts): route check:single-claim-paths through the session proxy so a seat can run it - #18935

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-18314-single-claim-paths-proxy-reexec
Sep 18, 2026
Merged

os-steve merged 2 commits into
mainfrom
claude/issue-18314-single-claim-paths-proxy-reexec

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #18314

Clause-②: no

check:single-claim-paths could not be run outside CI. It reads a PR's file list over the network, node's fetch does not read HTTPS_PROXY, and in an agent container the request therefore left without its credential and GitHub answered 401. 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 53dd5aaa on 2026-09-15 and main has moved. On 625db0e (2026-09-18, this container) it still reproduces exactly:

$ PR_NUMBER=18313 GITHUB_REPOSITORY=objectstack-ai/objectstack node scripts/check-single-claim-paths.mjs
Error: GitHub API 401 for /repos/objectstack-ai/objectstack/pulls/18313/files?per_page=100&page=1
exit 1

What changed

One file, scripts/check-single-claim-paths.mjs, +117 / -1 across two commits:

  • imports PROXY_FLAG, PROXY_REARM_GUARD and proxyRearmPlan from scripts/pm/check-half-states.mjs, and re-execs this process with --use-env-proxy when a proxy is configured and this run is not already routed through it;
  • declares its own re-exec guard, OS_SINGLE_CLAIM_PATHS_PROXY_REARMED, and presents it to the imported plan through a small pure proxyPlanEnv();
  • performs the hand-off only on the judging path — a NOT WIRED run reaches no network, so re-execing it would spend a process to reprint the identical wiring verdict — and forwards the child's exit status verbatim, so the documented 0 / 1 / 2 contract is unchanged;
  • adds a nine-case self-test battery and raises the pinned battery-roster floor from 7 to 8.

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.mjs unchanged", and the real fork behind that is a fourth verbatim copy versus extracting a shared helper. Measured on 625db0e, neither is what the tree wants, because the helper already exists:

  • proxyRearmPlan is an exported function with 13 importers in this repo — the card's "the fix exists three times" table counts performers, not the decision;
  • one importer is already outside scripts/pm/: scripts/check-issue-citations.mjs takes it as ./pm/check-half-states.mjs, which is exactly this file's position in the tree;
  • scripts/pm/check-expected-skips.mjs pins 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 import is 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

proxyRearmPlan reads one env name to decide "already re-armed once this run". Four sibling instruments hand it env: process.env unchanged, 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:

$ node scripts/pm/check-clause2-carriers.mjs --pair 18313
ℹ️  re-exec with --use-env-proxy: HTTPS_PROXY is set (...) and node's fetch does not read it.
...a real verdict. exit 2

$ OS_HALF_STATES_PROXY_REARMED=1 node scripts/pm/check-clause2-carriers.mjs --pair 18313
(no re-exec banner at all)
ℹ️  the token in GITHUB_TOKEN/GH_TOKEN was refused (HTTP 401); falling back to the token-less public read...
check-clause2-carriers: PREREQUISITE NOT MET — GET /repos/.../pulls?state=open... -> HTTP 403. exit 3

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.

what result
the card's repro, after the fix exit 0 + the clean verdict, banner on stderr
node scripts/check-single-claim-paths.mjs --self-test exit 0, 74 cases pass (65 before)
NOT WIRED legs: no PR_NUMBER / no GITHUB_REPOSITORY / empty GITHUB_TOKEN exit 2 each, byte-identical text, no re-exec attempted
own guard pre-set, otherwise identical no re-exec, 401, exit 1 — the guard is what fires it
a sibling's guard pre-set, otherwise identical re-execs, exit 0 — no cross-suppression
a non-zero child status (PR_NUMBER=99999999, a 404 in the child) exit 1 reaches the caller through the hand-off
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands 29 families derived from the real change set
all 29, re-run at final head 7c3c674 29 of 29 exit 0
dispatch-gates --ran reconciliation 29 derived, 29 run, 0 NOT-MEASURED (derived from recorded exit codes), 0 UNRUN
pnpm lint — the repo-wide scan, not a narrowing exit 0, 68 s, at 7c3c674

Ablation — 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 left git diff HEAD empty.

  1. Delete the hand-off, run the self-test: 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.
  2. Delete the hand-off, run the card's repro: GitHub API 401 for /repos/objectstack-ai/objectstack/pulls/18313/files..., the exact pre-fix failure.

Both legs restored with blob == HEAD and an empty git 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 true for a deleted call. Counting the call sites is the repair; the got [1,true] above is the strengthened pin failing as it should.

Changeset: skip-changeset

Nothing published moves. Measured rather than assumed: across the 70 published packages in this workspace, 217 files[] entries were scanned and zero name a scripts path; files[] is package-relative and no package root lives under scripts/, so a repo-root script cannot be shipped by any of them. The root manifest is private: true. Positive control on the scan: the same pass finds all 70 packages shipping a dist entry, 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. A transport failure on this gate exits 1, which its own header defines as an accusation. PR_NUMBER=99999999 GITHUB_REPOSITORY=objectstack-ai/objectstack node scripts/check-single-claim-paths.mjs ends in an unhandled rejection and exit 1, and the header says 1 judged, an earlier open PR already claims a listed path and 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.
  2. The self-test handshake cannot fire. selfTestReachedVerdict = true executes synchronously, before the async block that prints the verdict, so an early return inside that block leaves the flag set — the exact #13798 hole 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").
  3. The four sibling instruments carry the guard hazard demonstrated above (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 for check-prerelease-pin-watch, check-required-contexts and check-override-consistency were 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

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>
@os-steve os-steve added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 18, 2026 — with Claude
@os-steve
os-steve marked this pull request as ready for review September 18, 2026 06:56
@os-steve
os-steve added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 16cb493 Sep 18, 2026
37 checks passed
@os-steve
os-steve deleted the claude/issue-18314-single-claim-paths-proxy-reexec branch September 18, 2026 07:25
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>
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

2 participants