Routing Strategies

Writing a Certified Strategy

Implement the Strategy interface, register it with the registry, add tunable params and capabilities, and declare a contract for certification.

A custom strategy is any type that implements the Strategy interface and gets registered with a Registry. This page walks the full path: implement, register, add optional capabilities and params, then declare a contract so the strategy can be certified and promoted — and, at the end, deploy one remotely as a governed tenant deployment. All examples use the same package types the built-in strategies use. For packaging a strategy as a standalone Go module you install into a build, see SDK extensions.

Descriptor + contract, same change — or it's rejected

The single hardest rule: a new strategy must ship its descriptor and contract in the same change. A strategy name, alias, or recipe reference that can't resolve to a contract is rejected — registry coverage tests fail the build. Info() is also called first, at load time, for version-compatibility gating before any Select, and Select must be safe for concurrent calls. Keep those three invariants and the rest is detail.

1. Implement the interface

The contract is two methods (pkg/strategy/interface.go):

type Strategy interface {
    Info() Info
    Select(ctx context.Context, req *SelectRequest) (*SelectResponse, error)
}

Here is a complete, minimal strategy — pick the highest-weight candidate, breaking ties by lowest current load:

package mystrat

import (
    "context"

    "github.com/ductor-io/ductor/pkg/strategy"
)

type HeaviestStrategy struct{}

func NewHeaviestStrategy() *HeaviestStrategy { return &HeaviestStrategy{} }

// Info is called FIRST, at load time, for compatibility checking.
func (s *HeaviestStrategy) Info() strategy.Info {
    return strategy.NewInfo(
        "heaviest",
        "Selects the highest-weight candidate, ties broken by lowest load",
        "1.0.0", // plugin version
    ).WithCapabilities(strategy.CapabilityWeighted)
}

// Select runs on every routing decision. req.Candidates is guaranteed non-empty.
func (s *HeaviestStrategy) Select(
    ctx context.Context,
    req *strategy.SelectRequest,
) (*strategy.SelectResponse, error) {
    if len(req.Candidates) == 0 {
        return nil, strategy.ErrNoEligibleCandidates
    }

    best := &req.Candidates[0]
    for i := 1; i < len(req.Candidates); i++ {
        c := &req.Candidates[i]
        if c.Weight > best.Weight ||
            (c.Weight == best.Weight && c.CurrentLoad < best.CurrentLoad) {
            best = c
        }
    }

    return &strategy.SelectResponse{
        Selected: best,
        Reason:   "highest_weight",
        Metadata: map[string]any{"weight": best.Weight},
    }, nil
}

var _ strategy.Strategy = (*HeaviestStrategy)(nil)

Three hard rules

  1. Select must be concurrency-safe — it is called from many goroutines at once. If you keep state, guard it with atomics or a lock.
  2. Don't mutate durable state inside Select. Read the frozen req.State snapshot; return StateMutationIntents for changes to apply after commit. See the state plane.
  3. Return a non-nil Selected on success, or a real error (ErrNoEligibleCandidates, ctx.Err()) — never nil, nil.

Info fields that matter

Info (pkg/strategy/info.go) drives compatibility and discovery:

  • Name — lowercase, alphanumeric + underscores, 1–64 chars (validated by regex). This is the registry key.
  • InterfaceVersion — set automatically by NewInfo to the current strategy.InterfaceVersion. Ductor refuses to load a strategy whose interface version it can't speak.
  • PluginVersion — your own SemVer, for tracking and updates.
  • Capabilities — the optional features you support (see below).
  • MinRouterVersion (optional) — refuse to load on older Ductor.

Info.Validate() enforces the name and SemVer rules, so a malformed Info fails fast at load time rather than mid-route.

2. Register it

Strategies reach the routing pipeline through a Registry. The simplest path is to register the instance directly via a factory:

type heaviestFactory struct{}

func (heaviestFactory) Create(cfg strategy.StrategyConfig) (strategy.Strategy, error) {
    return NewHeaviestStrategy(), nil
}

func Register(reg strategy.RegistryInterface) error {
    if err := reg.Register("heaviest", heaviestFactory{}); err != nil {
        return err
    }
    // Optional: a friendlier alias.
    return reg.RegisterAlias("heavy", "heaviest")
}
  • Register(name, factory) stores the factory; the instance is created lazily and exactly once on first Get. Factories may call back into the registry to resolve dependencies (a base or fallback strategy) without deadlocking.
  • RegisterAlias(alias, target) points a second name at the strategy; alias chains resolve transitively.
  • Configure / GetWithConfig produce named or per-config instances so the same factory can back several tuned variants.

