Status: accepted (2026-08-15) - amends 0019 in part
Context
Appendix D's mainEventLoop re-enters its own body with continue after
the invoke pass, when invoking left the internal queue non-empty:
macrostep(externalEvent)
if not running:
break
for state in statesToInvoke.sort(entryOrder):
for inv in state.invoke.sort(documentOrder):
invoke(inv)
statesToInvoke.clear()
if not internalQueue.isEmpty():
continueIn this port main_event_loop/1 is that outer body - the fold ADR-0019
bounds lives inside it, called once per pass through the loop - so
Appendix D's continue becomes a self-call of main_event_loop, and each
self-call re-enters macrostep/1, which today starts a fresh
max_macrostep_rounds budget on every call.
That reopens exactly the hole ADR-0019 closed, one loop further out.
ADR-0019's Context describes a livelock where a deterministically erroring
cond on an eventless transition hands control back and forth between
rounds of one fold forever, with no external entity able to cancel it from
inside the pure core. The same shape now composes across invoke re-entries
instead of within one fold: two states whose <invoke> arguments
deterministically fail (ADR-0031's failure path) can hand each other
control forever. Each state's macrostep terminates within its own budget;
each state's invoke pass raises error.execution for the failing
argument; the post-invoke re-check sees a non-empty internal queue and
continues; the next macrostep starts with a fresh budget and repeats.
The pure core still has no external entity to cancel it - ADR-0019's own
words, one loop further out:
Inside
Statifier.Interpreter.macrostep/1's fold there is no external entity: a macrostep that never reaches quiescence never returns, and the calling process hangs with no recourse short of killing it.
ADR-0019's Decision scoped the budget to one fold: "each microstep/1
call within one fold consumes one round", with the rounds-spent count "a
fold-local accumulator, threaded through the private macrostep/2
(becoming macrostep/3)". Its Consequences state "a later handle_event/2
on the returned machine_state begins a new macrostep with a fresh
budget" - true of a single fold within one external call, and silent about
a second fold inside the same call reached by an invoke re-entry rather
than a new handle_event/2. Narrowing "per fold" to "per
main_event_loop/1 call" is therefore an amendment to that Decision, not
an application of it, and the house record for a narrowing amendment is
the amends-in-part form ADR-0020 already used against this same record.
Decision
The round budget is spent across the whole main_event_loop/1 call,
including every invoke re-entry, not reset per fold. The budget is
threaded through a new private main_event_loop/3 exactly as it is
already threaded through the private macrostep/3: the private
macrostep/3's return grows a fourth element, rounds_left, and
main_event_loop/3 passes the rounds_left one fold returned as the
budget for the next fold it starts on re-entry, rather than
machine_state.max_macrostep_rounds again.
terminal_effects/2 moves from macrostep/1 to main_event_loop/3, so
exactly one terminal effect is emitted per main_event_loop/1 call.
Today macrostep/1 appends terminal_effects/2 once, at the end of its
one fold. With re-entries possible, appending it inside each fold would
emit one terminal-effect row per re-entry - Trace.MacrostepStable,
Effect.Done, or Effect.BudgetExhausted two or more times for what is,
from handle_event/2's caller's perspective, one call. main_event_loop/3
instead calls the private macrostep/3 directly with the budget it is
carrying and appends terminal_effects/2 exactly once, when it stops
looping: from the last fold's outcome when the internal queue is empty,
or from :budget_exhausted when the loop wants to re-enter and
rounds_left was 0. This is what keeps ADR-0019's three-outcome
exclusivity - "the three macrostep outcomes (stable, done,
budget-exhausted) stay mutually exclusive per macrostep" - true across a
re-entry: without the move, a fold that both reaches quiescence and leaves
the budget exhausted could emit both a stable row and a budget-exhausted
row for the same call.
Effect.BudgetExhausted's shape and its budget field's meaning are
unchanged. The field still reports machine_state.max_macrostep_rounds,
the budget that was granted at the start of the main_event_loop/1 call.
ADR-0032 does not edit what the field means - only its scope, from "spent
within one fold" to "spent within one call, across every fold that call's
re-entries produce" - which is the amendment itself.
Public macrostep/1 keeps its arity, signature, and behavior. It
calls the private macrostep/3 once with ms.max_macrostep_rounds,
discards the returned rounds_left, and appends terminal_effects/2
itself, exactly as main_event_loop/3 does for its own last fold. Every
existing caller and every existing test of macrostep/1 is untouched;
only main_event_loop/1's internal delegation changes shape.
The ADR-0002 mechanical-deviation comment for this narrowing sits
immediately above defp main_event_loop/3 in lib/statifier/interpreter.ex,
the private function that carries the budget across re-entries, joining
the existing hoisting comment ADR-0019 placed above defp macrostep/3
in the same module. Appendix D's continue has no notion of a budget at
all; the comment cites this record rather than re-deriving the argument
inline.
For a document with no <invoke>, the invoke pass produces no
invocations, states_to_invoke clears to empty, the internal queue is
whatever the fold already left it as, and the loop runs exactly one fold
and emits exactly one terminal effect - byte-identical to today's
behavior. The existing macrostep and budget test suites passing unchanged
is the proof of that equivalence.
Consequences
- A chart whose invoke arguments deterministically and repeatedly fail
terminates with a defined
{:budget_exhausted, _}outcome withinmax_macrostep_roundsrounds of the wholemain_event_loop/1call, rather than looping across re-entries indefinitely - the same guarantee ADR-0019 gives within one fold, now given across the outer loop ADR-0031 and Appendix D's invoke pass add. ADR-0019's Decision and Consequences stand for a single fold reached from onehandle_event/2call with no invoke re-entry; this record narrows "per fold" to "permain_event_loop/1call" for the case a fold is reached via re-entry instead. A later, distincthandle_event/2call still begins with a freshmachine_state.max_macrostep_roundsbudget, exactly as ADR-0019 states - this record does not touch that boundary.terminal_effects/2's call site moves, but its own behavior - which effect it appends for which outcome - is unchanged. The move is what makes the site of the ADR-0002 deviation commentmain_event_loop/3rather thanmacrostep/3for this particular narrowing, even thoughmacrostep/3still carries ADR-0019's original comment for the within-fold budget itself.- Public
macrostep/1's signature, arity, and observable behavior are unchanged; no existing caller or test needs to change because of this record. - A future reader diffing
main_event_loop/1against Appendix D's outer loop finds this record at thecontinueself-call, rather than re-deriving why a re-entry does not get a fresh budget from the code alone.