Skip to main content
A stage is where an entity is; a gate is a checkpoint it has passed; a timer is what happens if it sits somewhere too long. All three are declared on the flow, not scattered through handlers.

Stages: the lifecycle

A flow declares its lifecycle in schema.yaml as a stages: map:
schema.yaml
Three rules give the lifecycle its shape:
  • One stage at a time. An entity is always in exactly one stage, and only a handler’s advances_to moves it — to a single named stage, never a list.
  • Stages belong to the flow. There is no global stage enum; assigned in your ticket flow and assigned in someone else’s are unrelated.
  • You never declare a state field. The entity’s current stage is platform-tracked; expressions read it as _entity.current_state (_entity.* is platform metadata, entity.* is your declared fields).
Every stateful flow must declare at least one terminal stage — a lifecycle with no exit is a boot error. There is no “on stage entry” hook: entry work belongs to the handler whose advances_to enters the stage (it runs in the same transaction as the transition), and time-based follow-ups belong to the stage’s timers. A flow that declares no stages: is stateless: it processes events without a lifecycle, and advances_to is invalid there. Stateless still allows entities — a handler that writes entity fields automatically creates one, with no stage attached. The stage is also where the lifecycle’s machinery attaches, declared on the stage itself rather than scattered through handlers:
  • Timers (stages.<stage>.timers) fire when an entity has sat in the stage too long — see Timers below.
  • Decision gates (stages.<stage>.gate) pause the stage for a typed human verdict with every outcome declared — see Human in the loop.
  • Joins wait in a stage for a declared set of arrivals, with dedup and timeout owned by the platform — see Parallel work.

Terminal stages

Terminal is a one-way door. Once an entity is terminal:
  • New events targeting it are rejected before any handler runs (recorded as terminal_reject).
  • Its timers are cancelled and its agent sessions terminated.
  • All of its state is preserved: queryable, never deleted.
There is no reopen mechanism. To continue work, create a new entity with the relevant data copied over; the old one stays terminal to preserve the audit trail. Backward transitions to non-terminal stages are allowed — declare a cycle as a bounded loop so it carries a cap and an explicit escape. The prohibition is specifically on leaving a terminal stage.

Gates

A gate is a named boolean on the entity, set by sets_gate and checked by guards. Gates model “which milestones are complete” in validation-style flows. They default to unset and are not auto-reset; clear_gates: true resets every gate on the entity at once (it is all-or-nothing). Gate names are scoped to the flow, so they stay stable as instances come and go.

Timers

Time is durable and declarative, in two forms with one owner each. Stage timers are the staged-lifecycle form — declared on the stage, armed when an entity enters it, cancelled when it leaves:
schema.yaml
Each entry in the timers: list is one timer. An entry with advances_to is part of the state machine — it shows up as a real edge when you inspect the flow (swarm describe --graph). This is the canonical form for “an entity sat here too long.” Node timers cover what stages cannot anchor: event-triggered delays and boot-anchored periodic work (a nightly report timer with start_on: boot, recurring: true). They are declared on a system node with prefixed triggers (state:/event:/boot); see nodes.yaml for the full form. Both forms are persisted: a timer survives restart, and one that came due while the process was down fires on recovery. When any timer fires, its event enters the normal event loop like any other — timers are just delayed event producers.