# Writing a Certified Strategy (/docs/strategies/writing-a-custom-strategy)



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](/docs/strategies/balance) use. For packaging a strategy as a
standalone Go module you install into a build, see
[SDK extensions](/docs/sdks/extensions).

<Callout title="Descriptor + contract, same change — or it's rejected" type="warn">
  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](/docs/strategies/contracts) 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.
</Callout>

## 1. Implement the interface [#1-implement-the-interface]

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

```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:

```go
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)
```

<Callout title="Three hard rules" type="warn">
  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](/docs/strategies/contracts#strategy-state-plane).
  3. **Return a non-nil `Selected` on success**, or a real error
     (`ErrNoEligibleCandidates`, `ctx.Err()`) — never `nil, nil`.
</Callout>

### Info fields that matter [#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 [#2-register-it]

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

```go
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](/docs/concepts/routing-pipeline).

## 3. Add optional capabilities [#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.

| Interface             | Method                  | Capability        | Use when                                        |
| --------------------- | ----------------------- | ----------------- | ----------------------------------------------- |
| `ExplainStrategy`     | `SelectWithExplanation` | `explain`         | You can return per-candidate reasoning.         |
| `BatchStrategy`       | `SelectBatch`           | `batch_select`    | Scoring many routables at once is cheaper.      |
| `HealthCheckStrategy` | `HealthCheck`           | `health_check`    | You depend on a resource that can be down.      |
| `RankedStrategy`      | `SelectRanked`          | `ranked_select`   | You can emit an ordered slate.                  |
| `AllocationStrategy`  | `Allocate`              | `allocation_plan` | You plan a whole batch with global constraints. |

```go
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)
```

<Callout title="Advertise only what you implement" type="warn">
  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.
</Callout>

## 4. Declare tunable params [#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`).

```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) [#5-declare-a-contract-for-certification--promotion]

To run under [governance](/docs/strategies/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.

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

<Callout title="Feature requirements travel with the contract" type="info">
  If your strategy reads facts from the
  [feature snapshot](/docs/strategies/contracts#strategy-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.
</Callout>

## 6. Certify and promote [#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](/docs/strategies/governance#strategy-certification).

The recommended path to production:

<Steps>
  <Step>
    **Unit-test `Select`** for correctness and concurrency (the built-ins use
    property and mutation tests in `pkg/strategy/builtin`).
  </Step>

  <Step>
    **Register** the strategy and confirm it resolves via `reg.Get`.
  </Step>

  <Step>
    **Shadow** it against live traffic to measure divergence with zero risk.
  </Step>

  <Step>
    **Certify** it against the suites your contract requires.
  </Step>

  <Step>
    **Promote** it into a pool — as a raw strategy or bound into a
    [recipe](/docs/strategies/contracts#strategy-recipe).
  </Step>
</Steps>

## 7. Deploy it remotely (out of process) [#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.

<Callout title="Remote strategies are governed, not free" type="warn">
  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](/docs/strategies/governance#strategy-certification) receipt, and a
  remote strategy should be [shadowed](/docs/strategies/governance#strategy-shadow-campaigns)
  before any production binding. Never put endpoint URLs, credentials, or secret refs
  in contract fields.
</Callout>

### Secure the transport (required) [#secure-the-transport-required]

A remote deployment sends the full `SelectRequest` — candidate attributes, the
whole [feature snapshot](/docs/strategies/contracts#strategy-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:

```yaml
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](/docs/reference/configuration#routing-engine-routing)
for every field.

<Callout title="What breaks if you skip this" type="warn">
  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.
</Callout>

The `custom_strategy_canary` recipe drives the rollout: shadow → certify →
promoted deployment → guardrailed canary. See
[Governance → custom and remote strategies](/docs/strategies/governance#custom-and-remote-strategies)
for the full flow.

## Related [#related]

<Cards>
  <Card title="Strategies overview" href="/docs/strategies">
    The interface, registry, and capability model in full.
  </Card>

  <Card title="Contracts" href="/docs/strategies/contracts">
    Contracts, feature snapshots, and the state plane in depth.
  </Card>

  <Card title="Governance" href="/docs/strategies/governance">
    Shadow campaigns, certification, and custom/remote runtimes.
  </Card>

  <Card title="SDK extensions" href="/docs/sdks/extensions">
    Package a strategy as a standalone Go module and install it into a build.
  </Card>
</Cards>
