Active Block Window — pump-owned block promotion (QMSTSV)¶
Status: decision (confirmed). Resolves the blocked decision fork in
MQIZ5M (Option B) and the architectural question in QMSTSV (the pump owns a
pump-promoted active block window). Maintainer confirmed: newHead is the
promotion signal; active_block = max(newHead, drained_block). This document
records the settled design so the implementation can be sliced behind the
already-landed tripwires.
TL;DR (the decision)¶
Yes — block promotion should be a pump-owned lifecycle transition, and it is
exactly MQIZ5M Option B. The pump owns a single promoted active_block
that BOTH the solver and the sim derive from, replacing the three scattered
max(_, pool_state_head) re-derivations. Promotion is newHead-driven:
newHeads(N) promotes the active block to N. Because a push WS cannot prove
“the last event for N arrived”, the new block is the promote signal.
active_block = max(newHead_promoted, drained_block) — the state clock
(drained_block) only catches the pump up on a header stall so state never
outruns the solve anchor. The promote gate and the publish gate are distinct:
active_block advances optimistically on newHead, while the final publish stays
log-closed (quiesce-gated) and re-solves on stragglers. The promoted object is
a numeric label + a lazily-built revm view — never a second authoritative
state. BotState remains the single source of truth; the EVM window is a
derived projection.
Context (traced)¶
The pump advances two independent clocks:
Header clock —
current_blockinrun_with_stream(block_pump.rs), advanced bynewHeads+logsevents.State clock —
pool_state_head()(bot_core/mod.rs:630), the maxupdate_blockacross all pools. No single owner “decides” it; it is a derived maximum.
The settle drain (the resolve→solve stage hooks on the one StageHandlers seam
since the MROOY7 cutover; formerly SolveCoordinator::on_drain) resolves its
anchor from the driver’s current_block — the solver then re-anchors to the
state clock (SolveAnchor::resolve: max(block, pool_state_head()),
bot_core/solve_anchor.rs) because a hop’s update_block can exceed the
lagging header clock:
solve_cycle.rs—solve_block = max(block_number, pool_state_head())block_pump.rs:609— verifieranchor = max(block, pool_state_head())dispatch.rs:593(degenbot-arbitrage, the Python-driven sim) —sim_block = max(candidate.solve_block)
So “solve for a block”, “verify at a block”, and “simulate at a block” are
three independent derivations of the same quantity, welded together by a
max() that only holds because “an unchanged pool has byte-identical state from
its update_block to head” — a coincidence of the data, not an architectural
guarantee. Every time a maintainer must remember “and the sim must anchor to
head, not the pump clock”, they are reconstructing an invariant the structure
should already enforce. MQIZ5M’s +1-wei / IIA class is the failure of this
invariant under a header stall (pools advanced 12 blocks past current_block).
Promotion semantics (newHead-driven, publish log-closed)¶
The active block window has two gates that must not be conflated:
Promote gate (
active_block) — newHead-driven.newHeads(N)promotes the active block to N. This is the solve+sim target. It advances eagerly on the header notification because the last-event arrival is unknowable via a push WS (the same principle ADR-008 D1 states: “a push WS cannot prove no more logs for N”).Publish gate (
drained_block/ cursor) — log-closed. Only the firstremoved:falselog for N+1 (the D1 tombstone) closes block N for state-completeness; onlyDrainedfeedslast_processed_block(). The final publish is quiesce-gated (ADR-008 D2). A newHead-promoted solve duringLogsArrivingis fallible: straggler events trigger a re-solve, and the sim discards intermediate results. This is ADR-008’s existing eager solve-on-LogsArrivingsemantics, not new machinery.
The distinction matters: newHead is a promote/liveness signal, never a completeness signal. That is the full reason ADR-008 demoted the header for state-completeness — and it still holds here at the publish layer.
The stall case — the ONE pump-owned max¶
active_block = max(newHead_promoted, drained_block). newHead is the driving
signal in normal live operation. drained_block (the state clock) only catches
the pump up when a header stall lets ordered backfill advance state past the
stalled newHead-driven block (MQIZ5M’s exact 12-block-ahead scenario). This is a
single central max at the promote point — not three scattered
max(_, pool_state_head) re-anchors — and it makes pool_state_head ≤ active_block an invariant (backfill applies block-by-block in order), so
future-price solves are impossible by construction.
Why pump-owned promotion is right¶
The pump is the only component that knows when a block is sealed — it owns
the ADR-008 lifecycle (Observed → LogsArriving → LogsApplied → Drained), and
only Drained feeds last_processed_block(). Liveness (newHead promotion) and
state-completeness (drain) are both pump-owned concerns. Block release is
therefore a job only the pump can do, and it is currently being done implicitly
and redundantly by three max()s and the header-fed current_block variable.
Promoting once at the drain/settle point:
makes solve, verify, and sim take the same object instead of three re-derivations that must be manually kept in agreement;
collapses
dispatch.rs:593’smax(candidate.solve_block)— every candidate already solved at the promoted block, so the batch max is the promoted block;single point to reason about what block we are solving for, testable in isolation.
Resolves MQIZ5M’s A/B/C fork → B¶
Option A (clamp backfill ≤
current_block) defers state but does not eliminate the A/B problem: during a long header gap it stalls the pump, and the skipped mined blocks cannot be backrun anyway, so the deferral is wasted work. It is also a backfill-behavior change that couples a WS-liveness failure into pool-state admission.Option B (promote
current_blockto the backfilled head) is the correct semantics: you cannot backrun a block you have passed, and state that has advanced to head is the chain’s current state — so solving + simming at head is correct, not a “future price”. Realization (confirmed): newHead is the driving promote signal (newHeads(N)→active_block = N), andactive_block = max(newHead_promoted, drained_block)so a header stall never lets the state clock outrun the solve anchor. Active-block promotion is eager/optimistic; publish stays log-closed (quiesce-gated).Option C (per-block snapshots) is exact but expensive and contradicts the I/O-free / single-source-of-truth architecture.
Recommendation: Option B, realized as the pump-promoted active block window.
The residual race — promotion alone does NOT fix it¶
While tracing, I confirmed that the deeper trigger of the MQIZ5M IIA class
is a mid-solve state mutation: pool_state_head() is a moving target, and
backfill can advance a pool between the pump reading the head and the solver
finishing. Promotion removes the labeling inconsistency (solve/sim both at one
promoted block) but cannot freeze BotState mid-solve.
That residual is handled by the already-landed tripwires, which must be kept as the fail-fast race net:
U6RNHH— solve-stage future-price tripwire (is_future_valuerejects a hop whoseupdate_blockexceeds the solve anchor).TQ43TU— solve-time staleness gate (defers a hop whose price clock trails far behind).
Under a correct promotion these tripwires become near-impossible (the promoted
block is max(update_block), so no hop can exceed it by construction) but they
remain the guard for the mid-solve-advance window. Do not remove them with the
promotion; the promotion makes them the exception, not the rule.
Concrete implementation touch-points (for the slice)¶
block_pump.rs— introduce the pump-ownedactive_block. newHead event promotes it (active_block = N); the state clock catches it up on a stall (active_block = max(active_block, drained_block)). Use the promoted block foron_drain/solve/verify in place of the raw header-fedcurrent_blockvariable (which is demoted to a liveness/ordering probe only) and the one-shotanchorat:609.solve_cycle.rs— consume the promotedactive_block(threaded as the solve anchor) instead of re-derivingmax(block_number, pool_state_head()).solver_state_verifier.rs:297— anchor to the promotedactive_block.dispatch.rs:593(degenbot-arbitrage) —sim_blockbecomes the promoted block threaded throughcandidate.solve_block; the per-batchmaxcollapses (keepmaxas a defensive invariant assertion, not a re-anchor).Thread the promoted block through the existing solve_block → DispatchCandidate → dispatch path (the FFI seam is unchanged; this is a value, not a type).
Publish path unchanged in shape: keep the quiesce gate (D2) + D1 log tombstone for
drained_block; straggler events re-solve, sim discards intermediates (existing ADR-008 semantics).
Validation gates¶
RED unit test (the acceptance gate): pump promotes
active_blockon a newHead event;on_drain/solve/verify/sim all receive the same promoted block == latest newHeads; andpool_state_head ≤ active_blockholds throughout.Stall test: drive a backfill-ahead-of-header desync and assert
active_blockis caught up to the drained/state clock by the single pump-ownedmax(never solving below the state).Publish test: a newHead-promoted solve during
LogsArrivingdoes NOT publish until quiesced; a straggler re-solves and the intermediate result is discarded.Keep
U6RNHH/TQ43TUregression nets green (they must still fire on a mid-solve advance in the absence of promotion).Per-
MQIZ5M: a live-node re-run proving no future-price solve during a backlog after the promotion lands.
Non-goals¶
Not a change to the FFI seam or a new Python mirror.
Not the
UO3JM4take-overdraw discriminator (that was resolved separately; its residual points at this same stale-state class, so it is the operational payoff of landing this).No
Option A/Cimplementation, no per-block snapshots, no elimination of the tripwires.