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:
| Form | Passes when |
|---|---|
context.field == literal | the context field equals the literal |
context.field != literal | the context field differs from the literal |
context.field | the 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}' # passesBehaviors:
- Truthiness is loose -
context.vippassed with{"vip":"gold"}and failed with{"vip":false}or no context at all. Any value other than missing,null, orfalsecounts. - Top-level fields only - the dotted form does not traverse nested
objects. A guard of
context.payment.ok == truefailed even with{"payment":{"ok":true}}: the wholepayment.okstring 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.
