Phase 1 — semantics doc
docs/002. hsmc-semantics-formal.md
Phase 2 — deterministic execution journal
hsmc/src/journal.rs (TraceEvent, JournalSink, ActionKind)
hsmc-macros: __chart_journal! emission at every observable site
(entry/exit/action/transition/timer arm-cancel-fire/during/emit/
event delivery/terminate). Per-state during count helpers, stable
chart-hash via FNV-1a. Action observation moved out of run_action
and into call sites so ActionKind is accurate.
Public API on the generated machine: journal(), take_journal(),
clear_journal(), CHART_HASH.
Phase 3 — verification crate (Creusot scaffolding)
verification/{Cargo.toml,mise.toml,requirements.txt,INVARIANTS.md}
verification/src/{event_queue.rs,timer_table.rs} — Pearlite
contracts on simplified mirrors of the runtime types. cargo
creusot generates Coma IR cleanly; VCs not yet discharged.
Correspondence tests drive the verified mirror and the runtime
type with the same op sequences and assert observable parity.
Tests — 113 deterministic-flow tests across 22 files. Each compares a
hand-built expected journal byte-for-byte against actual, anchored
to a spec section: initial entry, lateral/self/up transitions,
bubbling, timers, emit, ordering, durings, step/dispatch
equivalence, root-targeting, current_state, termination.
Tooling — mise.toml (root): stable Rust + cargo subcommands.
verification/mise.toml: pinned nightly-2025-11-13 + opam + python
venv + uv. just verify is fully self-installing.
Bug fix — root state targetable by the chart's own name (e.g.
`on(Trig) => ChartName;`) per spec §"How transitions work".
CHART_HASH — stable FNV-1a fingerprint over the chart's structural
definition; mismatch on replay = different chart.
19 KiB
hsmc — How Your Chart Behaves
Status: DRAFT. This is the canonical answer to "what will my statechart actually do?" When this document and another disagree, this one wins.
Audience: anyone writing a chart. The goal is that after reading this,
you can predict every action call, transition, and timer fire just by
looking at your statechart! block.
Why hsmc is small (read this first)
hsmc ships the minimum set of useful statechart primitives. No guard
conditions, no orthogonal/parallel regions, no history states, no deferred
events, no internal transitions, no state-local context.
The bet is on simple, consistent semantics. The small surface area makes hsmc easy to use, easy to reason about, and easy to predict. Once you internalize a small set of rules, every edge case below just follows from them — you don't have to memorize anything new.
What a chart contains
Each primitive, in roughly the order a developer first meets them.
- State — a named node. The root state (the machine itself) is just a state. Every state is just a state.
- Nested state — a state inside another state. Same thing as a state.
- Entry action — a function called once when a state is entered.
- Exit action — a function called once when a state is exited.
- Transition — exits one state and enters another, anywhere else in the chart. Walks from the current state up to the lowest common ancestor (LCA) of source and target, calling exits along the way, then walks down to the target, calling entries along the way.
- Action (without transition) — a function call attached to a trigger. No state change. That's it.
- Default child — every non-leaf state declares one default child. Whenever a state with children is entered, the default child is entered immediately after that state's entry actions finish — it's effectively an automatic transition into the child.
- Trigger: event vs. duration — an event is a variant of your event
enum. A duration is a timer (
after Durationfires once,every Durationfires repeatedly). Triggers attach to transitions and to actions. Timers and events are dispatched slightly differently — see the bubbling rules below. emit()— pushes an event onto the machine's queue from inside an action. The event is dispatched after the current event has fully finished processing. No re-entrant dispatch.during:activity — a cancellable async function. Started after the entry actions of its owning state finish; cancelled before the exit actions of that state begin to run when the state is left.terminate— names an event that, when received, exits the entire active path bottom-up and shuts the machine down. Only valid at root.
Where the machine "is"
At any moment, the machine is in a path of states from root down to one
innermost state. If you're in C inside B inside A (which is inside
the root), then you are simultaneously in all of Root, A, B, and
C. The root is always active until the machine terminates.
This is the most important rule in this document. Every other rule below is downstream of it. You are simultaneously in every state on the path from the root to the innermost active state. If a rule somewhere else seems weird, come back to this one — it's probably the resolution.
current_state() returns the innermost active state, which is usually a
leaf, but can be a composite (a state with children) if you got there via
an up-transition (see below) and haven't moved since.
How events are handled
Event comes in. Start at the innermost active state. Does it have a handler for this event? If yes, that handler runs and the event is consumed — no further handling.
If no, walk up to the parent. Repeat. If you walk all the way to the root and still nothing handles it, the event is silently discarded.
That's all. Three other things follow from this rule:
- A handler on a leaf state shadows a handler on its ancestor for the same event. The leaf wins because the search starts there.
- If a single state has multiple handlers (actions and/or a transition) for the same trigger, the actions fire in declaration order, then the transition fires last.
- Timer triggers do not bubble. A timer belongs to the state that declared it; it fires only in that state's scope. Events bubble; timers don't.
How transitions work
The current state (for transition purposes) is the innermost active state. The target can be any state in the entire chart — there's a global namespace, so state names must be unique across the chart.
The root state is targetable too: it's named after the chart itself. So
in statechart! { Foo { ... } }, an on(Trig) => Foo; is a valid
transition whose target is the root. From any state inside the chart this
is an up-transition (root is always on the active path) — exiting only
the states strictly between current and root, with no entry actions and
no default-descent on root. Use this when you want to wind everything
back up to the chart's own scope.
To run a transition:
- Find the LCA of current and target. (They might share a parent; they might both be on the same root-to-leaf path; the LCA is always defined because the root is always an upper bound.)
- Walk up from the current state, exiting each state on the way (cancel durings → run exit actions) until you hit the LCA. The LCA itself is not exited.
- Walk down from the LCA to the target, entering each state on the way (run entry actions → start durings). The LCA is not re-entered.
- If the target has children, follow the default-child chain down to a leaf, entering each as you go.
The three flavors below are just three relationships between current and target. The same algorithm covers all of them — they only look different in which steps fire.
Transitioning to a sibling (or anywhere not on your active path)
The standard case. LCA is somewhere strictly above both current and target. Exit up to LCA, enter down to target, default-descend if the target has children. Already covered above.
Transitioning to yourself
Self-transition: target equals the innermost active state. The standard transition algorithm above gives the clean answer: the LCA of the target with itself is its parent, so we exit only the target and re-enter only the target. Nothing above the target is exited or re-entered.
Concrete example. Active path is Root → SomeState → SomeOtherState. A
transition fires whose target is SomeOtherState.
- Exit
SomeOtherState(cancel durings, run exits). - Re-enter
SomeOtherState(run entries, start durings). SomeStateis never exited, never re-entered, never touched. Its timers and durings keep running.
Timers declared on the target restart, because the target itself was exited and re-entered.
If the target happens to be a composite (it has children, but no child is active right now because you got there via an up-transition), the same default-child rule still applies on re-entry: exit the composite, re-enter it, then descend through its default chain. Self-transitions don't add a new rule — they're just the case where source and target are the same state.
Personally I don't find self-transitions all that valuable, but people talk about them all the time, so they're supported.
Transitioning to one of your parents (the important one)
The case to internalize. When you transition to an ancestor of the current state, you exit the current state and any states between, but you do not re-enter the ancestor. You were already there.
- The ancestor's entry actions don't fire.
- The ancestor's
defaultis not followed. - The ancestor's timers don't restart.
- The ancestor's durings keep running.
You cannot enter a state you never left. Spelling it out: starting from
leaf C inside B inside A, a transition to A exits C then B,
and now A is the innermost active state with no active child.
Timers
- A timer starts when its state is entered.
- A timer is cancelled when its state is exited.
- Descending into a child state is not an exit — the parent's timers keep running.
- Timers don't bubble. Only the state that declared the timer handles its expiry.
- Re-entering a state restarts its timers.
If two timers happen to expire on the same step() tick — rare under
real wall-clock timing, but possible at step() granularity since
step() decrements all timers at once — the runtime today fires the
oldest (longest-armed) timer first, because it sits earlier in the
active-timers array. Don't depend on this. It's a likely outcome of
the current implementation, not a contract. If two timers must fire in a
specific order, model them as one timer plus an explicit emit() chain.
Entry and exit ordering
Putting it all together:
- Entry runs outer-to-inner: when entering a path of states, the outermost state's entry actions fire first, then its child's, and so on down to the innermost. Within a single state, entry actions fire in declaration order. After entry actions of a state finish, that state's durings start. Then the default child (if any) is entered, recursively.
- Exit runs inner-to-outer: when leaving a path of states, the innermost state exits first, then its parent, and so on up. Within a single state, exit actions fire in declaration order. Before exit actions of a state begin, that state's durings are cancelled.
So a full transition reads top-to-bottom as: cancel durings on the way out → run exits on the way out → run entries on the way in → start durings on the way in.
emit() — sending events from inside an action
An action can call self.emit(Ev). The event goes on the machine's
internal queue. It is not processed immediately. The runtime finishes
handling whatever event it's currently handling — including every entry
and exit action of any transition — and only then dequeues Ev and
dispatches it normally. No re-entrant dispatch.
Once the current handler finishes, the runtime drains anything queued during handling before sleeping or returning to the outer loop.
If the queue is full, emit() returns Err(QueueFull). Subsequent
actions in the same chain still run; they may attempt further emits, but
those also fail. At end of tick, the runtime surfaces Err(QueueFull)
out of step() / run() so the top-level driver can react. A queue
overflow is a hard signal that something is wrong with sizing or with the
producer; it is never silently swallowed.
during: activities — async work scoped to a state
A during: is an async function that runs while its owning state is on
the active path. It is started after the entry actions of that state
finish, and it is cancelled before the exit actions of that state begin.
A during's future may be dropped at any .await point — when an
external event arrives, when a timer fires, when another during on the
same active path completes, or when a transition leaves the during's
state. This is the Rust async concept of cancellation: the future is
not given a chance to "finish gracefully" — it's dropped, and dropping
runs whatever destructors Drop impls have, but no further .awaits
will ever resume.
The practical rule: write durings as cancel-safe state machines. Every
.await must be a clean resume point where dropping the future leaves
the borrowed fields in a re-enterable state. Don't straddle awaits with
mutations that need to commit atomically. If you need to learn what
cancel-safety looks like in practice, the README has the worked examples,
and the tokio docs on cancellation are the standard reference.
Multiple during: declarations on the same active path race against each
other (and against incoming events and timers). Each during borrows
disjoint &mut slices of the context — Rust's split-borrow checker
rejects overlapping borrows at compile time, so concurrent durings can
never step on each other's state.
Termination
When a terminate event is received, the machine exits the entire active
path bottom-up: innermost first, then each parent, up to and including
the root. After that, the machine is done — is_terminated() is true,
run() returns Ok(()), further step() calls are no-ops returning
None. Any pending events in the queue are dropped. Any in-flight
durings are dropped at their next .await.
Once you adopt the simple, consistent semantics above, this just falls out — termination is "exit everything you're currently in, in the usual exit order, then stop."
step() vs run() vs dispatch()
Three ways to drive the machine. Same underlying behavior; the differences are about who decides when to advance time.
step(elapsed)— synchronous. You call it; it does at most one unit of work (one timer expiry, or one queued event). For bare-metal / testing.dispatch(ev).await— async helper. Pushesevonto the queue, then drains the queue (and any timers that come due) until quiescent. For injecting events from the same task that owns the machine.run().await— async. Owns the task body. Races durings against the external event channel and timers, and processes whatever wins, in a loop, until termination.
For the same external event sequence and the same elapsed-time inputs, the three produce the same observable trace.
What you cannot do, and why
The "minimum useful primitives" bet, made concrete. For each missing feature: why we don't have it, and what to do instead. (First-pass drafts. Edit freely.)
- Guard conditions on transitions — Why not: guards bury control
flow inside an action body, so the chart's structure stops telling you
the whole story. What to do instead: split the source into two
states whose entry conditions differ, or have an action emit a
follow-up event (
Okvs.Bad) and let the chart branch on that. - Orthogonal / parallel regions — Why not: true parallelism
multiplies state-space and forces a join-semantics for shared events.
What to do instead: run two charts as two tasks and connect them with
channels, or use multiple
during:activities for concurrent async work within a single state. - History states (shallow or deep) — Why not: history is hidden mutable state — re-entering a parent goes "somewhere" based on invisible bookkeeping. What to do instead: store the last child explicitly in your context, then transition directly to it on re-entry.
- Deferred events — Why not: deferring means an event you sent now can resurface much later, with non-obvious ordering. What to do instead: handle the event where it arrives (even if just to log and drop it), or have the producer check state before sending.
- Internal transitions — Why not: "transition that doesn't exit or
enter" is a separate semantics layer just to skip exit/entry. What to
do instead: declare an
action(Trig) => fn(no transition). That's exactly what an internal transition is. - State-local context — Why not: hierarchical context types make
borrowing painful and tie the chart's structure into your data model.
What to do instead: put all your state in the root context;
during:activities can borrow disjoint fields of it. - Event priorities beyond FIFO — Why not: priorities allow starvation and make ordering implementation-dependent. FIFO is predictable. What to do instead: if some events are higher-priority, deliver them on a separate channel into a separate, faster-cadence machine.
- Runtime statechart modification — Why not: the chart is the static structural definition. Mutating it at runtime defeats compile-time validation and makes the trace meaningless. What to do instead: design every variation up front as states or sub-states.
- Inter-machine communication primitives — Why not: a built-in
messaging layer would have to pick winners between embassy / tokio /
bare-metal channels. What to do instead: use whatever channel
primitive your runtime gives you (embassy
Channel, tokiompsc), and feed events intodispatch()orSender::send().
Quick reference
| Scenario | What fires |
|---|---|
| Initial machine start | Root entries → default-descend down through each child's entries → durings start at each entered state. |
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → default-descend if target has children. |
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If innermost has children, default-descend after re-entry. |
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. No default-descent. Ancestor's durings and timers continue. |
| Transition into a state with children | After the target's entries fire, default-descend through each default child's entries down to a leaf. |
| Timer fires | The declaring state's handler runs (timers don't bubble). |
| Event with no handler | Silently discarded. |
| Event handled at innermost | Innermost state's handler runs (actions in declaration order, then transition if any). |
| Event handled at ancestor | Search bubbles up; first ancestor with a handler runs it. Innermost is still innermost; transition (if any) starts from innermost. |
Action calls emit() |
Event queued. Dispatched after the current event's handling fully completes. |
Queue is full when emit() is called |
emit() returns Err(QueueFull). Remaining actions in the same chain still run; further emits in this tick also fail. step() / run() surfaces QueueFull at end of tick. |
terminate event delivered |
Exit innermost → … → exit root. Pending events dropped. Durings dropped. run() returns Ok(()). |