ADR-049: The engine’s interface is one stage seam — the engine recedes to composition machinery¶
Status: accepted (2026-09-14; architecture-review #11 candidate 2, grilling decisions Q1–Q9 all accepted; ergo epic 5TBT7L, tasks 2NLZE3 / 3WI4EO / RS64JJ / 5AFSXM / RPEBMX / MHLURV / XURYVA; commits c43941f34..1e35449b8). The one-impl-block census gate lands red at c43941f34 (T1) and goes green, wired into the lint chain, at 1e35449b8 (T6).
Context¶
Architecture review #11, candidate 2, found the arb engine with two doors and no single file that showed the interface:
The Python driver reached the raw engine through
Arc<parking_lot::Mutex<ArbitrageEngine>>— its PyO3 wrapper held the engine value and called inherent engine members directly.The block pump crossed the
StageHandlers/PumpControltraits (ADR-046) — a different interface again — and the engine’s ownEngineStagesimplementation sat between the two.
Behind both doors the engine’s inherent surface had smeared into twelve impl ArbitrageEngine blocks across eight files: the retune packing in mod.rs, path lifecycle in lifecycle.rs, delivery in delivery_policy.rs, solve/drain/intake across event_routing.rs and engine_stages.rs, diagnostics, the block cursor, the seat host. The shape made the interface undiscoverable — a caller had to know which themed file a method happened to live in — and concentrated churn on a shared grab file (engine_stages.rs / mod.rs). The inherent twins (one operation existing as both an engine method and a machine method or stage hook) had already produced real drift: ADR-046’s on_pump_ended twin silently skipped the loud-close log the delivery-liveness contract requires.
The vocabulary test that resolves the mess: the engine is not the interface. The stage surface is.
Decision¶
D1 — ArbitrageEngine is pub(crate) machinery: exactly one inherent impl block¶
ArbitrageEngine becomes pub(crate); it never leaves degenbot-bot. Its entire inherent surface is ONE impl ArbitrageEngine block in arb_engine/mod.rs, holding only real composition work: the constructors (new / with_core / with_core_cfg), the apply_retune body, the phase state, the core() handoff, path_pools(), the require_phase* gates, and the cfg(test) pool-registration helpers.
The standing invariant is the census gate just check-engine-impl-blocks, wired into lint-rust-check, the prek pre-commit tier, and CI. It asserts exactly ONE impl ArbitrageEngine { block, in arb_engine/mod.rs; a failure prints the per-file census. The same gate runs the symmetric degenbot-python symbolic isolation census: ArbitrageEngine appears in degenbot-python/src exactly once — the pyclass compat string name = "ArbitrageEngine",. That string is a deliberate Python-API compatibility exemption (the wrapper type’s real name is PyArbEngine); the rejected escaped-name hack is recorded below.
D2 — EngineStages is the ONE external seam¶
Every external consumer crosses the stage surface:
the block pump, through its
StageHandlers(eight product hooks) andPumpControl(seven driver pokes) trait implementations onEngineStages(ADR-046);the PyO3 driver:
PyArbEngineholdsArc<EngineStages>, and observation, control, registration, and thecore()handoff all re-source onto it. Construction crosses viaEngineStages::with_core_cfg/with_core— the driver never names the engine type;the umbrella
standalone_consumerexample, the Rust consumer’s entry, uses the same seam.
Registration, observation, solve control, and the core handoff have no second path. The seam is the interface; the engine recedes to composition machinery.
D3 — operator re-tuning is a typed EngineRetune value¶
The config-derived operating knobs — event-buffer max age (expiry enable), the admission trio (target depth / retention blocks / enable), path cap, the profit window, force-deferred — are one typed value, arb_engine/retune.rs::EngineRetune. It is packed ONCE from the caller’s BotConfig (from_config; the KAHU5W/J4HN66 construction-stance discipline) and applied through the ONE knob-write body ArbitrageEngine::apply_retune: once at construction and per runtime operator retune via EngineStages::apply_retune. It is the engine’s twin of the fleet’s centralized posture feeders + wake-on-retune. A channel install (set_result_channel) and set_inline_simulator are wiring, not a retune, and stay discrete.
Naming discipline: not “stance” (the fleet-migration stance of ADR-042, and the KAHU5W per-construction construction-stance values) and not “posture” (the fleet’s cordon concept) — see CONTEXT.md’s “Engine retune” entry.
D4 — machine-direct free functions are the internal style¶
The retired shape is the thematic inherent impl block — the “smear.” Members that are not composition work no longer live as inherent ArbitrageEngine methods scattered by theme. They are free functions over &mut ArbitrageEngine / &ArbitrageEngine, owned by the machine they concern (the cycle, the registry, delivery, the cursor) and called from EngineStages and the tests. EngineStages::run_solve_cycle drives the machine directly; the delivery_policy / lifecycle members became machine-direct free functions; event_routing.rs is deleted wholesale.
The point is structural, not aesthetic: the free-function style is enforced by the one-impl-block gate, so a member that is not composition work has nowhere to hide as an inherent engine method. The interface is one file; the machines own their bodies.
Test-harness discipline. White-box tests reach the machines and free functions directly; they never re-add an inherent engine member. The T4 shims (run_test_cycle, merge_detached_for_test, finalize_for_test, process_updates) and the YI5NGB boot-stamp probe live in the cfg(test) arb_engine::test_harness module as free functions over the engine value.
Retired shape¶
The two-door engine is retired: a pub ArbitrageEngine the driver can hold and call directly, alongside the EngineStages seam. The compile is the guard — the engine type is pub(crate) and the census forbids any inherent member outside the one composition block. Inherent engine twins of stage hooks and pump pokes (ADR-046) retire with it.
Considered options rejected¶
(a) Keep the thematic inherent impl blocks (the smear). Rejected: it is the one-layer-out rebuild — the interface stays undiscoverable (a caller must know the theme file), churn stays concentrated on a shared grab file, and the gate has nothing to assert. The free-function style (D4) plus the census gate is the structural fix.
(b) Two-tier
pubengine — keep registration + core directly drivable on apubengine while pushing everything else through the seam. Rejected: a second door invites the next direct grab; “there is no second door” is the whole point. The driver’s registration path goes throughEngineStagestoday.(c)
solve_dirtymoving intoSolveCycleinstead of onto the stage surface. Rejected: it smuggles a stance-gated core write (the pre-cycle buffered-event expiry) into the cycle machine’s interface. That expiry belongs under the stage hook’s held engine lock (EngineStages::run_solve_cycle); the cycle stays a pure per-epoch machine.The escaped pyclass-name hack. A draft renamed the wrapper’s Python symbol via an escaped literal to dodge the census. Rejected in orchestrator review: the plain
name = "ArbitrageEngine",compat string is the honest API-compat exemption; the census exempts exactly that one symbol rather than obscuring it.
Consequences¶
The driver’s Rust signatures change shape (
Arc<EngineStages>instead of the raw engine mutex) but the Python-visible API is byte-identical: the class name staysArbitrageEngine, and no Python method signature moved.Rust consumers (
standalone_consumer) construct throughEngineStages::with_core_cfgand drive the same seam.The census is a standing invariant: a future inherent member outside
mod.rsfailslint-rust-check/ prek / CI until it is either composition work (moved tomod.rs) or a machine-direct free function.Hard cutover, no feature flag (AGENTS.md); the
arb_enginesuite, kept green every slice, is the characterization net.ADR-043 telemetry labels are byte-identical; the golden snapshots passed unchanged across T4.