3. Reaching a conclusion: terminals and outcomes¶
In step 2 the order reached DONE, but DONE alone doesn’t say how it ended — delivered or
cancelled. That distinction is the outcome: the domain verdict the surrounding system
routes on.
Three different things called “the result”¶
It helps to keep three axes separate, because they answer different questions:
Axis |
Where |
Answers |
|---|---|---|
status |
|
the lifecycle state the engine manages — |
outcome |
|
the domain verdict — |
result |
|
the data the run produced |
success is the one privileged label: “did it succeed?” means outcome == "success". Every
other label is just a named variant of not success (UML’s named final states). The engine
imposes no default — a normal completion is not silently “success”, and an error is not
silently “failed”.
Declaring the verdict¶
A terminal state declares its verdict with the final sugar: final <Name> <outcome>. Our
order now ends two ways — Delivered (success) or Cancelled (cancelled):
machine order {
initial Cart
state Cart { on enter on_cart }
state AwaitingPayment { on enter request_payment }
state Paid { on enter capture_payment }
final Delivered success { on enter deliver }
final Cancelled cancelled { on enter cancel_order }
from Cart to AwaitingPayment on PlaceOrder
from AwaitingPayment to Paid on PaymentAuthorized
from AwaitingPayment to Cancelled on CancelOrder
from Paid to Delivered on Deliver
}
stateDiagram-v2
[*] --> Cart
Cart --> AwaitingPayment : PlaceOrder
AwaitingPayment --> Paid : PaymentAuthorized
AwaitingPayment --> Cancelled : CancelOrder
Paid --> Delivered : Deliver
Delivered --> [*]
Cancelled --> [*]
final Delivered success { … } is sugar for a leaf state that, when sunk through, ends the
execution with outcome = "success". It can still carry hooks (on enter deliver), which fire
as the terminal is entered.
Run both endings¶
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 }
final Delivered success { on enter deliver }
final Cancelled cancelled { on enter cancel_order }
from Cart to AwaitingPayment on PlaceOrder
from AwaitingPayment to Paid on PaymentAuthorized
from AwaitingPayment to Cancelled on CancelOrder
from Paid to Delivered on Deliver
}
"""
def _record(stm, message):
stm.execution_ctx.setdefault("history", []).append(message)
actions = {
name: (lambda stm, event, _m=name, **inputs: _record(stm, _m))
for name in ["on_cart", "request_payment", "capture_payment", "deliver", "cancel_order"]
}
defn = definition_from_dsl(SOURCE, "order", actions=actions)
runner = DurableRunner(DictStore(), {defn.id: defn})
def run(events):
exe = runner.create(defn.id)
for kind in events:
exe = runner.process(exe.id, Event(kind=kind))
return exe
happy = run(["PlaceOrder", "PaymentAuthorized", "Deliver"])
cancelled = run(["PlaceOrder", "CancelOrder"])
print(f"happy -> {happy.active_path:10} status={happy.status.name} outcome={happy.outcome}")
print(f"cancelled -> {cancelled.active_path:10} status={cancelled.status.name} outcome={cancelled.outcome}")
happy -> Delivered status=DONE outcome=success
cancelled -> Cancelled status=DONE outcome=cancelled
Both runs reach DONE, but now the outcome tells them apart — and that is the value the
rest of your system branches on, stores, or reports.
Who makes sure you declared it?¶
Because there is no engine default, it would be easy to forget the verdict on a terminal and
silently get outcome = None. harel catches that with static validation: a rule
requires every execution-ending terminal to declare an outcome, and flags the ones that
don’t — before you ever run the machine. We turn that on in step 14.
Next: not every transition should fire unconditionally. In step 4 we gate transitions on guards.