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”A stateflow is declared once, at the top level of a schema, and a field binds it:
stateflows: - name: order_lifecycle initial: draft states: - name: draft on: submit: { target: submitted } - name: submitted on: approve: { target: approved } - name: approved type: final
entities: - name: sales_order fields: - name: status type: string defaultvalue: draft stateflow: order_lifecycle # the bindingThe two halves are separate because a machine is not the property of one entity. The same definition governs a field of as many entities as bind it:
entities: - name: purchase_order fields: - name: approval_state type: string stateflow: order_lifecycle # the same machine, another entity, another fieldA definition therefore carries no field: of its own — the field that binds it is what names the
column the machine runs on — and an entity cannot define a flow inline. Both are refused at load,
naming what to write instead.
Four ways a machine could look declared and constrain nothing, all refused:
| What you wrote | Why it is refused |
|---|---|
stateflows: inside an entity | A definition lives at the top level; bind it from the field |
field: inside a definition | The binding names the field, so a definition that named one would disagree with a second binding |
stateflow: <name> naming no definition | The refusal lists the definitions the schema declares |
| a definition no field binds | It would constrain no write |
| two fields of one entity binding the same definition | An entity keys its machines by name, and a response reports them that way; declare a second definition |
Setting stateflow: on a field also changes its compiled type from string to state, which is what
schema exports and entity introspection show.
Grammar
Section titled “Grammar”| Key | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | Identifier; the {name} segment of the introspection route, and what a field writes in stateflow:. |
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, starlark, wasm — the languages data.svc offers by default (scripts.runtimes). lua cannot be offered yet and go is not available on data.svc. |
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 | create_failed / update_failed | Non-string value sent for the state field. |
invalid transition on <entity>.<field>: unknown state "<x>" | 422 | create_failed | Create supplied a state the machine does not declare. |
invalid transition on <entity>.<field>: unknown target state "<x>" | 422 | update_failed | Update requested a state the machine does not declare. |
... <from> → <to>: current state "<from>" not found in stateflow | 404 | update_failed | Stored value predates the state list, or initial: is not a declared state. |
... <from> → <to>: no valid transition exists | 422 | update_failed | No event in the current state targets that value. |
... <from> → <to>: guard condition denied the transition | 422 | update_failed | Guard returned false. |
stateflow guard eval: <err> | 500 | update_failed | Guard script failed to compile or threw. |
stateflow exit script #<i>: <err> | 500 | update_failed | The i-th (0-based) exit script failed. |
stateflow action script #<i>: <err> | 500 | update_failed | The i-th actions script failed. |
stateflow entry script #<i>: <err> | 500 | update_failed | The i-th entry script failed. |
stateflow <name> not found | 404 | stateflow_not_found | Introspection asked for an unknown flow. |
stateflow name missing from path | 400 | 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:
{ "errors": [ { "code": "update_failed", "message": "hook stateflow (AfterValidate): invalid transition on sales_order.status: draft → shipped: no valid transition exists" } ]}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 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 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 no_tenant.
Each object carries the definition and a bound_by list — every entity field that binds it — because a
definition governs no field of its own.
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.
stateflows: - name: order_lifecycle 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
entities: - name: sales_order description: "Customer sales order" fields: - name: order_no type: string modifiers: [required, unique] - name: status type: string defaultvalue: draft stateflow: order_lifecycle # Supplied on the approve transition — the guard requires it. - name: approved_by type: string nullable: true - name: total_amount type: integer defaultvalue: 0
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 } ], "meta": { "entity": "sales_order", "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.
Not currently active
Section titled “Not currently active”These keys load without error and have no effect on a write. Reject them in review, and use the alternative given.
| Key | Position | Use instead |
|---|---|---|
stateflowfield: | field | No effect. The stateflow: key on the field is the binding. |
state-transitions: | field | No effect. Express allowed transitions as the machine’s events:. |
condition: | event | Not evaluated. Use guard:. |
type: final | state | Decorative. Declare no events on the state 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. |