# place_order — an order lifecycle ## What it does The canonical e-commerce order, from cart to delivery: a payment that may be declined and retried, fulfilment (pick, pack, choose a carrier), shipping and delivery — plus cancelling while the payment is pending, and the two unexpected failures a real order meets: the payment gateway or the warehouse raising. `run.py` replays seven scenarios, each on a fresh order, and prints every step: ```text === payment retried, then paid === (start) -> Cart PlaceOrder -> AwaitingPayment PaymentDeclined -> AwaitingPayment PaymentAuthorized -> Fulfilling.Picking Picked -> Fulfilling.Packing Packed -> Fulfilling.Express Dispatched -> Shipped Delivered -> Delivered status=DONE outcome=success ``` ## The machine ```{mermaid} stateDiagram-v2 [*] --> Cart state "Cart" as Cart Cart : on enter#58; on_cart state "AwaitingPayment" as AwaitingPayment AwaitingPayment : on enter#58; request_payment state "Retrying" as Retrying Retrying : on enter#58; on_retry state "Paid" as Paid Paid : on enter#58; capture_payment state "Fulfilling
on enter: start_fulfilment" as Fulfilling { [*] --> Fulfilling_Picking state "Picking" as Fulfilling_Picking Fulfilling_Picking : on enter#58; pick state "Packing" as Fulfilling_Packing Fulfilling_Packing : on enter#58; pack state "Express" as Fulfilling_Express Fulfilling_Express : on enter#58; book_express state "Standard" as Fulfilling_Standard Fulfilling_Standard : on enter#58; book_standard Fulfilling_Picking --> Fulfilling_Packing : Picked state Fulfilling_Packing__choose_carrier <> Fulfilling_Packing --> Fulfilling_Packing__choose_carrier : Packed Fulfilling_Packing__choose_carrier --> Fulfilling_Express : choose_carrier=express Fulfilling_Packing__choose_carrier --> Fulfilling_Standard : choose_carrier=standard } state "Shipped" as Shipped Shipped : on enter#58; ship state "Delivered" as Delivered Delivered : on enter#58; deliver Delivered : outcome#58; success state "Cancelled" as Cancelled Cancelled : on enter#58; cancel_order Cancelled : outcome#58; cancelled state "PaymentError" as PaymentError PaymentError : on enter#58; on_payment_error PaymentError : outcome#58; failed state "FulfilmentError" as FulfilmentError FulfilmentError : on enter#58; on_fulfilment_error FulfilmentError : outcome#58; failed Cart --> AwaitingPayment : PlaceOrder AwaitingPayment --> Paid : PaymentAuthorized state AwaitingPayment__choose <> AwaitingPayment --> AwaitingPayment__choose : PaymentDeclined
/ context.declines = context.declines + 1, context.last_decline = event.reason AwaitingPayment__choose --> Retrying : [context.declines < context.max_retries] AwaitingPayment__choose --> Cancelled : else AwaitingPayment --> Cancelled : CancelOrder Retrying --> AwaitingPayment Paid --> Fulfilling Paid --> PaymentError : error Fulfilling --> FulfilmentError : error Shipped --> Delivered : Delivered Delivered --> [*] Cancelled --> [*] PaymentError --> [*] FulfilmentError --> [*] Fulfilling_Express --> Shipped : Dispatched Fulfilling_Standard --> Shipped : Dispatched ``` What it shows: - **A typed context** (`context { declines: int = 0 max_retries: int = 1 ... }`): the order's own data, checked by `harel validate` wherever the model reads or writes it, and by `create()`. - **A `choose` with `set`** on `PaymentDeclined`: the model counts the decline and retries while `context.declines < context.max_retries`, through a transient `Retrying` state, else cancels — a decision over the order's own data, made in the model. - **A selector**, `choose_carrier`, inside the `Fulfilling` composite: the carrier is a decision that belongs in code (rates, destination, weight); `returns {"express", "standard"}` lets `harel validate` check both branches are handled. - **`on error`**: an unexpected exception in `capture_payment`, or anywhere inside `Fulfilling`, goes to a failed terminal (`PaymentError`, `FulfilmentError`) instead of dead-lettering the order. A declined payment is not an error: it is a modelled event. - **`CancelOrder`, not `Cancel`**: `Cancel` belongs to the control plane (`runner.cancel()`); cancelling an order is business, with its own event. ## How it is built - `order.stm` — the machine. - `actions.py` — the `(stm, event, **inputs)` actions, which record each step in the context's `history` (and raise when a scenario sets `simulate_payment_error` or `simulate_fulfilment_error`), and the `choose_carrier` selector. - `run.py` — compiles the machine, prints its PlantUML, and drives each scenario with a `DurableRunner` over a `DictStore`. An action raising, routed by `on error`: ```{mermaid} sequenceDiagram participant S as run.py participant R as DurableRunner participant E as engine participant A as capture_payment participant D as DictStore S->>R: process(id, PaymentAuthorized) R->>E: process(exe, PaymentAuthorized) E->>A: on enter Paid A--xR: raises (gateway timeout) R->>E: an error event (an on error is in scope) E->>A: Paid to PaymentError: on enter on_payment_error R->>D: commit(exe: PaymentError, DONE, outcome failed) R-->>S: exe (PaymentError) ``` The step that raised is dropped: the order goes from `AwaitingPayment` straight to `PaymentError`, with the exception in `context._error`. ## How it runs - **Semantics: synchronous** — `DurableRunner`, since each scenario reads the state after every event. - **Execution model: background**, the default: a script, nothing to choose. - **Action errors**: an action error that nothing models would dead-letter the order (the runner's `on_action_error="fail"`, its default); here the model handles both with `on error`, so it never comes to that. ## Run it ```text uv run python -m examples.place_order.run harel validate examples/place_order/order.stm order ```