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.
Binding a machine to a field
Section titled “Binding a machine to a field”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: finalfield: 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.
Grammar
Section titled “Grammar”| Key | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | Identifier; 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. |
field | string | yes | Entity field the machine governs. Empty makes the machine inert. |
initial | string | no | State written on create when the payload omits the field. |
states | list | yes | The state set. |
description | string | no | Parsed and discarded — comment only. |
| Key | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | The value stored in the column. Matched exactly — case and spacing included. |
on | map | no | Event name → transition. The map key is the event identifier reported by introspection and passed to scripts as event. |
type | string | no | final or empty. Carried into introspection; not checked by the write path. |
description | string | no | Parsed and discarded. |
| Key | Type | Required | Meaning |
|---|---|---|---|
target | string | yes | Destination state. This is the value a caller writes to fire the event. |
guard | script | no | One boolean script. False rejects the transition. |
guards | list of scripts | no | List form. Only the first entry survives the load; the rest are discarded silently. |
exit | list of scripts | no | Runs first, after the guard passes. |
actions | list of scripts | no | Runs after exit. |
entry | list of scripts | no | Runs last, after actions. |
condition | script | no | Parsed 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.
Script reference
Section titled “Script reference”Guards and every script slot use the schema-wide script shape.
| Key | Meaning |
|---|---|
language | Evaluator token. expr (the default when omitted), cel, js / javascript, lua, starlark, go, wasm. |
expression | Inline one-line body. Wins when both it and script: are set. |
script | Inline full body. This is source code, not a path. |
script-file | Path to an external file. Parsed, but see the caution below. |
function | Entry 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.
Create
Section titled “Create”| Payload | Result |
|---|---|
| State field absent or null | Set to initial. If initial is empty, left unset. |
| Value is a declared state | Accepted. |
| Value is not a declared state | Rejected — unknown state "<x>". |
| Value is not a string | Rejected — 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.
Update
Section titled “Update”The hook is evaluated in this order. Each row short-circuits the rest.
| Condition | Outcome |
|---|---|
| State field not in the payload | No enforcement — nothing to transition. |
| Value is not a string | Rejected — state field must be a string. |
| Value is not a declared state | Rejected — unknown target state "<x>". |
| Current state cannot be resolved | Allowed — see the caution below. |
| Current state equals the target | Allowed, no event runs. |
Stored current state is not in states | Rejected — current state "<x>" not found in stateflow. |
No event in the current state has target: <value> | Rejected — no valid transition exists. |
| Guard returns false | Rejected — guard condition denied the transition. |
| Otherwise | exit → actions → entry 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.
Guards
Section titled “Guards”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 result | Behaviour |
|---|---|
true | Transition proceeds to the script slots. |
false | Transition rejected, guard condition denied the transition, HTTP 422. The write does not happen. |
| Script errors | Mutation 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.
Execution order
Section titled “Execution order”Per transition, the order is fixed:
guard → exit[0..n] → actions[0..n] → entry[0..n] → transition metadata → writeEach 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.
What scripts see
Section titled “What scripts see”Guard, exit, actions and entry all receive the same bindings, built once per transition.
| Binding | Type | Value |
|---|---|---|
tenant | string | Tenant key of the request. |
user_id | string | Caller identity. |
roles | list | Caller roles. |
entity | string | Entity name. |
from | string | Current state. |
to | string | Target state. |
event | string | Name of the matched event. |
payload | map | The mutation payload — the fields being written. |
There is no row binding and no user object.
Where it runs
Section titled “Where it runs”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 existsErrors
Section titled “Errors”| Message | HTTP | Code | Cause |
|---|---|---|---|
invalid transition on <entity>.<field>: state field must be a string | 422 | v2.create_failed / v2.update_failed | Non-string value sent for the state field. |
invalid transition on <entity>.<field>: unknown state "<x>" | 422 | v2.create_failed | Create supplied a state the machine does not declare. |
invalid transition on <entity>.<field>: unknown target state "<x>" | 422 | v2.update_failed | Update requested a state the machine does not declare. |
... <from> → <to>: current state "<from>" not found in stateflow | 404 | v2.update_failed | Stored value predates the state list, or initial: is not a declared state. |
... <from> → <to>: no valid transition exists | 422 | v2.update_failed | No event in the current state targets that value. |
... <from> → <to>: guard condition denied the transition | 422 | v2.update_failed | Guard returned false. |
stateflow guard eval: <err> | 500 | v2.update_failed | Guard script failed to compile or threw. |
stateflow exit script #<i>: <err> | 500 | v2.update_failed | The i-th (0-based) exit script failed. |
stateflow action script #<i>: <err> | 500 | v2.update_failed | The i-th actions script failed. |
stateflow entry script #<i>: <err> | 500 | v2.update_failed | The i-th entry script failed. |
stateflow <name> not found | 404 | v2.stateflow_not_found | Introspection asked for an unknown flow. |
stateflow name missing from path | 400 | v2.missing_name | Introspection 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"}HTTP surface
Section titled “HTTP surface”| Method | Path | Purpose |
|---|---|---|
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 | /stateflows | List 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.
Querying available transitions
Section titled “Querying available transitions”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.
Worked example
Section titled “Worked example”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:
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:
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 existsThe stored row is untouched. A state the machine never declares is refused the same way, with unknown target state "teleported". The declared step succeeds:
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:
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 transitionSending both fields in one request satisfies it, because the guard reads the payload:
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.
Parsed but not enforced
Section titled “Parsed but not enforced”These keys load without error and do nothing. Reject them in review.
| Key | Position | Reality |
|---|---|---|
stateflows: | schema top level | Introspection only. Never constrains a write. |
stateflow: | field | Retypes the field to state. Does not bind a machine. |
stateflowfield: | field | Read by the parser, never compiled into anything. No effect. |
state-transitions: | field | A from/to allow-list that no hook reads. Zero protection. |
condition: | event | Never evaluated. Use guard:. |
type: final | state | Decorative. Declare no events instead. |
description: | flow, state | Discarded during compilation. |
entryActions: / exitActions: | state | Unknown keys, dropped silently. Use per-event entry: / exit:. |
guards: entries after the first | event | Discarded at load time. |
function:, script-file: | script reference | Not honoured by the write-path evaluator; a file-only reference errors at runtime. |