ServicesFSM

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 optional timeout. Referencing a state that does not exist fails with 400 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 list

Names 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 20

Over 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-order

Metrics 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 --force

Limits 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

On this page