Skip to content
Talk to our solutions team

Stateflows

A stateflow is a state machine attached to one field of one entity. It declares the states that field may hold and, per state, the events that may leave it. A write that moves the field to a state no event targets is rejected inside the mutation, before any SQL runs.

There is no “fire a transition” endpoint. You fire a transition by writing the new value of the state field on the entity’s normal REST update route. The engine resolves the row’s current state, finds the event, evaluates its guard, runs its scripts, and either lets the write through or fails it.

Declare the machine inside the entity, under stateflows:, and name the column it governs with field:.

entities:
- name: sales_order
fields:
- name: status
type: string
defaultvalue: draft
stateflows:
- name: order_lifecycle
field: status
initial: draft
states:
- name: draft
on:
submit: { target: submitted }
- name: submitted
on:
approve: { target: approved }
- name: shipped
type: final

field: is load-bearing. A flow with an empty field: is inert: the hook returns before doing anything.

The field-level key stateflow: <name> is optional and does one thing only — it changes the field’s compiled type from string to state, which is visible in schema exports and entity introspection. It does not bind the machine and does not enable enforcement.

KeyTypeRequiredMeaning
namestringyesIdentifier; the {name} segment of the introspection route. Flows are keyed by name — a second flow with the same name on one entity replaces the first.
fieldstringyesEntity field the machine governs. Empty makes the machine inert.
initialstringnoState written on create when the payload omits the field.
stateslistyesThe state set.
descriptionstringnoParsed and discarded — comment only.
KeyTypeRequiredMeaning
namestringyesThe value stored in the column. Matched exactly — case and spacing included.
onmapnoEvent name → transition. The map key is the event identifier reported by introspection and passed to scripts as event.
typestringnofinal or empty. Carried into introspection; not checked by the write path.
descriptionstringnoParsed and discarded.
KeyTypeRequiredMeaning
targetstringyesDestination state. This is the value a caller writes to fire the event.
guardscriptnoOne boolean script. False rejects the transition.
guardslist of scriptsnoList form. Only the first entry survives the load; the rest are discarded silently.
exitlist of scriptsnoRuns first, after the guard passes.
actionslist of scriptsnoRuns after exit.
entrylist of scriptsnoRuns last, after actions.
conditionscriptnoParsed and never read. Use guard:.

entry: and exit: are keys on an event, not on a state. There is no state-level entry or exit list; a state reached by three events repeats its entry scripts on all three. State-level keys such as entryActions: are unknown to the parser and are dropped without a warning.

Guards and every script slot use the schema-wide script shape.

KeyMeaning
languageEvaluator token. expr (the default when omitted), cel, js / javascript, lua, starlark, go, wasm.
expressionInline one-line body. Wins when both it and script: are set.
scriptInline full body. This is source code, not a path.
script-filePath to an external file. Parsed, but see the caution below.
functionEntry function name. Ignored on the write path.

Every entry in guards:, exit:, actions: and entry: must be a mapping. A bare string where a script mapping is expected is a hard unmarshal error that fails the whole schema load.

PayloadResult
State field absent or nullSet to initial. If initial is empty, left unset.
Value is a declared stateAccepted.
Value is not a declared stateRejected — unknown state "<x>".
Value is not a stringRejected — state field must be a string.

No event script ever runs on create — initialisation is not an event. For insert-time logic use a before_create trigger.

defaultvalue: on the field is applied earlier in the pipeline than the stateflow check, so it wins over initial: when the two disagree. Keep them equal; a defaultvalue: that is not a declared state makes every create fail.

The hook is evaluated in this order. Each row short-circuits the rest.

ConditionOutcome
State field not in the payloadNo enforcement — nothing to transition.
Value is not a stringRejected — state field must be a string.
Value is not a declared stateRejected — unknown target state "<x>".
Current state cannot be resolvedAllowed — see the caution below.
Current state equals the targetAllowed, no event runs.
Stored current state is not in statesRejected — current state "<x>" not found in stateflow.
No event in the current state has target: <value>Rejected — no valid transition exists.
Guard returns falseRejected — guard condition denied the transition.
Otherwiseexitactionsentry run, then the write proceeds.

The current state is read from the stored row by primary key, so enforcement requires the mutation to identify a single row by key.

One guard per event. guard: takes precedence; otherwise the first entry of guards: is used and every later entry is discarded at load time. Compose multiple conditions into one expression instead of listing them.

