Route Authoring (Route DSL v2)
The single public format for authoring routes — one versioned RouteAggregate per tenant that compiles down to the shared workflow DAG and routing primitives.
Route DSL v2 is Ductor's sole public route-authoring format and the source the no-code editor saves. A Route is how a unit of Work gets bound to a 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 and 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.
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 (authority and activation) and
ADR-007 (generation/activation), summarized in docs/routing/route-dsl-v2.md.
Before you start
Have the routing topology and runtime dependencies in place before you publish a route revision:
- The pools and recipients the route selects from exist, are active, and expose the attributes your CEL expressions read.
- Any rules attached to those pools are valid CEL and
safe to evaluate with the route's
matchorder. - Recipient provider bindings point at existing
connections; the route source stores
connection_ref, never credentials. - Strategy names and pipeline references resolve to the strategy pipelines declared for the aggregate or pool.
- Operators know where to inspect the generated workflow definitions and runs after the route is active.
Dashboard task sequence
Use the no-code editor as an operator-facing path to build the same
RouteAggregate documented here:
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.
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.
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.
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.
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.
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
A tenant owns 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.
How a request is routed
The root dispatcher evaluates enabled routes in source order and calls the
exact child for the 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.
`match` is workflow choice, not a pool rule
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, and rule actions cannot choose a
workflow. Rules still shape candidate filtering/boosting inside a route's
selection; route match sits one level up.
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:
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
idand exactly one authoring category —do(a typed action),choose(a selection), orbranch(switch/parallel/scatter/race/for_each). - All expressions are typed objects (
{ expr: "<cel>" }), never magic string interpolation. Routematch.exprsees the routing match environment (routable,now); body expressions seeinput,steps, and lexicalbindings. The compiler translates these into the workflow IR — you never write${{ … }}yourself. - An
assignedroute must contain exactly onerole: finalselection and execute it once on every successful path; anunassignedroute must contain no final selection and give a stablereason. An emptysteps: []is valid only for anunassignedroute. - Strategy references are always typed: a direct strategy is
{ name, params }; a pipeline use is{ name, version }and resolves exactly one declared strategy pipeline. Bare strategy strings and "latest" pipeline lookups are rejected.
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
The compiled WorkflowDefinition is a read-only explanation
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.
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
Pools & recipients
The active targets, capacity, attributes, and kill-switches routes depend on.
Connections
The credential bindings action steps dispatch through.
Workflows
Published definitions, runs, snapshots, timelines, and audit records.
Workflow runtime
Runtime budgets, retention, archival, and safety nets for durable runs.
The routing pipeline
The Filter/Select/Assign stages a compiled route drives.
The DAG workflow model
The coordinator and graph model authored routes lower onto.
Strategy pipelines
The composable filter/select/fallback stages a route references.
Rules
Pool-level CEL policy that shapes selection inside a route.
Rules
Manage routing rules — CEL expressions attached to a pool, with priority, enable/disable, validation, and the cross-instance cache invalidation that makes edits take effect live.
Durable Runs & Workflows
Manage workflow definitions (draft → publish → immutable version) and operate runs (trigger, pause, resume, cancel, signal, continue-as-new) over the management API.