Wire your Register into the app the same way the built-ins are wired in pkg/strategy/builtin/registry.go and optional modules in modules/strategies/*/register.go. Once registered, a pool selects the strategy by name and the pipeline runs its Select in the Select stage.

3. Add optional capabilities

Implement a capability interface when your strategy can do more, and advertise it in Info().Capabilities. The pipeline type-asserts before using each one.

InterfaceMethodCapabilityUse when
ExplainStrategySelectWithExplanationexplainYou can return per-candidate reasoning.
BatchStrategySelectBatchbatch_selectScoring many routables at once is cheaper.
HealthCheckStrategyHealthCheckhealth_checkYou depend on a resource that can be down.
RankedStrategySelectRankedranked_selectYou can emit an ordered slate.
AllocationStrategyAllocateallocation_planYou plan a whole batch with global constraints.
func (s *HeaviestStrategy) SelectWithExplanation(
    ctx context.Context, req *strategy.SelectRequest,
) (*strategy.SelectResponse, error) {
    resp, err := s.Select(ctx, req)
    if err != nil {
        return nil, err
    }
    resp.Explain = &strategy.SelectExplanation{
        Summary: "chose the highest weight, tie-broken by lowest load",
    }
    return resp, nil
}

// Remember to advertise it:
// NewInfo(...).WithCapabilities(strategy.CapabilityWeighted, strategy.CapabilityExplainDecision)

Advertise only what you implement

A declared capability without its interface is a bug the contract validator will catch — for example, a contract with a ranked_slate output must carry the ranked_select capability, and an allocation_plan output must carry allocation_plan. Keep Info().Capabilities honest.

4. Declare tunable params

If your strategy takes options, describe them with StrategyParamSpec (pkg/strategy/registry_describe.go). The platform validates option values against these specs before they are persisted, so a bad knob never reaches a pool (pkg/strategy/validate_params.go).

params := []strategy.StrategyParamSpec{
    {
        Key:     "load_penalty",
        Label:   "Load penalty",
        Kind:    "slider",
        Min:     ptr(0.0),
        Max:     ptr(10.0),
        Step:    ptr(0.1),
        Default: 1.0,
        Description: "How strongly current load lowers a candidate's rank.",
    },
    {
        Key:     "tie_break",
        Kind:    "enum",
        Enum:    []string{"lowest_load", "random"},
        Default: "lowest_load",
    },
}

Supported Kind values are number, slider, boolean, string, and enum. Numeric kinds validate against Min/Max/Step; enum/string validate against Enum. Read your options in Select from req.Options and default anything missing.

5. Declare a contract (for certification & promotion)

To run under governance — certification, shadow campaigns, promotion gates — a strategy declares a StrategyContract (pkg/strategy/contract.go). It is the machine-readable promise of what your strategy needs, emits, and guarantees.

contract := strategy.StrategyContract{
    Name:            "heaviest",
    Version:         "1.0.0",
    SourceKind:      strategy.StrategySourceModule,
    ObjectiveTags:   []strategy.StrategyObjectiveTag{strategy.StrategyObjectiveFairness},
    Capabilities:    []string{strategy.CapabilityWeighted, strategy.CapabilityExplainDecision},
    OutputShapes:    []strategy.StrategyOutputShape{strategy.StrategyOutputSingleSelection},
    ExecutionShapes: []strategy.StrategyExecutionShape{strategy.StrategyExecutionLinearRoute},
    StateModel:      strategy.StrategyStateStateless,
    Determinism:     strategy.StrategyDeterministic,
    FallbackPolicy:  strategy.StrategyFallbackWarn,
    Params:          params,
}

if err := contract.Validate(); err != nil {
    return err // fails fast on any inconsistency
}

Validate() enforces every field and cross-field rule: valid source kind, objective tags, output/execution shapes, state model, determinism, and fallback policy; unique param keys; and the capability/output consistency rules. Two rules worth calling out:

  • An exploratory bandit (Determinism: StrategyExploratoryBandit) must declare a learning_dataset governance requirement.
  • A replayable remote strategy must declare StrategyExplanationRemoteEvidence.

Feature requirements travel with the contract

If your strategy reads facts from the feature snapshot, declare them as FactRequirements with a scope (candidate / routable / global) and Required flag. Each must be a known feature key (KnownStrategyFeatureKey) or marked Custom. This is what lets the platform verify a pool can actually supply what your strategy needs before activating it.

6. Certify and promote

With a contract in place, the strategy can be gated by a certification suite (pkg/strategy/certification.go): golden, adversarial, determinism, and performance-budget cases produce an immutable receipt with a pass/fail matrix and an expiry. A contract that declares a certification governance requirement can't be activated until it holds a passed (or waived) receipt. See Governance → certification.

The recommended path to production:

Unit-test Select for correctness and concurrency (the built-ins use property and mutation tests in pkg/strategy/builtin).

Register the strategy and confirm it resolves via reg.Get.

Shadow it against live traffic to measure divergence with zero risk.

Certify it against the suites your contract requires.

Promote it into a pool — as a raw strategy or bound into a recipe.

7. Deploy it remotely (out of process)

Not every strategy belongs in the core binary. A remote strategy runs out-of-process behind a gRPC PluginService.Select (a Connect RPC) and plugs into the same Select boundary as a built-in. The moving parts:

  • pkg/strategy/pluginproto/ — maps a SelectRequest/SelectResponse to and from the wire (ToPluginSelectRequest, FromPluginSelectResponse).
  • pkg/strategy/remotegrpc/ — the RemoteStrategy adapter that dials a PluginService endpoint and enforces payload-size bounds.
  • application/routing/remotestrategy/ — the loader that exposes a promoted deployment in the live registry.
  • domain/strategydeployment/ — the deployment model (version, binding, activation).

You implement one method: PluginService.Select. On the Ductor side the strategy is a governed, tenant-specific promoted deployment — not a global registration.

Remote strategies are governed, not free

A remote strategy carries a nondeterministic_remote determinism profile, and if it claims replayability it must record remote evidence — the contract validator enforces this. Enterprise activation fails closed without a fresh passing certification receipt, and a remote strategy should be shadowed before any production binding. Never put endpoint URLs, credentials, or secret refs in contract fields.

Secure the transport (required)

A remote deployment sends the full SelectRequest — candidate attributes, the whole feature snapshot, and tenant_id — to your endpoint on every routing decision. That traffic must be encrypted and authenticated, and Ductor now enforces it at registration time.

The endpoint address must be https://. For grpc and connect runtimes, Validate() rejects any endpoint that isn't HTTPS. Plain http:// is allowed only when the host is exactly localhost, 127.0.0.1, or ::1 — a convenience for local bootstrap. The host is parsed and matched exactly, so a lookalike like http://localhost.evil.com is rejected, not treated as loopback.

auth_ref and tls_ref now resolve, and they fail closed. Previously these were declarative — the endpoint had to name at least one, but the names were never followed. They now resolve against a credentials map at registration time, and the deployment does not register if any of these hold:

  • the ref names a credentials entry that doesn't exist;
  • the entry is missing the material its ref promises (an auth_ref with no token source, or a tls_ref with no CA and no client keypair);
  • the token, CA bundle, or client keypair can't be read.

When they resolve, tls_ref builds an HTTP client that pins the server to the supplied CA bundle at MinVersion TLS 1.2 — presenting a client keypair for mTLS when tls_cert_file/tls_key_file are set — and auth_ref attaches Authorization: Bearer <token> to every call via a Connect interceptor.

The secret material lives in a routing.strategy_deployments.credentials map, keyed by ref name, never in the deployment record or a contract field:

routing:
  strategy_deployments:
    static:
      - deployment_id: canary-001
        tenant_id: acme
        environment: production
        strategy_name: heaviest
        runtime_kind: connect
        endpoint: https://strategy.internal.acme.example:443
        artifact_ref: sha256:2f0c…            # your artifact digest
        auth_ref: strategy-bearer             # → credentials["strategy-bearer"]
        tls_ref: strategy-mtls                # → credentials["strategy-mtls"]
        # …status, capabilities, version, etc.
    credentials:
      strategy-bearer:
        token_env: DUCTOR_STRATEGY_TOKEN      # env var holding the bearer token
      strategy-mtls:
        tls_ca_file: /etc/ductor/tls/ca.pem
        tls_cert_file: /etc/ductor/tls/client.pem   # cert + key must be set together
        tls_key_file: /etc/ductor/tls/client-key.pem
        tls_server_name: strategy.internal.acme.example

An entry may resolve a bearer token (token_env or token_file), TLS material (tls_ca_file, and/or a tls_cert_file/tls_key_file pair for mTLS), or both — but it must resolve at least one, and tls_cert_file and tls_key_file must always appear together. See the configuration reference for every field.

What breaks if you skip this

A deployment that used to pass validation with a bare auth_ref/tls_ref and an http:// endpoint will now fail to register — an http:// non-loopback address is rejected outright, and a ref that points at a missing or incomplete credentials entry is rejected fail-closed. Wire the credentials map and switch the endpoint to https:// before you promote, or the deployment never enters the live registry.

The custom_strategy_canary recipe drives the rollout: shadow → certify → promoted deployment → guardrailed canary. See Governance → custom and remote strategies for the full flow.