Skip to content

Bound publish-handoff's five network calls and its post-write exit class - #395

Merged
randomparity merged 9 commits into
mainfrom
feat/bound-publish-handoff-384
Sep 16, 2026
Merged

randomparity merged 9 commits into
mainfrom
feat/bound-publish-handoff-384

Conversation

@randomparity

@randomparity randomparity commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Applies the network-bound convention recorded in
ADR 0068 and
references/network-bounds.md to all five network calls in
skills/return-to-town/scripts/publish-handoff. Writes no new record: the mechanism, the bound,
the write rule and this caller's classification were all already decided.

What changed

bounded_call is transcribed from the reference unchanged, so the repository has one copy of the
idiom and no second place for it to drift. Two local wrappers sit on top of it:

  • bounded_network_call names a capture slot inside the mktemp -d 0700 workspace the script
    already allocates and its EXIT trap already removes, and relays the captured stderr on every
    status but 124 — gh writes non-fatal material there while exiting 0, and that reached the
    operator before these calls were bounded. On 124 it withholds it, because the writer was killed
    mid-stream and a partial diagnostic reads as an answer.
  • timed_out reports an exceeded bound as the script's own fault — exit 2, its "could not run"
    class, which is the row the reference's classification table assigns this caller. 124 stays
    internal and is never an exit status.

All five sites captured into a command substitution, which blocks in the parent and bounds
nothing, so all five are restructurings rather than wrapper swaps: each reads its value back out
of the stdout capture file. jq takes the file as an operand in place of a here-string, the
git ls-remote output is re-read with cat, and the readback response becomes a path that
assert_stored_body reads with --rawfile unchanged.

Each call issues one request, so each takes the 30-second bound. The bound is one script-level
constant and is documented as approximate rather than as a deadline.

What a timeout reports

Every timed-out call exits 2 and names itself. The three calls before the write add that nothing
was posted. The write reports that it may or may not have landed — a bound does not make a write
atomic, and a call killed after GitHub created the comment but before its URL came back leaves a
published block the script cannot see. The readback reports that the comment was created, names
its URL, and reports its stored copy unverified. None is retried.

skills/return-to-town/SKILL.md no longer says these calls carry no bound. The re-run rule is
qualified at its own sentence, and the paragraph below it gives each call's diagnostic its own
next action rather than sending the reader to GitHub in every case.

Two further causes join exit 2, both from moving a captured value into a file: awk failing to
scan the create response for a URL, and cat failing to read back the refs git ls-remote
reported. Each is a scan that could not run, so each faults rather than collapsing into the
emptiness check below it — which would report a scan that could not run as one that found
nothing.

The create-response scan reads through a redirect rather than an operand. POSIX awk reads an
operand shaped name=value as a variable assignment and takes stdin instead, so a relative
TMPDIR whose first component is an identifier followed by = would have made that scan read the
script's own stdin. The redirect still reads the value back out of the capture file.

Test determinism, and why this mechanism

Issue #384 names two ways to reach the bound without waiting 30 seconds — a FIFO the test blocks
on, or an injectable bound — and asks for the choice to be stated. This takes a third: sed
rewrites the one bound constant in a copy of the script, run through the fixture's existing
HELPER_PATH.

A FIFO is a file each case must create, open and tear down, and leaves a blocked writer to reason
about when a case aborts. An injectable bound puts a permanent environment key in a shipped
executable for a test's benefit, which an operator could set and collect false timeouts from. The
copy adds no shipped surface, and the fixture fails the case by name if the constant is ever
renamed. Its cost is that five cases exercise a copy whose only difference is the bound.

The margins are stated rather than left implicit: the rewritten bound is 2 seconds against a call
that sleeps 30 — 15x on the bound firing — and that same constant governs the calls which must
succeed in the case, four of them in the readback case at roughly 30 ms each, which is 60x on
the other side.

Five cases cover all five sites. git stays real everywhere except the one case that bounds
git ls-remote, which installs a shim that hangs on ls-remote and execs the real git for
everything else.

Not in this change

