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.
Definitions, entity state, transition history and scheduled timeouts are stored in PostgreSQL. A successful transition records the new state, its history event and any next timeout together. Redis carries live transition notifications. Deleting an entity removes its history and pending timeouts. Recent transition metrics still count its activity, and failed-timeout evidence remains available until the tenant is removed.
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 and allow up to 64 characters. Entity IDs allow up
to 128 characters. Recreating a definition 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 --limit 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 --filter 'current_state[eq]=delivered'
snug fsm entity-list -n docs4-order --limit 50 --sort -transition_count # then --cursor <page.next_cursor>, or --all
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
}Both lists speak the standard list contract.
The entity list filters on current_state, entity_id, transition_count,
created_by, created_at, and updated_at, and sorts on created_at,
updated_at, version, and transition_count. The definitions list takes
q over name and description, filters on state_count, version,
created_by, created_at, and updated_at (e.g.
filter[state_count][gte]=5), and sorts on created_at, updated_at, and
state_count.
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.
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
Reliability
What happens when a webhook fails, and the levers you have: retry budgets, backoff, the dead letter queue, redrive, and queue stats. Everything below
Guards and Timeouts
Guards make a transition conditional on the caller's context; timeouts move an entity automatically when it has sat in a state too long.
