ADR-050: A public EngineDriver is the Rust driver seam — the engine stays crate-private behind the one stage seam¶
Status: accepted (2026-09-14; RSP-3a design phase, epic RGZG4S, task OJI4FH; decides Gap G1 / task 5XOGRK; supervisor review accepted the driver-over-EngineStages layering and the PyArbEngine re-parent). Implementation is tracked by the RSP task chain 5XOGRK (the driver), XFEJUG (registration pipeline), L4E7RI (consume/dispatch), and IUGFLH (the ownership sweep that ratifies the lift). The facade does not exist yet; the crate sources remain the last word on what the code does today.
Context¶
The pure-Rust settlement bot (rust/examples/settlement_bot) is the parity twin of the Python driver (examples/eth_settlement_arbitrage_v2_v3_v4_rust.py + src/degenbot/runner/*). Slice 1 (config/CLI/DB boot) is committed, and it stops at a hard wall the audit ledger records as Gap G1 (docs/architecture/rust-settlement-bot-parity.md, rows 6–8 and downstream 16):
arb_engine::ArbitrageEngineispub(crate)machinery. The only external driver of the pump/solve lifecycle is the PyO3 wrapperPyArbEngine(viaPumpState) indegenbot-python.BlockPump::subscribe/resume_from_subscribeare public but requireArc<dyn StageHandlers>+Arc<dyn PumpControl>+ReorgCoordinator— i.e. the engine itself.EngineStagesis public but is the stage/observer seam, not a driver: it has nosubscribe/resume/stop, no snapshot-seed access, no verify-config pokes, no registration lifecycles, and no result-batch producer.
Two recorded doctrines meet head-on:
CONTEXT.md / ADR-049 record the
pub(crate)choice as deliberate (epic5TBT7LQ2b): the engine’s interface is one stage seam,EngineStages; the engine recedes to composition machinery behind it, and the one-impl-block census gate (just check-engine-impl-blocks) keeps a second door from re-opening. CONTEXT.md’s glossary even says avoid the phrase “engine facade” — “a facade fronts ANOTHER still-pub surface; the whole point is there is no second door.”AGENTS.md says a pure-Rust MEV bot must be buildable from
cargo add degenbot— “Rust is the engine; Python is a driver shell, not a co-implementation.”
Meanwhile the startup ritual and phase machine live twice in shape if not yet in code: EngineRegistry.start() (src/degenbot/arbitrage/engine_registry.py) owns S-read → subscribe → verify-config and deliberately stops before resume(); BotRunner (src/degenbot/runner/bot_runner.py) owns the _Phase FSM (New → Started → Running → Closed), the “attach consumer before resume” ordering, and the “stop the pump first, then cancel the consumer” teardown. A Rust driver that reimplemented this would be a twin implementation of one concept — exactly the ADR-046 failure mode (on_pump_ended had an inherent twin that silently skipped a required log; two implementations, only one correct). Task IUGFLH (RSP-9) already frames this as a LIFT candidate: the sequencing is correct for every driver, so the core should own it once.
Decision¶
D1 — The public Rust driver is degenbot_bot::arb_engine::EngineDriver, built ON the one stage seam; ArbitrageEngine stays pub(crate)¶
EngineDriver composes the already-public Arc<EngineStages> (the engine’s one seam) with the pump session state that currently lives in the PyO3 PumpState: the shared Arc<Bot>, the ReorgCoordinator, the shutdown flag, the subscribe state, the pump JoinHandle, the verify provider, and the result/block channel ends.
This is not the “engine facade” CONTEXT.md forbids. That note rejects a facade that fronts a still-pub engine — a cosmetic wrapper over a second door. EngineDriver fronts nothing that is publicly reachable by another path: the engine type remains pub(crate), and every EngineDriver member that touches engine state crosses EngineStages. The crate keeps one door to the engine (EngineStages) and gains one driver seam above it (EngineDriver), which is a layer, not a second door. The type is named a driver, matching the ledger’s G1 wording and the driver/engine vocabulary (docs/architecture/rust-owned-bot.md).
The Python PyArbEngine becomes a second adapter onto the same EngineDriver (D7) — so both the pure-Rust consumer and the FFI consumer share exactly one implementation of the ritual.
D2 — EngineDriver owns the pre-pump ritual; the backfill has exactly one owner¶
Constructor / adoption:
impl EngineDriver {
/// Standalone-Rust construction: adopts the shared Bot, builds the stage seam.
pub fn new(bot: Arc<Bot>, cfg: &Arc<BotConfig>) -> Self;
/// Adapter adoption (the PyO3 wrapper already built its stages).
pub fn from_stages(bot: Arc<Bot>, stages: Arc<EngineStages>) -> Self;
pub fn core(&self) -> Arc<StateLock<BotState>>;
pub fn bot(&self) -> &Arc<Bot>;
pub fn stages(&self) -> &Arc<EngineStages>; // observer/registration escape hatch
}
new calls EngineStages::with_core_cfg(bot.state_arc(), cfg, bot.active_delta()), builds ReorgCoordinator::new(bot), creates the result/block channel pair and installs the result channel on the stage seam — the constructor work PyArbEngine::new does today, moved down.
The startup ritual (mirrors EngineRegistry.start() exactly):
impl EngineDriver {
/// S-read → subscribe(W) → verify-config; stops BEFORE resume().
pub async fn start(
&mut self,
node_http: &str,
node_ws: &str,
verify_state_view: Option<&str>,
) -> Result<u64, DriverError>; // returns W
pub async fn subscribe(&mut self, node_ws: &str) -> Result<u64, DriverError>;
pub async fn resume(&mut self) -> Result<(), DriverError>;
pub fn stop(&self) -> Result<(), DriverError>;
pub fn snapshot_seed_block(&self) -> Option<u64>;
pub fn set_snapshot_seed_block(&self, block: Option<u64>);
pub fn set_verify_rpc_url(&self, node_http: &str);
pub fn set_verify_state_view(&self, addr: &str);
}
S (snapshot seed) ownership. S is a shared-
BotStatefact, not driver state. The DB path sets it inBot::load_snapshot_from_db; the non-DB path callsset_snapshot_seed_block(S)where the consumer computesS = min(newest_block)across its snapshots (the facade only stores it).startreads S beforesubscribeso the phase transition usesEnginePhase::after_subscribe(current, core_has_snapshot)and lands atSnapshotLoaded(the construction-time-load path).subscribe. Gate with
EnginePhase::allow_subscribe; callBlockPump::subscribe(node_ws, bot, stages.clone(), stages.clone(), reorg.clone(), shutdown.clone()); storeSubscribeState; advance the phase; return W. A second subscribe while subscribed is a typed phase/state error.verify-config.
set_verify_rpc_urlbuilds the verify provider;set_verify_state_viewstores the V4StateViewaddress. Both are the pokesEngineRegistry.startperforms after subscribe.resume — the auto-backfill is driver-owned.
resumegates onEnginePhase::require(SnapshotLoaded), rejects an already-Resumedphase, takes theSubscribeState, and awaitsBlockPump::backfill_with_drain(W, stream)synchronously (applyingS+1..Winclusive underBotState::process_backfill_logs, zero result batches), then spawns the live loop (pump.run_with_stream(combined, W)), stores the handle, and advances toResumed. The consumer never callsbackfill_from_snapshot— ownership is the driver’s, because the backfill’s synchrony (the consumer’s registration draining the per-pool backfill buffer must not race the backfill) is a core invariant, and leaving the call to each consumer would re-create the twin and the race.Ordering.
start()stops short ofresume(). The consumer attaches its result consumer betweenstart()andresume(), then callsresume()as the single gate after which batches flow — theBotRunner.runinvariant (“create the consumer BEFORE resume — closes the stale-backlog window”).
D3 — The result-batch stream is ResultBatch over the existing unbounded channel¶
ResultBatch is already public. The driver exposes the channel end once:
pub fn take_result_receiver(&mut self) -> Option<UnboundedReceiver<ResultBatch>>;
pub fn take_block_receiver(&mut self) -> Option<UnboundedReceiver<BlockNotification>>;
pub fn latest_results(&self) -> (HashMap<u64, SolvePathResult>, u64);
The consumer takes the receiver before resume(); stop() closes the channel so a pending recv().await returns None exactly once (the incident-2026-08-20 #2 end-of-stream contract owned by the delivery lifecycle). The PyO3 __anext__ adapts the same receiver unchanged, so the Python async-iterator shape survives byte-for-byte.
D4 — Registration, lifecycles, retune, cap — delegate to the one stage seam¶
pub fn register_and_solve_path(&self, hops: Vec<PoolHop>) -> Result<(u64, bool), PathRegistrationError>;
pub fn register_path(&self, hops: Vec<PoolHop>) -> Result<(u64, bool), PathRegistrationError>;
pub fn deregister_path(&self, path_id: u64) -> bool;
pub fn set_path_cap(&self, cap: Option<usize>);
pub fn path_count(&self) -> usize;
pub fn path_dedups(&self) -> u64;
pub fn path_info_for(&self, path_id: u64) -> Option<Result<PathInfo, PathInfoBuildError>>;
pub fn apply_retune(&self, retune: &EngineRetune);
pub fn set_inline_simulator(&self, sim: Arc<dyn InlineSimulator>);
pub async fn run_v3_registration_lifecycle(&self, address: &str, snapshot_block: Option<u64>) -> Result<(), VerifyError>;
pub async fn run_v4_registration_lifecycle(&self, pool_manager: &str, pool_id_hex: &str, snapshot_block: Option<u64>) -> Result<(), VerifyError>;
pub fn run_v3_registration_lifecycle_sync(&self, address: &str, snapshot_block: Option<u64>) -> Result<(), VerifyError>;
pub fn run_v4_registration_lifecycle_sync(&self, pool_manager: &str, pool_id_hex: &str, snapshot_block: Option<u64>) -> Result<(), VerifyError>;
PathRegistryFullmapping. In Rust the refusal is the typedPathRegistrationError;RegistryFull { cap, registered }stays the benign stop signal andInvalid(_)stays the caller bug. The facade returns the enum verbatim — noStringflattening. The PyO3 layer keeps its existingmap_path_registration_err(→PathRegistryFullError/ValueError), unchanged.Lifecycles. The
run_v3/v4_registration_lifecycle+_syncbodies move down fromPumpState(D7). The core owns the quarantine → seed-verify → drain+pin → post-drain-verify →set_livechoreography (ADR-022 D1); the async/blocking twin is the async form + a runtimeblock_on, replacing the PyO3future_into_pyshape.VerifyError(mismatch = fatal; RPC = retryable) is the typed Rust error. The retry policy is not lifted here: the values and the retry-with-backoff loop stay consumer-side (RSP-9IUGFLHrow).inline sim. The core member is
set_inline_simulator(Arc<dyn InlineSimulator>)(already onEngineStages). The Pythoninstall_inline_simulatorstays indegenbot-python: it builds theInlineSimHookfrom the session’sPySimulateContextand registers the escalation port, then calls the core member. A pure-Rust consumer builds its ownInlineSimulatorand calls it directly.
D5 — PumpPhase (formerly EnginePhase) is the pump-protocol phase; Stopped is a driver terminal latch; phase violations are typed¶
The engine-session protocol truth stays the existing FSM (Created → Subscribed → SnapshotLoaded → Backfilled → Resumed); the driver reproduces today’s transitions exactly (after_subscribe landing at SnapshotLoaded; resume landing at Resumed). No new discriminant is added to it.
Pump-protocol naming. The enum is now PumpPhase: it is a protocol-phase machine for subscribe/load_snapshot/backfill/resume ordering, NOT the operator-facing lifecycle. The one operator lifecycle (Registered → Enabled → Running → Stopped | Halted | Disabled) is owned by strategy_host::StrategyHost; a consumer reads the pump machinery through EngineDriver::current_phase() and its sibling accessors and never derives operator legality from it.
The driver adds a terminal stopped latch (stopped: AtomicBool), because the engine enum cannot express teardown. Every driver member except stop() rejects once stopped. stop() is deliberately any-phase and idempotent — the BotRunner.shutdown contract (the SIGINT/partial-startup teardown depends on it).
EnginePhase::require’s String error is wrapped at the driver boundary in a typed PhaseError { method, current, required }; DriverError is the closed enum (Phase(PhaseError), Subscribe, Resume, Registration(PathRegistrationError), Verify(VerifyError), …). A consumer matches on the variant; no string matching. The existing EnginePhase::require surface is unchanged for the engine-internal callers.
D6 — stop() mirrors BotRunner.shutdown/__aexit__ ordering¶
stop() sets the shutdown AtomicBool, takes and abort()s the pump handle, block_ons the join so the WS subscription futures (and the Arc<dyn StageHandlers> clones) drop before return, clears the subscribe state (re-allowing a later subscribe), and sets the stopped latch. Idempotent — a second call is a no-op.
The ordering contract is stated once here and consumed by every driver: stop the pump first → the delivery lifecycle closes the result/block channels → a pending consumer recv().await sees the natural end → the consumer ends cleanly → then the consumer task is cancelled/joined. Cancelling the consumer first leaves the pump holding the WS task on the shared runtime, blocking process exit up to BACKFILL_TIMEOUT_SECS (60s) — the exact bug BotRunner.__aexit__ documents. The driver owns the stop; the consumer owns its own task cancellation.
D7 — The Python PyArbEngine / PumpState re-parents onto EngineDriver¶
The PyO3 wrapper holds Arc<EngineDriver> and delegates start/subscribe/resume/stop, snapshot_seed_block (get/set), set_verify_rpc_url/set_verify_state_view, the four lifecycle entry points, set_path_cap, set_inline_simulator, and the result channel. PumpState’s session fields (bot, reorg coordinator, shutdown, subscribe state, pump handle, verify provider) collapse into EngineDriver; what remains of PumpState is the block-stream GIL adapter co-owned with PyBot, or it dissolves entirely.
Why re-parent rather than leave Python as-is: the alternative is two implementations of the ritual — PumpState (Python) and EngineDriver (Rust) — which is the ADR-046 twin-drift failure with a Python/Rust accent. Re-parenting makes the FFI and the pure-Rust drivers share exactly one correct implementation, keeps the Python-visible signatures byte-identical (ADR-049’s discipline), and lets the conformance tests live once in degenbot-bot. The Python wrapper keeps only the things that are genuinely Python: GIL detachment, future_into_py adaptation, and block_on over the shared runtime.
D8 — release_python_state stays driver policy, not a facade member¶
BotRunner’s post-registration trim (release_python_state + drop the Python pool/token caches) is Python-companion scaffolding — a Python-object-lifetime concern with no Rust-core counterpart (rust-owned-bot.md records the Rust engine as canonical state). It stays in the Python driver. The facade does not grow an “release” member.
D9 — RSP task mapping¶
Facade member group |
Consuming task |
|---|---|
constructor/adoption, |
|
|
|
|
|
the D2/D7 LIFT decision (ritual sequencing, phase machine, VerifyClaims dance, key maps) |
|
The once-per-pool key maps and the VerifyClaims TOCTOU dance remain XFEJUG’s to lift or keep per the sweep; this ADR only fixes the driver seam they attach to.
D10 — Historical snapshot: the 0.7 kill list was untouched by this decision¶
At the time of this decision, the change was limited to degenbot-bot and degenbot-python. It deleted nothing on the AGENTS.md 0.7 kill list: src/degenbot/migrations/, the alembic/sqlalchemy dependencies, DatabaseSessionManager, the SQLAlchemy models package, ALEMBIC_HEAD, the ensure_schema Alembic branch, and the query_only pragma all remained exactly as they were.
Current status: this D10 snapshot is historical. CPBCNS subsequently completed the SQLAlchemy retirement recorded in ADR-052 D7; the ORM dependency, DatabaseSessionManager, and models package are no longer part of the Python database surface.
Retired shape¶
The Python-only pump ritual is retired: PumpState’s subscribe/resume/stop/snapshot_seed/set_verify_*/run_v3/v4_registration_lifecycle bodies become thin delegations to EngineDriver. The “a pure-Rust consumer must re-derive the handshake” assumption is retired with Gap G1. The engine type stays pub(crate); the one-impl-block census is untouched.
Considered options rejected¶
(a) Make
ArbitrageEnginepublic with a curated API. Rejected. It re-opens the two-door engine ADR-049 D1 closed: apubengine reachable directly alongside theEngineStagesseam. The cost is exactly the ADR-046 twin-drift class — two consumer-visible shapes of “drive the engine” — plus every internal refactor (theSolveCycle/DeliveryPolicy/registry internals) becoming a public breaking change, and the census invariant losing its meaning. A curatedpubAPI is still a second door; the engine is not the interface.(b) Keep the ritual in Python and reimplement it in the Rust example. Rejected: two implementations of one concept, only one likely to be fixed when a backfill-synchrony-class bug lands. The Rust example is the parity twin, not a second copy of the driver.
(c) Extend
EngineStagesitself withsubscribe/resume/stop. Rejected on ADR-046 D1’s vocabulary test:EngineStagesis the stage/observer seam (the pump’s hooks + the driver’s reads), whilesubscribe/resume/stopare pump-session sequencing that needsBot+ReorgCoordinator+ runtime/task ownership — state the stage surface deliberately does not hold. Folding them in re-mixes the two conversations the ADR-046 split exists to separate.(d) A Python-only extension of
PyArbEngine. Rejected: it does nothing for thecargo add degenbotconsumer, which is the whole point.(e) A new
degenbot-engine-drivercrate. Rejected: there is no second consumer crate and the pump/session types already live indegenbot-bot; a new crate is sample-of-one packaging.EngineDriverlives inarb_enginebesideEngineStages.
Consequences¶
A pure-Rust consumer can drive the full settle lifecycle —
start→ attach theResultBatchconsumer →resume(auto-backfill) →register_and_solve_path/ lifecycles → consume batches →stop— with zero Python. Ledger rows 6–8 and downstream 16 unblock; the parity example retracts its Gap-G1 tail.The startup ritual and phase machine have one implementation.
EngineRegistry.start()andBotRunnerbecome Python-side policy (config, SIGINT, pool-cache trim) over a Rust-owned sequencing contract.The Python-visible API is byte-identical: same class name, same method signatures; only the Rust ownership behind
PyArbEnginechanges.The degenbot umbrella re-exports
EngineDriver(viadegenbot_bot::arb_engine/degenbot::bot), socargo add degenbotreaches it without naming a sub-crate.Risks to manage in implementation: the
PumpState→core move is a behavioral cutover over the verify machinery (the async/blocking twin, the provider ownership, the GIL-detach shape);degenbot-bot’s test suite and the pyo3 parity tests are the characterization net; telemetry labels (ADR-043) must stay byte-identical.IUGFLHrecords the supersedure pointer for the previously “stays-python” sequencing doctrine so CONTEXT.md can be updated by the implementing task.
Open questions deferred to implementation¶
Whether
EngineDrivershould expose blockingsubscribe/resumewrappers beside the async ones, or stay async-only with the consumer owning the runtime.Whether
PumpStateshrinks to a block-stream adapter or dissolves entirely intoPyBot+EngineDriver.The exact
DriverErrorvariant set (how much of the underlyingString/VerifyErrorsurface is preserved vs wrapped).Whether the verify provider is constructed by the driver (
set_verify_rpc_url) or injected as a typed handle.Whether
EngineDrivershould own a conveniencerun_until_stoppedloop or leave the main loop entirely to the consumer (the ledger’s L4E7RI surface).