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
Selectmust be concurrency-safe — it is called from many goroutines at once. If you keep state, guard it with atomics or a lock.- Don't mutate durable state inside
Select. Read the frozenreq.Statesnapshot; returnStateMutationIntentsfor changes to apply after commit. See the state plane. - Return a non-nil
Selectedon success, or a real error (ErrNoEligibleCandidates,ctx.Err()) — nevernil, 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 byNewInfoto the currentstrategy.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 firstGet. 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/GetWithConfigproduce 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.
| 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. |
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 alearning_datasetgovernance 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 aSelectRequest/SelectResponseto and from the wire (ToPluginSelectRequest,FromPluginSelectResponse).pkg/strategy/remotegrpc/— theRemoteStrategyadapter that dials aPluginServiceendpoint 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_refwith no token source, or atls_refwith 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.exampleAn 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.
Related
Governance
Ship strategy changes safely — shadow campaigns, experiments, certification, optimization, and governed remote strategies, plus the five distinct evaluation surfaces.
Connectors
How Ductor reaches the outside world — providers, actions, triggers, and connections — the governed action inventory the execute stage draws on.