ServicesFSM

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.

Guard expressions

A guard rides on a transition rule:

{ "to": "shipped", "guard": "context.paid == true" }

The grammar is deliberately small - exactly three forms:

FormPasses when
context.field == literalthe context field equals the literal
context.field != literalthe context field differs from the literal
context.fieldthe field is present and neither null nor false

Literals may be true, false, null, numbers, or strings - quotes on strings are optional, context.tier == gold and context.tier == "gold" mean the same thing. A failing guard rejects the transition:

snug fsm transition -n docs4-order -e docs4-o1 -t shipped
# {"status":400,"msg":"Guard condition failed: context.paid == true","error":"guard_failed"}
snug fsm transition -n docs4-order -e docs4-o1 -t shipped -c '{"paid":true}'   # passes

Behaviors:

  • Truthiness is loose - context.vip passed with {"vip":"gold"} and failed with {"vip":false} or no context at all. Any value other than missing, null, or false counts.
  • Top-level fields only - the dotted form does not traverse nested objects. A guard of context.payment.ok == true failed even with {"payment":{"ok":true}}: the whole payment.ok string is looked up as one key.
  • Guard syntax is not checked at create time. A definition whose guard does not start with context. is accepted, but every transition through that rule then fails:
{
  "status": 400,
  "msg": "Invalid FSM definition: unsupported guard expression: paid == true",
  "error": "invalid_fsm_definition"
}

State timeouts

A state may declare a timeout - an automatic transition after the entity has been in it for after_seconds:

"running": {
  "allows": ["done", "stalled"],
  "timeout": { "after_seconds": 2, "transition_to": "stalled" }
}

A background processor polls on an interval (5 seconds by default), so the transition lands shortly after the deadline, not at the exact instant. Verified with the definition above: the entity was moved running to stalled about 2.5 seconds after entering running, and the history records the system as the actor:

{
  "entity_id": "docs4-j2",
  "from": "running",
  "to": "stalled",
  "actor": "system:timeout",
  "context": { "from_state": "running", "reason": "timeout", "timeout_seconds": 0 },
  "version": 2
}

(The timeout object also accepts a reason string, but the recorded history context currently carries the generic "reason": "timeout" regardless.)

The rule that bites: transition_to must also appear in the state's allows list. Creation only checks that the target state exists; the automatic transition is then validated like any other. With stalled absent from running's allows: the timeout fired, was rejected as an invalid transition, retried 4 times (the default), and the entity landed in the dead letter queue - still in running.

The dead letter queue

Automatic transitions that keep failing end up dead-lettered, inspectable per machine:

snug fsm dead-letter -n docs4-job -l 50
{
  "total": 1,
  "entries": [
    {
      "fsm_name": "docs4-job",
      "entity_id": "docs4-j1",
      "from_state": "running",
      "to_state": "stalled",
      "version": 1,
      "retries": 4,
      "error": "Invalid transition from 'running' to 'stalled'",
      "failed_at": 1787898608
    }
  ]
}

fsm metrics surfaces the same count as dead_letter_count. There is no purge or redrive endpoint - entries are inspection-only, and the entity itself remains in its old state. Retry pacing (attempts, backoff, total budget) is the FSM_TIMEOUT_RETRY_* family in the FSM CONFIG reference.

On this page