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.
Prepare existing resources
The dashboard's Lead distribution recipe references existing resources. Its
pool_id, primary_recipient_id, and secondary_recipient_id answers are exact
names, such as lead-owners and synthetic-north; they are not the UUIDs
returned by the management API. Keep these names unique in your tenant and use
lowercase kebab-case so they are valid Route DSL handles. Draft materialization
does not create runtime pools, recipients, or memberships.
For a connector-free demonstration, use an ordinary tenant key with pool and
recipient read/write permissions. Run this in Bash with curl 7.76 or later and
jq, against an authenticated instance:
set -euo pipefail
: "${DUCTOR_API_KEY:?Set your ordinary tenant API key}"
BASE=${DUCTOR_API_URL:-http://localhost:8080}
ductor_api() {
curl --fail-with-body --silent --show-error --max-time 30 \
-H "Authorization: Bearer $DUCTOR_API_KEY" "$@"
}
POOL=$(ductor_api -X POST "$BASE/api/pools" -H 'Content-Type: application/json' \
-d '{"name":"lead-owners","strategy":"smooth_weighted_round_robin","status":"STATUS_ACTIVE"}')
POOL_ID=$(echo "$POOL" | jq -er '.data.id | strings | select(length > 0)')
for NAME in synthetic-north synthetic-south; do
ductor_api -X POST "$BASE/api/pools/$POOL_ID/recipients" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg name "$NAME" '{name:$name,type:"agent",status:"STATUS_ACTIVE",state:"RECIPIENT_STATE_AVAILABLE",weight:1,capacity:{max_concurrent:10,daily_limit:100},timezone:"America/New_York"}')" \
| jq -e '.data.id | strings'
done
ductor_api "$BASE/api/pools/$POOL_ID" | jq -e '.data'Use fresh demonstration names, or inspect and reuse the existing resources. A conflict or failed read is not an empty pool. Confirm membership, status and capacity through the management API before continuing. These API calls are ordinary user actions; enabling the editor, configuring its tenant credentials, and granting environment access are separate administrator setup.
In the dashboard, choose Lead distribution, enter those three names, use
smooth_weighted_round_robin, and set delivery_wait to 2s. Review checks the
expanded source against current resources and capabilities before enabling
Create authoritative draft. If it reports an unresolved pool or recipient,
correct the prerequisite and choose Check prerequisites again. This check
creates no generation or workflow run.
The saved draft is still editable. Open Explain, run a fresh preview, review the compiled definitions, and activate the exact preview. Activation requires current mutation permission in the configured environment. A prior successful preview does not grant that permission or waive a changed active revision.
The review distinguishes saved draft revision from activation source revision. Several saved drafts can begin at revision1; publication allocates an unused source revision from the environment's complete history. A rollback does not make old source revisions reusable.
After activation, Durable runtime proof starts a real persisted workflow.
For this connector-free recipe, keep the supplied routable JSON, review the
zero-connector-call estimate, acknowledge the real-run notice, and choose
Run active generation. Wait for completed and inspect the root and child
run IDs and persisted timeline, including delivery-window. The two-second wait
runs in the coordinator. A trigger acknowledgment alone does not prove completion.
The Scenarios tab is a separate fixture-based check and does not replace this
real-run evidence. If the service is unavailable, use Retry after it recovers;
an unavailable observation does not mean the workflow is empty or completed.
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.