# Route Authoring (Route DSL v2) (/docs/management/route-authoring)



**Route DSL v2** is Ductor's sole public route-authoring format and the source
the no-code editor saves. A <Term name="Route" /> is how a unit of
<Term name="Work" /> gets bound to a <Term name="Worker" />; this is where you
author one. You describe *what* routing should do — ordered routes, the pools and
recipients they select from, the strategies that pick a winner, and the steps each
route runs — and the compiler lowers that description onto the existing
[workflow DAG](/docs/concepts/dag-workflow-model) and
[routing pipeline](/docs/concepts/routing-pipeline). It does **not** introduce a
second workflow runtime: authored routes run on the same coordinator and workers
as everything else — the durable execution guarantee under the whole
[clearing lifecycle](/docs/clearing-layer).

<Callout title="Where it lives">
  The language and compiler live under `application/routing/routedslcompiler/`
  (parse, canonicalize, lower); authoring services under `routeauthoring/`;
  published artifacts under `routeartifacts/`. The normative contract is
  [ADR-006](https://github.com/ductor-io/ductor) (authority and activation) and
  ADR-007 (generation/activation), summarized in `docs/routing/route-dsl-v2.md`.
</Callout>

## Before you start [#before-you-start]

Have the routing topology and runtime dependencies in place before you publish a
route revision:

* The [pools and recipients](/docs/management/pools-and-recipients) the route
  selects from exist, are active, and expose the attributes your CEL expressions
  read.
* Any [rules](/docs/management/rules) attached to those pools are valid CEL and
  safe to evaluate with the route's `match` order.
* Recipient provider bindings point at existing
  [connections](/docs/management/connections); the route source stores
  `connection_ref`, never credentials.
* Strategy names and pipeline references resolve to the
  [strategy pipelines](/docs/strategies/pipelines) declared for the aggregate or
  pool.
* Operators know where to inspect the generated
  [workflow definitions and runs](/docs/management/workflows) after the route is
  active.

## Dashboard task sequence [#dashboard-task-sequence]

Use the no-code editor as an operator-facing path to build the same
`RouteAggregate` documented here:

<Steps>
  <Step>
    ### Shape the topology [#shape-the-topology]

    Start with recipients, pools, and connection references. A pool is routable only
    when it is active and its kill-switch is off, and recipients must be available
    and have capacity before a strategy can select them.
  </Step>

  <Step>
    ### Order routes deliberately [#order-routes-deliberately]

    Place the most specific `match` routes first, then the broader routes, and add an
    explicit `default` route when unmatched work should produce a stable
    `unassigned` result instead of `ROUTE_NO_MATCH`.
  </Step>

  <Step>
    ### Keep route matches separate from pool rules [#keep-route-matches-separate-from-pool-rules]

    Use route `match.expr` to choose the generated workflow. Use pool rules to
    filter, boost, or redirect candidates inside that workflow after the pool has
    loaded.
  </Step>

  <Step>
    ### Select the strategy path [#select-the-strategy-path]

    Reference direct strategies or exact pipeline versions. Bare strategy strings
    and "latest" pipeline lookups are rejected, so the published generation can be
    replayed against the same selection contract.
  </Step>

  <Step>
    ### Publish and watch the run evidence [#publish-and-watch-the-run-evidence]

    Publishing produces an immutable generation with pinned workflow ids, versions,
    hashes, and source spans. After activation, inspect runs, snapshots, attempts,
    timelines, and audit records through the workflow management surfaces.
  </Step>
</Steps>

## Evidence of success [#evidence-of-success]

A successful authoring pass leaves evidence in several places: the aggregate has
a canonical hash, the active `(tenant, environment)` pointer names one immutable
generation, generated workflow definitions are pinned by version, routing
decisions carry explanations, and workflow run reads expose status, steps,
timeline, audit, and attempt-level events. If a control action is accepted, poll
the run afterward; `accepted` means the intent was durably enqueued for the
coordinator, not that the state has already changed.

## One aggregate, one generation [#one-aggregate-one-generation]

A tenant owns &#x2A;*one versioned `RouteAggregate`** per environment. That single
canonical revision owns:

* stable route IDs and **first-match order**;
* owned pools, recipients, and rules, plus explicit references to external
  resources;
* recipient-to-provider **connection references** — never credentials or secrets;
* versioned strategy pipelines;
* triggers and recursive route bodies;
* the normalized source, schema version, canonical hash, and source spans.

Compiling one revision produces **one immutable generation**: a root dispatcher
workflow, one exact-version generated child workflow per route, any compound
children needed for repeated or cancellation-owning bodies, the owned routing
topology and bindings, and a manifest tying every artifact ID, version, hash,
and source span back to the aggregate revision. The active
`(tenant, environment)` pointer names that exact generation — nothing resolves
"latest"; missing or inconsistent evidence fails closed.

```mermaid
flowchart TD
    Src["RouteAggregate revision<br/>(canonical source + AST)"] -->|"compile + lower"| Gen
    subgraph Gen["one immutable generation"]
        direction TB
        Root["root dispatcher workflow<br/>(pinned id@version)"]
        Root --> ChildA["route child A (pinned id@version)"]
        Root --> ChildB["route child B (pinned id@version)"]
        Root --> Compound["compound children (repeat / cancel)"]
        Owned["owned topology, bindings, trigger intents"]
        Manifest["manifest (ids, versions, hashes, spans)"]
    end
    Gen -->|"runs on"| DAG["shared DAG coordinator + workers"]
```

## How a request is routed [#how-a-request-is-routed]

The root dispatcher evaluates enabled routes **in source order** and calls the
exact child for the &#x2A;*first true `match`**. The explicit `default` route runs
only when no route matches — it is not error fallback after a chosen route fails.
A request that matches no route and has no default returns a typed
`ROUTE_NO_MATCH` result; it never silently falls through to some pool.

<Callout title="`match` is workflow choice, not a pool rule" type="info">
  Routing resolves a *workflow* before pool rules execute. So a route's `match`
  selects which generated workflow handles the request — it cannot be expressed
  as a [pool rule](/docs/management/rules), and rule actions cannot choose a
  workflow. Rules still shape candidate filtering/boosting **inside** a route's
  selection; route `match` sits one level up.
</Callout>

## The source shape [#the-source-shape]

The source is a strict YAML 1.2 subset — one document, block mappings and
sequences, JSON-compatible scalars, comments. Anchors, aliases, tags, merge
keys, duplicate keys, and unknown fields are rejected. Map order is not
semantic; `routes`, `steps`, `cases`, `branches`, and strategy `stages` are
ordered sequences and their order *is* semantic.

The only root shape is a `RouteAggregate`:

```yaml title="route-aggregate.yaml"
api_version: ductor.io/route/v2
kind: RouteAggregate
metadata:
  id: north-america-leads
  name: north-america-leads
topology:
  recipients:
    - id: rep-east
      providers:
        salesforce:
          connection_ref: conn-salesforce-east
  pools:
    - id: sales
      recipients: [rep-east]
strategy_pipelines: []
routes:
  - id: enterprise
    match: { expr: 'routable.attributes.segment == "enterprise"' }
    result: { kind: assigned }
    steps:
      - id: choose-owner
        choose:
          select:
            pool: sales
            role: final
            bind: owner
            strategy: { name: availability_first, params: {} }
            dispatch: { selection: single, body: selected }
            steps: []
  - id: default
    default: true
    result: { kind: unassigned, reason: no-applicable-route }
    steps: []
```

A few load-bearing rules from the grammar:

* Every step has a stable `id` and exactly one authoring category — `do`
  (a typed action), `choose` (a selection), or `branch`
  (`switch`/`parallel`/`scatter`/`race`/`for_each`).
* All expressions are typed objects (`{ expr: "<cel>" }`), never magic string
  interpolation. Route `match.expr` sees the routing match environment
  (`routable`, `now`); body expressions see `input`, `steps`, and lexical
  `bindings`. The compiler translates these into the workflow IR — you never
  write `${{ … }}` yourself.
* An `assigned` route must contain **exactly one** `role: final` selection and
  execute it once on every successful path; an `unassigned` route must contain
  no final selection and give a stable `reason`. An empty `steps: []` is valid
  only for an `unassigned` route.
* Strategy references are always typed: a direct strategy is `{ name, params }`;
  a pipeline use is `{ name, version }` and resolves exactly one declared
  [strategy pipeline](/docs/strategies/pipelines). Bare strategy strings and
  "latest" pipeline lookups are rejected.

## Recipes and packs [#recipes-and-packs]

Published **RouteRecipes** and **RoutePacks** are exact-pinned, reusable
artifacts. They are not a separate model or runtime language — they **expand and
materialize ordinary Route DSL through the same revision-fenced compile
pipeline**. A recipe or pack lowers to the same generated workflows and topology
as hand-written DSL; the only difference is provenance.

## What `type: workflow_definition` YAML is *not* [#what-type-workflow_definition-yaml-is-not]

<Callout title="The compiled WorkflowDefinition is a read-only explanation" type="warn">
  The `type: workflow_definition` YAML accepted by the workflow loader is an
  **internal IR / debug export**. It is useful for inspecting the DAG a route
  compiled to — but it is **not** a public authoring tier, **not** an eject
  format, and **not** something the route editor can import and republish. Author
  routes in Route DSL v2; treat the compiled workflow definition as output you
  read, never a source you edit.
</Callout>

This is the crux of the "one authority" design: there is exactly one editable
source (the `RouteAggregate`), and everything downstream — generated workflow
definitions, graph operations, serialized IR — is a **reproducible product** of
that source plus the pinned capability snapshot. Formatting and comments don't
change the canonical `source_hash`; reordering routes, cases, branches, or
stages does.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Pools & recipients" href="/docs/management/pools-and-recipients">
    The active targets, capacity, attributes, and kill-switches routes depend on.
  </Card>

  <Card title="Connections" href="/docs/management/connections">
    The credential bindings action steps dispatch through.
  </Card>

  <Card title="Workflows" href="/docs/management/workflows">
    Published definitions, runs, snapshots, timelines, and audit records.
  </Card>

  <Card title="Workflow runtime" href="/docs/operations/workflow-runtime">
    Runtime budgets, retention, archival, and safety nets for durable runs.
  </Card>

  <Card title="The routing pipeline" href="/docs/concepts/routing-pipeline">
    The Filter/Select/Assign stages a compiled route drives.
  </Card>

  <Card title="The DAG workflow model" href="/docs/concepts/dag-workflow-model">
    The coordinator and graph model authored routes lower onto.
  </Card>

  <Card title="Strategy pipelines" href="/docs/strategies/pipelines">
    The composable filter/select/fallback stages a route references.
  </Card>

  <Card title="Rules" href="/docs/management/rules">
    Pool-level CEL policy that shapes selection inside a route.
  </Card>
</Cards>
