Route Execution Model
Steps, edges, archetypes, scatter/gather, sub-workflows, waits, and ContinueAsNew — the vocabulary you use to describe durable work.
A Ductor workflow is a directed acyclic graph of steps. You describe what should happen and in what order; the runtime figures out when each step is runnable, dispatches it, retries it on failure, and drives the graph to a terminal state. This page is the vocabulary reference: the kinds of steps you can place in a graph, how edges connect them, and the higher-order patterns (fan-out, sub-workflows, waits) that make the model expressive.
If you haven't read Coordinator & Step Workers yet, start there — it explains who executes this model. This page is about what you can express.
Definitions, plans, and runs
Three nouns show up constantly, and they are not the same thing:
| Term | What it is |
|---|---|
| Definition | The authored graph — a WorkflowDefinition of steps, edges, triggers, and policies. Immutable once published; only drafts can be edited in place. |
| Compiled plan | The definition after Compile() runs a topological sort and pre-compiles every CEL expression. Published as an immutable CompiledManifest with a content hash. |
| Run | One execution of a definition — a row in eec_workflow_run (the eec_ prefix marks Ductor's durable Enterprise Eventing Core tables). It carries live status, per-node state, run context, and a record_version for optimistic locking. |
A definition is authored (via the optional web dashboard, a seed, or the bridge SDK) as a Go/
JSON/YAML structure — not protobuf. Compilation is deterministic: it does a
Kahn topological sort so the entrypoints (zero-indegree steps) and execution
order are stable, and it compiles run_if, for_each, and scatter-key CEL
programs once at publish time rather than on every tick. The result is
serialized into a CompiledManifest whose manifest_hash is a SHA-256 over
canonicalized JSON, so the same definition always hashes identically.
Steps
A step is a node in the graph with a ref (unique within the definition) and a
type. Ductor has fifteen built-in step types. Use the canonical name in the
type column — aliases are rejected at compile time.
| Step type | Value | What it does |
|---|---|---|
| Action | action | The generic work step — invoke a connector, run routing finalization, call your logic. |
| Wait | wait | Pause until a timeout, timestamp, cron, or external signal. |
| Approval | approval | Pause until a human approves or rejects. |
| Scatter | scatter | Fan out over a collection produced by an items_expr. |
| For each | for_each | Process a frozen collection sequentially through one exact-version child workflow. |
| Gather | gather | Collect the results of a preceding scatter. |
| Dataflow | dataflow | Transform, filter, partition, or reduce payloads with bounded, deterministic CEL. |
| Race | race | Resolve as soon as the first qualifying predecessor finishes. |
| Subworkflow | subworkflow | Trigger a child workflow run. |
| Human loop | human_loop | Pause until a case is resolved by a human analyst. |
| AI action | ai_action | A single LLM inference call through the AI inference proxy. See Workflow AI steps. |
| AI agent | ai_agent | An agentic tool-calling loop with execution bounds. See Workflow AI steps. |
| Bridge | bridge | Invoke a remote SDK workflow over the bridge protocol. |
| Digest | digest | Collapse N upstream events sharing a digest_key into one downstream tick (modes regular, backoff, timed). |
| Event set | event_set | Durable in-run wait that fans in a set/quorum/count of correlated external events (completion_policy: all_required, quorum, until_timeout). |
Archetypes
Internally each step type maps to a namespaced archetype kind
(core.action, core.scatter, ai.agent_loop, …). Vendor plugins can
register their own vendor.<name>.* archetypes. As a workflow author you work
with the step types above; the archetype layer is the extension seam that lets
the engine grow new step kinds without a core rewrite.
Edges
Edges connect steps and decide what runs next based on the outcome of the source step:
success/error— fire on the corresponding terminal outcome.true/false— fire on the boolean result of a conditional step.approved/rejected— the approval-step outcomes.done— fires regardless of outcome, after the outcome-specific edges.
When a step has multiple predecessors, a join strategy decides when it
becomes runnable: all (the default — wait for every predecessor) or any
(run as soon as one predecessor completes). A per-step failure policy
(on_error) chooses whether a failed step halts the run (fail) or lets the
graph continue down the error edge (continue). Conditional fan-out is
expressed with choice groups — ordered, mutually-exclusive edges each guarded by
a CEL condition, with a default fallback.
Node lifecycle
Every step in a run has a node state, tracked independently of the run's own status:
A node in waiting records why it is parked — a WaitingType of timer,
approval, child, signal, digest, interaction, event, or
event_set — plus the data needed to wake it (an expiry, a request ID, a child
run ID, an event name, and so on).
Scatter and gather
Scatter/gather is Ductor's fan-out/fan-in primitive. A scatter step
evaluates a CEL items_expr to a list and turns each element into a child unit
of work; a gather step points back at the scatter (scatter_ref) and
aggregates the children into a single result.
Scatter runs in one of two modes:
inline(default) — each child is a task on the same run, bounded bymax_inline_children(default 256, hard cap 10,000). Cheap, but everything shares one run row.child_workflow— each child is a full child run, with no inline bound (up to 10,000 async children). Use this when children are heavy or need their own lifecycle.
The gather collapses children into {"result": [...], "error": {...}}. What
happens to a failed child is governed by a stream-error strategy —
partition (default: failures go into the error map, the scatter still
completes), include, drop, or raise. Throttling (interval_ms) staggers
child dispatch so a 5,000-way fan-out doesn't stampede downstream systems.
Fan-out width is frozen at expansion time
The effective max_inline_children is stamped onto the scatter node the first
time it expands. A concurrent change to tenant config cannot shrink an
in-flight scatter — the width a run started with is the width it finishes with.
Sub-workflows
A subworkflow step spawns a child run of another definition. It runs in one of two modes:
sync— the parent parks inwaitinguntil the child reaches itswait_fortarget (terminalby default, or a namedcheckpoint:<name>), then resumes with the child's output.async— the parent advances as soon as the child row is inserted; it does not wait.
Timeouts are handled explicitly (on_timeout: fail_parent, detach, or
continue_with_error) — and a timeout never silently cancels the child. This is
how you compose large workflows out of smaller, independently-testable ones.
Waits, signals, and human steps
Not all progress is CPU-bound. Several step types exist purely to pause a run until the outside world catches up:
- Wait parks on a durable timer (
eec_workflow_timer). You can express the delay as a duration, an absolute RFC-3339 timestamp, a cron expression, or a CELuntil_expr. The coordinator resolves fired timers on its next tick. - Approval and human loop park a run until a person acts — an approve/
reject decision, or a case reaching its
awaiting_status(defaultresolved). Approvals carry an on-timeout policy (fail,approve, orskip). - Event set parks until a correlated set of external events arrives —
useful for "wait for all three downstream systems to acknowledge." You choose
a completion policy (
all_required,any,quorum,count,until_timeout) and a timeout policy.
External signals are the general wake mechanism. A signal is a durable row
keyed by (run_id, request_id); inserting one best-effort wakes the
coordinator, which drains pending signals in order on its next tick, inside the
same transaction that commits the tick. Duplicate signals are rejected by the
(run_id, request_id) key, and unmatched-signal accumulation is bounded — no
silent unbounded growth.
ContinueAsNew
A run's history grows with every transition. Left unchecked, an infinite or
very long-lived workflow would accumulate unbounded state.
ContinueAsNew (CAN) solves this: the current run is closed out
(terminated) and a fresh successor run is started, carrying forward only
the state that matters — the trigger input, subject, parent/root lineage,
nesting depth, run context, and search attributes (stamped with
continued_from_run_id).
CAN is a control intent, not a step. It can be issued two ways:
- Explicitly by an operator or API call.
- Automatically when a run crosses a transition-count threshold. There are
three tiers — a
softwarning, ahardlimit that synthesizes a CAN, and aterminatesafety net that force-stops a run that cannot continue (for example, because its definition was unpublished).
The successor run ID is deterministic (derived from the run ID and request ID), so a duplicate CAN submission across replicas resolves to the same successor rather than spawning two. CAN also interlocks with draining: if signals or controls are still pending, CAN defers until they're handled (bounded, so it can't defer forever).
Where to go next
- Coordinator & Step Workers — who executes this graph.
- Optimistic Locking — how
record_versionkeeps concurrent ticks correct. - Idempotency & Exactly-Once — the guarantees the model actually provides.
- The Tiered Fair Queue — how step tasks and wakeups are delivered.