Managing Resources

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 match order.
  • 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.

one immutable generation compile + lower runs on RouteAggregate revision(canonical source + AST) shared DAG coordinator + workers root dispatcher workflow(pinned id@version) route child A (pinned id@version) route child B (pinned id@version) compound children (repeat / cancel) owned topology, bindings, trigger intents manifest (ids, versions, hashes, spans)

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:

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