Chapter 12 · Volume II — The ADL Domain Language

State Machines

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

KeyTypeRequiredDescription
fieldstringyesThe field this state machine controls. MUST exist in the resource’s object: block. Typically "status".
initialstringyesThe value auto-set on the field when a record is created. MUST be a declared state name.
statesobjectyesMap of state names to state definitions.

State Definition

KeyTypeRequiredDescription
descriptionstringnoHuman-readable description of this state.
finalbooleannoIf true, the state has no outgoing transitions. Default false.
transitionsobjectnoMap of transition names to transition definitions. Omitted for final states.
onEnterstring[]noHook names to execute when entering this state.
onExitstring[]noHook names to execute when leaving this state.

Transition Definition

KeyTypeRequiredDescription
tostringyesTarget state name. MUST be a declared state.
guardsstring[]noRole names that can perform this transition. OR-evaluated — any matching role passes.
requiredFieldsstring[]noFields that must be non-null for the transition to proceed.
setsobjectnoField 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):

GuardMeaning
adminUser has the admin role
ownerUser owns the record (resolved via owner FK)
authenticatedAny logged-in user
Any stringUser 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:

  1. The field has a non-null, non-empty value in the request body (the transition request includes the field), OR
  2. 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:

  1. Check which required fields are already populated on the record.
  2. Prompt the user for the missing required fields before enabling the transition button.
  3. 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

ValueMeaning
"now()"Current UTC timestamp (ISO 8601)
nullClear the field (set to null)
"$user.id"The authenticated user’s ID from the JWT
"$user.username"The authenticated user’s username
Any literalString, 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:

  1. State A’s onExit hooks execute (in array order)
  2. The sets values are applied
  3. The record is persisted with the new state
  4. State B’s onEnter hooks 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 requiredFields that 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:

AspectState MachineWorkflow
ScopeSingle resourceCross-resource process
States/stepsStates of one recordSteps involving multiple actors and records
TransitionsUser-initiated, one at a timeMay advance automatically (wait, timeout)
PersistenceState stored as a field on the recordWorkflow instance stored separately
GuardsRole-based, per-transitionStep-level conditions and assignees
Side effectssets, hooksActions: create, update, transition, notify, webhook
Typical useOrder lifecycle, ticket workflowApproval chains, loan origination, publishing pipeline
DeclarationstateMachine: block on the resourceworkflows: 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:

  1. Render transition buttons showing only the transitions available from the current state
  2. Disable or hide transitions where the user’s role does not match the guards
  3. Prompt for required fields before enabling the transition button
  4. 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