# Rust settlement-bot consumer parity ledger Epic: **RGZG4S** (ergo) — "Rust-owned settlement bot — consumer parity + gap discovery". This document is the living census for the epic's core question: **can a `cargo add degenbot` consumer build the same settlement-arbitrage bot that `examples/eth_settlement_arbitrage_v2_v3_v4_rust.py` builds via the Python driver (`src/degenbot/runner/`, ~4.8K lines) — with zero Python?** Per AGENTS.md, Rust is the engine and Python is a driver shell; this ledger tests that claim end-to-end instead of assuming it. ## Methodology (the gap-smoking loop) The parity example (`rust/examples/settlement_bot`) is built **as** the test: 1. Port the next Python-driver phase against the umbrella crate's public surface and attempt to compile/run. 2. Where compilation or semantics fail, the failure is a **numbered gap** (G1..G5 below); each gap has an ergo task and a classified row in the ledger. 3. Close the gap in the core crates (never in the example), then advance. 4. Land the running dual-driver gate (RSP-8) so parity is continuously asserted, not re-audited. The crate lives under `rust/examples/` (not `examples/rust/`) because cargo rejects workspace members not hierarchically below the workspace root ("workspace member ... is not hierarchically below the workspace root"). Status vocabulary for the ledger: - **REACHABLE** — a pyo3-off umbrella consumer reaches the same behavior today (evidence linked). - **PARTIAL** — the leaf exists but the driver-level glue/semantics differ or are missing. - **BLOCKED** — no public path exists (hard wall). - **DRIVER-POLICY** — deliberately driver-owned ("stays-python" per `degenbot/runner` design); the Rust example reimplements it locally rather than the library owning it. ## Ledger | # | Python-driver surface (source) | Rust status | Evidence / gap | |---|---|---|---| | 1 | CLI flags `--live/--permutation/--node` (`runner/cli.py`; ADR-062 D6) | REACHABLE | argv spelling is Rust-owned (ADR-051 D2): `degenbot-cli` declares the ONE clap tree (`rust/crates/shells/degenbot-cli/src/argv.rs`) over the clap-free `degenbot-cli-core` `Command` model (`rust/crates/shells/degenbot-cli-core/src/command.rs`), and the `--database`/`--chain-id`/`--node` driver flags resolve through `degenbot-config`'s resolvers. `cargo build -p degenbot-cli` produces the `degenbot` binary; `cargo add degenbot` reaches the same values via `degenbot::config`. Proof: the no-Python console gate (`.github/workflows/cli-no-python-gate.sh`, CI job `cli-no-python`) runs the argv set end to end. The parity example now carries one scheme-classified `--node` per transport (ADR-062 D6) and resolves through the same `degenbot::config` resolvers. | | 2 | `ArbitrageConfig.build` — operator/executor envs, dispatch tunables, retry knobs, fail-fast parse errors (`runner/config.py`) | DRIVER-POLICY | reimplemented driver-side in the example (mirrors constants byte-for-byte); `degenbot-config`'s `BotConfigLoader` cascade does not cover these env keys by design, and the node pair is no longer part of this cascade (row 3 owns it) | | 3 | Node URI cascade: `--node` > `DEGENBOT_RPC_{HTTP,WS,IPC}_CHAINID_` > `[nodes]` tables > error, no localhost default, capability-scoped (`src/degenbot/config/__init__.py`; ADR-062 D1/D3) | REACHABLE | `degenbot_config::{resolve_node_request_uri, resolve_node_subscription_uri, resolve_node_uri, node_http_env_name, node_ws_env_name, node_ipc_env_name}` (`rust/crates/foundation/degenbot-config/src/resolvers.rs`; re-exported as `degenbot::config`) own the four-layer cascade with the same fail-loud "no localhost default" posture; the request scope is ipc > ws > http and the subscription scope ipc > ws (ADR-062 D3). The `[nodes]` file tables are live typed schema vocabulary (ADR-062 D2), not retired: the parity example resolves its construction and feed endpoints from an `ipc` entry in one operator file (`rust/examples/settlement_bot/src/main.rs`). `degenbot-cli`'s repeatable `--node` feeds it through `CliContext::node_request_uri` / `CliContext::node_subscription_uri` (`rust/crates/shells/degenbot-cli-core/src/context.rs`). | | 4 | DB path resolution (`_make_arbitrage_config`: config.toml `database.path` or `~/.local/state/degenbot/db/degenbot.db`) | REACHABLE | `degenbot_config::resolve_database_path` (`rust/crates/foundation/degenbot-config/src/resolvers.rs`; re-exported as `degenbot::config`) owns `--database` > `DEGENBOT_DB_PATH` > `database.path` > `/degenbot/db/degenbot.db` (XDG state home), with `~` expanded against `HOME` (ADR-062 D1/D4). The file layer is live — `database.path` is a declared typed key, not a retired layout item; the parity example resolves its path through the same resolver (`rust/examples/settlement_bot/src/main.rs`), with the `offline.fixture_db` typed key as its test override. `degenbot-cli`'s global `--database` feeds it through `CliContext::database_path` (`rust/crates/shells/degenbot-cli-core/src/context.rs`). No driver resolves the path ad hoc. | | 5 | DB snapshot load → seed block S (`Bot.load_snapshot_from_db`, `EngineRegistry.start` snapshot read) | REACHABLE | `Bot::new` + `load_snapshot_from_db` + `snapshot_seed_block` — proven by `standalone_consumer.rs` slice 7 (`fixture_snapshot_seed_block`) and by the example's boot slice | | 6 | Engine handshake: `engine.subscribe(ws)` → first WS block W; set S on shared state; `resume()` with **auto-backfill S+1..W-1** inside the pump; stop/phase machine (`engine_registry.py`, `bot_runner.py`) | REACHED-via-EngineDriver | `degenbot::EngineDriver::start` (subscribe → verify-config, stops pre-`resume`) + `resume` (driver-owned `BlockPump::backfill_with_drain` = `S+1..W`, then spawns the live loop) + `stop` (any-phase, idempotent, terminal latch). Shipped by ergo **5XOGRK** (ADR-050) | | 7 | Result-batch consumption (engine `__anext__` stream of `ResultBatch` per block) | REACHED-via-EngineDriver | `EngineDriver::take_result_receiver` hands out the unbounded `ResultBatch` receiver once (attach pre-`resume`); `stop` closes it so a pending recv sees end-of-stream. Shipped by ergo **5XOGRK** | | 8 | Path registration `register_and_solve_path` (+ dedup, + path cap) | REACHED-via-EngineDriver | `EngineDriver::register_path`/`register_and_solve_path`/`deregister_path`/`set_path_cap`/`path_count`/`path_dedups` delegate to `EngineStages`; the typed `PathRegistrationError` propagates verbatim. Shipped by ergo **5XOGRK** | | 9 | Registration verify lifecycles (quarantine → seed-verify → drain/pin → post-drain verify → live; sync + async entry points) | REACHABLE | The core lifecycles are re-exported by the umbrella and proven in `standalone_consumer.rs`; `EngineDriver::run_v3/v4_registration_lifecycle` (+ `_sync`) expose them driver-side (5XOGRK). The at-most-once claim is CORE-owned (`bot_core::verify_claims::VerifyClaims`, entered by both lifecycle entry points, **ZTEUTA**), as is the registration outcome vocabulary + four memos (`bot_core::registration_ledger`, **ZTEUTA**); the example's `claims.rs` and `ledger.rs` twins are deleted and it reads the core types. The bounded retry dance is CORE-owned (`bot_core::verification_retry::retry_verification_call`, classifying transient `VerifyError::Rpc`/`Provider` against fatal `Snapshot`); both twins (`arbitrage/verification_retry.py`, the example's `retry.rs`) are deleted, and `EngineDriver::run_v3/v4_registration_lifecycle_with_retry` (+ `_sync`) wrap the lifecycle with the injected policy. Sweep **S7**: landed | | 10 | Pool construction from RPC (+ DB arm) | REACHABLE | umbrella re-exports `probe_pool_type` + `build_v2/build_v3/build_v4/build_erc20_metadata/build_aerodrome_v2/build_balancer_*/build_curve_pool`; proven in `standalone_consumer.rs` PoolBuilder slice | | 11 | Candidate-pool enumeration from DB (`build_paths.py` discovery query) | **REACHED/VERIFIED** | `degenbot_db::discovery_read::DiscoveryPoolRow` + `DegenbotDb::fetch_discovery_rows` / `SnapshotDb::fetch_discovery_rows` (held-deferred-tx). One read-only SELECT per family (V2 `UNION ALL` over every V2 subclass table, V3 likewise, + the V4 `uniswap_v4_pools` × `managed_pools` × `pool_managers` join) carrying base `pools` fields, token0/token1 `erc20_tokens` (address+decimals), the `exchanges` row, and per-family fee/`tick_spacing`/Aerodrome `stable`/V4 `pool_hash`+`hooks`+`state_view` columns. Fixture evidence: `rust/crates/foundation/degenbot-db/tests/discovery_read_parity.rs` over `fixtures/parity.db` (chain 8453, aerodrome_v3 V3 + uniswap_v4 V4) plus the V2-stable unit test in `discovery_read.rs`; umbrella reach proven by the `settlement_bot` boot slice → Gap **G2**, ergo **YFIOSF** | | 12 | Path discovery (`find_paths_async`, `discovery_batch_size` batching) | REACHABLE | `PathGraph`/`find_paths`/`OwnedPathFinder` reachable via the umbrella (`degenbot-pathfinding`); the driver-side batched wrapper (`batch_size<=1` per-path mode, bounded batches, one cooperative async hop per batch) is implemented in `rust/examples/settlement_bot/src/discovery.rs` over the umbrella iterator, with the candidate-token degree filter + V4 graph-id namespacing + `prune_dead_ends` mirroring `build_path_graph` — ergo **XFEJUG** | | 13 | Path-composition policy (hop bounds, duplicate pool, permutation filter — `arbitrage/policy.py` + `ArbitrageConfig`) | DRIVER-POLICY | example-implemented in `rust/examples/settlement_bot/src/policy.rs` (hop bounds pinned to the discovery 2/3 floor/cap, allow/deny token sets, duplicate-pool guard, permutation parse + per-depth pool-kind filter); discovery admits every token as an intermediate hop, mirroring the Python driver's sweep. **RSP-11 binding (KETJNN):** the driver parses `DEGENBOT_MAX_PATHS` (default 100000, 0/empty=uncapped) onto `EngineDriver::set_path_cap` **before** the crawl, treats the typed `RegistryFull` refusal as the benign stop of discovery, and emits the time-throttled (`DEGENBOT_REG_PROGRESS_SECS`, default 30s) reason-tagged progress summary (`progress.rs`) | | 14 | Sim context + in-process sim (`SimulateContext`, overrides, 7-call bundle) | REACHABLE | `degenbot::arbitrage::{SimulateContext, SimulatePath, FailBuckets, simulate_in_process_with_db, compute_priority_fee}` + `degenbot::simulation::apply_simulation_overrides`; proven in `standalone_consumer.rs` sim slice; behavioral parity proven by the `inspector_cafebabe_revert` dual-driver fixture pair | | 15 | Dispatch selection + encoding (`dispatch_profitable_results`, `DispatchCandidate`, `composers::PathInfo`, thin-margin filter) | REACHABLE | `degenbot::arbitrage::{dispatch_profitable_results, filter_thin_margin_results}` and `degenbot::cmd_executor::composers::*` re-exported (umbrella). Gap **G4** driver policy now lives in `rust/examples/settlement_bot/src/dispatch.rs`: `plan_batch` emits the typed `DispatchDecision` (skip-empty-hops / suppressed / thin-margin / sim) via `PathSuppression::is_suppressed` + the core thin-margin pre-filter; `run_sim_fanout` wraps the core fan-out. Ergo **L4E7RI** | | 16 | Sim fan-out + ordered single submitter (`_sim_submit.py` wiring, `max_simulate_concurrent=50`) | REACHABLE | `degenbot-submission::sim_pipeline::SimSubmitPipeline` owns the whole pipeline: bounded `tokio::Semaphore(cap)` fan-out + one ordered FIFO submit lane + the fail-loud `raise_if_failed` contract, unit-tested offline. The cap is injected as a plain `usize` (the config/posture value stays driver-side); the Python cockpit reaches it through `degenbot._ffi.submission.SimSubmitPipeline`, the pure-Rust example through the umbrella. `consume.rs` consumes `EngineDriver::take_result_receiver` per-block in order, closing end-of-stream exactly once on `stop()` (ADR-050 D6). Ergo **L4E7RI**. **RSP-10/11 binding (SGCAJ5/KETJNN):** `run_loop.rs` holds the consume/watch/operator session open after registration until SIGINT or the bounded `DEGENBOT_SMOKE_MAX_SECS` window (ADR-050 D6 teardown: stop pump → close operator → join watch/consumer), emitting per-block `[session] heartbeat` lines; the registration crawl stops at the `DEGENBOT_MAX_PATHS` cap instead of a 12.6M-candidate attrition pass | | 17 | Fee determination: `eth_feeHistory` percentiles + `next_base_fee` (`runner/_consume.py`, `dispatch.fetch_fee_history`) | REACHABLE | `degenbot_core::eip_1559::next_base_fee`, `degenbot::arbitrage::compute_priority_fee`, `degenbot::rpc::fetch_priority_fee_percentiles` (`AlloyProvider::eth_fee_history`), and `degenbot::submission::fetch_fee_history` are all reachable through the umbrella; `dispatch.rs::priority_fee` wraps the fee seam (unit-tested). Ergo **L4E7RI** | | 18 | Live submission: EIP-1559 sign + send + receipt monitor; dry-run guard that never signs | REACHABLE | `degenbot::submission::{TxSigner, dispatch_and_submit, monitor_pending_transaction, Dispatcher, PathSuppression, ReceiptProbe}` reachable through the umbrella. `submission.rs` owns the driver guard order (mutual-exclusivity / dry-run / inject-code) behind a `SubmissionSeam`; the live seam delegates to `dispatch_and_submit`. The dry-run path short-circuits before the seam — pinned by `submission.rs::tests::dry_run_never_reaches_the_seam`. Ergo **L4E7RI** | | 19 | Session watch / stuck-loop watchdog + session-end verdict (`_session_watch.py`) | DRIVER-POLICY | Detection is core-owned: `degenbot_bot::arb_engine::session_end` exposes the typed `SessionEndCause` (`PumpFinished`/`StallWatchdogTripped`) delivered once through `SessionEndFacts`, fed by `EngineDriver::wait_session_end` and the lifted `Heartbeat`/`stall_watchdog`. The Python cockpit reads the fact over the `session_end_future` FFI surface; `rust/examples/settlement_bot/src/session_watch.rs` ranks it into the byte-for-byte `SessionEndVerdict` (`PumpEnded`/`RegistrationFailed`/`WatchdogTripped`), keeping the same-batch registration-over-watchdog ranking and the observer-only cancellation (watch-as-observer: cancels the consumer, never owns the process) — ergo **KPLWUM** | | 20 | Operator Unix-socket channel (`add_path`/`discover`/`fleet_posture` — example + `operator_channel`) | DRIVER-POLICY | `rust/examples/settlement_bot/src/operator_channel.rs`: tokio `UnixListener` JSON-lines server honoring `--operator-socket`; `add_path`/`discover` through the driver `RegistrationPipeline`, `set`/`get_fleet_posture` through `degenbot::workers::posture::process` (reachable via the umbrella — no new gap), Python-compatible framing/response/error/unknown-op shapes, graceful `close()`; documented `--operator-inert` offline-serve mode — ergo **KPLWUM** | | 21 | Process diagnostics: GIL probe, tracemalloc, faulthandler, /proc-mem sampler (`eth_settlement_arbitrage_v2_v3_v4_rust.py` startup) | DEPARTURE (documented) | Python-interpreter-specific by construction; the Rust example substitutes tokio/tracing-native equivalents. Not a parity item. | | 22 | Logging/telemetry boot (`degenbot.logging`, telemetry facade) | REACHABLE-AND-WIRED | `rust/examples/settlement_bot/src/telemetry.rs` boots the stack in the Python-driver order: typed `BotConfigLoader` install (standard operator file + `DEGENBOT_*` env) → `tracing_subscriber` compact stderr console filtered by `degenbot::bot::telemetry::resolve_filters` → env-gated OTLP layer via `degenbot::bot::otel` (`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` > `OTEL_EXPORTER_OTLP_ENDPOINT`; absent endpoint = quiet no-op, never fatal) → `degenbot::bot::metrics::init_global_metrics` (`DEGENBOT_METRICS_ADDR`, default `127.0.0.1:9464`) → `degenbot::telemetry::{install_panic_hook, warn_retired_env_names}` + `degenbot::core::worker_census::emit_boot_table`. Shutdown flushes + shuts the `OtelHandle` and stops the scrape server via `Drop` (ADR-043 §6). The umbrella forwards `degenbot-bot/otel` as `degenbot/otel` (the example enables it), so `cargo add degenbot` reaches these `#[cfg(feature = "otel")]` modules. Proof: `tests/telemetry_boot.rs` + the 120 s mainnet dry-run (exit 0). Ergo **ZOBXVC** | ## Gap inventory - **G1 — engine driver exposure** (ergo **5XOGRK**, rows 6–8 + downstream 16): **CLOSED** by ADR-050. The public `degenbot::EngineDriver` (`degenbot_bot::arb_engine::EngineDriver`) composes the one public `EngineStages` seam with the pump session state; `ArbitrageEngine` stays `pub(crate)` (the one-door invariant). `EngineRegistry.start` + the `BotRunner` phase machine remain Python-side policy over the Rust-owned sequencing contract. The `PyArbEngine`/`PumpState` pair now delegates the ritual to the same driver. - **G2 — DB discovery reads** (ergo **YFIOSF**, row 11): **CLOSED**. `degenbot-db` ships the additive, read-only `discovery_read` surface (`DiscoveryPoolRow` + `fetch_discovery_rows` / `fetch_discovery_rows_on_conn`) covering every column `build_paths.py`'s construction path reads, on the `SnapshotDb` held-deferred-tx handle; verified against the frozen `parity.db` fixture (V3 aerodrome_v3 + V4 uniswap_v4, chain 8453) and the umbrella `settlement_bot` example. The Python driver now consumes this Rust-owned surface through `degenbot.db`; the former SQLAlchemy/Alembic layer is retired. Balancer/Curve are outside the candidate graph by construction and are intentionally not enumerated. - **G3 — discovery batching + registration pipeline** (ergo **XFEJUG**, rows 9, 12, 13 + claim TOCTOU): **CLOSED (driver-side, offline)**. `rust/examples/settlement_bot/src/` ships `discovery.rs` (graph build from the G2 discovery rows on the held-tx snapshot + batched lazy `OwnedPathFinder`), `policy.rs` (allowlist/hop-bounds/duplicate/permutation), `pipeline.rs` (the `_registration_unit` prep stages + offline-dry run), and `live.rs` (the per-candidate `build_v2/v3/v4` → `BotState` registration → core-owned retry-wrapped verify lifecycle → `register_and_solve_path` arm, gated on `SMOKE_RPC_URL`). The at-most-once claim and the four memos + typed build-refusal classification are the CORE's (`bot_core::verify_claims` / `bot_core::registration_ledger`, **ZTEUTA**), so the example holds no claim table or ledger of its own. The live arm is exercised only against a live node. - **G4 — consume/dispatch/submission** (ergo **L4E7RI**, rows 15–18): **CLOSED (driver-side, offline)**. `rust/examples/settlement_bot/src/` ships `consume.rs` (per-block ordered result-batch consumption + `BlockClock` + single end-of-stream on driver stop), `dispatch.rs` (typed `DispatchDecision` planning + `priority_fee`/`next_base_fee` wrappers + the `classify_revert`/`FailureKind` taxonomy), the shared core `degenbot-submission::sim_pipeline` (bounded `Semaphore` fan-out + single ordered FIFO submitter + fail-loud; reached through the umbrella), and `submission.rs` (the dry-run-safe `SubmissionSeam` over `dispatch_and_submit` + the config-window `monitor_with_config` nonce-expiry accounting), with 22 offline unit tests. All RPC-bound arms compile but are only exercised against a live node; the two new reach claims (row 17 `eth_feeHistory`, row 18 `TxSigner`) are compile-verified through the umbrella and none required a new G-row. The example now depends on `alloy` directly for the `U256`/`Address`/`Bytes` value types those public seams name (recorded as a nuance, not a gap: the umbrella exposes the functions but not the primitive aliases). - **G5 — session watch + operator channel + reconnect** (ergo **KPLWUM**, rows 19–20): **CLOSED (driver-side, offline; detection since lifted)**. `session_watch.rs` ranks the core session-end detection facts (`degenbot::session_end::{SessionEndCause,SessionEndFacts,SessionEndDetection}`) into its typed end-state verdict set over the live `EngineDriver` result-consumption loop, with the same-batch ranking and the observer-only cancellation discipline; the `Heartbeat`/`stall_watchdog` machinery is now the core's; `operator_channel.rs` ships the `--operator-socket` JSON-lines channel (the four ops, Python byte-compatible response shapes, unknown-op/error framing, graceful `close()`, plus the `--operator-inert` RPC-free serve mode the integration check drives). Fleet posture is reachable standalone through `degenbot::workers::posture::{process, PosturePolicyPatch}` (row 20 is DRIVER-POLICY, not a new G-row). The WS reconnect/abort-policy sub-item was not part of KPLWUM's landed slice (rows 19–20): the live arm keeps the existing `EngineDriver::start`/`stop` sequencing, and the SIGINT→stop→typed-consumer-report shutdown is covered by the inert mode + the live arm's `driver.stop()` ordering. - **G6 — telemetry boot parity** (ergo **ZOBXVC**, row 22): **CLOSED**. `rust/examples/settlement_bot/src/telemetry.rs` boots the same observability stack the Python driver boots, in its order: typed `BotConfigLoader` install (standard operator file + `DEGENBOT_*` env), `tracing_subscriber` compact stderr console filtered through `degenbot::bot::telemetry::resolve_filters`, env-gated OTLP layer through `degenbot::bot::otel`, Prometheus scrape endpoint through `degenbot::bot::metrics::init_global_metrics`, then `install_panic_hook()` / `warn_retired_env_names()` / `worker_census::emit_boot_table()`. The umbrella now forwards `degenbot-bot/otel` as its own `otel` feature, so `cargo add degenbot, features = ["otel"]` reaches `degenbot::bot::{otel, metrics, instruments}` — without the passthrough those `#[cfg(feature = "otel")]` modules were unreachable through the umbrella, which is why the example had no telemetry at all. An absent OTLP endpoint is a quiet no-op (the exporter's `localhost:4318` default is deliberately not taken); every telemetry failure degrades loudly, never fatally; `TelemetryBoot::drop` flushes + shuts the `OtelHandle` and stops the scrape server (ADR-043 §6). Proof: `tests/telemetry_boot.rs` (boot announcements, the 20-row parity-ledger stdout unchanged, retired-env WARN, env-OTLP activation, live `/metrics` 200 with `degenbot_metric_series`), plus a 120 s mainnet dry-run (`SMOKE_RPC_URL`, `DEGENBOT_MAX_PATHS=400`, exit 0) whose log shows the four boot lines, `session heartbeat` per block, `[session] run loop ended: WindowExpired`, `[engine] EngineDriver handshake OK`, and `telemetry shutdown: flushing OTLP spans`. Mid-run scrape (real exposition): `degenbot_metric_series{otel_scope_name="degenbot-bot"} 0`, `degenbot_state_lock_hold_seconds_count{mode="read",site="core",otel_scope_name="degenbot-bot"} 1`, `degenbot_detached_degraded_cycles_total{otel_scope_name="degenbot-bot"} 0`, `degenbot_state_lock_wait_seconds_bucket{mode="read",site="core",le="0.0001",...} 1`, `target_info{...}`. - **E2E running gate** (ergo **23DLCY**): **CLOSED (offline)**. The ledger is executable: the CI-safe fixture boot gate runs on both axes (Rust `boot_gate.rs` + Python `test_settlement_bot_boot_gate.py`) against the shared `fixtures/settlement_bot_boot.json` oracle, the recorded dual-driver decision diff (`dual_driver_gate.py` + `test_settlement_bot_dual_driver_gate.py`) diffs the Python/Rust streams modulo the documented permitted-divergence list, and seeded-divergence tests prove both comparators have teeth. The live anvil arm is wired behind `DEGENBOT_DUAL_DRIVER_GATE=1` + `DEGENBOT_FORK_RPC` (skip-by-default in CI). See [Running parity gate](#running-parity-gate-rsp-8-ergo-23dlcy). ## Running parity gate (RSP-8, ergo 23DLCY) The ledger is executable. The gate has two CI-safe, offline halves and one opt-in live half; the extractor contract is `grep '^parity-ledger row='`. ### 1. Fixture boot gate (offline, no RPC) Shared oracle: `tests/standalone_parity/fixtures/settlement_bot_boot.json`. Both consumers read the same JSON and must reproduce it: - **Rust consumer** — `rust/examples/settlement_bot/tests/boot_gate.rs` shells the built example against `rust/crates/foundation/degenbot-db/tests/fixtures/parity.db` with `--smoke-offline` and parses the machine-checkable stdout. - **Python consumer** — `tests/standalone_parity/test_settlement_bot_boot_gate.py` drives the PyO3 seams (`Bot.load_snapshot_from_db`, `build_path_graph`). The machine-checkable contract is the boot report itself: the `parity-ledger row= status= note=` lines, the `parity-ledger snapshot-seed-block S=` line, the `[boot] discovery enumerated candidate pools` line, the `[g3] graph built: nodes, candidate tokens, requested kinds [...]` line, and the `[g3] offline-dry pipeline: key=value ...` line. The consume/dispatch decision rows the gate pins are: | Row | Pinned status | Decision contract | |---|---|---| | `06-engine-subscribe-resume` | `REACHED-via-EngineDriver` | `EngineDriver::start → subscribe → verify-config` (stops pre-resume); `resume` owns the `S+1..W` auto-backfill | | `07-result-batch-stream` | `REACHED-via-EngineDriver` | `EngineDriver::take_result_receiver` (attach pre-resume); `ResultBatch` end-of-stream once on `stop` | | `08-register-and-solve-path` | `REACHED-via-EngineDriver` | `EngineDriver::register_and_solve_path` delegates to `EngineStages` | | `15-dispatch-selection` | `REACHABLE` | core `dispatch_profitable_results` / `filter_thin_margin_results` + driver `DispatchDecision` planning | | `16-sim-fanout-submitter` | `REACHABLE` | core `degenbot-submission::sim_pipeline` `SimSubmitPipeline` (bounded semaphore cap + single ordered FIFO submitter; fail-loud `raise_if_failed`) | | `18-live-submission` | `REACHABLE` | dry-run seam never signs; live seam is `dispatch_and_submit` | **Seeded-divergence proof (teeth).** The Rust test mutates one expected ledger status in an in-memory copy of the oracle and asserts the comparator fails; it also re-runs the real binary with the `DEGENBOT_DISCOVERY_CHAIN_ID` seam removed (enumeration drops 2 → 0) and asserts the comparator catches the live divergence. The Python test mutates `expected.snapshot_seed_block` and `python_reachable.graph_nodes` in memory and asserts the real PyO3 decisions do not match. The checked-in oracle is never modified. ### 2. Dual-driver decision diff (recorded; anvil opt-in) `tests/standalone_parity/dual_driver_gate.py` diffs the Python driver's and the Rust driver's decision streams against the recorded fixture `tests/standalone_parity/fixtures/dual_driver_decisions.json`, modulo the fixture's `permitted_divergence` list (currently `graph.candidate_tokens`: the Rust boot applies the 15-token ETH-mainnet discovery allowlist while the Python probe reads the unfiltered graph — the documented row-13 split). The pytest half is `test_settlement_bot_dual_driver_gate.py`. Live mode (`--live`) requires `DEGENBOT_DUAL_DRIVER_GATE=1` + `DEGENBOT_FORK_RPC` (+ `DEGENBOT_FORK_BLOCK`): it starts `anvil --fork-url ... --fork-block-number ...`, runs both drivers dry-run against the pinned fork, and reads the per-batch decision streams named by `DEGENBOT_DECISION_STREAM` (JSONL `{block, path_id, decision}`), which are **not emitted by either driver yet** — so live mode fails loudly on a missing stream rather than passing silently. `--record` regenerates the recorded fixture from the offline probes (no RPC). ### Invocation - `just test-settlement-parity` — Rust boot gate + pytest gates + recorded diff. - `uv run pytest tests/standalone_parity -q` — the standalone-parity axis. - `DEGENBOT_DUAL_DRIVER_GATE=1 DEGENBOT_FORK_RPC= DEGENBOT_FORK_BLOCK= uv run python tests/standalone_parity/dual_driver_gate.py --live` - `uv run python tests/standalone_parity/dual_driver_gate.py --record` ## Rust-ownership sweep (RSP-9 / ergo `IUGFLH`) The horizontal census sibling to the vertical RSP-2..RSP-8 slices. Every Python-owned driver surface is classified as one of: - **LIFT** — the core owns it once; both the pure-Rust and the Python driver call in through the same seam. - **KEEP-DRIVER** — it must remain host-side (asyncio loop ownership, SIGINT policy, OS/env cascade, display rendering). - **SPLIT** — one named seam; the mechanism/core fact lifts, the policy or host binding stays. `(landed)` marks a LIFT whose core implementation already exists (ADR-050 / ergo `5XOGRK` plus the driver-side `XFEJUG` / `L4E7RI` / `KPLWUM` slices). `(pending)` marks a LIFT decided here whose core work is not yet landed; see [Lift follow-ups](#lift-follow-ups). | # | Swept item (source) | Decision | Rationale | Consumers affected | |---|---|---|---|---| | S1 | `BotRunner._Phase` FSM (`New → Started → Running → Closed`) + `start()`/`run()` attach-consumer-before-`resume` sequencing (`runner/bot_runner.py`) | LIFT (landed) | ADR-050 D2/D7 moved the sequencing contract into `degenbot_bot::arb_engine::EngineDriver`; `BotRunner._Phase` is now a thin cockpit wrapper (config/SIGINT/trim policy) over the Rust-owned ritual, not a second implementation. | Python `BotRunner`; `rust/examples/settlement_bot`; ADR-050 | | S2 | `EngineRegistry.start()` pre-pump ritual (S-read → subscribe → verify-config, stops pre-`resume`) (`arbitrage/engine_registry.py`) | LIFT (landed, `5XOGRK`) | ADR-050 D2: `EngineDriver::start` owns subscribe + verify-config; ledger rows 6–8. `EngineRegistry.start` is now driver-side policy over the same ritual. | `BotRunner.start`; `EngineDriver` | | S3 | Startup backfill/resume ordering (auto-backfill S+1..W, single result-batch gate) | LIFT (landed, `5XOGRK`) | ADR-050 D2/D6: `EngineDriver::resume` awaits `BlockPump::backfill_with_drain` then spawns the live loop; the consumer attaches the receiver between `start` and `resume`. | `BotRunner.run`; `consume.rs` | | S4 | Address→`pool_id` key maps (`_v2_keys`/`_v3_keys`/`_v4_keys`, `knows_pool`) (`engine_registry.py`) | LIFT (landed, `ZTEUTA`) | `BotState::pool_id_for_identity` answers a pool IDENTITY (family + address, or the V4 `PoolManager`+`pool_id` pair) from the registration tables it already owns, and `EngineDriver::pool_id_for_identity` / `PyArbEngine.pool_id_for_pool` / `pool_id_for_v4_pool` expose it. The Python maps are deleted; `EngineRegistry` resolves hop keys by asking, and `knows_pool`/`knows_v4_pool` are the same question. No second pool-id map exists in Python. | `EngineRegistry`; `build_paths`; dispatch/encode path | | S5 | `VerifyClaims` at-most-once policy + claim tables (`arbitrage/_claims.py`; `claims.rs`) | LIFT (landed, `ZTEUTA`) | One owner: `degenbot_bot::bot_core::verify_claims::VerifyClaims` (claim-if-absent / wait-if-present / identity-checked release / abandon-reclaims), entered by `EngineDriver::run_v3/v4_registration_lifecycle` and therefore shared by the async driver, the blocking seat twins, and every `PyO3` caller of one session. Both twins are deleted (`arbitrage/_claims.py`, the example's `claims.rs`); the wake is a `watch` channel, so a peer cannot miss a settlement and a cancelled leader releases its window instead of stranding waiters. | `EngineRegistry`; `build_paths`; `EngineDriver` | | S6 | `register_path` pre-checks: pool-registered guard vs `path_predicate.evaluate` | SPLIT | The guard is LIFT-landed — the core's `register_path` rejects a `pool_id` not in the `BotState` (`register_path_rejects_pool_id_not_in_bot`), so the Python key-map `KeyError` path can retire. `path_predicate` is deployment policy (ADR-006 D7KMQO) and stays KEEP-DRIVER. | `EngineRegistry.register_path`; `_registration_unit` | | S7 | Verification retry dance (bounded retry-with-backoff loop) (`arbitrage/verification_retry.py`; `retry.rs`) | LIFT (landed) | `degenbot_bot::bot_core::verification_retry::retry_verification_call` owns the dance beside `VerifyError`, classifying transient `Rpc`/`Provider` against fatal `Snapshot`; `EngineDriver::run_v3/v4_registration_lifecycle_with_retry` (+ `_sync`, exposed over the verify-lifecycle FFI) wrap the at-most-once lifecycle with the injected policy. Both twins are deleted; the Python shell keeps only the FFI `RetryPolicy` value and the forward. | `build_paths`; `EngineDriver`; `arbitrage/engine_registry.py` | | S8 | `VERIFICATION_RETRY_*` knob values | KEEP-DRIVER | Deployment tuning (attempts/backoff/jitter); each driver parses its own env and injects the value into the lifted dance (S7). | `ArbitrageConfig`; example config | | S9 | Sim fan-out bounded-concurrency mechanism (`_sim_submit.py`; core `sim_pipeline`) | LIFT (landed, **S9/S11**) | Landed as the whole pipeline in `degenbot-submission::sim_pipeline` — **a revision** of this row's original `degenbot-workers` crate assignment: the bound, the ordered lane, and the loud-abort contract only mean anything together, so splitting them leaves two shallow halves every driver re-composes. `degenbot-workers` contributes only its sizing authority: the driver injects the derived cap as a plain `usize` (the value parsing stays driver-side, S10). | `degenbot-submission::sim_pipeline`; Python `_sim_submit`; example | | S10 | Sim fan-out policy numbers (`max_simulate_concurrent=50`, `DEGENBOT_SIM_PIPELINE_CONCURRENCY`) | KEEP-DRIVER | Config knobs; the fleet sizes its seats from typed config and drivers may pass a cap. | `ArbitrageConfig`; `SimSubmitPipeline` | | S11 | Ordered single submitter (FIFO fan-in; one nonce fetch per submit) (`_sim_submit.py`; core `sim_pipeline`) | LIFT (landed, **S9/S11**) | Same landed module as S9: the ordered submit lane ships beside the bound because they are one mechanism. Submission order = nonce order; one nonce fetch per submit at the moment of submit, byte-identical to the serialized loop the twins replaced. | `degenbot-submission::sim_pipeline`; Python `_sim_submit` | | S12 | Registration build-refusal taxonomy (`RegistrationOutcome`, `BuildRefusal`, `classify_build_refusal`) (`_registration_ledger.py`; `ledger.rs`) | LIFT (landed, `ZTEUTA`) | `degenbot_bot::bot_core::registration_ledger` now owns the closed vocabulary, the four memos, and the typed classification, on the `degenbot-decoders::revert::classify_revert` precedent. The example's `ledger.rs` is deleted (the example reads the core type); Python's `_registration_ledger.py` is a thin adapter that BUILDS its label enum from the core's exported tag list and maps Python exception TYPES onto the core's failure kinds — the taxonomy itself is parity-pinned on both sides. | `build_paths`; `degenbot::bot::bot_core::registration_ledger` | | S13 | Registration memos (`_registered_paths`/`_verified_pools`/`_unregistrable_pools`/`_rejected_paths`) + progress rendering | KEEP-DRIVER | Bookkeeping and display layered over core facts, shaped per pipeline instance. | `PathRegistrationPipeline`; `_render` | | S14 | `_session_watch` verdicts (`SessionEndVerdict`, watch-set, ranking, teardown) | SPLIT (detection landed) | Detection LIFTED to `degenbot_bot::arb_engine::session_end` — home `degenbot-bot`, beside `EngineDriver::wait_session_end` (the typed detection fact), since `degenbot-workers`' `FleetPosture` is the worker-lane fault vocabulary, a different fact set. The module owns the typed `SessionEndCause` (`PumpFinished`/`StallWatchdogTripped`) delivered once via `SessionEndFacts`, plus the lifted `Heartbeat`/`stalled`/`stall_watchdog` and `EngineDriver::wait_session_end`. Verdict ranking, consumer-cancel ordering, and SIGINT-adjacent teardown stay KEEP-DRIVER. | `_session_watch`; `KPLWUM`; `session_end.rs` | | S15 | Config cascades (`ArbitrageConfig.build`, `resolve_rpc_uris`, DB path) | REACHABLE | ADR-062 D1/D4/D7 assigns Rust the shared four-layer contract: `degenbot-config` owns the node (`resolve_node_request_uri` / `resolve_node_subscription_uri`), chain-id (`resolve_chain_id`), and database (`resolve_database_path`) cascades over one `BotConfigLoader` load, and the pure-Rust example consumes them. Only the operator/executor tunables stay driver-side (ledger row 2). | `runner/config.py`; `BotRunner`; example | | S16 | Process diagnostics (GIL probe, tracemalloc, faulthandler, `/proc` mem sampler) | KEEP-DRIVER | Python-interpreter-specific by construction (audit ledger DEPARTURE row 21); the Rust driver substitutes tracing-native equivalents. | example startup | | S17 | SIGINT binding + shutdown ordering | SPLIT | The stop-before-cancel ordering contract is LIFT-landed (ADR-050 D6: `EngineDriver::stop` closes the channels before the consumer cancels); the signal-handler binding is process/OS policy and stays KEEP-DRIVER. | `BotRunner`; `EngineDriver` | | S18 | Result-batch consumption loop + `BlockClock` + end-of-stream (`_consume.py`; `consume.rs`) | SPLIT | The receiver contract (hand out once; close on `stop` so `recv()` sees end-of-stream exactly once) is LIFT-landed via `EngineDriver::take_result_receiver` (ADR-050 D3/D6). The per-block dispatch loop + clock stay driver. | `consume_result_batches`; `consume.rs` | | S19 | Pool build + registration lifecycle ordering (`_registration_unit`) | SPLIT | Builds and the ADR-022 verify choreography are core-owned/reachable, and the sync lifecycles sit on `EngineDriver`; pipeline orchestration, retry-policy injection, and the memo policy stay driver. | `PathRegistrationPipeline`; `EngineDriver::run_v3/v4_registration_lifecycle_sync` | | S20 | Nonce expiry accounting (`blocks_before_nonce_expires` window) | KEEP-DRIVER (mechanism landed) | The window accounting is already core-owned (`degenbot_submission::monitor_pending_transaction`); only the block-window value is config. Consistency correction to any reading of ledger row 18 as driver-owned. | `degenbot-submission`; `submission.rs` | | S21 | Operator Unix-socket channel (`add_path`/`discover`/`fleet_posture`) | KEEP-DRIVER | Wire protocol + host deployment surface (ledger row 20); the underlying `degenbot-workers` posture API is already reachable. | `operator_channel.rs`; `BotRunner.enqueue_path`/`trigger_discovery` | | S22 | Dispatch selection/encoding policy (candidate shaping, thin-margin, suppression) | KEEP-DRIVER | The sim/submit arithmetic and taxonomy leaves are core-owned; only candidate-list shaping + display rendering remain (ledger row 15). | `_dispatch`; `dispatch.rs` | | S23 | Pool-cache trim / `release_python_state` | KEEP-DRIVER | Python-object-lifetime concern with no Rust counterpart (ADR-050 D8). | `BotRunner._trim_python_state` | | S24 | DB snapshot load + V3 tracker pre-population (`get_snapshots`) | SPLIT | The engine's DB snapshot load is core-owned/landed (`Bot::load_snapshot_from_db`, ledger row 5); the V3 `UniswapV3PoolTracker` pre-population is Python-construction scaffolding and stays driver. | `get_snapshots`; `ConstructionContext` | ### Lift follow-ups The S14 detection LIFT has landed; the ranking half stays driver-owned. S4, S5, S7, S9, S11, S12, and S14 have since landed. - **S4 key maps** → landed (`ZTEUTA`): `BotState::pool_id_for_identity` + `EngineDriver::pool_id_for_identity`; the Python mirrors are deleted. - **S5 `VerifyClaims`** → landed (`ZTEUTA`): `bot_core::verify_claims::VerifyClaims`, entered by the registration lifecycle; both twins deleted. - **S7 retry dance** → landed: `bot_core::verification_retry::retry_verification_call` owns the dance beside `VerifyError`; both twins deleted. - **S9/S11 sim fan-out + ordered submitter** → landed (`L4E7RI`): `degenbot-submission::sim_pipeline` owns the whole pipeline (crate-assignment revision from the original `degenbot-workers` row); `degenbot-workers` supplies only the injected cap value. - **S12 registration taxonomy** → landed (`ZTEUTA`): `bot_core::registration_ledger`; the example's `ledger.rs` is deleted and Python's ledger is a thin adapter. - **S14 session-end detection** → landed (`KPLWUM`): `degenbot_bot::arb_engine::session_end` owns `SessionEndCause` + `SessionEndFacts` + `Heartbeat`/`stall_watchdog`; both drivers read the core fact and keep their own ranking. ### RSP-1 ledger designation deltas The sweep refines three RSP-1 rows; the rest stand. No row remains `BLOCKED` after its lift landed (all lift-landed rows above cite their ADR-050 / task evidence). | Ledger row | Before | After | |---|---|---| | 9 (verify lifecycles / claim TOCTOU) | `REACHABLE` driver-side | `REACHABLE`; **S4/S5/S12 lifted** (`ZTEUTA`), **S7 lifted** | | 16 (sim fan-out + ordered submitter) | `DRIVER-POLICY` | **LIFT landed** (`degenbot-submission::sim_pipeline`; **S9/S11**); cap value stays driver-side | | 19 (session watch verdicts) | `DRIVER-POLICY` | **LIFT landed (detection; S14)**; ranking stays `KEEP-DRIVER` | Supersedure: the two recorded "stays-python" statements that this sweep reverses in part are (a) `runner/bot_runner.py`'s module docstring, which now carries an ADR-050 pointer beside the doctrine, and (b) the epic `5TBT7L` Q2b crate-private-engine note in `CONTEXT.md` ("Engine seam deepening"), which now carries a one-line ADR-050 supersedure. The `pub(crate)` one-door invariant itself stands — ADR-050 adds the `EngineDriver` *driver* seam above `EngineStages`, not a second engine door. ## Launcher consolidation (RSP-16, ergo `V6SUQO`) `./run_bot.sh` is the single launcher for both drivers: ``` ./run_bot.sh [--python|--rust] [start|stop|status|foreground|print-cmd] [-- args...] ``` - `--python` (the default, and the no-flag behavior) runs `uv run python examples/eth_settlement_arbitrage_v2_v3_v4_rust.py` with the five documented exports — byte-identical to the pre-consolidation launcher. - `--rust` runs `rust/target//degenbot-settlement-bot-example` (package `degenbot-settlement-bot-example`, the `cargo add degenbot` consumer), built on demand from a cheap staleness probe; `cargo build -p degenbot-settlement-bot-example` owns the real incremental work. `RUST_PROFILE` defaults to `release` and accepts `dev` for the workspace `opt-level = 1` development profile. - `print-cmd` is the CI-verifiable surface: the resolved driver, the full command array (passthrough included), the effective `RUST_PROFILE`, and every export, printed without building or launching (rc 0). - `stop`/`status` cover both driver process names (the Python example script and the Rust binary) in addition to the pidfile, which records the real driver pid whichever driver was started. - `--` ends launcher parsing; the remaining tokens are appended verbatim to the driver argv. The launcher never implies `--live`. Two recorded divergences between the drivers (deliberate, not defects): 1. **Run-length default.** The Python driver's live arm runs until SIGINT. The Rust example's live arm is gated on `SMOKE_RPC_URL` and only bounded by the optional `DEGENBOT_SMOKE_MAX_SECS` window; without `SMOKE_RPC_URL` it prints the offline parity ledger and exits (the CI-safe posture). The launcher binds `SMOKE_RPC_URL` to the resolved ws cascade for `--rust`, so both drivers arm the same node; the bounded window remains a Rust-only knob. 2. **Telemetry arming.** The Python driver arms OTLP when `DEGENBOT_OTEL=1` (the launcher's default), with endpoint precedence `OTEL_EXPORTER_OTLP_ENDPOINT` env > typed `telemetry` config > exporter default (`http://localhost:4318`). The Rust example's telemetry boot (`rust/examples/settlement_bot/src/telemetry.rs`) requires **both** a truthy `DEGENBOT_OTEL` and an explicitly configured OTLP endpoint — an absent endpoint is a quiet no-op, never the localhost default. A `--rust` run therefore needs the OTLP endpoint exported to emit spans, even though the launcher exports `DEGENBOT_OTEL=1` for both drivers. ## Guardrails - ADR-052 D6/D7 retirement is complete: the migration tree, session manager, ORM models, and SQLAlchemy dependency are gone from the Python runtime. The Rust database owner is authoritative; Python consumers use the stable `degenbot.db` mirror. This parity ledger's Rust-side DB work remains **read-only**. - Strategy semantics stay the shared contract: `classify_revert` labels and the 7-call bundle are compared via fixtures (per-leaf dual-driver parity tests exist, see `tests/standalone_parity/`); the running gate compares *decisions*, not log bytes.