ADR-0004: Core-loop state model — context struct + typed iteration, not whole-loop typestate
Status: accepted | Date: 2026-06-25
References: ADR-0001, ADR-0003, RFC-0001
Context
run_loop has crossed a complexity inflection. It now threads ~13 mutable bindings across iterations and exits through five breaks plus two continues. ADR-0003 added park/rescope/deny/provisioning gates on top of the existing apply-measure-decide-keep/discard core. The next addition gets risky: the invariants (re-baseline mutates a coherent set of fields together; you can't measure before apply, can't keep without a Reading, can't measure on top of an escalation) live only in the reader's head. A typestate pattern was proposed; this ADR decides how much typestate and where.
Decision
Type the linear sub-protocol; keep a plain state machine with an explicit context for the cyclic shell. Three changes: (1) A Run context struct bundling per-run threaded state, with segment-scoped fields factored into a Segment (re-scope becomes one assignment that can't half-update the goalpost). (2) An explicit LoopExit enum replacing scattered breaks and the escalated flag. (3) Typestate on the inner iteration only — Proposed to Applied to Measured, where only a Measured can be decided/kept, making "keep without a reading" and "measure before apply" compilation errors. The outer loop stays a plain for over a mutable Run; gates run before an iteration enters Proposed.
Consequences
Positive: the re-scope can't half-update the goalpost (Segment swap is atomic); the iteration protocol's ordering invariants are compile-enforced; run_loop's signature and body shrink; the ways a run can end are enumerated in one enum. The next gate plugs into a named context and a typed iteration instead of adding a 14th threaded binding. Negative/cost: more types and self-consuming transitions are slightly more verbose than inline statements, worth it only for the iteration protocol. A refactor of a core file must land as one reviewed change with the test suite green.
Addendum (2026-09-05): change (3) is retired. The iteration now runs as a work-graph plan through the shared executor on every run; the graph path had been opt-in behind --graph-loop with a parity harness proving it matched the typestate path on decisions, rows, kept commits, and shutdown token. With the loop's control flow held in a transition table (runloop::machine) and the executor's own lifecycle tables (plan::machine), the ordering invariants the typestate enforced at compile time are enforced by the plan's declared dependencies and asserted at runtime, and a single Turn in the table matched a single implementation in the code. Changes (1) and (2) stand. One behavioral difference is deliberate: a transport-class turn death is retried inside the propose task before the driver counts a dead attempt, so a single dead turn followed by a success leaves no infra-dead row.
Alternatives Considered
Type the linear sub-protocol (iteration); keep a plain state machine with an explicit context for the cyclic shell — a Run context struct with a Segment sub-struct, a LoopExit enum, and typestate only on the inner Proposed-Applied-Measured iteration (accepted)
- Pros: Re-scope can't half-update the goalpost (Segment swap is atomic), Iteration protocol ordering invariants are compile-enforced, run_loop shrinks to 'drive gates, run a typed iteration, fold the result into Run'
- Cons: More types (Iteration
zoo, marker structs) with self-consuming transitions are slightly more verbose, Refactor of a core file must land as one reviewed change with the test suite green
Whole-loop typestate (Loop, Loop, etc. with self-consuming transitions) (rejected)
- Rejected because: Nearly all state is shared and threaded through every phase, so a per-phase type would carry one big shared context plus a marker — the ceremony of typestate with none of the safety