feat(aura-runner, aura-cli): RunnerError propagation — the assembly crate stops killing the host

All 14 process::exit sites in aura-runner (member x8, measure x5,
translate x1) convert to RunnerError { exit_code, message } propagation;
run_signal_r / run_measurement return Result tuples carrying the
skipped-tap names beside the C18 reports (record shapes untouched), and
the CLI owns printing via the shared exit_on_runner_error arm (the
dispatch_reproduce pattern — reproduce now uses the same helper).
cost_knob becomes fallible: the sweep worker maps it into the cell's
MemberFault (closing the one hole in 'a worker never process-exits'),
the persist path keeps its string channel. The skipped-tap note is
CLI-printed from returned data, byte-identical.

The one deliberate behaviour change is the minuted C14 adjudication:
refusals in the content of what argv named (binding, synthetic mismatch,
compile, tap-bind content, exposes-neither) move exit 1 -> 2; the
boundary is FORM vs VALUE — an out-of-domain override value keeps the
runtime class via the panic containment, matching the campaign leg.
Environment/data/IO refusals stay 1 (TapPlanError::exit_class is the
single source). Named re-pins in exec.rs, run_refuses_unrunnable_
blueprint.rs, tap_recording.rs, graph_construct.rs; new library-level
test pins the milestone promise (a refusal returns in-process, the
embedding host survives). Prose is byte-identical everywhere.

Docs in lockstep: C28 deferred block closed (14 sites, hole closed),
C14 partition + form-vs-value boundary, C27 note emission, guide exit
classes. Fork minutes: issues/297#issuecomment-4868 and -4873.

