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_<id> > [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 > <state_home>/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)¶

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=<id> status=<status> note=<note> lines, the parity-ledger snapshot-seed-block S=<None|u64> line, the [boot] discovery enumerated <n> candidate pools line, the [g3] graph built: <n> nodes, <n> candidate tokens, <n> 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=<rpc> DEGENBOT_FORK_BLOCK=<n> 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.

#

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/<RUST_PROFILE>/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.