Make a transition conditional on the entity, the payload or the viewer, and keep every rule in one place.
A guard is an expression after when. If it is false, the event is refused: no state change, no effects. Guards are where business rules live.
APPROVE -> approved when (<= @entity.amount 500)
(set @entity.note "Approved")PAY -> paid when (> ?amount 0)
(set @entity.total ?amount)?amount is a field of the incoming event. It must be declared, either in the emitting trait's emits or in a local listens entry, so the validator can check its type.
Here the checkout refuses a payment of zero, and an unpaid order has no way to ship:
Checkout
on Order · states: 3 · transitions: 41app std-orb-sample-checkout "1.0.0"
2"std-orb-sample-checkout — the checkout program shown on orb.almadar.io's home page, running: an order that cannot ship before it is paid. The page shows this file as code beside the running trait, and each move is reported (MOVED) so its state machine can follow along."
3orbital Orders {
4 entity Order [runtime] {
5 id : string!
6 total : number
7 }
8
9 trait Checkout -> Order [interaction, instance] {
10 initial: pending
11
12 state pending {
13 INIT -> pending
14 (render-ui main { type: stack, gap: sm, children: [
15 { type: typography, content: "Unpaid", variant: h4 },
16 { type: button, label: "Pay 40", action: PAY, actionPayload: { amount: 40 } }
17 ] })
18 PAY -> paid when (> ?amount 0)
19 (set @entity.total ?amount)
20 (emit MOVED { event: "PAY" })
21 (render-ui main { type: stack, gap: sm, children: [
22 { type: typography, content: "Paid 40", variant: h4 },
23 { type: button, label: "Ship", action: SHIP }
24 ] })
25 }
26
27 state paid {
28 SHIP -> shipped
29 (emit MOVED { event: "SHIP" })
30 (render-ui main { type: stack, gap: sm, children: [
31 { type: typography, content: "Shipped", variant: h4 },
32 { type: button, label: "New order", action: NEW_ORDER }
33 ] })
34 }
35
36 state shipped {
37 NEW_ORDER -> pending
38 (emit MOVED { event: "NEW_ORDER" })
39 (render-ui main { type: stack, gap: sm, children: [
40 { type: typography, content: "Unpaid", variant: h4 },
41 { type: button, label: "Pay 40", action: PAY, actionPayload: { amount: 40 } }
42 ] })
43 }
44
45 emits {
46 PAY {
47 amount : number!
48 }
49 MOVED -> external {
50 event : string!
51 }
52 @description "Each move names the event it took, so a diagram beside the order can follow it."
53 }
54 }
55
56 page "/checkout" -> Checkout
57}What happens when PAY fires:
(> @payload.amount 0)(set @entity.total @payload.amount)APPROVE -> approved when (= @user.role "manager")
(set @entity.approvedBy @user.id)@user is the signed-in viewer. Declare its shape once with an [identity] entity, and the validator rejects a role that is not in the vocabulary:
▼entity Person [persistent: people, identity] {
id : string! @key
role : "employee" | "manager" = employee
}(and …), (or …) and (not …) combine conditions. When an event should do different things depending on a value, write one transition per case with exclusive guards, or let match write them for you:
▼REVIEW -> reviewed match @entity.priority {
"high" -> (notify warning "Escalated")
_ -> (notify info "Queued")
}A guard refuses an event; it does not hide the button that sends it. If a button should not appear, say so in the screen: a row action's when, or an (if …) around the node. Nothing is hidden implicitly, so the program reads exactly as it behaves.
Cross-orbital events connect separate orbitals.
The language where the rule is the program.