Build an expense approval flow from one state to four, with a guard that enforces a spending rule.
This tutorial builds an expense approval: a claim is drafted, submitted, then approved or rejected. Along the way it adds states, guards and the screens for each step. The finished orbital runs at the bottom of the page.
Start with the data. A claim has an amount and a note; it lives in memory while the page is open.
1entity Expense [runtime] {
2 id : string!
3 amount : number = 120
4 note : string = ""
5}Every trait needs at least one state and a screen for it. INIT fires when the page opens:
1app expense-step-2 "1.0.0"
2"Step 2: one state and its screen."
3orbital ExpenseOrbital {
4 entity Expense [runtime] {
5 id : string!
6 amount : number = 120
7 }
8
9 trait Approval -> Expense [interaction, instance] {
10 initial: draft
11
12 state draft {
13 INIT -> draft
14 (set @entity.amount 120)
15 (render-ui main { type: typography, content: (str/concat "Draft claim: " @entity.amount " EUR"), variant: h4 })
16 }
17 }
18
19 page "/expenses" -> Approval
20}Validate it with orb validate. It passes: one state, a screen, a page that renders. Notice the (set @entity.amount 120): every field a trait reads must be set by some transition. Nothing is assumed.
A claim is submitted, then approved or rejected. Each step is a state, and each button sends an event that some state accepts. If you add a button whose event no state handles, orb validate rejects it. Try it.
1state draft {
2 SUBMIT -> submitted
3 (render-ui main { type: stack, direction: horizontal, gap: sm, children: [
4 { type: button, label: "Approve", action: APPROVE, variant: primary },
5 { type: button, label: "Reject", action: REJECT, variant: secondary }
6 ] })
7}
8
9state submitted {
10 APPROVE -> approved
11 (set @entity.note "Approved")
12 REJECT -> rejected
13 (set @entity.note "Rejected")
14}Notice that approved and rejected need a way out too: a state with no outgoing transition is a dead end, and the validator says so. The finished version adds NEW_CLAIM and REVISE.
The company rule: claims over 500 EUR cannot be approved here. Write it on the transition:
APPROVE -> approved when (<= @entity.amount 500)
(set @entity.note "Approved")When the guard is false, APPROVE is refused and the claim stays submitted. The rule lives in exactly one place, and the verifier tests both sides of it.
Approval
on Expense · states: 4 · transitions: 7The program above as state machines, one per trait: each box is a state, each arrow an event that moves it, and the icons on an arrow are the effects that event runs. A ◇ on an arrow is a guard; hover it to read the check. How to read AVL
1app std-orb-docex-expense "1.0.0"
2"std-orb-docex-expense — an approval flow: four states, a guard on the amount, and a rule the shape of the program enforces."
3orbital ExpenseOrbital {
4 entity Expense [runtime] {
5 id : string!
6 amount : number = 120
7 note : string = ""
8 }
9
10 trait Approval -> Expense [interaction, instance] {
11 initial: draft
12
13 state draft {
14 INIT -> draft
15 (render-ui main { type: stack, direction: vertical, gap: sm, children: [
16 { type: typography, content: (str/concat "Draft claim: " @entity.amount " EUR"), variant: h4 },
17 { type: stack, direction: horizontal, gap: sm, children: [
18 { type: button, label: "Add 50 EUR", action: RAISE, variant: ghost },
19 { type: button, label: "Submit", action: SUBMIT, variant: primary }
20 ] }
21 ] })
22 RAISE -> draft
23 (emit MOVED { event: "RAISE" })
24 (set @entity.amount (+ @entity.amount 50))
25 SUBMIT -> submitted when (> @entity.amount 0)
26 (emit MOVED { event: "SUBMIT" })
27 (render-ui main { type: stack, direction: vertical, gap: sm, children: [
28 { type: typography, content: (str/concat "Submitted: " @entity.amount " EUR"), variant: h4 },
29 { type: typography, content: "Claims over 500 EUR cannot be approved here.", variant: small, color: muted },
30 { type: stack, direction: horizontal, gap: sm, children: [
31 { type: button, label: "Approve", action: APPROVE, variant: primary },
32 { type: button, label: "Reject", action: REJECT, variant: secondary }
33 ] }
34 ] })
35 }
36
37 state submitted {
38 APPROVE -> approved when (<= @entity.amount 500)
39 (emit MOVED { event: "APPROVE" })
40 (set @entity.note "Approved")
41 (render-ui main { type: stack, direction: vertical, gap: sm, children: [
42 { type: badge, label: "Approved", variant: success },
43 { type: button, label: "New claim", action: NEW_CLAIM, variant: ghost }
44 ] })
45 REJECT -> rejected
46 (emit MOVED { event: "REJECT" })
47 (set @entity.note "Rejected")
48 (render-ui main { type: stack, direction: vertical, gap: sm, children: [
49 { type: badge, label: "Rejected", variant: error },
50 { type: button, label: "Revise", action: REVISE, variant: primary }
51 ] })
52 }
53
54 state approved {
55 NEW_CLAIM -> draft
56 (emit MOVED { event: "NEW_CLAIM" })
57 (set @entity.amount 120)
58 (render-ui main { type: stack, direction: vertical, gap: sm, children: [
59 { type: typography, content: (str/concat "Draft claim: " @entity.amount " EUR"), variant: h4 },
60 { type: stack, direction: horizontal, gap: sm, children: [
61 { type: button, label: "Add 50 EUR", action: RAISE, variant: ghost },
62 { type: button, label: "Submit", action: SUBMIT, variant: primary }
63 ] }
64 ] })
65 }
66
67 state rejected {
68 REVISE -> draft
69 (emit MOVED { event: "REVISE" })
70 (render-ui main { type: stack, direction: vertical, gap: sm, children: [
71 { type: typography, content: (str/concat "Draft claim: " @entity.amount " EUR"), variant: h4 },
72 { type: stack, direction: horizontal, gap: sm, children: [
73 { type: button, label: "Add 50 EUR", action: RAISE, variant: ghost },
74 { type: button, label: "Submit", action: SUBMIT, variant: primary }
75 ] }
76 ] })
77 }
78
79 emits {
80 MOVED -> external {
81 event : string!
82 }
83 @description "Each move names the event it took, so the diagram beside the demo can follow it."
84 }
85 }
86
87 page "/expenses" -> Approval
88}What happens when APPROVE fires:
(<= @entity.amount 500)(set @entity.note "Approved")Run orb verify on it: every transition is played, the guard is tried with an amount above and below the limit, and each screen is checked.
Build a task manager adds stored data and lists.
The language where the rule is the program.