A macrostep is the unit that turns one admitted event into one settled chart result. Its internal work may contain several microsteps, but the result becomes observable at the macrostep boundary. No observer is entitled to a snapshot from the middle of that decision.
The fold is the semantic boundary
A macrostep is a deterministic pure fold:
(chart, bindings, snapshot, event) -> StepResult
The StepResult publishes a next snapshot, a serializable emit plan, and a trace of the microsteps
taken. The fold does not execute emits; it leaves those intents for the runtime outside the kernel.
State updates happen inside the fold because later selection may depend on them.
The source below is the same compiled chart used by the live pane. Press Drain to start one
external event and watch the internal drain settle.
import { State, Statechart, StatechartEngine, States, Transitions } from "@motive/motive";
import { Layer, Schema } from "effect";
export const Drain = Schema.TaggedStruct("Drain", {});
class Macrostep extends State.Compound<Macrostep>()("Macrostep") {
static states = States.make(() => [Ready, FirstHop, SecondHop, Settled]);
}
class Ready extends State.Atomic<Ready>()("Ready") {
static transitions = Transitions.make(this, ({ on }) => [on(Drain, FirstHop)]);
}
class FirstHop extends State.Atomic<FirstHop>()("FirstHop") {
static transitions = Transitions.make(this, ({ always }) => [always(SecondHop)]);
}
class SecondHop extends State.Atomic<SecondHop>()("SecondHop") {
static transitions = Transitions.make(this, ({ always }) => [always(Settled)]);
}
class Settled extends State.Atomic<Settled>()("Settled") {
static transitions = Transitions.make(this, ({ on }) => [on(Drain, FirstHop)]);
}
export const MacrostepChart = Statechart.make(Macrostep);
export const MacrostepLive = MacrostepChart.toLayer().pipe(
Layer.provideMerge(StatechartEngine.layerMemory),
);
Starting the macrostep chart…
The drain is a microstep sequence
One external event initiates the fold. After that initiating microstep, eventless transitions and
raised internal events drain as microsteps until the chart is stable or the root completes. In the
live chart, Drain moves from Ready to FirstHop; two eventless always transitions then move
through SecondHop to Settled.
The chart is stable when no eventless transition is enabled and the internal raised-event queue is empty. Stability is about available semantic work, not about whether the last microstep changed a value.
The limit is 256 microsteps, counting the initiating event. A would-be 257th microstep fails with
MicrostepLimitExceeded rather than hanging the engine. That failure is pre-commit: no partial
result from the unfinished macrostep may be published.
A microstep is one transition round
A microstep takes one conflict-free set of selected transitions. Its phases are ordered:
- compute the complete exit set and record affected history;
- run exit actions in reverse document order;
- remove exited states from the active configuration;
- run transition actions in selection order;
- compute entry geometry, including history restoration and default entry;
- enter states and run entry actions in document order; and
- update mounted timers, activities, and children to match the new geometry.
That order is observable inside the fold. Later microsteps observe updates made by earlier microsteps in the same macrostep. A microstep is not independently committed; it contributes to the eventual snapshot, plan, and trace of the macrostep that contains it.
One event, one atomic commit
When a handled macrostep stabilizes, it commits as one persisted event. The next snapshot, the serializable emit plan, and the microstep trace ride that same commit.
Atomicity has two failure directions:
- A failure before commit publishes none of the proposed next snapshot, mounted work, plan, or retained step.
- A failure while enacting work after commit does not roll back the snapshot or erase the committed intent. Recovery begins from those facts.
What determinism buys
The same chart, snapshot, and event produce the same result on every engine tier. Engines may vary durability, but they do not vary chart meaning.
After the commit
Side-effect execution sits after the commit. Atomic commit does not mean that every external effect has completed; it means the chart decision and its intent to perform later work have one publication boundary. Handler effects are therefore at-least-once, while the chart's own observation of what happened is exactly-once-in-the-fold.
Read traces without inventing intermediate state
The macrostep trace records the microsteps, transitions, exits, and entries that explain how the fold stabilized. It is evidence about the decision, not a list of independently current snapshots. The current snapshot is the state after the entire trace. Presenting an intermediate trace frame as committed state would create a state the engine never published and could not recover.
The law is compact: internal work may be plural; committed state is singular.