# 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.rs` green 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's `sidecar` vocabulary is > renamed for what it is (`backrun`, `connector_index`, hosted `Backrun*` > 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: - `EngineDriver` minted and owned its own hub, and the engine's `ResultBatch` / `BlockNotification` were 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.name` selected 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 ### D1 — `StrategyHost` owns the shared services; drivers attach `StrategyHost` (`degenbot-bot/src/strategy_host.rs`) is the per-process owner of the hub, the `RouteRegistry`, and the `NonceAuthority`. It is generic over strategy types and never names a strategy family; it loans the shared handles out by `Arc`, registers drivers by name, and drives their lifecycle. A **driver** is a strategy family's runnable loop (`BackrunDriver` today); the **host** decides which drivers may run. Vocabulary is host / driver / strategy; "sidecar" names the standalone two-process deployment, not a host member. ### 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)` where `HostHub { 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. ### D4 — NonceAuthority: sign-time issuance, lowest-free, contiguity, typed declines The `NonceAuthority` (`degenbot-bot/src/nonce_authority.rs`) is the host's single owner of the operator account's nonce space. - **Sign-time.** The only issuance path is `lease(strategy)`; there is no background allocator. A driver simulates first and stamps the signed transaction against the leased nonce, so the authority is queried at the last moment before signing. - **Lowest-free.** `lease` returns the lowest nonce not currently outstanding at or above the confirmed chain nonce, which makes the outstanding set a **contiguous prefix** above the chain nonce; a freed gap self-heals at the next sign, with no artificial self-send to fill it. - **At most one outstanding lease per strategy (v1).** A second `lease` for a strategy holding one declines `StrategyLeaseOutstanding`, matching both existing arms' behavior. - **Typed declines, never panic.** `DeclineKind::{StrategyLeaseOutstanding, BelowChainNonce, UnknownLease, Exhausted}`. `BelowChainNonce` is the fail-closed guard on the contiguity invariant: a tracked reservation below the confirmed chain nonce is a contradiction to release, not sign around. - **`release_lease` vs `release_strategy`.** The repackage path releases only the strategy's own stale lease (`release_lease`); `release_strategy` clears leases *and* broadcasts and is reserved for enable / disable / halt, because a broadcast may be genuinely in flight. - **Reorg-safe head.** `set_confirmed_reorg(confirmed)` is the only head entry point: a forward move advances the confirmed nonce, a backward move (a reorg rewind) restores every broadcast confirmed inside the rewound window `[confirmed, old)` to the outstanding set and returns a typed `ReorgAdvisory` per revoked lease. `set_confirmed` retains confirmed broadcasts in an owner-keyed `landed` index so the restore is possible; the index is never pruned and survives a tombstone, because a released strategy's transaction may still be on the wire. ### 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, `reconcile` closes each record to `Landed` (nonce below confirmed), `Stale` (nonce left the outstanding set without landing), or `Orphaned` (the immediate predecessor was vacated while this record stayed outstanding). The orphan test is deliberately immediate-predecessor only: with a gap at `n-1`, only `n` is the natural filler. Outcomes certify the nonce slot, not the exact bytes — a record signed but never broadcast can reach `Landed` through other account traffic. - **Notifications are typed and addressed.** One `Notification` per state change, keyed by nonce and addressed to the owning strategy; ordering is deterministic (strategy name, then nonce). `Orphaned { fillable_nonce }` converts to a `RepackageRequest` advisory — a recommendation to re-stamp at the vacated predecessor, never an automatic submission. - **The host owns delivery via a trait.** `degenbot-bot` cannot name the ledger (the submission crate depends on `degenbot-bot`), so `StrategyHost` holds `Arc`; `SubmissionLedger` implements it, mapping reconcile outcomes into the host's `StrategyNotice` vocabulary (`Landed`, `Stale`, `Orphaned`, and the reorg rewind's `LeaseRevoked`). `StrategyHost::on_head(confirmed)` refreshes the authority through `set_confirmed_reorg`, asks the reconciler to reconcile against the refreshed outstanding set, and delivers each notice only to the owning strategy. - **`HeadPolicy`** is the default v1 reaction to a typed notice: `Restamped { nonce }` on `Orphaned` / `LeaseRevoked`, `Reevaluate` on `Stale` (the lane's decide stage re-bids or drops), `Retired` on `Landed`. 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 `NonceSource` switch in `dispatch_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_submit` now takes one `Arc`, and `stamp()` 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 `/` 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::ObservedNotActedOn` and 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` / `strategies` on the engine adapter; unknown and unconfigured names raise typed errors (`UnknownStrategyError`, `UnconfiguredStrategyError`, base `StrategyHostError`). - The standalone sidecar deployment is not removed and stays green: `from_stages` still mints a private hub, `BackrunContext.namespace_root = None` keeps 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-submission` resolver, so both runtime shapes discover over one boot snapshot. The live per-head feed drives `on_head` from 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 authority is the sole nonce issuer The `NonceAuthority` is not merely the host's preferred nonce source; it is the only one. Every signing path in every runtime shape obtains its nonce from a `NonceLane` bound to the process `NonceAuthority`, and no second reservation table exists to disagree with it: - The dispatcher (`degenbot-submission/src/dispatcher.rs`) coordinates the pool mutual-exclusion set and the monitor task set only. Its former `pending_nonces` table, `claim_nonce` scan, `release_nonce`, and `pending_nonce_count` are deleted; nothing in the dispatch loop answers "which nonce is reserved?" from local state. - `dispatch_and_submit` takes an `Arc` and stamps through `NonceLane::stamp` (`submission_ledger.rs`). The `NonceSource` switch that once chose between a private dispatcher table and the authority is gone, so a call site cannot be wired to the weaker owner and still compile. - The standalone sidecar is a host of size one: `bin/backrun_sidecar.rs` mints its own authority, ledger, and lane and hands the lane through `BackrunContext`. The lane seeds the authority from the operator account's chain nonce at boot and refreshes it per head (`backrun_driver.rs`), exactly as the hosted boot does. - The Python settlement seam no longer computes a nonce. It forwards the submission-time chain read and resolves its lane from the host boot; a host-less process mints a process-local lane with a loud deprecation rather than a parallel reservation table (`degenbot-python/src/submission/submit.rs`). Issuance, broadcast promotion, landing, tombstone, and the reorg rewind are all authority writes. The one deliberate counterweight is the monitor's expiry release: a broadcast that never lands would otherwise hold its slot forever and wedge the account's contiguous prefix, so `NonceAuthority::release_broadcast` frees exactly that nonce when a monitor returns `Expired`. It is the only entry point that can re-open an outstanding broadcast, and its doc states the caller's assertion (the transaction will never land). Reconciliation keeps the authority's confirmed nonce current: a hosted head feed drives `StrategyHost::on_head`, and the standalone lane performs the same `set_confirmed_reorg` + ledger reconcile on each head, guarded on outstanding work so an idle lane pays no per-head chain read. ## 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 reads `outstanding_nonces` / `lease_of` / `has_outstanding`. The submission-time chain seed no longer writes `confirmed` directly: `NonceLane::observe_chain_nonce` routes through `set_confirmed_reorg`, so there is exactly one head entry. - **Head notices (host).** `StrategyHost::on_head` is the only site that constructs and delivers `HeadNotice`s, 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`).** `records` is the result store and `submitted_count` / `skipped_count` derive from it. The `instruments::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` / `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 by `a_host_minted_hub_wires_one_driver` and `the_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.py` owns the single `FakeEngine` / `FakeEngineRegistry` used 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 a `getattr` fallback 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.** `SessionPhase` and its total `on_start` / `on_run` / `on_query` / `on_shutdown` table live in `degenbot-bot/src/strategy_host.rs`, and the Python `_Phase` reads the verdict through `degenbot._ffi.session_phase_next`. Python translates the refusal into `PhaseError`; 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_exit` is private; the two reachable folds are `drive_and_fold` (the standalone sidecar) and `HostSupervisor` (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_watch` models the watch-set as a `_WatchSet` record whose `on_task_done` owns 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 the `Quiet | Streak | Warn` streak 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: `PumpPhase` is the pump-protocol phase machine and `DriverPose` the 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 `NonceLane` over the one `NonceAuthority`, 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, carrying `fillable_nonce`), and `Landed`. - **The hub head tick** firing while no `PendingTx` source is registered — the typed reaction is the hub class plus its declared overflow policy, and the per-poll await is `HeadSubscription::changed`. - **The stop funnel**: the clean-stop fold `drive_and_fold` -> `DriverPose::Stopped`, and the `SessionPhase` table 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. ## Related - **ADR-055** — pending-transaction strategy seams; D5 named this host as scheduled. The host is its Phase C governance layer. - **ADR-050** — the `EngineDriver` driver seam; the host composes drivers, and the hub hoist extends `EngineDriver` construction without changing ownership. - **ADR-049** / **ADR-046** — the one-door engine and the stage / handler split the driver attaches through. - **ADR-056** — the retired gated serving seam; `RouteRegistry` is the membership oracle the host loans. - **ADR-026** — settlement / backrun terminology; host / driver / strategy is the vocabulary this host fixes. - **ADR-043** — the observability standard; the policy fold emits structured events on the `degenbot.strategy.head` target. - `docs/architecture/strategy-seams.md`, `docs/architecture/phase-b-architecture.html`, `CONTEXT.md`.