Core Concepts

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:

TermWhat it is
DefinitionThe authored graph — a WorkflowDefinition of steps, edges, triggers, and policies. Immutable once published; only drafts can be edited in place.
Compiled planThe definition after Compile() runs a topological sort and pre-compiles every CEL expression. Published as an immutable CompiledManifest with a content hash.
RunOne 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 typeValueWhat it does
ActionactionThe generic work step — invoke a connector, run routing finalization, call your logic.
WaitwaitPause until a timeout, timestamp, cron, or external signal.
ApprovalapprovalPause until a human approves or rejects.
ScatterscatterFan out over a collection produced by an items_expr.
For eachfor_eachProcess a frozen collection sequentially through one exact-version child workflow.
GathergatherCollect the results of a preceding scatter.
DataflowdataflowTransform, filter, partition, or reduce payloads with bounded, deterministic CEL.
RaceraceResolve as soon as the first qualifying predecessor finishes.
SubworkflowsubworkflowTrigger a child workflow run.
Human loophuman_loopPause until a case is resolved by a human analyst.
AI actionai_actionA single LLM inference call through the AI inference proxy. See Workflow AI steps.
AI agentai_agentAn agentic tool-calling loop with execution bounds. See Workflow AI steps.
BridgebridgeInvoke a remote SDK workflow over the bridge protocol.
DigestdigestCollapse N upstream events sharing a digest_key into one downstream tick (modes regular, backoff, timed).
Event setevent_setDurable 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:

run_if false on_error continue retries exhausted timer / approval / signal / child pending running skipped succeeded completed_with_error failed cancelled waiting

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 by max_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 in waiting until the child reaches its wait_for target (terminal by default, or a named checkpoint:<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 CEL until_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 (default resolved). Approvals carry an on-timeout policy (fail, approve, or skip).
  • 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:

  1. Explicitly by an operator or API call.
  2. Automatically when a run crosses a transition-count threshold. There are three tiers — a soft warning, a hard limit that synthesizes a CAN, and a terminate safety 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