Documentation

Build a complete orbital

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.

1. The entity

Start with the data. A claim has an amount and a note; it lives in memory while the page is open.

lolo
1entity Expense [runtime] { 2 id : string! 3 amount : number = 120 4 note : string = "" 5}

2. One state

Every trait needs at least one state and a screen for it. INIT fires when the page opens:

lolo
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.

3. Moving between states

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.

lolo
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.

4. A rule as a guard

The company rule: claims over 500 EUR cannot be approved here. Write it on the transition:

lolo
1 2 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.

5. The finished orbital

State machine

Approval

on Expense · states: 4 · transitions: 7
emitsMOVED
INIT
RAISE
SUBMIT
(> @entity.amount 0)
APPROVE
(<= @entity.amount 500)
REJECT
NEW_CLAIM
REVISE
nowdraft
submitted
approved
rejected
Running live

Draft claim: EUR

The 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

std-orb-docex-expense.lolololo
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:

submittedAPPROVE
→
approved
GuardThe transition only fires when this check is true.
(<= @entity.amount 500)
@entity.amount500<=
Effects (2)What runs, in order, when the transition fires.
(set @entity.note "Approved")
@entity.note"Approved"set

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.

Orb

The language where the rule is the program.

© 2026 Almadar. Ljubljana, Slovenia.
Not reported here