2. Making states do something¶
The turnstile’s states were inert — they only marked where we were. Real machines run behaviour as they move: charge a card on entry, release a lock on exit, log progress.
From here on we follow one running example that grows across the tutorial: an online order. We start with four states and attach an action to each.
Hooks: on enter, on exit, on activity¶
A state can run an action at three moments:
on enter— when the state becomes active.on exit— when the state is left.on activity— when an event arrives that has no transition out of the current state (a “nothing happened, but react anyway” hook).
machine order {
initial Cart
state Cart { on enter on_cart }
state AwaitingPayment { on enter request_payment }
state Paid { on enter capture_payment }
state Delivered { on enter deliver }
from Cart to AwaitingPayment on PlaceOrder
from AwaitingPayment to Paid on PaymentAuthorized
from Paid to Delivered on Deliver
}
stateDiagram-v2
[*] --> Cart
Cart --> AwaitingPayment : PlaceOrder
AwaitingPayment --> Paid : PaymentAuthorized
Paid --> Delivered : Deliver
Delivered --> [*]
The hook semantics are standard UML: each level runs its own enter/exit hook (there is no “inherited” hook from an ancestor), enter fires outermost-first and exit innermost-first, and a self-transition back into the state you are already in fires nothing. That only matters once states are nested — see hierarchy.
Actions are just functions¶
An action has the engine’s contract (stm, event, **inputs). It can read and mutate the
execution’s context through stm.execution_ctx — a plain dict that travels with the
execution and is persisted with it. Here each action records a human-readable step:
from harel import definition_from_dsl, DurableRunner, DictStore, Event
SOURCE = """
machine order {
initial Cart
state Cart { on enter on_cart }
state AwaitingPayment { on enter request_payment }
state Paid { on enter capture_payment }
state Delivered { on enter deliver }
from Cart to AwaitingPayment on PlaceOrder
from AwaitingPayment to Paid on PaymentAuthorized
from Paid to Delivered on Deliver
}
"""
def _record(stm, message):
stm.execution_ctx.setdefault("history", []).append(message)
def on_cart(stm, event, **inputs):
_record(stm, "order created")
def request_payment(stm, event, **inputs):
_record(stm, "payment requested")
def capture_payment(stm, event, **inputs):
_record(stm, "payment captured")
def deliver(stm, event, **inputs):
_record(stm, "delivered")
defn = definition_from_dsl(
SOURCE,
"order",
actions={
"on_cart": on_cart,
"request_payment": request_payment,
"capture_payment": capture_payment,
"deliver": deliver,
},
)
runner = DurableRunner(DictStore(), {defn.id: defn})
print("compiled:", defn.id)
Handlers vs. literals, and how they get bound¶
Look at the DSL: on enter on_cart references on_cart by a bare name. That is a
handler — an abstract slot the machine declares but does not implement. You bind handlers
to real functions in one of two ways:
In the DSL, with a
bindblock — handy when the implementations live in a known module:bind { on_cart = myapp.orders.on_cart request_payment = myapp.orders.request_payment }Programmatically, with
actions={…}passed todefinition_from_dsl— which is what we do above. Its values may be dotted import paths or live callables (as here).actions=wins over an in-DSLbind, which makes it the seam for swapping implementations in tests.
A reference with two or more dotted segments, like myapp.orders.on_cart, is a
literal: a fully-qualified path the engine imports directly, no binding needed. So
on enter on_cart is a handler (bind it), while on enter myapp.orders.on_cart is a literal
(resolved on its own). Leaving a handler unbound is a hard error at build time — the machine
won’t compile with a dangling action.
Drive it¶
With the actions bound, create the execution and feed it the happy path:
exe = runner.create(defn.id)
print("start ->", exe.active_path, "| history:", exe.context.get("history"))
for kind in ["PlaceOrder", "PaymentAuthorized", "Deliver"]:
exe = runner.process(exe.id, Event(kind=kind))
print(f"{kind:<16} -> {exe.active_path}")
print("status:", exe.status.name)
print("history:", exe.context["history"])
start -> Cart | history: ['order created']
PlaceOrder -> AwaitingPayment
PaymentAuthorized -> Paid
Deliver -> Delivered
status: DONE
history: ['order created', 'payment requested', 'payment captured', 'delivered']
Two things to notice:
on_cartalready ran atcreatetime — entering theinitialstate fires itson enter, sohistoryis non-empty before we send a single event.The execution reached
DONE.Deliveredis a leaf with no outgoing transition — a sink. Sinking ends the execution. ButDONEwith no verdict doesn’t tell us whether the order succeeded or was cancelled. Declaring that verdict is the next step.