# Pipelines (/docs/strategies/pipelines)



A **strategy pipeline** composes existing routing strategies into a single
bounded, validated primitive. Instead of bolting an ad-hoc `base_strategy` and
`fallback_strategy` onto every scorer, you declare the stages once — **filter →
select → fallback** — name the pipeline, and point a pool at it. Each stage is a
real, contract-checked strategy, and the pipeline persists a stage-by-stage trace
so a decision is explainable end to end.

Pipelines are declared on pool config as `strategy_pipelines` and
`default_strategy_pipeline`, and can run from a workflow DAG through
`routing.strategy_pipeline`.

<Callout title="Declared separately from strategy_options" type="info">
  A pipeline is **not** a strategy option. It lives in its own `strategy_pipelines`
  map on the pool config and is referenced by `default_strategy_pipeline`. The two
  are mutually exclusive with a single `default_strategy` / `default_recipe`: pool
  validation rejects a config that sets both.
</Callout>

## Shape [#shape]

A pipeline has a `name`, a `version`, and an ordered list of `stages`. Each stage
declares a `type`, a unique `id`, and — for `select` and `fallback` — a
`strategy` with optional flat `options`.

```json title="A pool strategy_pipelines entry"
{
  "name": "vip-capacity",
  "version": "2026-06-29",
  "stages": [
    {
      "id": "vip_only",
      "type": "filter",
      "condition": "tag:vip",
      "on_no_candidates": "stage:fallback"
    },
    {
      "id": "score",
      "type": "select",
      "strategy": "priority_score",
      "options": { "tie_tolerance": 0.001 }
    },
    {
      "id": "fallback",
      "type": "fallback",
      "strategy": "smooth_weighted_round_robin"
    }
  ]
}
```

Attached to a pool it looks like this:

```yaml title="pool config"
default_strategy_pipeline: vip-capacity
strategy_pipelines:
  vip-capacity:
    name: vip-capacity
    version: "2026-06-29"
    stages:
      - id: vip_only
        type: filter
        condition: "tag:vip"
        on_no_candidates: "stage:fallback"
      - id: score
        type: select
        strategy: priority_score
        options: { tie_tolerance: 0.001 }
      - id: fallback
        type: fallback
        strategy: smooth_weighted_round_robin
```

The `vip-capacity` pipeline above runs its stages in order, with the filter's
`on_no_candidates` jump routing an empty VIP set straight to the fallback:

```mermaid
flowchart LR
  C["Candidates"] --> V["vip_only<br/>filter: tag:vip"]
  V -->|survivors| Sc["score<br/>select: priority_score"]
  V -->|on_no_candidates| Fb["fallback<br/>smooth_weighted_round_robin"]
  Sc --> W["Winner"]
  Fb --> W
```

### Stage types and bounds [#stage-types-and-bounds]

| Type       | Role                                                                            |
| ---------- | ------------------------------------------------------------------------------- |
| `filter`   | Prune candidates by a predicate; jump forward when it empties the set.          |
| `select`   | Run a selection strategy over the survivors. &#x2A;*At least one is required.** |
| `fallback` | The strategy to fall through to when a jump lands here.                         |

The compiler enforces hard bounds:

* a pipeline must contain **at least one `select` stage**;
* **stage IDs must be unique**;
* **jumps must point forward** to an existing stage (`on_no_candidates:
  "stage:<id>"`);
* the default maximum is **16 stages**.

## Filter predicates [#filter-predicates]

Filters are deliberately simple and candidate-scoped. The supported conditions
are:

| Predicate                 | Keeps candidates that…                 |
| ------------------------- | -------------------------------------- |
| `tag:<tag>`               | carry the tag.                         |
| `metadata:<key>=<value>`  | have the matching metadata value.      |
| `attribute:<key>=<value>` | have the matching attribute value.     |
| `has_capacity`            | have free capacity.                    |
| `min_weight:<number>`     | meet a minimum weight.                 |
| `max_load:<number>`       | are at or below a load ceiling.        |
| `min_capacity:<number>`   | have at least this much free capacity. |

The same predicates can be supplied as flat stage `options` instead of a
`condition` string — for example `{"tag":"vip"}` or `{"metadata_key":"region",
"metadata_value":"east"}`.

## Precedence [#precedence]

When several things could pick a strategy, resolution order is:

```text
request `strategy`  >  rule-result `strategy`  >  pool top-level `strategy`
   >  pool default_strategy_pipeline
```

A route-level or rule-level explicit `strategy` always means a single strategy
and wins. The pool's `default_strategy_pipeline` is consulted **only** when no
request, rule, or top-level pool `strategy` override exists.

## Decision metadata and explain trace [#decision-metadata-and-explain-trace]

A successful pipeline selection persists compact metadata keys on the decision —
never routable attributes or connector payloads:

* `strategy_pipeline`
* `strategy_pipeline_version`
* `strategy_pipeline_stage_count`
* `strategy_pipeline_stage`
* `strategy_pipeline_child_strategy`
* `strategy_pipeline_trace`

In explain mode the same stage-by-stage trace is returned under
`strategy_explain.steps`: each entry records the stage ID, candidate count, the
child strategy name, the selected ID, and any fallback or error transition.

## Running a pipeline from a DAG [#running-a-pipeline-from-a-dag]

A workflow DAG can execute the exact same primitive with the
`routing.strategy_pipeline` step. The input accepts `pipeline`, `routable`,
`candidates`, and `pool_id`, and it emits the selected recipient IDs plus the
pipeline name, version, final child strategy, and stage trace. See
[DAG workflow model](/docs/concepts/dag-workflow-model).

## Gotchas [#gotchas]

<Callout title="Three sharp edges" type="warn">
  * **Nested option values are rejected.** Each child strategy receives only its
    own flat `StrategyConfig`; a stage `options` map with a nested object fails
    validation. This is deliberate — it keeps every child strategy's config the
    same flat shape it would get standalone.
  * **Pipelines never pass through `Registry.GetWithConfig`.** They are compiled at
    the selector or DAG boundary, so a pipeline spec is never handed to the registry
    as if it were a single configured strategy.
  * **Batch preselection skips pipeline pools.** A pool that resolves to a strategy
    pipeline is skipped during batch preselection; those routables fall through to
    normal per-routable selection, where the compiled pipeline runs and applies
    health checks to each child strategy.
</Callout>

## Related [#related]

<Cards>
  <Card title="Contracts & recipes" href="/docs/strategies/contracts">
    Each child strategy is contract-validated against the `pipeline_stage` execution shape.
  </Card>

  <Card title="Eligibility" href="/docs/strategies/eligibility">
    The governed qualification layer that runs before a pipeline's filter stages.
  </Card>

  <Card title="Routing pipeline" href="/docs/concepts/routing-pipeline">
    The outer Validate → Enrich → Filter → Select → Assign flow a pipeline plugs into.
  </Card>

  <Card title="Route authoring" href="/docs/management/route-authoring">
    Declaring strategy\_pipelines and default\_strategy\_pipeline on a pool.
  </Card>
</Cards>
