DSL reference¶
A compact reference to the .stm language. Each form links back to the tutorial step that
introduces it.
Top-level declarations¶
import "lib.stm" # bring in another file (events/guards/bindings: bare)
import "lib.stm" as ns # machines/fragments namespaced as ns.Name
event Name { field: type other: type? } # type ∈ string|int|float|bool|any ; ? = optional
guard name = <predicate> # a reusable, composable predicate atom
bind { handler = pkg.mod.fn } # map handler names to implementations
machine Name { … } # a machine (version 1)
machine Name version 2 { … } # a later version of it (the store keeps the earlier ones)
fragment Name(params) { … } # a parametrized, reusable piece
A declared event is checked twice: by harel validate, which checks that guards and sets read
fields the event declares, and when it comes in — process() and send() raise EventError
for a required field missing or a value of the wrong type, before anything runs or queues.
Undeclared fields are let through, as are undeclared events and the reserved ones.
A machine’s version is a positive integer. A changed machine is a new version, with the old one kept loaded while executions run on it — see Changing a machine with executions in flight.
Inside a machine or state¶
initial StateName # where this (sub)machine starts
state Name { … } # a state; nest states inside for a composite
orthogonal Name { … } # an AND-state; each child state is a concurrent region
final Name <outcome> { … } # sugar: a terminal sink declaring its verdict
on enter <action> # hook: on entering
on exit <action> # hook: on leaving
on activity <action> # hook: event arrived with no transition
timeout 900 # arm a durable timer (seconds)
timeout context key # …with the delay read from context[key]
ttl 3600 # machine only: expire after this long without a domain event
context { n: int = 0 who: string? } # machine only: the context's typed fields (`= default`)
outcome <label> # this terminal's verdict (final is sugar over this)
carry k1, k2 # context keys a region propagates on Finished
defer EventA, EventB # hold these events while unhandled here; re-deliver on a state that handles them
cancel_on_failure # orthogonal: a region's non-success terminal cancels the rest + joins now
no history # don't restore history on re-entry
defer makes an event that has no transition in the current state wait instead of being
dropped: it goes onto a per-execution FIFO (persisted on the Execution) and is re-delivered as
soon as the machine enters a state that has a matching transition. Declared on a state it applies
there and in its substates; declared at the machine level it applies everywhere a state doesn’t
handle the event. Use it for out-of-order async events — a webhook or callback that can arrive
before the machine reaches the state that consumes it.
defer applies only to domain events. System events (Timeout, Cancel, Finished, Reset,
Start, SetState) are routed by the engine before the defer check and cannot be held this way.
Declared on a parent composite or at the machine root, defer is visible to orthogonal region
children that share the same Definition — they inherit the full ancestor chain of defer sets.
On an orthogonal node, cancel_on_failure ends the block as soon as any region reaches a
non-success terminal (the join resolves via its else), and a timeout on the node bounds the
whole parallel block. Both cancel the regions still running (fire-and-forget); a clean join
cancels nothing.
ttl (machine level only — a DslError on a state, fragment or inline invoke body) bounds
how long a root execution may go without receiving a domain event. Each one restarts the
budget; the machine’s own Timeouts don’t. When it runs out the engine delivers Expired: an
on Expired in scope takes the execution straight to a terminal, and without one it ends
forcefully, CANCELLED with outcome expired. A region or invoke child never carries a
ttl; an event broadcast to the regions still counts for the root.
See: deferred events, actions, outcomes, hierarchy, timers, orthogonal, payloads.
Transitions¶
from A to B # automatic (no trigger)
from A to B on Event # event-triggered
from A to B on E1 | E2 # multiple event kinds
from A to B on Event where <pred> # guarded (where only after `on`)
from A to B on error # action of A raised: route here (else the runner fails the exe)
from A select fn on Event { # computed branch (a selector)
"x" to B
"y" to C
else to D
}
from A select fn returns {"x","y"} { … } # declared branches (validatable)
from Fork join all to X else to Y # orthogonal-join sugar (all regions success)
from Fork join any to X else to Y # …at least one
from A choose on Event { # guarded choice: the first `when` that holds wins
when context.n > 3 to B
when context.n > 0 to D set context.n = context.n - 1 # a branch's own `set`, if it's taken
else to C # optional; without it, no match = the transition doesn't fire
}
from A to B on Event set context.n = context.n + 1, context.last = event.id # assignments
A set writes context.<field> from a reference (event.x, context.x), a literal, or one
arithmetic operation (+ - * /, numbers only) over two of those. Every right side is evaluated
against the context and event as the transition starts, and the values are applied between the
exits and the entries (an on exit sees the old context, an on enter the new one). An expression
that can’t be evaluated — a field that isn’t set, arithmetic on a non-number, division by zero, a
value that breaks the declared type — raises an ExpressionError, routed like an action error
(on error, else dead-letter), before any state is left. A choose’s guards see the context before
its own set; an automatic choose (no on) is re-evaluated after each step taken in its source,
and one without else in a state nothing can re-run it from is a validation error
(choose_can_hang). See the context in the model.
Predicates¶
field == value field != value
field < value field <= value
field > value field >= value
field in [a, b]
event.field == value # the same as the bare `field`: the event's data
context.field == value # the execution context
context.n >= context.max # the right side may be a reference too (namespaced)
field < event.limit
guardName # a named guard, as an atom
P and Q P or Q not P # composable; parenthesize as needed
A predicate on a field the event (or, for context.x, the context) does not carry — on
either side — is false (not an error), and so is a comparison between values that can’t be
compared ("abc" < 3, null < 3, x in 5). A reference names one field of its namespace — no
nested paths. On the right side it is always namespaced (context.x, event.x); a bare name
there is not a reference.
Actions¶
on enter handler # a handler — a bare name, bound via bind{} / actions=
on enter pkg.mod.fn # a literal — a dotted path, imported directly
on enter act(base: 10, k: v) # with inputs (literals or value-params inside a fragment)
Signature: def fn(stm, event, **inputs), reading/writing stm.execution_ctx.
Fragments & invoke¶
fragment F(work: action, ok: guard, target: state, budget: value, ev: event) { … }
use F(work=charge, ok=(status=="ok"), budget=30) as Local # splice as a child composite
fragment Outer(work: action, budget: value) { # a fragment may `use` another fragment
initial X state X {}
use F(work=work, ok=(status=="ok"), budget=budget) as Inner # …and forward its own params
}
invoke pkg.machine # run another machine as a black box (a leaf)
with { child_key: parent_key } # project input in (also on an orthogonal node: what each
# region's context starts with; else it starts empty)
invoke pkg.machine for item in coll # fan-out: one child per entry of coll
with { slice: item }
invoke { … machine body … } # inline target (QML-style)
A single invoke leaves only via on Returned where …; a fan-out leaves via a join.
Fragments nest: a fragment body may use another fragment and forward its own
parameters (action, guard, value, state and event) as the nested use’s args — resolved
against the enclosing fragment’s scope. Forwarding a name the enclosing fragment doesn’t declare
is a DslError.
Reserved events¶
Start, Finished, Timeout, Cancel, Reset, SetState, Returned, error, Expired
are the engine’s own events — don’t use these names for your domain events. (CancelOrder, not Cancel,
for a domain “cancel”.)
Cancel in particular is the control plane’s own teardown signal (see
control plane), not a business event: harel validate rejects an on Cancel
transition whose target is not itself a terminal state, and cancel() checks the same thing
itself at the moment it decides cooperative-vs-forceful, so an unvalidated Definition is not
silently exposed to it either. The cooperative cancel() path always resolves in the same step
the injected Cancel is processed — it never leaves the execution parked mid-cleanup. A model whose own cancellation needs more than that (release a lock now, wait
for a refund confirmation later, …) is modelling business cancellation, and belongs on its own
event name (CancelOrder), handled with ordinary transitions, free to take as many steps as it
needs — with no relation to cancel() at all.
Expired (the machine’s ttl running out — see timers) is held to the
same rule as Cancel: harel validate rejects an on Expired whose target is not a terminal,
and the engine won’t take an unsafe one at runtime (it ends the execution forcefully instead). An
on Expired in a machine with no ttl never fires, and is a validation warning.
on error fires when a state’s action raises: the engine synthesises an error event
carrying the exception’s type and message (so on error where type == "TimeoutError" routes
by kind) and also stashes it in context["_error"]. Guards are evaluated in declaration order;
the first match wins. If no guard matches — or there is no on error in scope at all — the
runner’s default policy applies: the execution is failed terminally (status=FAILED, dead-letter).
Model expected failures as action results routed by a selector; use
on error for genuine exceptions you want to recover from in the model.
See: on error tutorial.