Routing Strategies
What a routing strategy is, the Strategy interface and hot path, the registry, capability interfaces, and how strategies plug into the routing pipeline.
A strategy is a pluggable algorithm that answers one question: given a set
of eligible candidates for a unit of work, which one wins? It is the
decision-making core of Ductor's routing pipeline —
the swappable part that turns "these three candidates all qualify" into "route
to candidate B, and here's why." A strategy produces a
Route (Decision), and its explanation is the seed
of the work's eventual Receipt.
Most strategies rank candidates by weight, load, score, or learned value. A
few — shark_tank (broadcast-and-claim) and the capacity marketplace — clear
work through a market: candidates effectively bid, and the winner is chosen
by market rules rather than a fixed score. Those are the clearing-native
strategies. Priced market clearing (auctions, ping/post) runs today for lead
distribution. Agent RFQ — humans and agents bidding on work — extends the
same machinery: typed bid terms, buyer-side clearing, authenticated submission,
a published task spec, disclosure-scoped discovery, and an autonomous agent
bidder all ship as opt-in surfaces (agent_rfq.enabled, and
agent_rfq.bidder_enabled separately gating agent spending). Executing the won
work — turning an award into a run — is on the roadmap.
Everything a strategy needs is handed to it in a single request, and everything it decides comes back in a single response. Strategies never touch the database, Redis, or the network directly — the pipeline loads the facts, calls the strategy, and commits the result. That separation is what lets you swap a naive round-robin for a revenue-maximizing scorer without changing anything else.
Where strategies run
Strategies execute in the Select stage of the routing pipeline, after candidates have been validated, enriched, and filtered. See Routing pipeline for the full Validate → Enrich → Filter → Select → Assign flow.
The Strategy interface
Every strategy implements two methods, defined in pkg/strategy/interface.go:
type Strategy interface {
// Info returns metadata: name, versions, capabilities. Called FIRST,
// during loading, for compatibility checking — before any Select call.
Info() Info
// Select chooses one candidate from req.Candidates (guaranteed non-empty).
// MUST be safe for concurrent calls from many goroutines.
Select(ctx context.Context, req *SelectRequest) (*SelectResponse, error)
}Implementations must be:
- Thread-safe —
Selectmay be called concurrently from many goroutines. Any internal state (counters, cursors) must be synchronized. - Stateless, or backed by thread-safe state — a round-robin counter is fine if it uses atomics or a shared cache; a plain map is not.
- Idempotent for identical inputs — the same request should produce the same decision. (Randomized and bandit strategies are the deliberate exception: their non-determinism is part of the contract.)
The hot path: Info first, then Select
Loading a strategy is a two-phase handshake:
Info()is called once, at load time. It returns the strategy'sName,InterfaceVersion,PluginVersion, optionalMinRouterVersion, and its declaredCapabilities. Ductor checks interface-version compatibility here and refuses to load anything it can't speak to.Info()never runs on the hot path.Select()is called on every routing decision. This is the hot path: it receives a*SelectRequest, returns a*SelectResponse, and must be fast and concurrency-safe.req.Candidatesis guaranteed non-empty — the pipeline returns an error before calling a strategy if filtering left nothing.
What a strategy receives and returns
The SelectRequest (in pkg/strategy/types.go) carries everything the strategy
is allowed to see:
| Field | Type | Purpose |
|---|---|---|
Routable | *Routable | The item being routed — attributes, location, priority. |
Candidates | []Candidate | The eligible recipients, pre-filtered. Non-empty. |
PoolID | string | The pool being routed to. |
Options | DynamicMap | Strategy-specific configuration for this call. |
Context | *SelectContext | Trace ID, tenant ID, routing depth, DryRun flag. |
Features | *FeatureSnapshot | Typed snapshot of outcome/quality/capacity/SLA facts. |
State | *StrategyStateReadSet | Frozen state snapshot for stateful strategies (read-only). |
Each Candidate exposes ID, Weight, CurrentLoad, MaxConcurrent,
Location, Attributes, Tags, a pre-computed rule Score, and
LastAssignedAt. A strategy reads whichever of these it needs.
The SelectResponse carries the decision back:
| Field | Type | Purpose |
|---|---|---|
Selected | *Candidate | The winner. Must be non-nil on success. |
Reason | string | Short code, e.g. highest_weight, least_loaded, nearest_geo. |
Metadata | DynamicMap | Scores, distances, probabilities — anything worth surfacing. |
Explain | *SelectExplanation | Detailed per-candidate reasoning (see ExplainStrategy). |
Slate | *RankedSlate | An ordered slate when the strategy ranks alternates. |
StateMutationIntents | []… | Deterministic state changes to apply after the decision commits. |
State is read at Select, written after commit
Stateful strategies never mutate durable state inside Select. They read a
frozen State snapshot and return StateMutationIntents describing what should
change once the decision is durably accepted. This keeps selection replayable
and free of side effects. See Strategy state plane.
The registry
Strategies are discovered and instantiated through the Registry
(pkg/strategy/registry.go). It maps a name to a factory, creates instances
lazily, and resolves aliases.
reg := strategy.NewRegistry()
builtin.RegisterBuiltins(reg) // register the built-in pack
reg.RegisterAlias("rr", "smooth_weighted_round_robin")
s, err := reg.Get("smooth_weighted_round_robin") // lazy, lock-free hot pathKey properties:
- Lazy, exactly-once instantiation. Factories run inside a
sync.Onceon firstGet; after that, lookups are a lock-freesync.Mapload. A factory can safely call back into the registry to resolve a dependency (for example,fair_catchupresolving its base strategy) without deadlocking. - Aliases.
RegisterAlias(alias, target)points a second name at an existing factory. Alias chains resolve transitively with cycle protection. Every strategy's documented aliases below are real registry aliases. - Configured instances.
Configure(instanceName, factoryName, config)andGetWithConfig(name, config)produce named or per-config instances so the same algorithm can be bound to different parameters in different pools.
A pool names the strategy it wants; the routing pipeline calls reg.Get(name)
and runs Select. Swapping a pool's strategy is a config change, not a
redeploy.
The built-in registry
Ductor includes the following built-in strategy names. Aliases resolve to the same strategy factory.
| Name | Aliases | What it does |
|---|---|---|
smooth_weighted_round_robin | round_robin, weighted_round_robin | Fair weighted distribution (SWRR). |
consistent_hash | — | Route the same key to the same recipient. |
priority_score | — | Weight + load + availability blend. |
least_loaded | — | Lowest current load wins. |
random_weighted | — | Weighted random pick. |
power_of_two_choices | p2c | Sample two, take the less-loaded. |
failover | active_passive | Tiered active-passive HA. |
fair_catchup | catch_up | Fair distribution with catch-up debt. |
shark_tank | broadcast_claim | Broadcast, then first to claim wins. |
multi_objective_score | — | Tunable multi-signal scorer. |
sla_deadline | — | Deadline- and catchall-aware SLA fit. |
yield_optimized | — | Maximize expected commercial yield. |
portfolio_balance | — | Cap any one recipient's recent share. |
availability_first | — | Prefer candidates with fresh free time. |
soonest_available | — | Earliest qualifying free slot. |
coverage_balancer | — | Balance free minutes and busy load. |
connector_quota_aware | — | Filter/score by connector quota readiness. |
reliability_weighted | — | Score by reliability-readiness facts. |
entity_sticky_assignment | — | Prefer an entity graph's sticky recipient. |
capacity_weighted_allocator | — | Batch plan with capacity deductions. |
portfolio_quota_allocator | — | Batch plan under portfolio quotas. |
Two contrib modules add more names when installed and enabled:
predicted(modules/strategies/predicted/register.go) —predicted_valueplus the banditsthompson_sampling,linucb, andbandit. See Learning.geographic(modules/strategies/geographic) —geo_nearestandgeo_weighted. See Geo.
Every name must resolve to a contract
A hard rule of the routing surface: no strategy name, alias, recipe reference, or MCP/API surface may exist unless it resolves to a strategy contract, and every new strategy must ship its descriptor and contract in the same change. Descriptors render the authoring UI; contracts tell headless tools and validators what a strategy can safely consume and produce. See Contracts.
Capability interfaces
The base Strategy interface is deliberately small. A strategy that can do more
implements an optional capability interface and advertises it in
Info().Capabilities. The pipeline type-asserts for the interface before using
it, so a strategy only pays for what it supports.
| Interface | Capability constant | Method it adds |
|---|---|---|
BatchStrategy | CapabilityBatchSelect (batch_select) | SelectBatch — score many routables in one call. |
AllocationStrategy | CapabilityAllocationPlan (allocation_plan) | Allocate → a durable AllocationPlan with hard global constraints. |
ExplainStrategy | CapabilityExplainDecision (explain) | SelectWithExplanation — populate Explain with reasoning. |
HealthCheckStrategy | CapabilityHealthCheck (health_check) | HealthCheck — report readiness before use. |
RankedStrategy | CapabilityRankedSelect (ranked_select) | SelectRanked → populate Slate with a ranked / multi-winner list. |
The remaining capabilities are pure metadata (no method): CapabilityWeighted
(honors candidate weights), CapabilityStateful (keeps state across selections),
CapabilityGeoAware (uses location), CapabilityMetrics (exposes Prometheus
metrics), CapabilityMultiWinner (marks several winners in one slate), and
CapabilityPipelineStage (opts a strategy into bounded composite / pipeline
execution). The complete set — eleven constants — lives in
pkg/strategy/version.go; IsValidCapability and AllCapabilities enumerate
them.
Strategies read facts only from Features
A strategy may read only what arrives on the SelectRequest — chiefly
req.Features, the typed feature snapshot.
Strategies must never reach into outcome, quality, or connector repositories
directly. The pipeline loads the facts once, freezes them into the snapshot, and
hands them over; that is what keeps every decision replayable, shadowable, and
auditable.
Params, modes, and aliases
Throughout these pages each strategy is documented with three consistent attributes:
- Params — typed, tunable knobs. Each has a
kind(slider,number,boolean,string,enum), adefault, and for numeric kinds amin/max/step. Params are validated against their spec before they reach a pool, so dead knobs never get persisted (pkg/strategy/validate_params.go). - Modes — the shape of selection a strategy operates in:
weighted,scoring,single,ranked,multi_winner, orbroadcast. A strategy may support more than one. - Aliases — alternative registry names that resolve to the same strategy.
Choosing a strategy
Start from the problem, not the algorithm:
| If you need to… | Reach for | Category |
|---|---|---|
| Spread load fairly and cheaply | smooth_weighted_round_robin, power_of_two_choices | Balance |
| Send identical keys to the same recipient | consistent_hash | Balance |
| Fail over from a primary to a backup | failover | Balance |
| Blend several business signals into one score | multi_objective_score | Scoring |
| Beat an SLA / deadline | sla_deadline | Scoring |
| Maximize revenue per route | yield_optimized | Scoring |
| Cap any one recipient's share | portfolio_balance | Scoring |
| Learn who wins over time | thompson_sampling, linucb | Learning |
| Route by geographic proximity | geo_nearest, geo_weighted | Geo |
| Let recipients race to claim work | shark_tank | Markets |
| Produce ordered fallbacks | ranked slates | Ranked |
| Optimize a whole batch at once | allocation planners | Allocation |
| Match on skills, licenses, territory | eligibility profiles | Eligibility |
| Drain traffic away from failing targets | reliability strategies | Reliability |
| Route work to humans or AI agents | work assignment | Work |
| Compose stages into one primitive | strategy pipelines | Pipelines |
| Version, replay, and govern behavior | contracts & governance | Contracts |
Browse by category
Balance
Load spreading, stickiness, and failover — the seven workhorse distribution strategies.
Scoring
Score candidates on weight, SLA, yield, portfolio share, or predicted value.
Learning
Multi-armed and contextual bandits that learn winners from outcomes.
Geo
Distance-based and proximity-weighted routing.
Markets
Broadcast claims and durable market settlement records.
Yield & Quality
Analytics, forecasts, heatmaps, and quality signals consumed by scoring strategies.
Capacity Marketplace
Listings, orders, trades, and pricing for spare recipient capacity.
Ranked
Ordered slates: selected, alternates, eliminated, pending.
Allocation
Batch-level assignment plans optimized against global constraints.
Eligibility
Trait, skill, license, and territory matching.
Reliability
Readiness signals and reliability-weighted scoring.
Work
Assign work to humans, teams, queues, or AI agents.
Pipelines
Compose filter → select → fallback into one reusable primitive.
Contracts
Versioned contracts, recipes, feature snapshots, and the state plane.
Governance
Experiments, shadow campaigns, tuning proposals, certification, custom runtimes.
Write a custom strategy
Implement the interface, register it, and certify it for production.