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.

See: guards, selectors, payloads, on error.

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.

See: fragments, imports, invoke, fan-out.

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.