Repository navigation
fix(scripts): route check-closing-target-claim through the proxy, and make a transport failure speak - #18998
Conversation
…oxy once In an agent container api.github.com is reachable only through the session proxy, which is also where the credential is injected, and node's `fetch` does not read the proxy variables. An unrouted run therefore arrived without its credential, GitHub answered 401, and this gate folded that into a per-target UNDETERMINED behind exit 0 — a gate that judged nothing while its exit code read clean, which is the only reading a derived-gate sweep records. The decision is the shared `proxyRearmPlan`, imported and not restated; only the guard VARIABLE is this file's own, so a sibling instrument's re-exec can never suppress this one's. Taken only by a wired run about to read the API, never on `--self-test`. On an Actions runner there is no proxy in the environment, the plan never re-arms, and nothing about this gate changes there. Claude-Session: https://claude.ai/code/session_01BTeBejoPUvRHN8WdAJC6oF Co-authored-by: Claude <noreply@anthropic.com>
…DETERMINED `collect` used to swallow every throw into a per-target UNDETERMINED at exit 0, so "one number could not be read" and "this run cannot read GitHub at all" printed the same shape. A derived-gate sweep records exit codes, so the second one was recorded as measured green. The boundary is a TYPED status, never message text: `githubApi` throws `GitHubApiError` carrying the status or a network flag, and `isBoardNotRead` reads it. 404 stays per-target — it is what "that number does not resolve" looks like — and so does a truncated thread walk. 401, 403, 429, 5xx and a network error propagate out of `collect` to `boardNotReadRefusal`, the fleet's PREREQUISITE NOT MET shape at `EXIT_PREREQUISITE_NOT_MET`, imported rather than re-declared. A plain `Error` keeps its old reading, so no bug can promote itself to a whole-board refusal. No `::error::` annotation on that path: the exit code is what makes the job red, and an annotation would name an author who caused none of it. 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 |
|
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 |
Fixes #18947
Clause-②: no
scripts/check-closing-target-claim.mjsread GitHub through node's globalfetchwith no proxy route. In an agent container api.github.com is reachable only through
the session proxy — which is also where the credential is injected — and
fetchdoes not read the proxy variables, so every request arrived uncredentialled and
GitHub answered 401. This gate folded that 401 into a per-target
UNDETERMINEDatexit 0: honest in prose, green in the one thing a derived-gate sweep records.
Two commits, in the order the triage asks for: the route, then the negative control.
Before / after / control — this container, transcripts
Exit codes captured with redirect-then-
$?; no token is printed by any leg.PR_BODYbound #18844, the closingkeyword is written
CLOSINGbelow. The real command used the ordinary spelling; it ismasked here so that quoting a probe does not make this PR body bind a second card. The
keyword next to #18947 is left literal — that is this PR's own target, already bound on
line 1.
BEFORE (
d8b12fca9, the seat's own probe re-run at this base):AFTER, same invocation — the re-exec line, then a real per-target verdict:
⭐ The reading, reported and not steered: #18844 resolves to
TARGET_CLOSED, not to afinding. The classifying read was really spent (it is the second read, taken only on
the path about to go red), and it answered
state != open. The declined-number policythis file already documents is what declines it — the merge closes nothing, so there is
no second implementation to prevent.
AFTER, live acceptance pair against the real board, which is the stronger evidence
that the read reaches the thread at all — this card, this branch, and this card with a
branch nobody claimed:
CONTROL A — the negative control the triage requires: same command,
HTTPS_PROXY/https_proxyunset, so the request really fails at the transport:⛔ No longer exit 0. 「修好之后,一次真实的传输失败必须能让门禁出声」 — it speaks.
CONTROL B — the network-error leg, live:
HTTPS_PROXY=http://127.0.0.1:1(a deadport). This one also proves the hand-off is invisible in the exit codes: the parent
re-execs, the child refuses, and 3 comes back verbatim.
The boundary between transport and truncation, and the line that draws it
The card's own docblock said a transport failure is 「declared UNDETERMINED rather than
an unhandled rejection」. That sentence is what this card turns around, for the
whole-board case only. Where the catch sat: three sites inside
collect—apiOrNull(the queue leg's PR read and the classifying issue read) and the
tryaround thecomment-thread walk. All three swallowed everything.
The line that draws the new boundary is a single predicate, read at all three sites:
githubApinow throwsGitHubApiErrorcarrying theHTTP status, or
networkError: truewhenfetchitself rejected. A plainErrorfromanywhere else — a fake transport in the self-test, a bug — is not a transport
failure and keeps its old per-target reading, so no untyped throw can promote itself to
a whole-board refusal. Every pre-existing self-test case that threw
new Error('boom')is green unchanged.
the path names: that number does not resolve for this token. So does a thread walked
past
MAX_COMMENT_PAGES. Both remainexit 0+::warning::UNDETERMINED— 「neverclean, never an accusation」 — and both are pinned.
limit), 5xx (GitHub), or no connection at all. Those propagate out of
collect, sonothing at all is judged, and the dispatch turns them into
boardNotReadRefusal(error)— pure,
{ exit, lines }, driven offline by the self-test.is the same class — the board was not read — and routing it to UNDETERMINED would be
exactly today's silence under a different number. Called out here rather than folded in
silently. The card's claim comment also named 404 in a parenthetical; the ruling section
names 401/403/5xx/network and explicitly keeps 「a target whose number cannot be
read」 as UNDETERMINED, so 404 is per-target. That tension is resolved in the ruling's
direction, and stated rather than discovered.
::error::annotation on the refusal path, matching NOT MEASURED: the exitcode is what makes the job red, and an annotation would name an author who caused none
of it.
isBoardNotReadandre-throws anything else, so a genuine bug in this gate stays an unhandled rejection
where it is visible as one, instead of being filed under "GitHub was unreachable".
nothing in this repository gets 404 on every path, so every target reads UNDETERMINED
at exit 0. That is the same shape as a board of numbers that do not resolve and no
status distinguishes them. What this gate can tell apart is what it does tell apart.
The route
The shape
maincarries today, not PR #18980's unlandedguardparameter: the sharedproxyRearmPlanis imported,proxyPlanEnv()folds this file's own guard name(
OS_CLOSING_TARGET_CLAIM_PROXY_REARMED) onto the shared name, andrearmThroughProxy()is called once, only by a wired run about to read the API — never on
--self-test,never on a NOT WIRED run. A sibling instrument's guard must not suppress this one's,
which is pinned. On an Actions runner there is no proxy in the environment, the plan
never re-arms, and nothing about this gate changes there.
Exit register — before / after
3 is
EXIT_PREREQUISITE_NOT_MET, imported from the half-state module exactly ascheck-issue-citations.mjsspells it, ⛔ not re-declared here.The CI consumer needs no change — measured, not assumed.
.github/workflows/closing-target-claim-guard.ymlinvokes the script with a barerun: node scripts/check-closing-target-claim.mjs: nocontinue-on-error, no|| true,no
if:on the step. A non-zero exit fails the step and reds the job, so exit 3 is a redjob with no workflow edit. That exit is also unreachable on a runner for a routing
reason: there is no proxy there, and the token is the job's own.
Self-test
SELF_TEST_BATTERY_FLOORThe floor rose; no existing battery's pin moved. Two batteries added — the routing
decision (9 cases, offline, pinned exactly as the landed mapper pins it: set ⇒ route,
absent ⇒ none, flag present ⇒ none, own guard set ⇒ none, the patrol's guard set ⇒ still
routes, plus the two structural pins that the plan is imported and the hand-off is called
once before the first network read) and the transport boundary (16 cases: each refused
status, the 404 and untyped-throw exclusions, the fleet phrase, the quoted error,
⛔ not a verdict, exit 3 being none of 0/1/2, no::error::, no✓, the route named when aproxy is configured, and both UNDETERMINED legs still at exit 0).
Reader test
Held, by CONTROL A above and by the live acceptance pair.
Changeset
skip-changeset, measured rather than assumed. Every published package in this repodeclares
files[], and every entry resolves inside that package's own directory(
dist,README.md,CHANGELOG.md;@objectstack/specadds generated dirs andsrc/**/*.zod.ts).scripts/check-closing-target-claim.mjsis at the repo root, underno
package.jsonat all (there is none anywhere underscripts/), so it is inside nopublishable package directory and no
files[]entry can name it. The root manifest isprivate: true. Positive control: a file that is published —packages/spec/src/data/object.zod.ts— sits inside a published package directory and ismatched by that package's
src/**/*.zod.tsentry. Nothing published moves.Gates
Derived from this worktree with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack— 29commands, the derivation's own first line naming this repo and commit. Every one was
run, exit code captured before any pipe, and the record reconciled back with
--raninthe
COMMAND :: exit CODEform the reconciler asks for:All 29 exit 0, including this gate's own
pnpm check:closing-target-claim(105 cases)and
pnpm check:pm-dispatch-gates(1849 cases, 756.6s on this box, run detached behind anexit-capturing wrapper and waited on with
tail --pid).origin/mainmoved during therun (
f347c793e); the derivation was re-run after a fetch and the 29-command set isbyte-identical.
Repo-wide lint, not a narrowing.
eslint . --no-inline-configwas run in full atc1e174eba, after the final commit: 6858 files linted (eslint's own count from--format json), 0 files with problems, exit 0. So no narrowing needs declaring here.Local verification scope
its
--self-test, run above.derived families — here the two are the same thing:
pnpm check:closing-target-claimis
node scripts/check-closing-target-claim.mjs --self-test, and it is in the derivedlist.
pnpm lintis a repo-scan CI owns; it fitted in the foreground here and was run whole(above), so there is nothing narrowed to declare.
Files changed
scripts/check-closing-target-claim.mjs(only)Generated by Claude Code