No retry or backoff, no bound on the local git rev-parse --local-env-vars, and no new required
command — require_commands is unchanged and scripts/setup.sh does not gain timeout or
gtimeout, per ADR 0068.

Verification

  • just verify — one full local run, green, owned by the managed pre-push hook against the exact
    pushed object. An earlier full run established the clean baseline before implementation, and a
    third ran as the assembled-branch integration check after the last task. Three runs, roughly 10
    minutes each.
  • just test publish-handoff — 45 passed, 0 failed. It was 40 before this change.
  • The red claim behind the five new cases was re-derived with the implementation reverted and the
    test file kept: red-confirmed, command exit 1.
  • The version gate was checked both ways with BASE_SHA set: green at 5.10.1, and red at 5.10.0
    with the message that the tree differs from the base ref but the version did not increase.

Closes #384

Review exit payloads

Follow-up candidates

Title Evidence Trigger Recommendation Source
Applying this convention falsifies three present-tense statements in the reference that records it references/network-bounds.md — the publish-handoff:169 citation in the private-allocation property, the "all five of publish-handoff's" clause in the command-substitution property, and "no executable is bounded yet" above the classification table constructed Route to the #379 epic, or authorize a PR to touch the file: cite a function name rather than a line number, scope the "all five" sentence to appliers that have not landed, and qualify "no executable is bounded yet" as each applier merges. The same three statements go stale again as #386 and #387 land. branch review pass 1

Disclosed suppressions

Settled by this change's frozen failure model (docs/workflow/specs/2026-09-15-bound-publish-handoff-design.md, ## Failure model):

  • A missing sleep is not caught — every site calls the wrapper in a || rc=$? list, which suppresses set -e inside it, so the poll loop busy-spins and reports a false 124 on a call that was answering. sleep is not on require_commands, and adding it is excluded by this change's charter (owner: Record the timeout convention for git and gh calls in shipped executables #382).
  • No aggregate per-run budget: five 30-second bounds serially allow roughly 160 seconds plus grace windows before any diagnostic appears.
  • PID reuse inside one poll interval, same-uid, lets the bound signal an unrelated process. kill -0 is the only Bash 3.2 idiom available.
  • An attended git ls-remote sitting on an ssh passphrase or host-key prompt is contained by the bound and reported as an exceeded bound rather than named as a prompt.

Settled by references/network-bounds.md rather than by this change:

  • Bash prints a job-termination notice on the script's own stderr ahead of its diagnostic on the timeout paths. The reference accepts this where the stderr is prose, which it is here.

Not settled by either — residuals of the recorded mechanism, disclosed rather than closed, and shared with every other applier of this convention:

  • 124 is in-band with the callee's own exit status, so a gh or git that genuinely exits 124 would be reported as an exceeded bound. Neither ADR 0068 nor the reference reserves it.
  • The 0.1-second poll granularity adds at least 100 ms to every successful network call, because the loop always sleeps once before re-checking a child that may already have exited.

Applies ADR 0068 and references/network-bounds.md to the one caller whose
bounded write owns the post-write indeterminacy class. Records the spec, its
failure and threat models, and the two-task implementation plan.

Refs #384
Both passes' findings applied: the fixture root stubs the public-safety gate
rather than symlinking into a shim that cannot resolve there, the stderr
pass-through avoids ADR 0047's colon-discard gate, the re-run rule is qualified
at its own sentence, the tasks put their failing cases before the
implementation, and the bound and hang margins are stated.

Refs #384
Records why the fixture rewrites a bound constant in a copy rather than taking
either mechanism #384 names, routes the sleep/require_commands interaction to
the campaign orchestrator now that #382 is closed, and stops the SKILL.md
paragraph claiming every timed-out call is two-sided when only the write is.

Refs #384
Adds the bounded_call idiom recorded in references/network-bounds.md, a
bounded_network_call wrapper that names a capture slot in the existing 0700
scratch workspace and relays captured stderr, and timed_out, which reports an
exceeded bound as this script's own fault -- exit 2, the class the reference's
table assigns this caller.

gh pr view, gh api on the destination, and git ls-remote each stop capturing
into a command substitution and read their value back out of the stdout
capture file, which is what the bound requires: a command substitution blocks
in the parent and bounds nothing.

