Skip to content

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 is rejected as an invalid transition. After four failed attempts (the default), the timeout is recorded in the dead letter queue. The entity stays in running.

A timeout only acts on the state and version that scheduled it. Moving the entity forward makes the old timeout obsolete. Database failures leave the timeout pending without consuming its retry budget.

The dead letter queue

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

snug fsm dead-letter -n docs4-job --limit 50
{
  "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
    }
  ],
  "page": { "has_more": false }
}

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.