ADR-054: The strategy plumbing surface — ReplayOutcome, journal extractors, the planning workspace, and anchored discovery are the promoted seams

Status: accepted (2026-09-17; epic HO76KB, tasks J4SSTF → Z3YEUO: the frame-replay strategy was proven by the live soak report logs/backrun/replay_soak.md, which this ADR closes over).

Context

The pre-epic backrun pipeline decoded calldata: every strategy frame had to understand the wire (routers, aggregators, batch settle orders, eth-flow helpers…) — an unbounded-decode problem. The first extractor prototypes lived in degenbot_decoders::target_classifier, the analytic post-target estimator lived in degenbot_bot::bot_core::post_target, and the discover stage fanned 2-hop drift cycles from a QuoteFan/connector-star surface. The limit was structural: the wire carries MEV shapes the bot has to know about without knowing the specifics — what pool a frame touches, with what resulting state, and what cycle through those pools would still pay after the frame.

The HO76KB epic rebuilt the hot path on four facts every lane consumes equally. This ADR records the plumbing surface as promoted so the NEXT strategy doesn’t re-derive guarantees the merge already proves.

Decision

Seam 1 — ReplayOutcome carries the whole frame’s failure/success

degenbot_simulation::sim::evm::frame_replay::ReplayOutcome. One type for every possible result of having replayed a pending-tx frame: typed post-states of touched pools, wall/rpc_reads evidence, and explicit failure modes (ReplayStatus). A lane that observes an Err/Incomplete-variant ReplayOutcome knows whether the frame died honestly (RPC failure, nonce-gap) or simply was never actionable — the infamous “we closed our eyes” pseudo-success doesn’t exist on this surface.

Owner crate: degenbot-simulation. Producer: degenbot-submission’s frame pipeline and any other lane feeding the workspace.

Seam 2 — extract_pool_post_states = the journal is the only witness

degenbot_simulation::sim::evm::journal_pools::extract_pool_post_states. One function from (ReplayOutcome, descriptors) to typed PoolPostState, keyed off the replay’s journaled EV slots and nothing else: reserve words, slot0/liquidity words, and V4’s per-poolid state rows flow from the EVM journal, and the extract stage reports a skip reason per pool (extract_skip in the JSONL trace) instead of hallucinating a state.

The slot_layout module is the single source of truth for where pool state lives on-chain — a lane that needed the count of pool-reserve-word decoders from N=3 to N=1 in the first week of the epic.

Seam 3 — The PlanningWorkspace is the only solver state a frame touches

degenbot_submission::frame_pipeline (SidecarSolver + the workspace admission APIs in degenbot_bot::sidecar_engine). The contract is register with explicit state: nothing in solve/discovery reads the shared pool graph; a frame carries a private per-frame EVM whose chain view feeds this frame’s workspace admission and NOTHING else. The consequence: concurrent frames can never cross-contaminate, and the fleet’s live dispatch keeps the true queue discipline the ADR-052-era database had bought per-map.

Owner crates: degenbot-bot (engine) × degenbot-submission (frame pipeline).

Seam 4 — Anchored touched-set discovery replaces the star fan

degenbot_submission::anchored_dfs + degenbot_submission::path_selection::solve_witnesses. On the frame’s touched pools, the lane walks the connector-index multigraph for any depth-N cycle that closes in WETH (2-hop parity, 3-hop and beyond by curve). The old star fan (two_hop_cycles, QuoteFan, normalization, per-frame quote ranking) is deleted outright — not kept behind a flag — because the anchored lane is strictly the star’s superset by construction: every star cycle is a depth-2 anchored cycle, every quote fan that could close through WETH also closes a WETH lexicographic traversal if the walker is allowed to visit it.

Discovery evidence is committed per frame: sideways steps are the node's residual trees, not side-band quotes; the witness records which cycle’s price at the frame state justified the envelope’s compose.

Hook-difficulty decisions made here

  • Scratch EVM isolation. A frame’s replay runs against a fresh EVM instance whose backend is the frame’s own chain view; replaying a tx NEVER dirties the solver’s shared ScrapeDb. The cost is one EVM scaffold per frame (~µs); the win is that relaying the SAME tx twice cannot compound state, and a buggy frame cannot poison a sibling frame.

  • Register with explicit state. The solver workspace offers only admit({family, pools} + explicit state) — there is SECOND pool-state source a lane could ask for (the graph, the graph’s caches). This makes the seeded DB -> EVM side directly auditable at frame_pipeline.rs’s single admission surface.

  • Slot-layout is one module. No per-protocol “count the words” decoder scattered across strategies. The V2 reserves word, the V3 slot0+liquidity pair, and the V4 poolid row each have exactly one home — both extractors and the workspace read through it.

  • Bid from profit, envelope against the fork view. Compose≠decide: the lane computes bid = floor(profit * share) from the WETH-closed chain’s profit and validates the compose via the on-chain eth_callMany envelope, not by replaying the bid inside the workspace. The two checks read different evidence so one bug can’t flip both signs.

How a third strategy consumes these seams — worked example: a liquidation dry-run

A lane “observe undercollateralized AAVE positions” consumes exactly each seam in order, with no new machinery:

  1. The frame pump hands pending-liquidation ReplayOutcomes (seam 1) — no liquidation-specific decode of calldata; a health-factor-touching frame is the wire selector liquidationCall that “touched a market” { the journal (seam 2) extracts the typed post-state of that market’s debt-collateral record. The wire is understood as “pools whose state changed,” the classic slot-layout query.

  2. admit_extracted puts the affected market into a fresh frame’s PlanningWorkspace (seam 3) — the workspace reads nothing from the arbitrator’s long-lived state, so the lane needs no locks.

  3. Anchored discovery (seam 4) enumerates cycles from the debt token through the collateral token; a solver-side ceiling prices each — the whole enumerator is “which markets are depth-2 WETH-closeable from this debt token,” and nothing in the lane’s code knows AAVE-specific words.

  4. The liquidate-side contract call the lane emits goes through the same eth_callMany envelope verify each response epoch of composition uses.

The cost touched: none of the four seams. The lane’s own specialised code is “what sounds like a liquidation,” (detect) and “why a profitable relation between collateral and debt persists” (economics).

Cross-references

  • ADR-019 — the settlement/engine split this strategy’s stage layering inherits.

  • ADR-045 — the registry borrow contract; the strategy NEVER borrows from the registry because the discovery plane holds its own per-frame admission surface.

  • ADR-051 — the rust-owned console: the lane’s CLI face is a cargo binary, not a Python driver.

  • AGENTS.md — “Rust is the engine; Python is a driver shell” is the interpretation under which these seams are promoted: every other strategy lives purely in rust/crates/engine/degenbot-submission and degenbot-bot/degenbot-simulation, driving the strategy exclusively from this surface.

  • logs/backrun/replay_soak.md — the live evidence that these are the observed contracts, not the hoped-for ones: 59 live frames, zero fake conversions, latency budget with >99% slot headroom at p95.