Repository navigation
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
Conversation
…o PREREQUISITE NOT MET instead of the accusation code
The live path read a PR's file list with nothing catching a throw, so any
transport failure — a 404 from a number that resolves to no pull request, a
dead credential, a dropped socket — exited on node's unhandled rejection
status, 1. This file defines 1 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.
Its own header already forbade exactly 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"), so this is the file keeping its own rule.
The read — and only the read — is now 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 failure is turned into a refusal by a pure handler that returns
`{ exit, lines }` exactly as `judge` does, so the self-test drives the arm
offline with no process exit and no network.
The code is EXIT_PREREQUISITE_NOT_MET, imported from the fleet's shared
register rather than numbered locally, the way check-issue-citations and
post-stamped already spell "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 is
still red on it.
Probe, in an agent container, before and after
(PR_NUMBER=99999999 GITHUB_REPOSITORY=objectstack-ai/objectstack):
before: unhandled rejection, stack trace, exit 1
after: "PREREQUISITE NOT MET — the board was not read — GitHub API 404
for /repos/.../pulls/99999999/files... — not a verdict, not a
clean tree", exit 3
Live reverse control, PR_NUMBER=18935: unchanged, clean verdict, exit 0.
Self-test: a new battery of 19 cases, roster floor raised 8 -> 9 batteries,
74 -> 93 cases. It pins the code and the words, drives a throwing fake api
through collect into the same handler, holds a healthy api reaching a real
verdict as the reverse control, and pins structurally that the handler is
reached after the LAST network read — the live one — so a deleted live catch
reds rather than leaving the pin satisfied by the self-test's own calls.
Claude-Session: https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF
Co-authored-by: Claude <noreply@anthropic.com>
… the verdict prints, not before the async block that produces it
`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 — "selfTest()
returned without reaching its verdict" — 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 now is that last statement. The failing arm is left without
one deliberately: 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 what the new position catches.
Negative control (deliberate early return injected inside the block, on the
committed file, restored by blob hash): red, "selfTest() returned without
reaching its verdict", exit 1. Before this commit the identical injection
printed the same partial output and exited 0.
Claude-Session: https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF
Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Landing record — every pre-check met, blocked at the channel (skills seat, Every check on Generated by Claude Code |
…o a flag above the returned block is no longer a HELD
The probe injected `return;` as the FIRST statement of the dispatched function
and read one answer from it. A handshake set ABOVE the block that function
RETURNS stays unset under that injection, the dispatch refuses, and the row
read HELD -- while the same `return;` one statement lower, inside the block,
leaves the handshake SET and the run silent at exit 0. The 2026-09 census
cleared `scripts/check-single-claim-paths.mjs` HELD exactly that way, on the
revision that carried that ordering defect.
A file whose dispatched function returns a block is now read at both points and
HELD requires both. `HANDSHAKE-ABOVE-BLOCK` is the new verdict for "point 1
refused, point 2 exited 0", counted apart from HELD as ACCIDENT already is.
Measured on this base (223 rows, 217 with a resolvable entry): ONE row carries a
second point -- `check-single-claim-paths.mjs` -- so no other row's reading
moves, and every other row publishes `points: 1` with the reason it has one.
before --probe --only scripts/check-single-claim-paths.mjs -> 0 DEFEATED, 1 HELD, exit 0
after same command -> 1 HANDSHAKE-ABOVE-BLOCK, exit 0; point 1 refused,
point 2 exited 0 printing 0 bytes
control PR #18986's head (03310bc, the same file with the handshake moved
inside the block) -> HELD at BOTH points, both baselines exit 0
The anchor is the returned `async () => {` IIFE at the dispatched function's own
statement level: masked, line-anchored and at depth 0. Deliberately unadmitted,
each with its reason in the docblock: an `await (async () => {` in the middle of
the body (an early return there skips a battery while the self-test still
reaches its verdict -- hole 1, never this one), a returned `.then()` callback
and an `async function` expression (no carrier in this tree). The header also
records what the point CANNOT read: work whose failure is silent, measured on
`check-system-context-census.mjs`, whose handshake sits 48 lines above its own
battery floor and where both runs print the same bytes.
Costs: the second pair of spawns runs only for a row that HELD at the first
point AND has a second point, so a red-baseline checkout costs what it did.
Controls, both directions: the pair of fixtures differing only in WHERE the
handshake line sits (above -> HANDSHAKE-ABOVE-BLOCK, inside -> HELD at both);
five anchor decoys ahead of the real block, one per rule; an awaited mid-body
block and a block-free self-test both yielding no second point; a one-point row
reading identically through the composer and through the probe; and a row
DEFEATED at the first point probed exactly once.
Claude-Session: https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF
Co-authored-by: Claude <noreply@anthropic.com>
|
Landing attempt on the maintainer's word — refused a fourth time, with the allow rule on disk (skills seat, PR #19047 ( ⇒ The rules are right and on Generated by Claude Code |
|
Landing record — landed on the seat's record through the CCR route (skills seat, Channel: the two allow rules of PR #19047 ( Acts: Generated by Claude Code |
…o a flag above the returned block is no longer scored HELD (objectstack-ai#19026) Fixes objectstack-ai#18987 Clause-②: no The early-return probe in `scripts/measure-self-test-floor.mjs` read ONE injection point — `return;` as the first statement of the dispatched self-test function — and published a three-valued verdict from it. A handshake set ABOVE the block that function RETURNS stays unset under that injection, the dispatch refuses, and the row reads HELD, while the same `return;` one statement lower, inside the block, leaves the handshake SET and the run silent at exit 0. The 2026-09 census cleared `scripts/check-single-claim-paths.mjs` HELD exactly that way, on the revision that carried that ordering defect. A file whose dispatched function returns a block is now read at BOTH points, and HELD requires both. `HANDSHAKE-ABOVE-BLOCK` is the new verdict for "point 1 refused, point 2 exited 0", counted apart from HELD as ACCIDENT already is. ## The prerequisite: the instrument refused to run at all `node scripts/measure-self-test-floor.mjs` exits 1 on `main` — census, `--probe` and `--probe --only` alike — because `classifyHandshake` reads three landed spellings as `none` while `LANDED_HANDSHAKE_NAMES` sees them, and the live completeness check refuses rather than printing the smaller of two disagreeing populations. That is the defect objectstack-ai#18329 records, and nothing about this card could be measured without it, so the first commit widens the recogniser exactly as far as running the probe needs. objectstack-ai#18329 remains open and this PR does not address it beyond that prerequisite; the seat decides what is left of it. The three spellings, all of them the return value bound before it is compared: | file | spelling | |:--|:--| | `scripts/check-workflow-step-name-quoting.mjs` | `const verdict = selfTest();` then `if (verdict !== SELF_TEST_VERDICT)` | | `scripts/pm/check-expected-skips.mjs` | `const r = selfTest();` then `if (r.verdict !== SELF_TEST_VERDICT)` | | `scripts/pm/check-prior-rulings.mjs` | same record shape | **`LANDED_HANDSHAKE_NAMES` needed no addition** — its `*VERDICT` comparison pattern already matches all three, which is why the disagreement was one-sided. The refusal text prescribes widening both "with a control on each"; the measurement says only one of the two was short, and adding a spelling to the second opinion that it already reads would weaken it, not widen it. **The GUARD half is a boundary the measurement forced.** Admitted as "the binding compared against a named operand anywhere", a FOURTH row moved: `scripts/check-plugin-teardown-shape.mjs` binds `const selfTestCode = selfTest();`, refuses on its FLAG one line later, and then compares that binding against `EXIT_PREREQUISITE_NOT_MET` inside `process.exit(cmp ? a : b)` to choose which code to exit with. Choosing a value is not asking whether the self-test finished, so the bound spelling reads a comparison that heads an `if (...)` and nothing else. Its control is a fixture reduced from that file. Delta over the 223-row census: exactly the three rows move `none -> sentinel`. Before: 101 sentinel, 103 flag, 1 helper, 18 none. After: 104 sentinel, 103 flag, 1 helper, 15 none. ## The two controls, with exit codes **Negative control — `main`'s own `scripts/check-single-claim-paths.mjs`** (PR objectstack-ai#18986 has not landed, so the flag still sits above the returned block). Through the shipped path, `node scripts/measure-self-test-floor.mjs --probe --only scripts/check-single-claim-paths.mjs`: ```text before (this branch's first commit, one injection point) -- exit 0 0 DEFEATED, 1 HELD, 0 ACCIDENT, 0 NOT MEASURED. HELD scripts/check-single-claim-paths.mjs -- ✗ check-single-claim-paths self-test: selfTest() returned without reaching its verdict, after (two injection points) -- exit 0 0 DEFEATED, 0 HELD, 1 HANDSHAKE-ABOVE-BLOCK, 0 ACCIDENT, 0 NOT MEASURED. 1 of the 1 probed return a block and were read at BOTH injection points; HAB scripts/check-single-claim-paths.mjs -- point 1 refused (✗ check-single-claim-paths self-test: selfTest() returned wi) point 2, inside the block it returns: exited 0 printing 0 byte(s) ``` **Positive control — PR objectstack-ai#18986's head `03310bc914`**, the same file with the handshake moved inside the block as its last statement, probed in a second worktree at that revision (fetched into a ref of this session's own, never merged into this branch): ```text { "verdict": "HELD", "points": 2, "first": { "verdict": "HELD", "baselineExit": 0, "mutatedExit": 1, "mutatedBytes": 210 }, "second": { "verdict": "HELD", "baselineExit": 0, "mutatedExit": 1, "mutatedBytes": 210, "mutatedHead": "✗ check-single-claim-paths self-test: selfTest() returned without reaching its verdict," } } ``` Both legs' baselines exit 0, so both readings are available rather than inherited from a red tree. ## The anchor, and what it deliberately does not read The second point is the returned `async () => {` IIFE at the dispatched function's own statement level: read over the comment-and-literal mask, line-anchored, and at brace depth 0. Measured over the census: 217 rows resolve an entry and exactly ONE carries a second point, so no other row's reading moves; every one-point row publishes `points: 1` and the reason it has one, rather than an absent field. Unadmitted, each with its reason in the docblock rather than for want of a regex: - an `await (async () => {` in the MIDDLE of the body (`check-half-states.mjs`, `check-governed-queue-guard.mjs`): an early return there skips a battery while the self-test still reaches its own verdict, which is HOLE 1, and the two holes are never summed; - a returned `.then(callback)` and an `async function` expression: no carrier in this tree. The header also records what this point CANNOT read, because a reader will otherwise take a HELD for more than it is: work after the handshake whose failure is SILENT. Measured on `scripts/check-system-context-census.mjs`, whose handshake sits 48 lines above its own battery floor — a `return;` immediately after the flag leaves both runs printing the identical 18566 bytes at exit 0, so a mutation reports "no observable effect" and no probe can stand in for reading the ordering. That file is in the acceptance notes below. ## Tests - `node scripts/measure-self-test-floor.mjs` — exit 0, 223 rows (exit 1 on `main`). Its controls run inline on every invocation: **119 -> 154 control assertions**. - Ablations, each from the committed state through `scripts/ablation-replace.mjs`, each restored with the blob hash equal to HEAD's and `git diff HEAD` empty: - compose the second point's DEFEATED as HELD -> 2 controls red (`a handshake set ABOVE the block the self-test returns read HELD`); - drop the anchor's LINE-START rule -> anchor control red (anchor moved 627 -> 442); - drop the anchor's DEPTH rule -> anchor control red (anchor moved 627 -> 512); - drop the `if (...)` guard from the bound sentinel -> boundary control red (`read sentinel, not flag`). The first ablation of LINE-START came back GREEN and that is in the history: the only mid-line decoy was also nested, so DEPTH covered for it. Two decoys now isolate the rules, one each, and the re-run reds. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `e39670e33`: 26 families derived, all 26 run, **26 exit 0**; `--ran` reconciliation `✓ 26 derived famil(ies) accounted for — 26 run, 0 NOT-MEASURED`. - `pnpm lint` (the whole repo, `eslint . --no-inline-config`): exit 0 in 1m46s — the full run, so no narrowing to declare. - No package is touched, so there is no build closure and no package test to run. ## Changeset `skip-changeset`. Nothing published moves: the only changed path is `scripts/measure-self-test-floor.mjs`, which lives in the private root package (`@objectstack/spec-monorepo`, `private: true`), and no published package's `files[]` carries an entry escaping its own directory (measured: 0 of them). Symbol grep over `packages/`: `returnedBlockAnchor`, `probeVerdictPoints`, `measure-self-test-floor` -> 0 files; positive control `defineStack` -> 395. ## The census document is deliberately untouched `docs/audits/2026-09-self-test-shape-census.md` is the instrument's own output and is NOT regenerated here. Its population is 179 rows at `d30ccb9bd`; the census is 223 rows today, and its probe column is a full sweep this container cannot run in one foreground turn. Regenerating it would move rows for drift and for the recogniser widening as well as for this point, which makes "which rows moved" unreadable — the one thing a regeneration owes. A regeneration is its own card, and this PR's readings above name the row that changes: `check-single-claim-paths.mjs`, HELD -> HANDSHAKE-ABOVE-BLOCK. ## Reader test Run `node scripts/measure-self-test-floor.mjs --probe --only scripts/check-single-claim-paths.mjs`: on `main` it exits 1 printing no census at all, and on this branch it prints `1 HANDSHAKE-ABOVE-BLOCK` with `point 2 ... exited 0 printing 0 byte(s)` — the reading the census's HELD did not take. ## Acceptance notes To file (class b, a declared-contract violation; dedupe words: `check-system-context-census handshake above floor` · `selfTestReachedVerdict before battery floor` · `success line printed before the floor evaluates` · `self-test verdict ordering static` · `handshake not the last statement`): `scripts/check-system-context-census.mjs` sets `selfTestReachedVerdict = true` at its line 2849 and then runs 48 lines of battery floor, with its success line printed ABOVE the flag. AGENTS.md states 「Set the flag as the self-test's last statement, after its success line prints」, and the file's own comment claims the floor is 「Evaluated after every battery has had its chance and BEFORE the verdict」, which its own text contradicts. An early exit anywhere in that floor region leaves a run that printed `all cases passed`, exited 0, and never evaluated its floor — with the handshake reading "reached". Measured here: the mutation is invisible to this probe (identical 18566 bytes, both exit 0), so this is a static ordering finding and the instrument correctly reports that it observed nothing. noted, not filed: the record spelling of the bound sentinel (`const r = selfTest(); if (r.verdict !== ...)`) refuses with a TypeError rather than with the sentence its author wrote — after an early return the binding is `undefined` and the property read throws. It is a real, loud non-zero exit and the probe publishes whatever the run said, so the column's answer (`sentinel`) is correct; the wording is a property of the shape, recorded in the recogniser's docblock. Next toucher: whoever widens `classifyHandshake` again. noted, not filed: this instrument's docblock still quotes "181 rows" and "158 differently shaped self-tests" from an older base against today's 223. Prose drift in a file this PR already touches; correcting it here would bury the diff that matters. Next toucher: whoever regenerates the census document. --- _Generated by [Claude Code](https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18940
Clause-②: no
Two defects in one file, both of the same class —
scripts/check-single-claim-paths.mjsagainst 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.
githubApithrows on anynon-ok response and
fetchthrows on a dead socket, so a 404, a dead credential or a droppedconnection 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:After:
Live reverse control, same tree, a PR number that really resolves — unchanged:
Shape of the change:
judgestays outside thetryon purpose: it is pure and cannotthrow 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.
boardNotReadRefusal(error), returning{ exit, lines }exactly asjudgedoes. That is what lets the self-test drive the arm withno process exit and no network. The single
process.exitstays at the dispatch, where everyother verdict's exit already lives.
EXIT_PREREQUISITE_NOT_MET, imported from the fleet's shared register, the wayscripts/check-issue-citations.mjsandscripts/pm/post-stamped.mjsalready spell it, so thewording family is one phrase across the tree: "PREREQUISITE NOT MET — the board was not read".
(
.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.
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_NUMBERabsent) anddoes not enumerate the register, so there is nothing there to extend. The
run:line isunchanged and any non-zero exit is a red job.
B. The self-test handshake was set before the block that produces the verdict
selfTestReachedVerdict = truesat on the line directly abovereturn (async () => {— theblock 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. Theone 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 — thehandshake 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 provenagainst 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):
Leg 2 — the same injection on the pre-fix file (commit
d4b1f2817, i.e. after A and beforeB), restored into the tree with
git restore --source=... --worktreeso the index is untouched: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 --porcelainis empty after both legs, and the working blob equals the HEAD blob.Self-test counts
26c73fb4eSELF_TEST_BATTERY_FLOORrises 8 to 9 with the roster, because leaving it at 8 would let the newbattery 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
collectinto thesame 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 declarelet selfTestReachedVerdict = falseandread 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".
scripts/check-single-claim-paths.mjsscripts/check-osv-exemptions.mjsselfTest(); the flag is its last statement. The floor call at 675 is at the DISPATCH, after the flag is read at 657scripts/typecheck-configs.mjsselfTest(), floor at 377 in the dispatch, flag read at 357scripts/check-durability-degradation-log-level.mjsreadSeamsReachedVerdict(5609)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.mdscores thisvery 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 flagassignment 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 packagesdeclare
files[]and every one shipsdist,README.mdandCHANGELOG.mdonly — none shipsrepo-root
scripts/. The only reference to this script outsidescripts/is the private rootmanifest's own
check:single-claim-pathsline. The positive control for the method is that samescan 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.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom 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 beingpnpm check:pm-dispatch-gatesat 745s (1848 self-test cases) under contention with a siblingworktree running the same gate. Reconciled with
dispatch-gates.mjs --ran, each line recorded ascommand :: exit N: 29 derived, 29 run, 0 UNRUN, 0 NOT-MEASURED as a DERIVED zero rather than aclaimed one, and none of the 29 exited 3.
grep -naPover the C0 range plus DEL): no hits.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:
ever arrives, the register would be better as a table. Style, no behaviour.
MAX_PAGESis 30 in both loops with one comment explaining the file-list ceiling; the open-PRloop 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