State machines that react to events, check guards, run effects and render the screen.
A trait is a state machine bound to an entity. It lists states; each state lists the events it accepts, where each one leads, and what happens on the way.
1trait Approval -> Expense [interaction, instance] {
2 initial: draft
3
4 state draft {
5 SUBMIT -> submitted when (> @entity.amount 0)
6 (render-ui main { type: typography, content: "Submitted", variant: h4 })
7 }
8
9 state submitted {
10 APPROVE -> approved when (<= @entity.amount 500)
11 (set @entity.note "Approved")
12 REJECT -> rejected
13 (set @entity.note "Rejected")
14 }
15}EVENT -> target is a transition. Effects follow on their own lines and run in order.when (…) is a guard: if it is false, the event is refused and nothing changes.initial: names another.The header's bracket holds a category (interaction, lifecycle, integration, …) and instance (one state machine per entity row, the default) or collection (one for the whole entity).
Here is the full approval flow running. Try submitting a claim over 500 EUR and approving it; the guard refuses:
Approval
on Expense · states: 4 · transitions: 7The same trait as a state machine: each box is a state, each arrow an event that moves it, and the icons on an arrow are the effects that event runs. Hover a symbol to see what it does. 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")| Effect | What it does |
|---|---|
(set @entity.f v) | Assign a field. The only assignment form. |
(fetch E { … }) / (persist create|update|delete E data) | Read and write stored rows. |
(emit EVENT { … }) | Broadcast a declared event. |
(render-ui slot { … }) | Draw a pattern tree into a slot (main, modal, drawer, toast, …); null clears it. |
(navigate "/path") / (navigate-back) | Move to another page. |
(notify info "Saved") | Show a toast. |
(call-service provider action { … }) | Call an integration (LLM, email, payments, …). |
Operators compute values: (+ a b), (str/concat …), (array/map xs (fn x …)). They are all prefix; there is no infix. The operator reference lists every one.
| Sigil | Meaning |
|---|---|
@entity.field | A field of the bound entity |
?field | A field of the incoming event's payload |
@config.key | A value from the trait's config |
@user.field | The signed-in viewer |
now / state | This dispatch's timestamp / the current state |
A trait declares what it broadcasts, and another trait declares what it reacts to. Here the dial emits TARGET_CHANGED, and the status listens for it and maps it to its own SHOW event:
Dial
on Thermostat · states: 1 · transitions: 3Status
on Thermostat · states: 1 · transitions: 2Set a temperature between 16 and 28 °C.
1app std-orb-docex-thermostat "1.0.0"
2"std-orb-docex-thermostat — two traits in one orbital: one guards and emits, the other listens and reacts."
3orbital ThermostatOrbital {
4 entity Thermostat [runtime, shared] {
5 id : string!
6 target : number = 21
7 }
8
9 trait Dial -> Thermostat [interaction, instance] {
10 initial: idle
11
12 state idle {
13 INIT -> idle
14 (render-ui main { type: stack, direction: horizontal, gap: md, align: center, children: [
15 { type: button, label: "Colder", action: DOWN, variant: secondary },
16 { type: typography, content: (str/concat @entity.target " °C"), variant: h2 },
17 { type: button, label: "Warmer", action: UP, variant: secondary }
18 ] })
19 UP -> idle when (< @entity.target 28)
20 (emit MOVED { event: "UP" })
21 (set @entity.target (+ @entity.target 1))
22 (emit TARGET_CHANGED { target: @entity.target })
23 DOWN -> idle when (> @entity.target 16)
24 (emit MOVED { event: "DOWN" })
25 (set @entity.target (- @entity.target 1))
26 (emit TARGET_CHANGED { target: @entity.target })
27 }
28
29 emits {
30 TARGET_CHANGED {
31 target : number!
32 }
33 @description "The target temperature moved."
34 MOVED -> external {
35 event : string!
36 }
37 @description "Each move names the event it took, so the diagram beside the demo can follow it."
38 }
39 }
40
41 trait Status -> Thermostat [interaction, instance] {
42 initial: watching
43
44 state watching {
45 INIT -> watching
46 (render-ui main { type: typography, content: "Set a temperature between 16 and 28 °C.", variant: body, color: muted })
47 SHOW -> watching
48 (emit MOVED { event: "SHOW" })
49 (render-ui main { type: badge, label: (if (> ?target 23) "Heating up" (if (< ?target 19) "Cooling down" "Comfortable")), variant: (if (> ?target 23) warning (if (< ?target 19) info success)) })
50 }
51
52 emits {
53 MOVED -> external {
54 event : string!
55 }
56 @description "Each move names the event it took, so the diagram beside the demo can follow it."
57 }
58
59 listens {
60 Dial.TARGET_CHANGED -> SHOW
61 SHOW {
62 target : number!
63 }
64 }
65 }
66
67 page "/thermostat" -> Dial, Status
68}What happens when UP fires:
(< @entity.target 28)(set @entity.target (+ @entity.target 1))(emit "TARGET_CHANGED" { target: @entity.target })1emits {
2 TARGET_CHANGED {
3 target : number!
4 }
5}
6
7listens {
8 Dial.TARGET_CHANGED -> SHOW
9 SHOW {
10 target : number!
11 }
12}internal events (the default) stay within the orbital; external events cross to other orbitals and must declare their payload. Trait.EVENT resolves within the listener's own orbital; across orbitals, write Orbital.Trait.EVENT. Cross-orbital events shows both.
A tick is a periodic effect. It runs only while its when holds, so a tick bound to a state stops the moment the trait leaves it:
Timing
on Stopwatch · states: 2 · transitions: 41app std-orb-docex-stopwatch "1.0.0"
2"std-orb-docex-stopwatch — a tick: a periodic effect that runs only while the trait is in the running state."
3orbital StopwatchOrbital {
4 entity Stopwatch [runtime] {
5 id : string!
6 seconds : number = 0
7 }
8
9 trait Timing -> Stopwatch [interaction, instance] {
10 initial: stopped
11
12 state stopped {
13 INIT -> stopped
14 (render-ui main { type: stack, direction: horizontal, gap: md, align: center, children: [
15 { type: typography, content: (str/concat @entity.seconds " s"), variant: h2 },
16 { type: button, label: "Start", action: START, variant: primary },
17 { type: button, label: "Reset", action: RESET, variant: ghost }
18 ] })
19 START -> running
20 (emit MOVED { event: "START" })
21 (render-ui main { type: stack, direction: horizontal, gap: md, align: center, children: [
22 { type: typography, content: (str/concat @entity.seconds " s"), variant: h2 },
23 { type: button, label: "Stop", action: STOP, variant: secondary }
24 ] })
25 RESET -> stopped
26 (emit MOVED { event: "RESET" })
27 (set @entity.seconds 0)
28 }
29
30 state running {
31 STOP -> stopped
32 (emit MOVED { event: "STOP" })
33 (render-ui main { type: stack, direction: horizontal, gap: md, align: center, children: [
34 { type: typography, content: (str/concat @entity.seconds " s"), variant: h2 },
35 { type: button, label: "Start", action: START, variant: primary },
36 { type: button, label: "Reset", action: RESET, variant: ghost }
37 ] })
38 }
39
40 emits {
41 MOVED -> external {
42 event : string!
43 }
44 @description "Each move names the event it took, so the diagram beside the demo can follow it."
45 }
46
47 ticks {
48 second every 1s when (= state "running")
49 (set @entity.seconds (+ @entity.seconds 1))
50 }
51 }
52
53 page "/stopwatch" -> Timing
54}What happens when START fires:
▼ticks {
second every 1s when (= state "running")
(set @entity.seconds (+ @entity.seconds 1))
}Intervals are milliseconds, a duration (5s, 1m, 1h, 1d) or a cron string. A tick on a stored entity runs on the server while a page holds the trait; tag it [background] to run with nobody watching.
config { pageSize : int = 10 @label "Items per page" } declares a knob the trait reads as @config.pageSize. Knobs are what a call site may change when it imports the trait; see composition.
Pages put traits on screen.
The language where the rule is the program.