ADR-006: Bot as the per-chain orchestrator (unified state, RPC ownership, Engine-as-EventSink)¶
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.Partial supersession (epic
Y4VMWH, ADR-047, 2026-09-12): D4’s per-state-subject publisher/subscriber event bus — theLogDispatcherPoolStateSubscriberregistry and the Python subscriber bridge, i.e. the Deferred “solve-notification protocol” below — is retired. ADR-041 removed its engine consumer, theEpochDeltaledger owns dirt, andnotify_pool_state_changedbecamerecord_pool_state_changed. The orchestrator rows remain accurate.
Update (ADR-006 D3+D4 follow-up completion)¶
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.
Addendum (ergo epic BQM2OA / JFGCHJ / 4UBHP6 / MLJT4V — sealed seam + stored trait-object I/O)¶
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).
Addendum (epic GAYTBA — verify-race + exception-mapping fix)¶
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.
Context¶
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.
Decision¶
Adopt five sub-decisions.
D3 — Pool construction is a Bot concern only; the engine never constructs pools¶
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.
D4 — Bot is the per-chain orchestrator: owns RPC, chain_id, the pump, and its engines¶
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 ( |
Responsibility |
Owns dirty-set? |
Test seam |
|---|---|---|---|
|
Owns |
no |
— (calls through helpers) |
|
Pool/token registries, per-pool swap math, reorg journal, V3/V4 liquidity-event buffers. |
no |
math-in-isolation tests, zero I/O |
|
Holds |
no |
give it logs + a fake |
|
WS |
no |
give it an in-memory provider, assert block delivery |
|
The drain-point solve trigger + |
no (dirties live per-engine) |
give it a fake sink + fake dirty-set, assert Eager vs Drain timing |
|
|
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.
D5 — One Bot per chain; multi-chain is N Bots¶
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.
The Engine-as-EventSink topology (cycle-free)¶
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.
Considered options (the load-bearing reversals)¶
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.
Consequences¶
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.
ADR conflicts (recorded, not silent)¶
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.
Deferred¶
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.