FSM
Finite state machines as a service: define a machine once - states, the transitions each state allows, guards, timeouts - then drive any number of entities through it. The server enforces the definition on every transition and keeps a versioned per-entity history, state distribution metrics, a dead-letter queue, and a Mermaid rendering of the machine.
When to reach for it: order lifecycles, deployment pipelines, document review chains, game session phases - anywhere "which states may follow which" is a rule to enforce, not a convention to remember.
When not to: background work that needs queues, workers, and retry scheduling belongs to Job Queue; approval gates where a human must decide belong to Human-in-the-Loop.
Concepts
- A definition is a named machine. Each state lists the transitions it
allows- a target-state string or{"to": ..., "guard": ...}- plus an optionaltimeout. Referencing a state that does not exist fails with400 invalid_fsm_definition. - Entities are implicit - no "create entity" call. The first transition for a new entity ID starts it in the initial state and applies that transition in one step.
- Context is an optional JSON object on a transition. Guards evaluate against it, and it lands in the entity's history - it is not echoed in the transition response.
- Guards and timeouts condition and automate transitions - see their own page.
- History is versioned - each applied transition appends an event and
increments the entity's
version; reads return newest first. - Sharing - definitions and entities are tenant-wide: any user in the
tenant may transition any entity. Only deletion is restricted to the
creator or an admin; anyone else gets
403 forbidden.
Define a machine
States come from a file (-f) or inline JSON (-s) - one is required:
cat > states.json <<'EOF'
{
"pending": { "allows": ["processing", "cancelled"] },
"processing": { "allows": [{ "to": "shipped", "guard": "context.paid == true" }, "cancelled"] },
"shipped": { "allows": ["delivered"] },
"delivered": { "allows": [] },
"cancelled": { "allows": [] }
}
EOF
snug fsm create -n docs4-order -i pending -d "Order lifecycle" -f states.json
snug fsm listNames are unique per tenant - recreating one fails with
409 fsm_already_exists. snug fsm get -n docs4-order --mermaid renders
the definition as a stateDiagram-v2 with guards and timeouts as edge
labels; this is the live output for the machine above:
stateDiagram-v2
[*] --> pending
pending --> processing
pending --> cancelled
processing --> shipped: context.paid == true
processing --> cancelled
shipped --> delivered(In --output json mode the diagram arrives as {name, format, diagram}.)
Drive entities
snug fsm transition -n docs4-order -e docs4-o1 -t processing -c '{"reason":"payment authorized"}'
snug fsm state -n docs4-order -e docs4-o1
snug fsm history -n docs4-order -e docs4-o1 -l 20Over HTTP a transition is a POST to the entity, captured live:
curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"to": "processing", "context": {"reason": "payment authorized"}}' \
http://localhost:4000/api/v1/fsm/definitions/docs4-order/entities/docs4-o4{
"status": 200,
"msg": "OK",
"data": {
"entity_id": "docs4-o4",
"fsm_name": "docs4-order",
"transition_id": "rpwvYTvttnYX",
"from": "pending",
"to": "processing",
"success": true
}
}A transition the current state does not allow is rejected with
400 invalid_transition and the message
Invalid transition from 'processing' to 'delivered'.
History events carry from, to, actor, context, transition_id,
version, and a timestamp - epoch seconds, unlike the RFC 3339
created_at/updated_at elsewhere in the service. state for an entity
that has never transitioned is 404 entity_not_found, but history for
the same entity is 200 with an empty events array.
Bulk transitions
bulk-transition moves many entities to one target; bulk-transition-items
takes a per-entity target list (-j inline or -f file):
snug fsm bulk-transition -n docs4-order -e docs4-o2,docs4-o3 -t processing
snug fsm bulk-transition-items -n docs4-order \
-j '[{"entity_id":"docs4-o3","to":"shipped","context":{"paid":true}},{"entity_id":"docs4-o1","to":"delivered"}]'Entities fail independently - captured with one entity in a state that disallows the target:
{
"total": 2, "successful": 1, "failed": 1,
"results": [
{ "entity_id": "docs4-o2", "success": true, "from": "processing", "to": "cancelled" },
{ "entity_id": "docs4-o1", "success": false, "error": "Invalid transition from 'shipped' to 'cancelled'" }
]
}A partial failure is still an HTTP 200 and the CLI exits 0 - check
failed in the payload, not the exit code.
Watch the fleet
snug fsm entity-list -n docs4-order -s delivered # filter by current state
snug fsm entity-list -n docs4-order -p 1 --page-size 50
snug fsm metrics -n docs4-orderMetrics captured live:
{
"total_entities": 3,
"entities_by_state": { "cancelled": 1, "shipped": 1, "delivered": 1 },
"transitions_last_hour": 7, "transitions_last_24h": 7,
"dead_letter_count": 0
}Over HTTP the entity list also sorts (sort_by one of created_at,
updated_at, version, transition_count), and the definitions list
speaks the shared search grammar - q
matches name and description, and filters like
filter=state_count:range:5, work as documented there.
Deleting
A definition with live entities refuses to die - deleting it is
409 fsm_has_entities until every entity is removed. --force only skips
the CLI confirmation prompt; the entity check is server-side.
snug fsm delete-entity -n docs4-order -e docs4-o1
snug fsm delete -n docs4-order --forceLimits and configuration
Definitions are capped at 100 states, tenants at 100 definitions, and each
entity records at most 1000 transitions by default; those caps, the timeout
processor's poll interval, and its retry policy are the FSM_* variables in
the FSM CONFIG reference.
Reference
- FSM API - every endpoint, callable
- Guards and timeouts - guard grammar, automatic transitions, the dead letter queue
- Related: Job Queue for background work, Human-in-the-Loop for approval gates, Distributed State for locks and CAS coordination