ADR-128
The run's settlement is built once by the runtime and projected by every door
accepted · 2026-09-04 · L0 L3 L4 · cites 9
0ADR-128: The run's settlement is built once by the runtime and projected by every door #
Context #
On main at 2dd1996c the same run's terminal truth was folded seven
times, in seven places, with two vocabularies. The runtime wrote a
status word on the terminal frame (completed · failed ·
cancelled) beside a task tally and the cost fields (wave 7.e ·
#1247); the CLI's run_settled envelope derived succeeded · failed
· paused · cancelled from the outcome's booleans (settled_status);
the CLI's exit code derived from the same booleans a second time; the
service boundary (ServiceExecutionStatus::from_runtime) folded them a
third time with its own budget rule; the resident's JobStatus folded
that; trace outputs --json folded the journal's task frames into a run
state that included an invented recovered state; nika-dap's
TraceState printed completed on trace ls. nika-tui-core and the
TypeScript SDK folded the frames again on their side.
Two doors could — and did (#1443) — report different words for one run. A future surface (the native TUI · a remote session host · Telegram) would have had to reimplement the fold to say what a run settled as. The one-door program's law is « many doors, one judgment, one execution, one evidence trail »: a settlement is engine truth, computed once.
Decision #
- One type, at L0.
nika_event::settlement::RunSettlementcarries the run's state (RunState·succeeded·failed·paused·cancelled), WHY (RunCause·normal·human_gate·task_failed·output_contract·budget·operator·refused), the elapsed time, the task tally (TaskTally· total · ok · failed · recovered · skipped · cancelled · never_started), the spend (Spend· the metered total when any leaf was priced · priced/unpriced counts · aCostQualifier·priced·partially_priced·unpriced·unmetered· the pricing snapshot · the per-source attribution) and the failure named (SettlementError· code · message · the task, or none for a run-level cause). - One writer.
nika-runtimebuilds the settlement exactly once, at the boundary that ends the run (settlement::settle_run): the normal close (finalize_outputs), the operator's cancellation and the budget stop (abort_unran), the human gate (emit_paused). The terminal frame is written byemit_terminalfrom the settlement — its kind IS the state's kind, its flat fields ARERunSettlement::fields(). The outcome carries the settlement (RunOutcome::settlement);ok·cancelled·budget_exceededand the cost fields are its projections, set together bywith_settlement. - One reader.
RunSettlement::from_eventreads a terminal frame back;from_eventsfinds a journal's last terminal. The kind names the state (astatusword an older engine wrote is ignored); the tally isNoneon a frame that predates it (absent, never zero); the cause falls back to the least the state proves. - Every door projects. The CLI's exit code is a
matchon the state;run_settledflattens the settlement (status·cause·elapsed_ms·tasks·spend·error) beside its locators; a launch refusal settles asfailed·refusedwith its code (refusal_settlement). The service boundary's status is amatchon the state, its error the settlement's.trace outputs --jsonreads the terminal frame through the one reader and carries thesettlementobject;trace lsprints the state word. The inventedrecoveredrun state is gone: recovery is a tally on the settlement and a fact on the task row. - One vocabulary. A frame KIND (
workflow_completed) is an event name; a STATE (succeeded) is a settlement word. The two are mapped in one place (RunState::terminal_kind·from_terminal_kind). The wordcompletedno longer names a run state on any door. - Honesty laws kept.
total_cost_usdis absent when nothing was metered; the qualifier says what the total covers; unknown is never zero; a paused run keepsok(a decision point, never a failure) and is not final; the budget stop is afailedrun with causebudgetand theNIKA-1704error named on the settlement.
Consequences #
- Positive: one fold, one vocabulary, one reader — a surface that wants
the run's verdict deserializes the settlement; none refolds task frames.
The
run_settledenvelope now says WHY and how much, not only what. - Negative: the
statusfield on the terminal frame changed word for a successful run (completed→succeeded), andtrace lsprintssucceeded;nika-runtime-laws::compat_recordstill twins the task vocabulary (its deletion is the next slice). - Follow-ups: the resident publishes
JobStatusas a projection ofRunStatewithinterrupteddefined as ownership lost + evidence incomplete (#1443);trace lslearns whether arunningtrace's writer is alive andtrace verifyexits non-zero on INCOMPLETE (#1442);nika-tui-corereads the settlement instead of folding.
Alternatives considered #
- A
nika-wirecrate for every cross-door contract: rejected — the event crate already owns the frame vocabulary and every reader depends on it; a new crate adds a layer, not an authority. - Keeping
completedas the state word: rejected —run_settled, the resident and the SDK already spokesucceeded; one word had to win and the kind keepscompleted. - Making
RunSettlementa runtime type: rejected — readers (nika-trace·nika-dap· the resident · tui-core) must not depend on the runtime to read a frame.
Amendment · closing audit, 2026-09-04 #
Cancellation acknowledges an action, not an outcome. An active resident
job's cancel route signals its runtime owner and returns 202 while that
owner is running. A settlement returned during the grace period wins,
including success or failure racing cancellation. No returned settlement
means interrupted, never a fabricated runtime cancellation. A queued job
has no runtime owner: cancellation and execution claim compete under one
store lease, binding the winning identity and result atomically.
A pause ends an execution leg's observation, not the durable job's lifecycle. Its outputs, receipt and settlement live in its hash-chained pause event. SSE closes at that boundary even when replaying it after a later leg started; it never substitutes the later leg's receipt. Generic event append cannot create or replace a pause boundary. A resumed CLI leg is readmitted from the owned snapshot and receives a fresh execution and trace identity, even if the visible source was edited or deleted.
The durable job GET, idempotent admission replay and SSE all expose the same stored settlement. Projection fields are reconstructed from existing events rather than added to the v3 hash preimage: a final result uses the bound terminal sequence, not the last event with a suggestive payload. Old records without settlement fields remain absent, never invented. This is read compatibility for evidence, not a second execution law.
Regression evidence lives in nika-serve's job/result_tests.rs,
server/tests/cancel_race.rs, server/tests/pause_boundary.rs and SSE
tests, plus nika-cli/tests/ask_pty.rs. Library tests and the PTY target
have different runners; passing the former does not imply the latter ran.
read at v0.118.7 · the decision record ships with the engine