Repository navigation
Commit 7af6983
fix(scripts): check:single-claim-paths keeps its own two contracts — a failed board read exits PREREQUISITE NOT MET, and the self-test handshake is set by the verdict it certifies (#18986)
Fixes #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 #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>1 parent b146102 commit 7af6983
1 file changed
Lines changed: 160 additions & 5 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
123 | 123 | | |
124 | 124 | | |
125 | 125 | | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
126 | 131 | | |
127 | 132 | | |
128 | 133 | | |
129 | 134 | | |
130 | 135 | | |
131 | 136 | | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
132 | 146 | | |
133 | 147 | | |
134 | 148 | | |
| |||
156 | 170 | | |
157 | 171 | | |
158 | 172 | | |
159 | | - | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
160 | 183 | | |
161 | 184 | | |
162 | 185 | | |
| |||
191 | 214 | | |
192 | 215 | | |
193 | 216 | | |
| 217 | + | |
194 | 218 | | |
195 | 219 | | |
196 | 220 | | |
197 | 221 | | |
198 | | - | |
| 222 | + | |
199 | 223 | | |
200 | 224 | | |
201 | 225 | | |
| |||
544 | 568 | | |
545 | 569 | | |
546 | 570 | | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
547 | 613 | | |
548 | 614 | | |
549 | 615 | | |
| |||
586 | 652 | | |
587 | 653 | | |
588 | 654 | | |
589 | | - | |
| 655 | + | |
590 | 656 | | |
591 | 657 | | |
592 | 658 | | |
| |||
632 | 698 | | |
633 | 699 | | |
634 | 700 | | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
635 | 709 | | |
636 | 710 | | |
637 | 711 | | |
| |||
804 | 878 | | |
805 | 879 | | |
806 | 880 | | |
807 | | - | |
808 | 881 | | |
809 | 882 | | |
810 | 883 | | |
| |||
857 | 930 | | |
858 | 931 | | |
859 | 932 | | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
| 976 | + | |
| 977 | + | |
| 978 | + | |
| 979 | + | |
| 980 | + | |
| 981 | + | |
| 982 | + | |
| 983 | + | |
| 984 | + | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
| 988 | + | |
| 989 | + | |
| 990 | + | |
| 991 | + | |
| 992 | + | |
| 993 | + | |
| 994 | + | |
860 | 995 | | |
861 | 996 | | |
862 | 997 | | |
| |||
869 | 1004 | | |
870 | 1005 | | |
871 | 1006 | | |
| 1007 | + | |
| 1008 | + | |
| 1009 | + | |
| 1010 | + | |
872 | 1011 | | |
873 | 1012 | | |
874 | 1013 | | |
| 1014 | + | |
| 1015 | + | |
| 1016 | + | |
| 1017 | + | |
| 1018 | + | |
| 1019 | + | |
875 | 1020 | | |
876 | 1021 | | |
877 | 1022 | | |
| |||
898 | 1043 | | |
899 | 1044 | | |
900 | 1045 | | |
901 | | - | |
| 1046 | + | |
| 1047 | + | |
| 1048 | + | |
| 1049 | + | |
| 1050 | + | |
| 1051 | + | |
| 1052 | + | |
| 1053 | + | |
| 1054 | + | |
| 1055 | + | |
| 1056 | + | |
902 | 1057 | | |
903 | 1058 | | |
904 | 1059 | | |
| |||
0 commit comments