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 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.