closes #297
This commit is contained in:
2026-07-26 18:33:05 +02:00
parent 57c56ee4e2
commit fd303de94d
17 changed files with 530 additions and 222 deletions
+28 -6
View File
@@ -849,9 +849,31 @@ Exit codes: a clean run exits 0; a usage error exits 2; a refusal before any
cell runs (invalid document, missing project, unresolvable strategy) exits 1;
a run that **completes with one or more failed cells** exits 3 — the run record
and every healthy cell persist, and the failed cells are named on stderr
(#272). A **gate-emptied cell** — a `std::gate` stage that filters out every
member — is a *successful* cell, not a failed one: the gate legitimately
answered "no survivors", so the run still exits 0, the cell's realization is
recorded as truncated at that stage, and an informational `aura:`-prefixed
note names the cell on stderr. Exit 3 is reserved for cells that could not be
evaluated (faults), never for an empty-but-valid result.
(#272). A directly-run blueprint (`aura exec <blueprint>.json`, no campaign)
has no cells at all: its own refusals — a binding mismatch, a synthetic-data
mismatch, a compile error, a tap-bind content fault (unknown fold/tap,
duplicate) — are the content of the argv-named file and exit 2 like a usage
error; only its environment refusals (a tap-trace store write failure)
exit 1. The no-data family (no local data, no data in the requested window,
no recorded geometry) belongs to the real-data verbs — `aura run`,
`reproduce`, the campaign legs — where it likewise exits 1 (#297; the full
partition lives in [C14](design/contracts/c14-headless-two-faces.md)).
The class boundary is FORM vs VALUE, not "environment/data/IO vs everything
else": an `--override` value that is well-formed but out of the node's own
domain (e.g. a negative length) is not caught at the compile/bind seam above
— it panics inside the node's own constructor at bootstrap. `aura exec`
catches that panic at the dispatch boundary (`catch_member_panic`) and
renders it as a runtime-class refusal, exit 1 — the same class an
environment/data fault gets, and the same class the campaign leg gives the
identical value as a per-cell fault (#272). A form fault the surface detects
before the node ever runs (kind mismatch, unknown param, compile error)
stays class 2 argv-content; a well-formed value the node itself refuses in
its own domain is class 1, regardless of which leg hit it.
A **gate-emptied cell** — a `std::gate` stage that filters out every member —
is a *successful* cell, not a failed one: the gate legitimately answered "no
survivors", so the run still exits 0, the cell's realization is recorded as
truncated at that stage, and an informational `aura:`-prefixed note names the
cell on stderr. Exit 3 is reserved for cells that could not be evaluated
(faults), never for an empty-but-valid result.
@@ -59,6 +59,42 @@ caller branches on the failure class without parsing stderr.
in `crates/aura-cli/src/main.rs`, threaded from the run registry —
[C18](c18-registry.md)).
**`aura-runner`'s single-run refusals now honor this same partition (#297,
2026-07-26).** Before this cycle, every refusal inside `aura-runner`'s
single-run verb paths (`aura exec <blueprint.json>`, both the bias-output and
bare-measurement legs) exited 1 uniformly — a `std::process::exit(1)` baked
into the library itself, bypassing the partition above rather than
instantiating it. The #297 conversion turns every such site into a returned
`RunnerError { exit_code, message }` the CLI prints and exits on; the class
is now assigned by the same criterion as the rest of this partition — is the
fault in the content of what argv named, or in the environment the command
needs? The binding refusal, the synthetic/binding mismatch, the blueprint
compile error, and tap-bind content faults (unknown fold, unknown tap,
duplicate bind) are argv-named-content faults and exit 2; the no-local-data /
no-data-in-window / no-recorded-geometry refusals and a tap-trace store I/O
failure (`bound.finish`) are environment faults and stay at 1. `cost_knob`
(via `aura-runner::translate::cost_nodes_for`) has no single-run production
caller at all — its two production callers are both worker-side: reached
from inside a sweep worker (`run_blueprint_member`) it surfaces as a per-cell
`MemberFault::Bind` — never a process exit (the campaign contract
[C28](c28-stratification.md) already guaranteed); reached from
`persist_campaign_traces`'s C1 drift-alarm re-run it propagates as a `String`
through that function's existing channel, exiting 1 via the campaign run
summary (`campaign_run.rs`'s `?` after the run record is already persisted).
**The class-1 set is not only environment/data/IO.** It also covers the exec
blueprint leg's bootstrap domain-refusal path: an `--override` value that is
well-formed but out of a node's own domain (e.g. a negative length) is not
caught at the compile/bind seam above — it panics inside the node's own
constructor at bootstrap. `aura exec` catches that panic at the dispatch
boundary (`catch_member_panic`, `crates/aura-cli/src/main.rs`) and renders it
as a runtime-class refusal, exit 1 — the same class the campaign leg gives
the identical value as a per-cell fault (#272). The boundary is FORM vs
VALUE: a form fault the surface detects before the node ever runs (kind
mismatch, unknown param, compile error) is class 2 argv-content; a
well-formed value the node itself refuses in its own domain is class 1,
regardless of which leg hit it.
**Single document grammar (#319, 2026-07-25).** The five dual-grammar
subcommands (run/sweep/walkforward/mc/generalize) that used to keep two
grammars under one token are retired; one verb, `exec <target>`, dispatches
+8 -2
View File
@@ -42,7 +42,10 @@ input role, which `check_root_roles_bound` rejects ([C26](c26-input-binding.md))
observation is optional, a fed input is mandatory. A declared-but-unbound tap
compiles and runs, its producer evaluating and its output discarded (a no-out-edge
producer is a valid runnable sink — the Kahn sort emits it,
`check_ports_connected` gates only inputs).
`check_ports_connected` gates only inputs). The CLI surfaces this to the human
as a note (`aura: note: declared tap "…" unbound this run`) printed from the
unbound names the entry points return beside their report — never emitted by
the library itself (#297).
**Why.** Observability must be expressible in a hand-authored blueprint — the
measurement-shaped study computes in the graph and surfaces via taps, no throwaway
@@ -71,7 +74,10 @@ points, `run_signal_r` (`aura-runner::member`) and `run_measurement`
(`aura-runner::measure`) — both arms of the single CLI verb `aura exec` (#319),
whose repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection
data-reachable: no flag keeps the record-all default, any flag replaces the
plan entirely (unlisted taps stay unbound/inert). The boundary is thereby
plan entirely (unlisted taps stay unbound/inert). `BoundTaps` carries the
unbound tap names out (`skipped: Vec<String>`), riding beside the returned
report rather than inside it, so the CLI — not the library — prints the
unbound-tap note (#297). The boundary is thereby
fixed in place: *selecting* a subscription is run-mode authority, exercised
by the run-mode owner — on the one-shot path the CLI invocation itself, a
projection exercising this contract's authority, not a second home for
+18 -6
View File
@@ -167,12 +167,24 @@ demand-gated, no tracking issue): measurement runs as sweep-family citizens
(report unification, campaign engine generic-over-`M`), until a concrete
family/campaign demand exists.
**Deferred.** ~24 refusal sites inside `aura-runner`'s single-run verb paths still
terminate the process (`std::process::exit`; the #283 tap-plan refusals added four
— typed as `TapPlanError` before the exit, so the eventual conversion is a
mechanical rewrap); their conversion to `RunnerError` propagation is tracked as
**#297** (the campaign path already refuses via `MemberFault`, never a process
exit).
**#297 (closed, 2026-07-26).** `aura-runner` no longer terminates the host
process anywhere: the 14 recon-enumerated `std::process::exit` sites inside
the single-run verb paths (`member.rs` ×8, `measure.rs` ×5, `translate.rs`
×1 — the "~24" this paragraph used to cite predates the #319 dual-grammar
retirement and never updated in lockstep) are now `RunnerError { exit_code,
message }` constructors threaded through a fallible chain; the two entry
points, `run_signal_r` and `run_measurement`, return
`Result<(_, Vec<String>), RunnerError>`, and the CLI's `exec_blueprint_leg`
remaps via one shared `exit_on_runner_error` helper (the `dispatch_reproduce`
pattern). The one hole in "a sweep worker never process-exits" — `cost_knob`,
reachable from `run_blueprint_member` — is closed the same cycle: the
campaign worker maps its fault into the cell's own `MemberFault` channel
(per-cell isolation, this contract's stated campaign guarantee unchanged),
while the campaign trace-persist path — the only other production caller —
reports it through its existing string-error channel into the campaign
summary's exit. Refusal prose is byte-identical everywhere;
only who prints it changed, plus — per the adjudicated C14 partition — the
exit class of refusals whose fault is in the content of what argv named.
## See also
- [C1](c01-determinism.md) — determinism / bit-identity, the correctness invariant the layer cuts preserve