Guard resultBehaviour
trueTransition proceeds to the script slots.
falseTransition rejected, guard condition denied the transition, HTTP 422. The write does not happen.
Script errorsMutation aborts with stateflow guard eval: <err>, HTTP 500.

A guard sees the incoming payload, not the stored row — the pre-image is captured later in the pipeline. A rule that depends on a value must require the caller to send that value in the same request.

Per transition, the order is fixed:

guard → exit[0..n] → actions[0..n] → entry[0..n] → transition metadata → write

Each list runs in declaration order. The first error in any slot aborts the whole mutation: a failed exit script runs no actions and no entry; a failed action runs no entry. Nil entries are skipped. Side effects already committed by earlier scripts are not rolled back — that is the script author’s problem, the same posture as before_* triggers.

Return values are discarded. An action that returns an object does not patch those fields onto the row. To set additional columns during a transition, send them in the same request body.

Transition metadata (from, to, event, and the field name) is recorded on the mutation context after every script succeeds, for custom hooks to read. Nothing in the shipping service consumes it: history and audit record a transition as an ordinary column change, and the event bus publishes entity.updated — there is no state.changed event type and no from/to enrichment on the published payload.

Guard, exit, actions and entry all receive the same bindings, built once per transition.

BindingTypeValue
tenantstringTenant key of the request.
user_idstringCaller identity.
roleslistCaller roles.
entitystringEntity name.
fromstringCurrent state.
tostringTarget state.
eventstringName of the matched event.
payloadmapThe mutation payload — the fields being written.

There is no row binding and no user object.

Field validation runs before the stateflow check, so a guard never sees a payload that failed validation. Materialized computed fields are evaluated after it, so a guard cannot read a computed value produced by the same write — it must derive the value from payload itself. before_* triggers run after the transition is accepted, in the same phase.

Errors leaving the engine are wrapped with the hook name and phase:

hook stateflow (AfterValidate): invalid transition on sales_order.status: draft → shipped: no valid transition exists
MessageHTTPCodeCause
invalid transition on <entity>.<field>: state field must be a string422v2.create_failed / v2.update_failedNon-string value sent for the state field.
invalid transition on <entity>.<field>: unknown state "<x>"422v2.create_failedCreate supplied a state the machine does not declare.
invalid transition on <entity>.<field>: unknown target state "<x>"422v2.update_failedUpdate requested a state the machine does not declare.
... <from> → <to>: current state "<from>" not found in stateflow404v2.update_failedStored value predates the state list, or initial: is not a declared state.
... <from> → <to>: no valid transition exists422v2.update_failedNo event in the current state targets that value.
... <from> → <to>: guard condition denied the transition422v2.update_failedGuard returned false.
stateflow guard eval: <err>500v2.update_failedGuard script failed to compile or threw.
stateflow exit script #<i>: <err>500v2.update_failedThe i-th (0-based) exit script failed.
stateflow action script #<i>: <err>500v2.update_failedThe i-th actions script failed.
stateflow entry script #<i>: <err>500v2.update_failedThe i-th entry script failed.
stateflow <name> not found404v2.stateflow_not_foundIntrospection asked for an unknown flow.
stateflow name missing from path400v2.missing_nameIntrospection called with an empty name.

Rejections are client errors. Script failures are server errors — a broken guard is a deployment fault, not a caller fault.

The error body is the standard shape:

{
"error": "hook stateflow (AfterValidate): invalid transition on sales_order.status: draft → shipped: no valid transition exists",
"code": "v2.update_failed"
}
MethodPathPurpose
POST/rest/{entity}Create. Applies initial when the state field is omitted; rejects an undeclared state.
PATCH/rest/{entity}/id/{id}Fire a transition — send the state field with its target value.
PUT/rest/{entity}/id/{id}Same handler, same behaviour.
PATCH / PUT/rest/{entity}Same, with the primary key in the body. Missing key → 400 v2.missing_id.
GET/stateflowsList declared machines. Introspection only.
GET/stateflow/{name}One machine by name. Introspection only.

Every REST route above has an anonymous mirror under /anon/rest/.... The two introspection routes do not — they are JWT-only. Full request and response detail lives in the Data API HTTP reference.

The primary key is never part of the update SET — the handler strips it, and a body key that contradicts the path key is refused with 422 v2.id_mismatch.

GET /stateflow/{name} returns the shape of the machine so a UI can decide which buttons to enable. Guard, action, entry, exit and condition bodies are omitted deliberately — callers see only whether an event has a guard or actions.

