# Route Execution Model (/docs/concepts/dag-workflow-model)



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](/docs/concepts/coordinator-workers)
yet, start there — it explains *who* executes this model. This page is about
*what* you can express.

## Definitions, plans, and runs [#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 [#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](/docs/ai/workflow-ai-steps).                                    |
| AI agent    | `ai_agent`    | An agentic tool-calling loop with execution bounds. See [Workflow AI steps](/docs/ai/workflow-ai-steps).                                            |
| Bridge      | `bridge`      | Invoke a remote SDK workflow over the [bridge protocol](/docs/sdks/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`). |

<Callout type="info" title="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.
</Callout>

## Edges [#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 [#node-lifecycle]

Every step in a run has a node state, tracked independently of the run's own
status:

```mermaid
stateDiagram-v2
    [*] --> pending
    pending --> running
    pending --> skipped: run_if false
    running --> succeeded
    running --> completed_with_error: on_error continue
    running --> failed: retries exhausted
    running --> cancelled
    running --> waiting: timer / approval / signal / child
```

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

<Callout title="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.
</Callout>

## Sub-workflows [#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 [#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 [#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 [#where-to-go-next]

* [Coordinator & Step Workers](/docs/concepts/coordinator-workers) — who executes this graph.
* [Optimistic Locking](/docs/concepts/optimistic-locking) — how `record_version` keeps concurrent ticks correct.
* [Idempotency & Exactly-Once](/docs/concepts/idempotency) — the guarantees the model actually provides.
* [The Tiered Fair Queue](/docs/concepts/tiered-queue) — how step tasks and wakeups are delivered.
