12.1 Overview
A state machine declares a controlled lifecycle for a resource. Instead of allowing arbitrary writes to a status field, the state machine defines which values (states) are valid, which movements (transitions) are allowed from each state, who can perform each transition (guards), what data is required (requiredFields), and what side effects occur (sets, hooks).
State machines are appropriate when:
- A field’s value follows ordered progressions with business rules
- Different roles can perform different transitions
- Transitions have preconditions or side effects
- The lifecycle must be auditable and documented
State machines are NOT appropriate when any value can be set at any time (use an enum field instead — see §9.7).
12.2 Declaration
State machines are declared in the stateMachine: block of a resource:
resources:
Ticket:
stateMachine:
field: status
initial: open
states:
open:
description: "New ticket, not yet assigned"
transitions:
assign:
to: assigned
guards: [authenticated]
assigned:
description: "Assigned to an agent"
transitions:
start:
to: in_progress
guards: [owner, admin]
in_progress:
description: "Work underway"
transitions:
resolve:
to: resolved
guards: [owner, admin]
requiredFields: [resolution]
block:
to: blocked
guards: [owner, admin]
blocked:
description: "Waiting on external dependency"
transitions:
unblock:
to: in_progress
guards: [admin]
resolved:
description: "Solution provided"
transitions:
close:
to: closed
guards: [owner, admin]
sets:
closedAt: "now()"
closed:
description: "Ticket completed"
transitions:
reopen:
to: open
guards: [authenticated]
sets:
closedAt: null
resolution: null
assigneeId: null
assigneeName: null
Top-Level Keys
| Key | Type | Required | Description |
|---|---|---|---|
field | string | yes | The field this state machine controls. MUST exist in the resource’s object: block. Typically "status". |
initial | string | yes | The value auto-set on the field when a record is created. MUST be a declared state name. |
states | object | yes | Map of state names to state definitions. |
State Definition
| Key | Type | Required | Description |
|---|---|---|---|
description | string | no | Human-readable description of this state. |
final | boolean | no | If true, the state has no outgoing transitions. Default false. |
transitions | object | no | Map of transition names to transition definitions. Omitted for final states. |
onEnter | string[] | no | Hook names to execute when entering this state. |
onExit | string[] | no | Hook names to execute when leaving this state. |
Transition Definition
| Key | Type | Required | Description |
|---|---|---|---|
to | string | yes | Target state name. MUST be a declared state. |
guards | string[] | no | Role names that can perform this transition. OR-evaluated — any matching role passes. |
requiredFields | string[] | no | Fields that must be non-null for the transition to proceed. |
sets | object | no | Field values auto-applied when the transition executes. |
12.3 Transition Guards
Guards restrict which users can perform a transition. They are declared as an array of role identifiers:
transitions:
approve:
to: published
guards: [editor, admin]
Guards are OR-evaluated: if the user has ANY of the listed roles, the guard passes. The following role identifiers are available (same as §11.2):
| Guard | Meaning |
|---|---|
admin | User has the admin role |
owner | User owns the record (resolved via owner FK) |
authenticated | Any logged-in user |
| Any string | User has this specific role in their JWT roles array |
Guard Failure
If the user’s roles do not match any guard, the transition returns HTTP 403:
{
"status": "error",
"code": 403,
"error_code": "GUARD_FAILED",
"error_message": "Transition 'approve' requires role: editor, admin"
}
Guard Evaluation Order
Guards are checked AFTER transition permissions (§11.6) and BEFORE requiredFields validation. The full evaluation order for a transition request:
1. Does the transition exist from the current state? → 422 TRANSITION_DENIED
2. Does the user have transition permission (§11.6)? → 403 FORBIDDEN
3. Do the user's roles match the transition guards? → 403 GUARD_FAILED
4. Are all requiredFields present? → 422 REQUIRED_FIELD
5. Execute sets, hooks, and persist → 200 (or 500 on error)
AEL Guard Expressions
For complex guard logic beyond role checks, guards MAY be declared as AEL expressions:
transitions:
escalate:
to: escalated
guard: "hasRole('agent') && field('priority') == 'critical'"
Note: when using an AEL expression, the key is guard (singular string), not guards (array). The parser distinguishes the two forms automatically.
AEL guard expressions have access to the guard evaluation context (§36.4): user, record, and state-related functions (inState(), currentState()).
12.4 Required Fields
The requiredFields array lists field names that MUST have non-null values for the transition to proceed:
transitions:
resolve:
to: resolved
guards: [agent, admin]
requiredFields: [resolution]
Satisfaction Rules
A required field is satisfied if:
- The field has a non-null, non-empty value in the request body (the transition request includes the field), OR
- The field has a non-null, non-empty value in the existing record (the field was set in a previous operation).
The server checks both sources. If the field already has a value in the record, the client does not need to resend it.
Failure Response
If a required field is not satisfied:
{
"status": "error",
"code": 422,
"error_code": "REQUIRED_FIELD",
"error_message": "Field 'resolution' is required for transition 'resolve'"
}
UI Implications
The schema endpoint returns requiredFields for each transition. A UI SHOULD:
- Check which required fields are already populated on the record.
- Prompt the user for the missing required fields before enabling the transition button.
- Include the field values in the transition request body.
12.5 Auto-Set Fields (sets)
The sets block defines field values that are automatically applied when the transition executes:
transitions:
close:
to: closed
guards: [agent, admin]
sets:
closedAt: "now()"
closedById: "$user.id"
reopen:
to: open
guards: [authenticated]
sets:
closedAt: null
resolution: null
assigneeId: null
assigneeName: null
Set Values
| Value | Meaning |
|---|---|
"now()" | Current UTC timestamp (ISO 8601) |
null | Clear the field (set to null) |
"$user.id" | The authenticated user’s ID from the JWT |
"$user.username" | The authenticated user’s username |
| Any literal | String, number, or boolean literal value |
Override Behavior
Set values override any client-provided value for the same field in the transition request body. If the client sends { "closedAt": "2020-01-01" } and sets declares closedAt: "now()", the actual value is the current timestamp, not the client-provided date.
This ensures that auto-set fields cannot be manipulated by the client.
12.6 On-Enter and On-Exit Hooks
States MAY declare hooks that execute when a record enters or exits the state:
states:
published:
onEnter: [sendPublishNotification, updateSearchIndex]
onExit: [removeFromFeatured]
transitions:
archive:
to: archived
guards: [admin]
Execution Order
When a transition from state A to state B executes:
- State A’s
onExithooks execute (in array order) - The
setsvalues are applied - The record is persisted with the new state
- State B’s
onEnterhooks execute (in array order)
Hook Dispatch
Hook names reference Lua scripts or bus action handlers. The hook system dispatches an event on the message bus:
adl.{domain}.{resource}.{hookName}
Lua scripts subscribe to these events and execute custom logic. See §31.7 for hook integration details.
Before-Hook Abort
onExit hooks MAY abort the transition by returning an error. If an onExit hook fails, the transition is cancelled — the record remains in its original state and the client receives HTTP 422:
{
"status": "error",
"code": 422,
"error_code": "HOOK_FAILED",
"error_message": "Hook 'removeFromFeatured' prevented transition: featured article cannot be archived"
}
onEnter hooks fire AFTER the state change is persisted. They are fire-and-forget — an onEnter hook failure is logged but does not roll back the transition.
12.7 Generated Transition Endpoints
Each transition produces a POST endpoint:
POST /api/v1/{domain}/{resources}/:id/transitions/{transitionName}
Examples:
POST /api/v1/tickets/tickets/:id/transitions/assign
POST /api/v1/blog/posts/:id/transitions/approve
POST /api/v1/orders/orders/:id/transitions/ship
Request Body
The request body MAY include:
- Values for
requiredFieldsthat are not already set on the record - Additional field values to merge into the record (subject to field-level permissions)
{
"resolution": "Fixed by upgrading to version 2.3.1",
"internalNotes": "Customer confirmed the fix"
}
Fields not in requiredFields or sets are treated as a normal partial update — they are subject to the same field-level permission checks as PATCH.
Response
On success, the endpoint returns HTTP 200 with the full updated record:
{
"status": "ok",
"code": 200,
"data": {
"id": "ticket-1",
"status": "resolved",
"resolution": "Fixed by upgrading to version 2.3.1",
"resolvedAt": "2026-05-17T14:30:00Z"
}
}
12.8 Direct State Field Writes
When a state machine is defined on a field, direct writes to that field via PATCH or PUT are intercepted by the state machine. A PATCH with { "status": "published" } does NOT bypass the state machine — it is validated against the current state’s available transitions.
If the submitted value matches a valid transition’s target state, the transition executes (including guards, requiredFields, and sets). If no transition from the current state leads to the submitted value, the request is rejected:
{
"status": "error",
"code": 422,
"error_code": "TRANSITION_DENIED",
"error_message": "No transition from state 'draft' to 'published'. Available transitions: submit"
}
The recommended UI pattern is to use transition buttons (which call the transition endpoint) rather than a status dropdown (which would attempt a PATCH). The state machine’s transition endpoints are the canonical way to change state.
12.9 Final States
A state with final: true has no outgoing transitions. Once a record reaches a final state, it cannot leave it:
states:
cancelled:
description: "Order cancelled — terminal state"
final: true
Attempting to transition out of a final state returns:
{
"status": "error",
"code": 422,
"error_code": "FINAL_STATE",
"error_message": "No transitions available from state 'cancelled'"
}
Final vs No Transitions
final: true is both a behavioral constraint and a documentation marker. A state with no transitions declared (but without final: true) produces the same runtime behavior — no transitions are available. The final flag makes the intent explicit for documentation, UI rendering (terminal state badges), and tooling (state diagram rendering).
Reopenable “Closed” States
Some states appear terminal but need an escape hatch. For example, a “closed” ticket that can be reopened:
states:
closed:
# NOTE: No final: true — this state has a reopen transition
transitions:
reopen:
to: open
guards: [authenticated]
sets:
closedAt: null
resolution: null
This is a deliberate design choice. If final: true were set, the reopen transition would be impossible. The state is functionally “complete” for most purposes but retains the ability to reopen. The rich enum’s final: true (§9.3) is a UI hint that does not affect the state machine — a value can be final: true in the enum for badge rendering while still having outgoing transitions in the state machine.
12.10 State Machine vs Workflow
State machines and workflows (§25) are complementary mechanisms. This table clarifies when to use each:
| Aspect | State Machine | Workflow |
|---|---|---|
| Scope | Single resource | Cross-resource process |
| States/steps | States of one record | Steps involving multiple actors and records |
| Transitions | User-initiated, one at a time | May advance automatically (wait, timeout) |
| Persistence | State stored as a field on the record | Workflow instance stored separately |
| Guards | Role-based, per-transition | Step-level conditions and assignees |
| Side effects | sets, hooks | Actions: create, update, transition, notify, webhook |
| Typical use | Order lifecycle, ticket workflow | Approval chains, loan origination, publishing pipeline |
| Declaration | stateMachine: block on the resource | workflows: block at the domain level [v0.3] |
Rule of thumb: If the lifecycle involves only one record and one actor at a time, use a state machine. If it involves multiple actors, multiple resources, timeouts, parallel branches, or conditional routing, use a workflow.
State machines and workflows can coexist on the same resource. A workflow step MAY trigger a state machine transition on a resource.
12.11 Schema Endpoint
The schema introspection endpoint returns the full state machine definition:
{
"stateMachine": {
"field": "status",
"initial": "open",
"states": {
"open": {
"description": "New ticket",
"transitions": {
"assign": {
"to": "assigned",
"guards": ["authenticated"]
}
}
},
"assigned": {
"description": "Assigned to agent",
"transitions": {
"start": {
"to": "in_progress",
"guards": ["owner", "admin"]
}
}
}
}
}
}
A UI generator uses this data to:
- Render transition buttons showing only the transitions available from the current state
- Disable or hide transitions where the user’s role does not match the guards
- Prompt for required fields before enabling the transition button
- Show the state machine as a visual diagram
The schema endpoint returns the raw state machine definition. The UI MUST combine it with the current record’s state value and the current user’s roles to determine which transitions to render as active.
End of Chapter 12.
Next: Chapter 13 — Computed Fields