0007: No terminal receipt file for a run's outcome
- Status: Accepted
- Date: 2026-08-05
Context
Child fidelity makes a foreground run's exit code ambiguous by construction: the
runner exits with the child's own code verbatim, so a 106 is either the runner's
TIMEOUT or a child that happened to exit 106. The exit-code
contract already names the
JSONL stream as the authority that resolves it, which means a supervising adapter
reads the stream after every call.
A proposal asked for a cheaper answer: an opt-in run --outcome-json <path>,
atomically replaced at terminal completion with a versioned subset of runner_exit
plus run_id, a cleanup confirmation, a capture summary, and artifact locators —
never replacing the lifecycle stream, and with its absence after an abrupt runner
death remaining meaningful. This record is the decision on that proposal, taken after
the published machine-output schemas and the events subcommand landed.
Decision
Do not add a terminal receipt file. run keeps exactly one durable outcome artifact,
the required --jsonl lifecycle stream, and an adapter that wants the outcome without
opening it reads the failure envelope instead: under the global --error-format json, a runner-owned ending prints one bounded JSON object on stderr whose kind is
spelled exactly like the terminal runner_exit event's source, and a child's own
exit prints none. The envelope's presence, not the numeric value, is what separates
the two readings of a reserved-band code.
The adapter-facing form of this rule is in the integration guide, "Telling outcomes apart" and "No terminal receipt file".
Alternatives considered
-
run --outcome-json <path>, the proposal. Rejected, on four grounds.- It would not remove the read it exists to remove. The supervising adapter this
decision was measured against — a foreground driver of this binary — consumes
four event types after each call:
run_started(root_pid,mechanism,abrupt_cleanup),members_snapshot,output_captured, andrunner_exit. It treats a missingrun_startedoroutput_capturedas its own named failure reason. A terminal receipt carries terminal facts; the start-time facts exist only in the stream. To actually retire that reader the receipt would have to grow into a second, full serialization of the run. - The read is seven lines. A foreground run emits seven events, eight with
--capture-dir, and nine if a requested resource cap (--max-memory/--max-processes/--cpu-quota) also contributes its post-runlimit_evidence. It was six when this decision was taken; the seventh is the unconditionalresource_summary(T-317), the one growth that is not a caller opting in — and one extra line does not change this argument, which turns on the read being a bounded handful either way. Every other way the stream grows is a caller asking for more events on purpose — those two axes plus--snapshot-interval's extramembers_snapshotsamples. The full ordering rule is indocs/schema.md, "Ordering". - A cheaper answer already exists. The
--error-format jsonenvelope resolves exactly the ambiguity the proposal names, with no path to allocate, no artifact to clean up, and no new schema family to version. - A receipt has no honest failure mode. It would be written after the child's
code is already decided, so a failed write or a failed replace leaves three bad
options: fail the run, which rewrites the child's exit code and breaks the
contract's core rule; succeed silently, which makes the receipt's absence
ambiguous — "the runner died abruptly" or "the receipt could not be written" —
and so destroys the one property the proposal wanted to preserve; or record the
failure in the stream, which only a reader of the stream would see. This is the
project's existing principle applied to a new channel: a
--jsonlwrite failure is already outside every event's reach, because no event can report the failure of the channel that would carry it. Unlike the stream, a receipt carries nothing else, so its failure is total rather than partial.
A fifth, smaller cost is worth recording: the stream is created or truncated at the start of the run and its
run_startednames therun_id, so a reader can confirm the file belongs to the run it asked about. A receipt that must never appear early or partially cannot be truncated at start, so one left at the same path by an earlier run survives the current run's abrupt death and reads as valid. It is mitigable by comparing arun_idfield — but that comparison is the adapter-side logic the receipt was supposed to save. - It would not remove the read it exists to remove. The supervising adapter this
decision was measured against — a foreground driver of this binary — consumes
four event types after each call:
-
A bounded terminal read over an arbitrary stream (
events --outcome --file). Deferred, not rejected. The primitive already exists —wait --report-outcomegates on arun_startednaming the id, then scans a bounded 64 KiB tail in reverse for the last well-formedrunner_exit(wait.rs) — but it is reachable only for a run the invocation itself observed live, and a finished foreground run has already deleted its own registry record, sowait --report-outcomehonestly answersstatus: "unknown"for it.eventsreads a stream a different way on purpose and offers no terminal-only mode. If reason 2's seven-line read is ever measured to matter, the answer is to expose that existing read-side primitive over--file— one flag on a read-only subcommand, reusing the already-publishedwait --report-outcomeshape — rather than to add a second write path. Recorded so a revisit starts from the smaller option.
Consequences
run has one durable outcome channel, and --jsonl stays required, so there is no
configuration in which a receipt would be an adapter's only artifact. Adapters get a
documented stream-free disambiguation that costs one flag they can leave on for every
invocation. The residual gap is named rather than papered over: there is still no
first-party bounded terminal read over an arbitrary stream file, and closing it is a
read-side change if it is ever justified. Reopening this decision should rest on a
measured cost, not an anticipated one.