Status: implemented (D1 landed at original slice landing; D2/D5 retained as decided; D3+D4 completed in a follow-up — see Update). Recorded during the
unify-the-two-Bots grilling, June 2026. Revises the "Rust Core" enumeration in
ADR-005 and resolves ADR-005's deferred "ArbitrageEngine lock unification" item, and
supersedes the ADR-003 arrangement where the engine held its own Arc<Mutex<Bot>>.
Implementation is a separate body of work; this ADR records the settled shape only.
The EventSink / on_block interface signature is intentionally not specified here —
see "Deferred" — only the topology is decided.
Partial supersession (epic
MROOY7, ADR-041): theSolveCoordinatorhelper row below was retired inSZJUKL— dirty tracking is theEpochDeltaledger and solve triggering is theStageHandlershooks driven inline by the pump. TheBot/BotState/LogDispatcher/BlockPump/ReorgCoordinatorrows remain accurate.
The original landing claim ("all 11 slices landed; ADR-006 acceptance verified")
was incorrect for two sub-decisions: D3 (delete engine.register_v2/v3/v4_pool)
and D4 (relocate subscribe/backfill_from_snapshot/resume + verify plumbing
onto Bot) stalled after D1 (the shared Arc<RwLock<BotState>>) landed. The
consequence: pool registration was rerouted through PyBot.register_v* (the
builders) to avoid the duplicate-address panic D1 made real, stranding the
verify_on_register gate on the unreachable engine.register_v* methods — it
never fired. The only verify that ran was a single end-of-discovery batch.
D3+D4 were completed in a follow-up (ergo epic OP7YLN):
- D4 —
SnapshotStore/register_with_cl_buffers/run_cl_verificationmoved tobot_core::snapshot_verify(generic over the engine type);subscribe/backfill_from_snapshot/resume+verify_liquidity_mapsand theset_verify_*config moved ontoPyBotvia a sharedPumpState(co-owned byPyBot+PyArbitrageEngine); the engine keeps thin delegating wrappers. - D3 — the orphaned
PyArbitrageEngine::register_v2/v3/v4_pool+ theverify_on_registerflag +set_verify_on_registerwere deleted (zero production callers; the live registration path isPyBot.register_v*). - A fail-fast two-step verify (snapshot block + backfill block) was re-seated at
engine_registry.register_v3/v4_pool'sapply_buffer_*drain seam — the detection at the offending pool the dead gate failed to provide — and a hot-loop recurringverify_liquidity_mapswas added so post-release / in-loop desyncs surface instead of trading silently.
The D4 "Bot is the per-chain orchestrator" framing is reinforced by the
completion of the sealed _from_py_pool seam across every pool family
(V2/V3/V4/Balancer-weighted/Balancer-stable/Aerodrome/Curve). Two D4-load-bearing
consequences now hold:
- I/O callables live on the Rust core as pyo3-free trait objects, not as
Python callbacks held by the companions. Each per-family read surface the
companion previously held is a stored
Arc<dyn Trait>on theVxPoolState, with a Py-adapter indegenbot-python: the V3/V4 tick fetcher (MLJT4V), the Curve data provider (JFGCHJ), the Balancer rate provider (4UBHP6). The companion reads through the handle; the trait object is the I/O seam a standalone-Rust consumer (examples/standalone_consumer.rs) drives directly with no Python — the strong-form standalone target of D4. - Cross-pool references resolve within the shared
BotState. The Curve metapool's base-pool dependency — the one case that broke single-arg construction — resolves through the existingpool_id_by_addressindex onBotState(the go-betweencurve_base_pool()constructs a handle over the base pool_id sharing the sameArc<RwLock<BotState>>). No Python registry; the D1 shared-core topology is the enabler of single-arg construction.
Companion identity is now fully handle-readable (the Polars _from_pydf end
state); direct __init__ is forbidden on every companion. See ADR-005's
"sealed _from_py_pool seam" footer for the per-family commits + the
BasePoolPort go-between design (BQM2OA).
The two-step verify as originally landed compared engine-current tick data
against on-chain@snapshot_block at step-1. Under the bot's rolling start
(EngineRegistry.engine.resume() runs before build_paths so paths
discover against fresh blocks), the live pump applies Mint/Burn journals onto
engine-current between registration and step-1. Step-1 then read
(seed + journal) vs on-chain@snapshot (pre-journal) — a false mismatch on
every active pool (logs/perm-V2-V3-V2.log: mismatches only at the snapshot
block, journal_len=1, update_block postdating the snapshot; never at the
live/backfill block). Two compounding bugs amplified this:
- Per-family exception mapping (AGVGNH).
Pump::verify_v3/v4_liquidity_mapsmappedLiquidityVerifyErrorto a plainPyRuntimeError.build_paths'except RuntimeErrorarm silently swallowed genuine mismatches as skipped paths (non-fatal) instead of routing them to the fatalVerificationMismatchErrorarm. Fixed by routing throughmap_liquidity_verify_error(Mismatch → VerificationMismatchError,Rpc → VerificationRpcError), mirroring the batch path. - Rolling-start race (CBCH6H). Step-1 now compares the pinned snapshot
seed against on-chain@snapshot_block, not engine-current.
V3PoolState/V4PoolStateretainsnapshot_seed(a copy of the registrationtick_data) forTrackedpools, immutable acrossapply_*_liquidity_update. NewPyBot.verify_v3/v4_snapshot_seedmethods take the seed and verify it via the raw-tick-dataverify_v3/v4_liquidity_mapfunctions; the seed is consumed once (take_*_snapshot_seed) so memory is bounded across 18k pools.EngineRegistry._verify_pool_at_blockroutes step-1 (verify_seed=True, seed) and step-2 (verify_seed=False, engine-current after the drain).
The rolling-start design is preserved (resume still precedes build_paths); the fix closes the verify race at its cause rather than reordering startup.
The Rust core BotCore::register_v2/v3/v4_pool was kept (the live
insert at the BotState layer, used by the builders via PyBot.register_v*);
D3 deleted only the unreachable pyo3 engine surface.
ADR-005 (Polars-Inspired Three-Layer Architecture) left two things open that turned out to be load-bearing:
- Two
Botinstances.PyBotholdsArc<parking_lot::RwLock<Bot>>(instance A — the library session, whatPyLiquidityPool/PyErc20Tokenread through); theArbitrageEngineholds its ownArc<parking_lot::Mutex<Bot>>(instance B — a separateBot::new()atarb_engine/mod.rs:401, what the pump mutates). The two registries never share state. The backrun example registers the same pools into both (once viabot.build_pool()→ Bot A, once viaengine.register_v2_pool()reading the Python pool's reserves out → Bot B). The duplication is load-bearing for correctness today:Bot::register_v2_pool/register_v3_poolpanic on duplicate address,register_v4_poolreturnsErr— so the two registries must be separate or the double-registration flow panics. The symptom is the documented stale-state caveat (docs/architecture/rust-owned-bot.md§17: "Encoding uses amounts from the same block (before dispatch); long-term fix is Rust-owned encoding" — encoding reads Bot A, the pump mutates Bot B). - The deferred "ArbitrageEngine lock unification." ADR-005 deferred unifying the
engine onto the shared
Arc<RwLock<Bot>>"until the engine's access pattern is ready to give up its independent lock." The stale-state mitigation is now friction, not a placeholder — the trigger has been met.
Two other facts surfaced during grilling:
- Today's
Bot(Rust core) is pure state.rust/src/bot_core/mod.rsBotstruct:pools/pool_addresses/tokensregistries, reorg journal, V3/V4 liquidity-event buffers — nochain_id, no RPC, no I/O. ADR-005's standalone-core consequence was read as "acargo add-able math crate" under that reading. - Today's Python
bot.pyis multi-chain by accident. It "swallowed a multi-chain connection manager, pool managers, token managers." The multi-chain-ness is not a designed invariant to preserve — nothing prevents a Python user from instantiating two single-chain Bots.
The grilling user's intent, restated to remove an inversion in the early framing:
"preserve the ability for a Rust user to operate a bot just like a Python user. Polars
offers this same split-UX." Standalone-Rust-core therefore means a Rust user runs the
whole bot — state + math + RPC + subscriptions + chain I/O — without Python, not "a
pure-data math crate." Putting RPC + I/O on the Rust core enables full standalone in
the strong form; keeping Bot pure-data would hand a Rust user the math and no way to
drive it — the weak form.
Adopt five sub-decisions.
PyBot and ArbitrageEngine adopt a clone of the same Arc<parking_lot::RwLock<Bot>>
instead of each constructing their own Bot. Constructed via two layered constructors
neither of which is Python-privileged:
Bot::new(chain_id, rpc_url)— allocates theArcand constructs a complete Bot (state + RPC + pump + engine vec). Standalone-Rust canonical path.ArbitrageEngine::with_core(core: Arc<RwLock<Bot>>)— adopts an existing Arc.PyBot::from_core(core: Arc<RwLock<Bot>>)— adopts an existing Arc (mirrors the engine).- The no-arg
ArbitrageEngine::new()/PyBot::new()sugar is kept for standalone no-pyo3 tests and the cold-start path, defined aswith_coreover a self-allocated Arc. The ~10tests.rssites callingArbitrageEngine::new()keep working.
On the live Python path, the session allocates the Arc once (via PyBot) and the engine
adopts a clone. No branch on "which runtime am I in" — the shared-buffer pattern is a Rust
pattern Python happens to participate in, matching Polars' RwLock<DataFrame> +
Arc-shared storage.
Resolves ADR-005's deferred "ArbitrageEngine lock unification." Supersedes ADR-003's
engine-holds-its-own-Arc<Mutex<Bot>> arrangement.
The shared Arc<RwLock<Bot>> is parking_lot::RwLock (not Mutex), matching today's
PyBot tier and the hot loop's read-dominant access pattern (per-pool calcs, tick reads
during solves — concurrent readers under Python 3.13+ free-threading). The pump's
brief apply_* write windows are the only exclusivity; Python reads stay concurrent.
ArbitrageEngine keeps its own parking_lot::Mutex<ArbitrageEngine> for genuinely
engine-level state (path_pools, path_resolved, pool_to_paths, results,
results_block, dirty_v2/v3/v4, delivered, deregistered, next_path_id,
pending_new_paths, result_tx, min/max_profit, last_processed_block — ~15 fields,
all solver-dispatch/result-batching/pump-coordination). ADR-003's peer-module split —
Bot owns pool/token state; engine owns path/solver state — is preserved.
Lock order is unchanged: engine-then-core. The pump's engine.lock() call sites
(~10, in arb_engine_pump.rs) are untouched. The change is inside the engine: the
~30 self.core.lock() sites in event_routing.rs/lifecycle.rs/solver_dispatch.rs/
diagnostic.rs/mod.rs become self.core.read() / self.core.write() (a mechanical
classification — apply_*/buffer_*/register_* → write; get_*/*_pool_count/
resolve/solve reads → read). No engine field acquisition changes.
PyBot/PyLiquidityPool methods take the core RwLock alone and never touch the
engine Mutex; the pump and PyArbitrageEngine take engine-Mutex then core-RwLock.
ADR-003's deadlock-surface-empty rule stays intact.
The engine-level register_v2_pool(params) / register_v3_pool(params) /
register_v4_pool(params) methods are deleted (the three currently at
arb_engine/mod.rs:448–494 that delegate to self.core.lock().register_*). Pool
registration lives on Bot (Bot::register_v2_pool / register_v3_pool /
register_v4_pool), where it already is. The engine discovers pools at register_path
time by resolving pool_id against its associated Bot.
- Python-driven path: the builder writes through
PyBot's Arc into the sharedBot(py_bot.register_v2_pool(...)→pool_id); the engine's intake isengine.register_path([pool_ids...]), resolving eachpool_idagainst the shared Bot. No second registration, no panic — the duplicate panic/ErrBotraises on real double-registration stays meaningful rather than becoming a false positive. - Rust-only path:
core.write().register_v2_pool(...)→pool_id, thenengine.register_path([pool_id]). Same call shape as Python; no Python in sight.
This deepens the engine module (intake shrinks to register_path; pool-creation
concentrates in Bot) and enforces "engine is math/path focused, owns no pool state"
literally.
Bot absorbs what ADR-005 enumerated as "Python session" orchestration, on the Rust side:
chain_idbecomes aBot-level construction-time field (Bot::new(chain_id, rpc_url)). Todaychain_idlives per-TokenEntryonly; after D4 the Bot-level invariant lets the engine validate that every pool in its paths shares its Bot's chain, and prevents cross-chain pool-ID collisions from going undetected onceBots can be shared.- The RPC
AlloyProviderlives onBot(one — since one Bot = one chain).subscribe/start/backfill are Bot-owned I/O. TheArbitrageEnginePump(arb_engine_pump.rs) generalizes to a chain pump living onBot(the per-block WSnewHeads+logsloop, address filtering, gap/timeout backfill — unchanged mechanics, relocated owner). - A
Vec<Box<dyn EventSink>>onBotholds zero or more attached engines. Bot owns the per-block loop that drives them.
Naming (ADR-005 layer-naming rule preserved): Bot remains the name of the callable
orchestrator — the bare noun reserved for the Python companion matching PyBot minus
Py. Today's pure-state Bot fields fold into Bot as private fields. There is no
separate public BotDriver/BotSession module — the orchestrator is one deep
interface.
Bot is a thin deep interface over cohesive private services — not one monolithic struct.
The responsibilities D4 names (RPC, decode dispatch, state mutation, subscriber
notification, pump, solve-trigger, reorg restore) are not all on one struct. Bot is a
low-method-count facade (caller surface ≈ new(chain_id, rpc_url), attach_engine,
register_pool, start — four methods) delegating to pub(crate) helper modules, each its
own private deep module with its own test seam. This is the codebase-design "interface
vs implementation" distinction: Bot's interface stays small (callers and tests cross
that seam); the helpers are private to the implementation and never widen the public
surface. Per-helper modules under bot_core/:
Helper (pub(crate)) |
Responsibility | Owns dirty-set? | Test seam |
|---|---|---|---|
Bot (the interface) |
Owns Arc<RwLock<BotState>>, chain_id, the helpers; delegates. Thin facade. |
no | — (calls through helpers) |
BotState (today's pure-data Bot) |
Pool/token registries, per-pool swap math, reorg journal, V3/V4 liquidity-event buffers. | no | math-in-isolation tests, zero I/O |
LogDispatcher (decoder registry / event bus) |
Holds Vec<Box<dyn LogDecoder>>; receives raw logs; produces typed events targeting state-subjects; owns the StateSubscriber (Weak<dyn>) registry; notifies subscribers after BotState mutation releases the core write lock. |
no | give it logs + a fake BotState, assert notify ordering |
BlockPump (today's arb_engine_pump.rs, generalized) |
WS newHeads+logs transport, Rust-side address/topic filtering, gap/timeout backfill, the drain loop. Owns the tokio task. |
no | give it an in-memory provider, assert block delivery |
SolveCoordinator |
The drain-point solve trigger + SolvePolicy. Subscribes to BlockPump's drain tick and block-boundary; asks attached engines (each keeping its own per-pool-subject dirty-set, seeded by LogDispatcher notifications) to solve dirties per policy. |
no (dirties live per-engine) | give it a fake sink + fake dirty-set, assert Eager vs Drain timing |
ReorgCoordinator |
removed-flag handling, restore_before_block over BotState, snapshot/restore. |
no | proptest on the journal, no I/O |
The dirty-sets stay on each engine (an engine knows which pools are in its paths —
today's dirty_v2/v3/v4 on ArbitrageEngine), seeded by subscriber notifications from
LogDispatcher. SolveCoordinator fires the drain tick; each engine owns its own
solve_dirty. LogDispatcher and SolveCoordinator stay distinct ("which-pool-changed"
vs "when-to-solve") and don't collapse into one module. The helpers are not given PyO3
wrappers of their own — Bot and engines are the only Python-visible surfaces.
This revises ADR-005's "Rust Core" enumeration (which listed Bot's contents as
"data + state-machine logic + DexIdentity preset registry," zero I/O). It revises the
enumeration in the direction of more standalone-Rust coverage, not less: standalone now
means a Rust user runs the whole bot (state + math + RPC + subscriptions + chain I/O),
matching the Polars split-UX (pl.DataFrame Rust == pl.DataFrame Python, same core,
two driving surfaces). It revises the shape argument behind ADR-005's "Rejected: the
Python Bot class is the #[pyclass]" (orchestration coupled to the state owner) —
the orchestration now lives on the pyo3-free Rust core, so the GIL/lifetime objection
shrinks; the positive decision (Bot is the deep callable module) is taken deliberately.
A Bot is scoped to exactly one chain + one RPC. A user running two strategies on two
chains — e.g. a mainnet V2/V3/V4 arbitrage Bot (via ArbitrageEngine) and a Polygon
Aave-liquidation Bot (via a future AaveLiquidationEngine) — instantiates two Bots.
The Python bot.py "swallowed multi-chain connection manager, pool managers, token
managers" is an accident to unwind: bot.py becomes a single-chain facade over one
PyBot; multi-chain is the caller instantiating multiple facades. There is no
multi-Bot coordinator layer — two chains → two Bot.from_config_file() calls by the
user, two PyBots, no coordination between them.
D4 + D1 imply a reference problem: Bot owns the pump that drives engine.process_block,
so Bot must reference its engines; engines reference Bot (to read pools, request I/O).
Both strong → an Arc cycle neither can drop. Resolved by dependency inversion via a
sink:
- Bot → Engine: only a
Box<dyn EventSink>(a one-method trait). No strong type-bound knowledge the sink isArbitrageEngine. - Engine → Bot: no strong ref. The
&Botthe engine needs to read pool state is passed in with eachon_blockcall by the pump. Ad-hoc I/O mid-solve (a one-offeth_call, backfill) goes through a passed&dyn BotIotrait or aWeak<RwLock<Bot>>resolved at call time — the Weak breaks the cycle in both directions.
on_block(&mut self, bot: &Bot, ...) — the engine implements EventSink,
ArbitrageEngine today; a future AaveLiquidationEngine implements the same trait with
no Bot/pump/PyBot change. This is the leverage: N strategies reuse one Bot topology;
the blast radius of "add a new strategy" is one new Engine impl. Locality: a solving bug
lives in the engine; a subscription/pump bug lives in Bot; never smeared across one.
The previously-trapped pure helpers from candidate 2 (SnapshotStore,
register_with_cl_buffers, verify plumbing) move onto Bot/the chain pump where
they're testable without pyo3. Candidate 2's py_binding.rs lift-out is absorbed into
this work.
- Two
Bots (status quo / ADR-005 deferred). Rejected: the stale-state mitigation (§17) is real friction, the duplicate-registration panic makes double-registration fragile, two lock disciplines + two registries is un-canonicalized state this ADR replaces. The deferral trigger ("until the engine's access pattern is ready to give up its independent lock") is met. - Single
Botis pure state + a separateBotDriverorchestrator (Design Y). Rejected: preserves ADR-005's pure-data enumeration but bifurcates "the bot" into two structs with an awkward seam; the user's stated model ("Bot should act as the orchestrator") wants one callable thing. Folding state intoBotas private fields gives the same testability (private internal seam) without the public split. - Make
engine.register_*idempotent instead of deleting them. Rejected: silently merges two distinct construction-intents ("I'm the authority creating this pool" vs "I'm subscribing to one someone else created") behind one call; a misconfigured path or stale handle reuse would silently succeed instead of failing loudly;register_v4_pool's hook/dynamic-fee filtering is ambiguous on the second call. Two intents want two methods — and D3 collapses both ontoBot(the engine neither registers nor attaches, it resolvespool_ids atregister_pathtime). - Keep RPC/connection management Python-side (topology i). Rejected: contradicts
"a Rust user operates a bot just like a Python user." Both users must construct
Bot::new(chain_id, rpc_url)and own the connection Rust-side; a Python-side RPC authority strands the Rust-only user without I/O. - Collapsing to a single
Arc<RwLock<Bot>>with no engine-level lock. Rejected: the engine's ~15 engine-level fields are genuine solver/batching/pump-coordination state (not pool state); forcing them onto the Bot's lock either conflate two peer modules' state (widening critical sections, reintroducing serialization) or force&self+ per-field cells (complexity the dirty-tracking sets genuinely need atomic with the solve). Keep two locks, engine-then-core.
- The §17 stale-state caveat closes. Encoding reads the same
Botthe pump updated (one shared Arc). "Long-term fix is Rust-owned encoding" becomes reachable — the future-Rust-owned-encoding path reads through the sameBotthe engine wrote. Bot::new(chain_id, rpc_url)is the canonical construction for both runtimes. Rust-only and Python-driven users hit identical code.alloy-provider+ tokio becomedegenbot-coredependencies — accepted: this is what "full standalone" costs, and the crate split (ADR-005 deferred) will carry them on the binding/core crates as needed.- Adding a strategy = one new
Engineimpl.AaveLiquidationEngineproof: noBot/pump/PyBotchange. TheEventSinkseam is the strategy-extension point. bot.pyshrinks toward a single-chain facade; its swallowed multi-chain managers move to the caller or retire. A user wanting two chains writes twoBots.- Engine construction surface shrinks (D3 deletes
register_*on the engine; intake becomesregister_pathresolvingpool_ids). The engine module deepens: one intake method, one intent. - Lock discipline gains a classification pass (~30
self.core.lock()→read/write). Mechanical; ADR-005's read/write guard split invariant applies to these newly-shared sites. - Two ADRs revised, recorded here not silently: ADR-005's "Rust Core" enumeration +
"Rejected: Python Bot is the #[pyclass]" shape argument; ADR-003's
engine-holds-own-
Arc<Mutex<Bot>>arrangement. The revision is toward more standalone.
- Contradicts ADR-005 "Rust Core" enumeration (
Bot= pure state, zero I/O). Reopened deliberately: standalone-Rust-core redefined to the strong form (full bot, no Python), which the user affirmed is the intended meaning, and which Polars' split-UX models. The positive construction (Botis the deep callable orchestrator) is taken deliberately; the GIL objection to coupling shrinks because the orchestration is pyo3-free. - Supersedes ADR-003's engine-holds-
Arc<Mutex<Bot>>arrangement (the engine adopts the sharedArc<RwLock<Bot>>per D1+D2). ADR-003's peer-module split (Bot=state, engine=paths) and engine-then-core lock order are preserved unchanged. - Contradicts
docs/architecture/rust-owned-bot.md§13.2 + §17 (two-Bot description, stale-state caveat). To be updated at implementation; this ADR is authoritative in the interim.
- ADR-005 (Polars-Inspired Three-Layer Architecture) — this ADR revises the "Rust
Core" enumeration and resolves the "Deferred: ArbitrageEngine lock unification" item.
ADR-005's Py-prefix naming, the
PyBot/PyLiquidityPool/PyErc20Tokenhandle topology, and the wrapper-is-the-sharing-mechanism principle are preserved. - ADR-003 (BotCore as the state layer, peer to ArbitrageEngine) — the peer-module
split and engine-then-core lock order are preserved; the
engine-holds-its-own-
Arc<Mutex<Bot>>arrangement is superseded by D1+D2. gain forward pointers to this ADR; a new {EventSink} term records the decided concept. - Architecture review
/tmp/architecture-review-20260617-200733.htmlcandidate #1 — the candidate this grilling opened on.
- The solve-notification protocol — PARTIALLY RESOLVED. The
EventSinktopology is refined to a per-state-subject publisher/subscriber event bus:LogDispatcher(apub(crate)helper onBot) owns a decoder registry + aWeak<dyn StateSubscriber>registry; pool-state events are decoded and applied toBotStatebyBotitself (state owner decodes the events that mutate its owned state); after the core write lock releases,LogDispatchernotifies subscribers perpool_id. The engine implementsStateSubscriber(on_state_updated(pool_id)) — it dirtiespool_idin its own per-engine dirty-set (the dirty-set stays on the engine, not moved toBot) taking the engineMutexalone (core write already released — D2's engine-then-core order preserved). ASolveCoordinatorhelper fires the drain-point solve tick (coalesced re-solve on empty log queue, Idea 1 — the existing eager-processing invariant restated in bus vocabulary; no new solve debounce — solves keep coalescing at the drain point as today). Today's existing reverse index is recognized as this pub/sub realized centrally. - A pluggable per-state-subject
SolvePolicy(Idea 2a) — DEFERRED. ASolvePolicyenum (Draindefault /Eager/Block/Manual) live-set onSolveCoordinator, runtime-mutable, would let a live searcher (Eager/Drain) and a batch backtester (Block/Manual) reuse one engine. Idea 2 composes on top of Idea 1 (Idea 1 is theDraindefault instance — not mutually exclusive). Deferred on evidence: one concrete second consumer short of the "two adapters = real seam" bar; recorded so future reviews don't re-derive it. (Note:SolvePolicy(when-to-solve) is kept distinct from the lifecycleEnginePhasestate machine (what's-legal-to-call-now, Idea 2b) — orthogonal axes, never folded into one state machine.) - The reorg handling protocol — RESOLVED: optimistic per-event journal rollback,
no
on_reorgmethod. The WSremoved: truereplay ordering is not specified by any standard, so the design makes no ordering assumption. On everyremoved: truelog for pool P at blockB(the removed event's own block, present on every log),ReorgCoordinatorcallsReorgJournal::restore_before_block(B)for P, writes the landed-at state into currentBotState, and fires the sameon_pool_state_updated(P)notification as a forward update — no separate method. This is correct-by-construction becauserestore_before_blockis idempotent and order-insensitive: newest< B→ no-op returning current state (harmless); newest≥ B→ pops all deltas at/after B and lands at the largest-block delta< B(a controlled unwind to exactly the pre-B state, which naturally also handles any intermediate-block deltas since the while-loop pops everyback().block() >= B). Chronological arrival → controlled single-block unwinds; reverse-chronological → first call pops multiple blocks, subsequent calls no-op; out-of-order/interleaved across pools → each pool restores against its own journal independently. The removed event's content is unused (only its block number + pool identity), because the journal's stored "before" values are the source of truth. Themax_depthbound is unchanged — aremoved: trueevent whose block is below the journal's earliest surviving delta hitsErr(NoStatePriorToBlock)→ fail-stop, exactly as today. The single-methodPoolStateSubscribercovers both forward and reverted updates uniformly; reorg is just a burst of the same per-pool notify. (Retracts the earlier "separateon_reorg(target_block)method" recommendation, which assumed a single bulk reorg signal — the WS protocol actually replays unwound events per-log, and the ordering is unspecified, so the design must handle any order.) - The subscriber-notify payload — RESOLVED: shape (i), bare
pool_id. The engine's sole per-update input ison_pool_state_updated(&mut self, pool_id: u64)(the trait ships asPoolStateSubscriberuntil a second state-subject type proves generality). Engine decodes nothing and takes noBot/core lock at notify — it dirtiespool_idin its own set and returns, readingBotStateonly later insidesolve_dirtyunder engine-then- core-read (preserves the coalesced α design literally).pool_to_pathsindexes on barepool_id; the solver re-derivesIntHopState/IntV3TickRangeSequencefrom currentBotStateon every solve (today'srebuild_and_solve_affectedalready does this — event-kind agnostic). Event-kind payloads (shape (ii)) rejected as speculative machinery for a solver short-circuit no current consumer uses — widen later if and only if a concrete short-circuit emerges; a futureAaveLiquidationEngineneeding richer payloads for its (non-pool) state-subjects is a different trait instance, not a widening of the pool-state one. AaveLiquidationEngine— the proof-of-seam future strategy. Referenced to justify theEventSinktrait; not designed in this ADR.bot.pyunwinding (removing the swallowed multi-chain managers, becoming a single-chain facade). Direction decided (D5); concrete migration is implementation.SnapshotStore/register_with_cl_buffers/ verify-plumbing relocation (candidate 2 + D4 absorption). Target owner decided (Bot/chain pump); concrete file moves are implementation.- ADR-005 crate split (
degenbot-core/degenbot-python/ umbrella) — remains deferred as in ADR-005; D4'salloy-provider/tokio dependency lands on the core crate when the split occurs, consistent with "full standalone" now being the target.