{
"name": "order_lifecycle",
"field": "status",
"initial_state": "draft",
"states": {
"draft": { "name": "draft", "events": { "submit": { "name": "submit", "target": "submitted" } } },
"submitted": { "name": "submitted", "events": {
"approve": { "name": "approve", "target": "approved", "has_guard": true },
"reject": { "name": "reject", "target": "draft" } } },
"approved": { "name": "approved", "events": { "ship": { "name": "ship", "target": "shipped" } } },
"shipped": { "name": "shipped", "type": "final" }
}
}

GET /stateflows wraps the same objects as {"stateflows": [...], "count": N}, sorted by name. Both routes require a JWT and a resolved tenant; without a tenant they return 403 v2.no_tenant.

An order lifecycle: draft → submitted → approved → shipped, with submitted → draft as the rejection path, and a guard that refuses approval unless the approver arrives in the same request.

entities:
- name: sales_order
description: "Customer sales order"
fields:
- name: order_no
type: string
modifiers: [required, unique]
- name: status
type: string
defaultvalue: draft
# Supplied on the approve transition — the guard requires it.
- name: approved_by
type: string
nullable: true
- name: total_amount
type: integer
defaultvalue: 0
stateflows:
- name: order_lifecycle
field: status
initial: draft
states:
- name: draft
on:
submit: { target: submitted }
- name: submitted
on:
approve:
target: approved
guard:
language: expr
expression: 'payload.approved_by != nil && payload.approved_by != ""'
reject: { target: draft }
- name: approved
on:
ship: { target: shipped }
- name: shipped
type: final
access:
actions:
read: { language: expr, expression: "true" }
create: { language: expr, expression: "true" }
update: { language: expr, expression: "true" }

External access is deny-by-default, so the entity needs access rules before any of this is reachable.

Driving it:

Terminal window
curl -X POST https://<host>/rest/sales_order \
-H 'Authorization: Bearer <jwt>' \
-H 'Content-Type: application/json' \
-d '{"order_no":"SO-1","total_amount":500}'
{ "data": [ { "id": "01J...", "order_no": "SO-1", "status": "draft", "total_amount": 500 } ], "count": 1 }

The state field was omitted, so initial: draft was applied. A jump straight to shipped is refused — draft declares only submit:

Terminal window
curl -X PATCH https://<host>/rest/sales_order/id/01J... \
-H 'Authorization: Bearer <jwt>' \
-H 'Content-Type: application/json' \
-d '{"status":"shipped"}'
422 invalid transition on sales_order.status: draft → shipped: no valid transition exists

The stored row is untouched. A state the machine never declares is refused the same way, with unknown target state "teleported". The declared step succeeds:

Terminal window
curl -X PATCH https://<host>/rest/sales_order/id/01J... \
-H 'Authorization: Bearer <jwt>' \
-H 'Content-Type: application/json' \
-d '{"status":"submitted"}'

Approving without the approver trips the guard:

Terminal window
curl -X PATCH https://<host>/rest/sales_order/id/01J... \
-H 'Authorization: Bearer <jwt>' \
-H 'Content-Type: application/json' \
-d '{"status":"approved"}'
422 invalid transition on sales_order.status: submitted → approved: guard condition denied the transition

Sending both fields in one request satisfies it, because the guard reads the payload:

Terminal window
curl -X PATCH https://<host>/rest/sales_order/id/01J... \
-H 'Authorization: Bearer <jwt>' \
-H 'Content-Type: application/json' \
-d '{"status":"approved","approved_by":"u-admin"}'

approved → shipped then completes the lifecycle. shipped declares no events, so nothing leaves it.

These keys load without error and do nothing. Reject them in review.

KeyPositionReality
stateflows:schema top levelIntrospection only. Never constrains a write.
stateflow:fieldRetypes the field to state. Does not bind a machine.
stateflowfield:fieldRead by the parser, never compiled into anything. No effect.
state-transitions:fieldA from/to allow-list that no hook reads. Zero protection.
condition:eventNever evaluated. Use guard:.
type: finalstateDecorative. Declare no events instead.
description:flow, stateDiscarded during compilation.
entryActions: / exitActions:stateUnknown keys, dropped silently. Use per-event entry: / exit:.
guards: entries after the firsteventDiscarded at load time.
function:, script-file:script referenceNot honoured by the write-path evaluator; a file-only reference errors at runtime.