ADR-057: The strategy host — one process, many drivers over one operator account¶
Status: accepted (2026-09-19). Records the landed dynamic host (commits 184b800fe, 8015f23f3, b9c8d8954, 73d902f9d, 971917465, 6e057be05). Basis:
Amendment (2026-09-20): the standalone sidecar file is removed. This ADR originally kept
bin/backrun_sidecar.rsgreen as a parallel deployment shape (“a host of size one”) per its “not removed and stays green” consequence. The operator has since ruled there is no future for multiple binaries: the hosted one-process boot is the only runtime shape, the bin and its runbook are deleted, and the backrun lineage’ssidecarvocabulary is renamed for what it is (backrun,connector_index, hostedBackrun*types). The ADR text below is the historical record of what landed; read “standalone sidecar” mentions against that retirement. ADR-055 D5’s scheduled Phase C, the Phase B substrate (ADR-056 and the hub/registry slices), and the executed design.scratch/strategy-arch-survey/phase-c-design.md. The crate sources remain the last word on what the code does today.
Context¶
Phase B left a single-strategy process with a healthy shared substrate:
degenbot-eventhub owns per-process intake fan-out, a boot-snapshot
RouteRegistry answers pool membership, and the gated serving seam is retired
(ADR-056). No layer owned governance over more than one strategy in one
process:
EngineDriverminted and owned its own hub, and the engine’sResultBatch/BlockNotificationwere once-only receiver slots — a second driver could not attach to the same hub.Each driver scanned and reserved operator-account nonces privately. Two drivers on one EOA is a correctness defect: two independent reservation tables can sign the same nonce.
There was no runtime admission for a strategy:
strategy.nameselected exactly one arm at boot, and nothing could register, enable, or disable at runtime.Submission outcomes (landed / stale / orphaned) had no per-strategy record and no typed delivery to the owning driver.
ADR-055 D5 recorded the dynamic host as scheduled, not speculative. Phase B had made the mechanics cheap; this decision adds the governance on top and lands it.
Decision¶
D2 — The driver lifecycle is a frozen-tombstone FSM¶
DriverPose: Registered → Enabled → Running → {Stopped, Halted, Disabled}. Operator
verbs are register, enable, disable, list; driver-originated moves are
start and halt. Halted (a self-halt) and Disabled (operator) are
terminal tombstones with no exit edge — no auto-restart. Every illegal move
answers with a typed FsmDecline, never a panic; enable on an unknown name is
HostError::UnknownStrategy, and on a known-but-unconfigured facet is
HostError::UnconfiguredStrategy. A halted driver stores its halt_detail; a
terminal state is a frozen record that list reports but nothing resurrects.
start_driving boots every enabled driver that registered a
DriverSpawnFactory, hands it the driver’s LaneNamespace, and returns a
DriverTask; a driver-task panic is folded by the lane boundary into a
DriverExit::Halted tombstone rather than reaching the host process. A facet
may legitimately register no factory — the settlement pump arm is its own
driver — and is skipped rather than failed.
D3 — The hub is hoisted; the driver attaches via a bound pair¶
Hub::add_named_unbounded_source is &mut self, so the engine’s named channels
must be registered before the hub is shared. StrategyHost::mint(registry, nonce, register: impl FnOnce(&mut Hub) -> T) mints the hub, hands the closure
the exclusive &mut Hub, wraps it, and returns (StrategyHost, HostHub<T>)
where HostHub<T> { hub, attachment: T }. EngineDriver::from_stages_with_hub
consumes that pair; from_stages keeps its signature and mints a private hub
through the same assemble body, so the standalone and Python paths are
byte-identical.
The pair exists so channels minted on one hub cannot be attached to another by
accident: a mis-attach would be a silent dead stream. A caller can still pass a
forged closure, so the guarantee is a documented convention on mint, not a
type-level one.
D5 — Submission ledger, notifications, and HeadPolicy actions¶
SubmissionLedger (degenbot-submission/src/submission_ledger.rs) is the
per-strategy record of what happened to each signed submission:
SubmissionRecord { nonce, target, bundle_hash, built_at_head, state } with the
closed FSM Signed → Broadcast → {Landed, Stale, Orphaned} (terminal).
Reconciliation is nonce-level. Given the chain’s confirmed nonce and the authority’s outstanding set,
reconcilecloses each record toLanded(nonce below confirmed),Stale(nonce left the outstanding set without landing), orOrphaned(the immediate predecessor was vacated while this record stayed outstanding). The orphan test is deliberately immediate-predecessor only: with a gap atn-1, onlynis the natural filler. Outcomes certify the nonce slot, not the exact bytes — a record signed but never broadcast can reachLandedthrough other account traffic.Notifications are typed and addressed. One
Notificationper state change, keyed by nonce and addressed to the owning strategy; ordering is deterministic (strategy name, then nonce).Orphaned { fillable_nonce }converts to aRepackageRequestadvisory — a recommendation to re-stamp at the vacated predecessor, never an automatic submission.The host owns delivery via a trait.
degenbot-botcannot name the ledger (the submission crate depends ondegenbot-bot), soStrategyHostholdsArc<dyn HeadReconciler>;SubmissionLedgerimplements it, mapping reconcile outcomes into the host’sStrategyNoticevocabulary (Landed,Stale,Orphaned, and the reorg rewind’sLeaseRevoked).StrategyHost::on_head(confirmed)refreshes the authority throughset_confirmed_reorg, asks the reconciler to reconcile against the refreshed outstanding set, and delivers each notice only to the owning strategy.HeadPolicyis the default v1 reaction to a typed notice:Restamped { nonce }onOrphaned/LeaseRevoked,ReevaluateonStale(the lane’s decide stage re-bids or drops),RetiredonLanded. The re-bid — economics re-check and fresh sign — stays with the caller.One nonce seam for both submission paths (superseded). v1 landed with a
NonceSourceswitch indispatch_and_submit:Dispatcher { start }kept a standalone driver’s private reservation table,Authority(lane)stamped through the host authority. The post-acceptance hardening deleted the switch:dispatch_and_submitnow takes oneArc<NonceLane>, andstamp()is the only sign-time entry, with the default repackage-on-decline loop — see the authority supplement below.
D6 — A driver’s run artifacts live under its own namespace¶
LaneNamespace::under(state_root, id) is <state_root>/<strategy> with
session/ and quarantine/ subdirectories. A hosted driver receives its
namespace at the driving edge; the standalone sidecar passes None and keeps
the process-global journal root. This is what lets two drivers on one host write
run artifacts without colliding; the host stays free of the submission crate’s
file vocabulary.
D7 — Deferred, deliberately¶
Shadow-feedback between drivers. v1 is exclusion-only: reconciliation records the nonce-level shadow (the strategy’s exact bytes may not be the transaction that consumed the slot) as
ShadowPosture::ObservedNotActedOnand makes no decision from it. Feeding the shadow back to a driver’s decide stage — win / accept-the-shadow, repackage, or wait — is a post-v1 revisit triggered by forensics.More than one outstanding lease per strategy. v1 binds at most one, matching both arms today; multi-outstanding candidates are a post-v1 revisit that depends on the shadow-feedback design.
Process-level submission arbitration. Drivers submit independently; contention at the relay is observed through existing submission records and telemetry, with no in-process arbiter until live byte evidence says drivers collide meaningfully.
Lane hot registration. The host can register only strategies the config named at boot; registering a driver the config never named is out of v1.
The ADR-018 engine-family generalization. The host landed over the existing
EngineDriver; parameterizing settled-block stage payloads and per-family fleet globals stays on-demand (ADR-055 D5). The host was the load-bearing half; the generalization is not pulled by a sample of one.
Consequences¶
One process can run settlement arbitrage (via its pump arm) and backrun (as a hosted driver) over one operator account, with one hub, one boot-snapshot registry, and one nonce authority. Adding a driver is one strategy impl, one spawn factory, one config facet, and a registration.
The operator gains
enable_strategy/disable_strategy/strategieson the engine adapter; unknown and unconfigured names raise typed errors (UnknownStrategyError,UnconfiguredStrategyError, baseStrategyHostError).The standalone sidecar deployment is not removed and stays green:
from_stagesstill mints a private hub,BackrunContext.namespace_root = Nonekeeps the process-global root, and the settlement-only Python boot is observably unchanged.Residuals recorded with the landings: the Python boot still hands the host an empty route registry (the hosted driver’s discovery fan waits on boot-DB wiring) — status: resolved (post-acceptance hardening): the hosted boot now builds its registry through the shared
degenbot-submissionresolver, so both runtime shapes discover over one boot snapshot. The live per-head feed driveson_headfrom the Python settlement consumer’s accepted-header clock, with the guard (has_hosted_activity) short-circuiting a settlement-only boot so it pays no new RPC.
Supplement — the result and delivery plane has one writer per fact¶
The post-Phase-C survey found several facts advertised through two channels. The single-writer ruling, applied without changing runtime behavior, is: the authority writes nonce lifespan; result and notice channels are consumed by their callers, never written by a second site.
Nonce lifespan (authority). Issuance (
lease), promotion (record_broadcast), tombstone (release_strategy), the monitor’s expiry escape (release_broadcast), and the head refresh (set_confirmed_reorg) are the only mutations. Everything else readsoutstanding_nonces/lease_of/has_outstanding. The submission-time chain seed no longer writesconfirmeddirectly:NonceLane::observe_chain_nonceroutes throughset_confirmed_reorg, so there is exactly one head entry.Head notices (host).
StrategyHost::on_headis the only site that constructs and deliversHeadNotices, and the value it returns is the same value it sends to each owning strategy’s sink. Two consumption channels (in-process return, driver sink subscription), one writer and one value; a future subscriber receives exactly what the fold does, never a duplicate of a different fact.Submission results (
SubmitOutcome).recordsis the result store andsubmitted_count/skipped_countderive from it. Theinstruments::pipeline()counters emitted in the same branches are a telemetry projection of the same events, not a second result store; dry-run is counted but not profit-summed by construction.The Python settlement lane (
SETTLEMENT_LANE). The process-global slot is written exactly once at host boot (install_settlement_lane) and read by the settlement submit path; a host-less process mints a process-local lane with a loud deprecation instead. One installation per process, justified by the settlement arm being the Python-driven half of that single process.HostHub<T>/mint. The hub-and-channels pair keeps the naive cross-hub attach inexpressible; the closure’s register-on-the-hub-it-was-handed guarantee stays a documented convention (typed enforcement is overkill for a single engine family), pinned bya_host_minted_hub_wires_one_driverandthe_mint_closure_registers_on_the_host_hub.
Supplement — test doubles hold the seam contract¶
The Python driver shell is a driver, not a co-implementation: it calls a narrow slice of the engine surface and translates the result. Its tests therefore hold one contract double for that slice, and the double is bound to reality rather than hand-maintained in parallel.
One engine double.
tests/fakes/engine.pyowns the singleFakeEngine/FakeEngineRegistryused by the runner and registry suites. A private copy per module was the drift mechanism: a surface change had to be applied in five places, and the copies that lagged were exactly the ones agetattrfallback in the production path silently tolerated.The interface is declared, not duck-typed. The engine methods the driver shell depends on are listed once. The production seam calls them directly through the typed stub — there is no
getattr(engine, "method", None)soft seam that lets a double opt out of the contract.The double is parity-checked. A parity test reflects the fake against the real pyclass and the stub: the fake implements every seam member, the real engine exposes every seam member, the stub declares every seam member, and the fake defines none of the retired surface. Where a behavioural path exists (default registration order, the enable/disable vocabulary) the fake is driven against the real engine and the outcomes compared.
Fixtures assert only real postures. A double never advertises a surface the real engine removed, and no test asserts a posture the real surface cannot reach. A fake that is larger than reality is the same defect as one that is smaller.
The same discipline governs decision ownership: a Python fixture must not pin a decision the Rust owner makes. Admission, configured-ness, the driver transition table, and the reconcile guard live in the host and are exercised through the host’s typed verbs; Python-side branches that re-derive them are removed rather than tested.
Supplement — the residual closure set (T2 / R-NEW-1)¶
The T2 consolidation left five residuals blocked on peer-owned files or a follow-up pass. They are now closed; this records the closure so the set is not re-opened by a later survey:
Cockpit session-phase legality is Rust-authored.
SessionPhaseand its totalon_start/on_run/on_query/on_shutdowntable live indegenbot-bot/src/strategy_host.rs, and the Python_Phasereads the verdict throughdegenbot._ffi.session_phase_next. Python translates the refusal intoPhaseError; it never authors the transition matrix. A parity test derives the expected vocabulary from the Rust source, so a matrix change in one language cannot stay green.The driver-exit fold is host-internal.
StrategyHost::record_driver_exitis private; the two reachable folds aredrive_and_fold(the standalone sidecar) andHostSupervisor(the hosted/Python boot). No external caller can re-implement the await-then-fold ritual or bypass the tombstone rule.The session watch’s task set is typed.
degenbot.runner._session_watchmodels the watch-set as a_WatchSetrecord whoseon_task_doneowns the same-batch ranking (a fatal registration outranks a watchdog trip); the await loop applies the returned transition instead of holding mutable locals.The silent-veto smoke FSM is per-session Python-owned state.
_SessionState.submission_smoke(SubmissionSmoke) carries theQuiet | Streak | Warnstreak and throttle clock, so the observer never leaks across sessions. It is display-only telemetry (stays-python), not a legality decision, so it stays out of the Rust host.The engine-session projection naming pass landed earlier:
PumpPhaseis the pump-protocol phase machine andDriverPosethe operator lifecycle; the two are not conflated (ADR-050’s convergence note).
The remaining T2 structural residual — folding the engine session into a
host-owned Live sub-state (R-4) — is deliberately not part of this closure:
it rewrites EngineDriver subscribe/resume/stop ownership and every engine
consumer, and the read-only naming half already landed.
Supplement — the strategy-addition proof gate¶
An integrator can add a strategy family using the landed seams alone. The proof
is a mock third family integration test
(rust/crates/engine/degenbot-submission/tests/mock_third_family.rs) that registers,
enables, runs, and stops through the real host/seam paths without touching
production code.
What the gate pins:
Admission through
StrategyHost::register+enable, including typed declines for an unknown name, an unconfigured facet, a duplicate registration, and a twice-enabled driver.Lane consumption through a
NonceLaneover the oneNonceAuthority, with lowest-free contiguity across two strategies and a released-gap refill.Ledger attach and classification through
attach_reconciler:Stale(a formative terminal miss),Orphaned(an observed record’s event, carryingfillable_nonce), andLanded.The hub head tick firing while no
PendingTxsource is registered — the typed reaction is the hub class plus its declared overflow policy, and the per-poll await isHeadSubscription::changed.The stop funnel: the clean-stop fold
drive_and_fold->DriverPose::Stopped, and theSessionPhasetable the Python cockpit translates.A deformed-seam attempt: enabling twice and disabling a terminal driver each decline with a typed error.
The complementary runbook is
docs/architecture/adding-a-strategy.md, which documents the same seams with
their signatures and file anchors. No production edit was needed to make the
mock possible; that absence is the gate.