Refs #384
The create and the readback are the two calls a bound cannot make atomic, so
each reports what it actually knows: the write may or may not have landed, and
the readback names the comment that was created and whose stored copy went
unverified. Neither retries, and both take exit 2 rather than the finding
status, because a timeout checked no condition.

SKILL.md's re-run rule is qualified at its own sentence and the paragraph
below it now says which call reports which state, replacing the paragraph that
said these calls carry no bound.

Refs #384
The re-run rule no longer prohibits re-running and then instruct a re-run four
sentences later: the prohibition is on re-running from the exit status alone,
and each call's diagnostic now carries its own next action.

The spec's Classification paragraph names both new exit-2 causes rather than
one -- the awk scan at the write and the cat read at the ls-remote site are
each a scan that could not run.

Refs #384
POSIX awk reads an operand shaped name=value as a variable assignment and takes
stdin instead, so a relative TMPDIR whose first component is an identifier
followed by = would have made the URL scan read this script's own stdin and
report a scan that could not run as one that found nothing. The redirect still
reads the value back out of the capture file, which is what the bound requires.

cat at the ls-remote site takes -- for the option-shaped case, and the spec's
threat model now records that the captures reach jq, awk and cat as operands.

Refs #384
The redirect pushed the statement one character past the 100-column
preference; the file's own prior form for this scan was already a
continuation.

Refs #384
@randomparity

Copy link
Copy Markdown
Owner Author

verdict: approve
exit: none
findings: 3
iterations: 1
security: approve
delivered-head-sha: 325d44b

Forge whole-branch review

forge review: not required (cast mode has no whole-branch reviewer -- the branch adversarial pass is quest step 6 trial-loop at iterating depth)

Review exit payloads

Follow-up candidates

Title Evidence Trigger Recommendation Source
Applying this convention falsifies three present-tense statements in the reference that records it references/network-bounds.md — the publish-handoff:169 citation in the private-allocation property, the "all five of publish-handoff's" clause in the command-substitution property, and "no executable is bounded yet" above the classification table constructed Route to the #379 epic, or authorize a PR to touch the file: cite a function name rather than a line number, scope the "all five" sentence to appliers that have not landed, and qualify "no executable is bounded yet" as each applier merges. The same three statements go stale again as #386 and #387 land. branch review pass 1

Disclosed suppressions

Settled by this change's frozen failure model (docs/workflow/specs/2026-09-15-bound-publish-handoff-design.md, ## Failure model):

  • A missing sleep is not caught — every site calls the wrapper in a || rc=$? list, which suppresses set -e inside it, so the poll loop busy-spins and reports a false 124 on a call that was answering. sleep is not on require_commands, and adding it is excluded by this change's charter (owner: Record the timeout convention for git and gh calls in shipped executables #382).
  • No aggregate per-run budget: five 30-second bounds serially allow roughly 160 seconds plus grace windows before any diagnostic appears.
  • PID reuse inside one poll interval, same-uid, lets the bound signal an unrelated process. kill -0 is the only Bash 3.2 idiom available.
  • An attended git ls-remote sitting on an ssh passphrase or host-key prompt is contained by the bound and reported as an exceeded bound rather than named as a prompt.

Settled by references/network-bounds.md rather than by this change:

  • Bash prints a job-termination notice on the script's own stderr ahead of its diagnostic on the timeout paths. The reference accepts this where the stderr is prose, which it is here.

Not settled by either — residuals of the recorded mechanism, disclosed rather than closed, and shared with every other applier of this convention:

  • 124 is in-band with the callee's own exit status, so a gh or git that genuinely exits 124 would be reported as an exceeded bound. Neither ADR 0068 nor the reference reserves it.
  • The 0.1-second poll granularity adds at least 100 ms to every successful network call, because the loop always sleeps once before re-checking a child that may already have exited.

@randomparity
randomparity merged commit 097bda4 into main Sep 16, 2026
5 checks passed
@randomparity
randomparity deleted the feat/bound-publish-handoff-384 branch September 16, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bound publish-handoff's five network calls and its post-write exit class

1 participant