The block-epoch pipeline¶
Ergo epic MROOY7 · decision record: ADR-041 · data-plane spike outcome: the stateview feasibility doc (spike KWKEVV; canonical copy on branch pi-fabric/stateview-spike)
Implemented (2026-09-07, ergo epic
MROOY7/PLRGIN). The tables below are the signed-off design the epic executed against; As-built at the end of this file maps the landed architecture onto the real types and files.
This is the working design reference for the block-epoch pipeline: how one block flows from logs to settled results through a single stage machine, over a single seam between the pump and the engine. Every implementation task of the epic builds against the stage table and seam retirement list below, which the user checkpoint has signed off; code lands against them as the epic’s tasks execute.
There are two orthogonal axes:
Runtime lifecycle — process-level phases: Boot → Subscribed → SnapshotLoaded → Resumed. It never interleaves with per-epoch work; see its table below.
The block-epoch stage machine — the per-epoch stages of one block, below.
The existing PumpPhase enum (formerly EnginePhase;
Created/Subscribed/SnapshotLoaded/Backfilled/Resumed) is remapped in
full onto axis 1: its role —
registration/snapshot/backfill ordering — is runtime-level, not per-epoch, so
it contributes no sub-state to the Solved/Simulated stage rows.
Epoch invariants¶
I1.
Epoch { block, seq }is the only block coordinate.BlockContextcarries it; every stage transition, decision, span, and metric records it. The anchor soup (solve/sim/verifier/backfill anchors,last_solved_block,last_drained_block,BlockMetadata) is deleted.I2.
seqis monotone non-decreasing; it bumps exactly once perRewind.blockmay regress on reorg;seqnever does.(block, seq)pairs compare byseqfirst, thenblock.I3. A
BlockContextwhose(block, seq)does not match the active epoch fails fast — mirroring the repository’s fail-loud posture (ADR-021): stale contexts are never silently applied.I4. Writers to
StateLock<RwLock<BotState>>exist only in the Streaming stage of the active epoch. Quiesced..Simulated take read guards (cheap-read, per the spike). Gated..Finalized commit only delivery/results surfaces, never pool state.I5. Exactly one
Publishper quiesce cycle; Published implies the block’s completeness verdict (tombstone-by-successor or settle) was obtained.I6.
Rewind{to_epoch}may originate from any stage; at most one rewind is in flight, and a second fails fast.I7. The delivery cutoff (glossary: Last complete block, on
BotState) is monotone and never reset by a resume (ADR-028 addendum (a)); it is mirrored from the Finalized stage’s tombstone verdict.
Stage table¶
Stage |
What happens |
Data-plane posture ( |
Emits / decides |
Sub-state absorbed (from the six machines) |
Retires |
|---|---|---|---|---|---|
Streaming |
Live WS log events + gap |
The only stage permitted to write |
Dirty pools into the epoch’s |
|
|
Quiesced |
All dispatched logs for the open block applied; completeness classified (tombstone vs settle; early-slice/debounce gates) |
Read guard |
|
|
inline quiesce/ |
Resolved |
The block’s |
Read guard (path index) |
The epoch’s affected-path set |
|
|
Solved |
Solver runs the affected paths |
Cheap-read: read guard through the solve — |
Solved candidates |
Solver dispatch |
|
Simulated |
In-process revm simulation — the sole executor (ADR-019) |
Read guard |
Derivation/profit facts per candidate (tri-state per ADR-030) |
simulation bookkeeping only ( |
the sim anchor of |
Gated |
Risk/size/profit rechecks on solved+simulated candidates |
None (pure evaluation) |
Deliver/reject verdicts per bucket (ADR-040) |
|
|
Published |
Winning bundle to execution; sinks subscribe here (delivery channel, submission, Python) |
None (results written to delivery/FFI surface, not pool state) |
|
|
|
Finalized |
Epoch closed: delivery cutoff stamped, results final |
Cutoff monotone on |
Epoch close |
|
cursor stamps in |
Rewind{to_epoch} |
Reorg unwind from any stage to a fresh epoch at an earlier block |
|
|
The pump’s reorg-episode and resume tracking ( |
the |
The pump’s (block_pump.rs) reorg-episode and resume tracking folds into
Rewind handling; its WS transport + watchdog machinery later moves to
degenbot-ingestion (epic task 5WTYYQ).
Runtime lifecycle (orthogonal axis)¶
Phase |
Meaning |
Stage-machine relation |
|---|---|---|
Boot |
Process init: config, telemetry, DB |
No stage machine exists |
Subscribed |
WS subscriptions + topic/address filters up (degenbot-ingestion) |
Streams feeding; no active epoch |
SnapshotLoaded |
Snapshot seed epoch |
Fresh stage machine; gap |
Resumed |
Live log flow enters Streaming |
The per-epoch stage cycle runs; delivery-cutoff monotony preserved across resumes |
EnginePhase is the in-code representation of this axis, remapped here from
any per-epoch reading; its registration/snapshot/backfill ordering role is
runtime-level, never per-epoch stage sub-state.
Epoch invariants at the stage boundaries¶
See invariants I1–I7 above; the spike-derived numbers binding the stage table
are: writers confined to Streaming (log-burst window p99 ≤ 100 ms);
state_lock_hold/state_lock_wait p99 ≤ 0.1 ms across 19.6 M
acquisitions on the still-contended pre-stage-machine architecture; rewind
bounds above.
Seam retirement list¶
The parallel accounting system is deleted, not wrapped (Q6: hard cutover):
DrainSink/Enginedual seam → oneStageHandlersseam. One trait; a future second engine implements it or there is no second engine.NoopStubEngineis the executable spec keeping the trait honest (see non-goals).DirtySets+EngineSubscriberclassification →EpochDelta. Log application records touched pools as a byproduct of dispatch; affected-path derivation reads the delta.EngineSubscribershrinks to liveness + notification only.SolveCoordinator/drain_lock/DispatchOwner/DrainerHealthdissolved. The stage machine owns the edge conditions; delivery and submission are sinks at the Published edge, not seams in front of the engine.Engine
Mutexoff the solve path. Stage separation makes the cheap-read on Quiesced..Simulated uncontended by construction. Registration/FFI locking viaStateLockremains (slow operator path, not the solve path).Anchor soup → a single
EpochonBlockContext. solve/sim/verifier/ backfill anchors,last_solved_block,last_drained_block, andBlockMetadatacollapse into oneEpochonBlockContext.
Non-goals¶
Multi-engine machinery. No registry, no routing, no second-engine configuration surface. Landmine guard:
NoopStubEngineimplementsStageHandlersalongside the real arb engine and is exercised in a scripted conformance harness (synthetic block stream driving the full lifecycle + a reorg + a backfill episode, asserting hook completeness, stage order, and epoch monotonicity — Q4). It is test-declared: a conformance harness, never runtime-selectable.Materialization / COW StateView machinery. Cheap-read won unambiguously (spike
KWKEVV); the alternative mechanisms’ costs are retained only asRewind-bounding data.Tripwire re-expansion. In-process desync detection retires (desync unrepresentable post stage-confinement); the upstream, Published-edge RPC-disagreement check stays and stays loud (ADR-021 posture).
Backwards compatibility of any retired surface (hard cutover, Q6). Pinned behavioral tests are ported; stale APIs do not get a parallel life.
Schema changes. ADR-010/011 Alembic ownership and the 0.7 kill list (see the repository
AGENTS.md) are untouched by this epic.
Migration order (mirror of the epic task graph)¶
# |
Ergo task |
Lands |
Stage coverage |
|---|---|---|---|
1 |
|
Typed |
(config axis; orthogonal) |
2 |
|
StateView spike — cheap-read for all families; Q3 gate closed |
data plane |
3 |
|
|
context for all stages |
4 |
|
|
Streaming → Resolved |
5 |
|
StateView data plane: write confinement + engine lock off the solve path (seam #4); reposition the ADR-021 tripwire |
Streaming ↔ Quiesced..Simulated; Published ( |
6 |
|
The |
all (trait shape) |
7 |
|
Unified stage machine: fold the six machines, |
all |
8 |
|
Per-stage OTel spans + metrics carrying epoch attributes |
observable across all |
9 |
|
Seam retirement: delete |
Published-edge + drive wiring |
10 |
|
Extract |
Subscribed + Published |
11 |
|
Regression: capture-replay sweep + live Jaeger soak A/B; flip ADR-041 to implemented; update |
proves all |
The order above is dependency-ordered per |
|||
( |
As-built (post-MROOY7)¶
Every type and file below is verified to exist in the merged tree (epic MROOY7
landed through PLRGIN; see ADR-041,
status implemented). Task ids referenced: typed config KAHU5W, data-plane
spike KWKEVV, epoch contexts T6IYKY, ledger LXDY4C, lock/confinement
2UVG3E, trait YM2FZR, machine fold 7NFYQW, stage telemetry BF43PM, seam
retirement SZJUKL, transport extraction 5WTYYQ, final integration PLRGIN.
Crate map¶
degenbot-ingestion — the pyo3-free WS transport (boundary contract: ingestion
emits, the runtime decides — the crate knows nothing about BotState or the stage
machine). Source: rust/crates/integrations/degenbot-ingestion/src/
ingestor.rs—WsIngestor: one WS connection;newHeads+ unfilteredlogsmerged into oneIngestEventstream (stream_select, fair interleave).subscribe_with_handshake→SubscribeBoundary— the MJXP5Z one-stream handshake (no drop+resubscribe; handshake-consumed logs are re-injected), with the DFQYM5 log-liveness boundary (LOG_CATCHUP_SETTLE_SECS= 15 settle window before the header-confirmed fallback). Gap backfill =fetch_logsovereth_getLogsinDEFAULT_BACKFILL_CHUNK_SIZE(2000-block) chunks;BACKFILL_TIMEOUT_SECS(60) is both the idle/degraded window and the handshake deadline.exact_relevant_indicesis the client-side exact-topic pre-filter the WS-completeness cross-check uses (the server-side OR-list over-matches on some nodes).events.rs—IngestEvent(BlockHeader|Pool);PoolEventcarries{ epoch, log_index, payload }so consumers order/drop without touching the payload.topics.rs—RELEVANT_TOPICS,is_relevant_log: the single Rust-side hot pre-filter (also the backfill filter’s OR-list source and the dispatcher’s defensive re-check).filter.rs—build_backfill_filter/backfill_filter(single-block shape).watchdog.rs—Watchdog: the transport liveness windows (HEADER_STALENESS_SECS= 30,LOG_SILENCE_SECS= 60,LOG_WAIT_MAX_AGE_SECS= 5 — the SONJQA span-force-close bound) + per-episodesilence_alarm_count. The decisions on those windows belong to the stage machine (watchdog_phase).examples/headless_boot.rs— the standalone no-Python boot smoke (just test-standaloneruns it).
degenbot-bot — the runtime (the stage machine, its driver, and the engine):
bot_core/stage_machine.rs—StageMachine(7NFYQW): the ONE pure, I/O-free machine over one block epoch — no provider, no timers, noInstant, no locks. The six retired machines (incl. the standaloneBlockClock,PumpFSM) are folded in as sub-state (hard cutover). EmitsHeaderDecision,LogDecision,StageDecision,CompletenessDecision; exposeswatchdog_phase(Healthy/HeaderStale/LogsSilent) — the phase space the dissolved no-progress accounting mapped onto. Invariants I1–I7 (above) are pinned by the ported tests.bot_core/epoch.rs—Epoch{ block, seq }+BlockContext(T6IYKY): the only block coordinate. The derivedOrdis(seq, block)— a rewind sorts ABOVE any earlier-generation epoch (a monotone cursor never regresses across a rewind even though the block moves down);ensure_currentfail-fasts stale contexts withStaleEpoch.bot_core/epoch_delta.rs—EpochDelta(LXDY4C): the per-epoch touched-pool ledger keyed byAffectedKey(degenbot-solvers), recorded as a byproduct ofBot::dispatch_log;take_keysis the drain’s atomic consumption; a rewind RELABELS the ledger (set_epoch) and retains keys (solve-cursor state, not block-window state).bot_core/stage_handlers.rs—StageHandlers(YM2FZR): the one engine seam, encoding the stage table as a required-hook trait (no default bodies: adding a hook without updating every implementer is a compile error) plus the exhaustiveStageorder table (legal_successors, sizedALL_STAGES).bot_core/block_pump.rs—BlockPump: the thin async driver. Work executes INLINE at the machine’s decision points (no FIFO); it feedsBot::dispatch_logper log, drives the stage hooks, and runs the driver-sidereorg_flying_staleI3 check at each work site.bot_core/log_dispatcher.rs—LogDispatcher: decoder registry +dispatch(decode → apply under a write guard → release → notifyPoolStateSubscribers; records into the epoch’sEpochDeltaas a byproduct). StrictDEGENBOT_WS_COMPLETENESSmode fails loudly on malformed-event drops. Span:degenbot.log.dispatch.bot_core/reorg_coordinator.rs—ReorgCoordinator: per-event journal rollback (idempotent + order-insensitive restore-before-block);NoStatePriorToBlock→ the pump shuts down gracefully (never a silent stale state). Rewind is a Bot concern, never a stage-hook seam.bot_core/solve_anchor.rs—SolveAnchor: the request block floored by the pool-state head, itself anEpoch(the head-floor desync rule from MQIZ5M/IIA/ 0x99ac8c).bot_core/state_lock.rs—StateLock: the diagnosticparking_lot::RwLockwrapper (Z4Z6VO) guardingBotState; hold-tracking forensics off by default (DEGENBOT_STATE_LOCK_DIAG), the blocked-wait warn threshold (500 ms) always on.bot_core/stage_telemetry.rs+bot_core/pump_telemetry.rs— BF43PM: onedegenbot.epoch.runROOT per block epoch (carryingepoch.block+epoch.seq) and onedegenbot.stage.<stage>span per transition (stage.from→stage.to,queue.age_us); open rows (streaming,rewind) are force-closed pastSTAGE_MAX_AGE_SECSbyforce_close_aged(the SONJQA/G3 law). Metric seriesdegenbot.stage.publish_cycle,degenbot.stage.rewind,degenbot.stage.rewind_durationare label-free (epoch context rides spans).arb_engine/engine_stages.rs—EngineStages(SZJUKL): the arb engine’sStageHandlersimplementation — the dissolved coordinator/fan-out/wrapper types collapsed into one. Owns the two genuine-async-boundary channels retained from ADR-006/027: the block clock (BlockClockPipe, header ticks, never queued behind solver work) and the result batch written at the Published edge.arb_engine/solve_cycle.rs— the detached solve cycle (unconditional since the WFF6MM cutover; theDEGENBOT_DETACHED_SOLVESstance retired with the in-cycle arm): the solve cycle enqueues and returns, collapsing the engine-Mutexhold on the solve path to enqueue-end.
degenbot-config — typed config (KAHU5W). BotConfig is declared exactly once
in schema::SCHEMA (one declaration ⇒ the typed field, the DEGENBOT_* env
mapping, the TOML path, and the generated docs/rust-config-keys.md); the loader
is fail-closed 12-factor (CLI > env > file > defaults) and the boot path installs
it in the process-wide holder every call site reads (bot_core/stance.rs,
stance::config()).
degenbot-core — block_clock_pipe.rs. BlockClockPipe / BlockNotification:
the shared, engine-neutral block-clock channel (ADR-027’s direct pipe, relocated
out of the retired coordinator). This live channel type is unrelated to the
retired BlockClock machine.
degenbot-python — the driver shell. The PyO3 layer subscribes like any other
sink: PyBot owns the pump lifecycle (PumpState, bot/pump.rs), the result-batch
consumer (bot/engine/result_channel.rs) and the header block_stream hang off
the Published/block-clock edges, and PySubscriberAdapter (bot/subscriber.rs)
bridges PoolStateSubscriber callbacks. No raw WS stream reaches Python.
The as-built epoch cycle¶
Subscribe —
WsIngestor::subscribe_with_handshake→ boundaryWfrom log-stream liveness (header-confirmed fallback past the settle window).Backfill —
[S+1..W]viafetch_logsapplied throughBotState::process_backfill_logs; the resume drops WS logs for blocks ≤W.Resume loop — per log:
StageMachine::observe_log→Bot::dispatch_log(decode → write under the Streaming-confined guard → release → ledger record → notify). Per header:observe_header(a header alone NEVER advances the cursor; the D1 tombstone-by-successor rule survives unchanged).Stages —
on_streaming_complete(quiesce + completeness classify + debounce/early-slice gates) →on_resolve(EpochDelta::take_keys→ affected paths) →on_solve→on_simulate(in-process revm, the sole executor) →on_gate(deliver/reject buckets, ADR-040) →on_publish(exactly one per quiesce cycle) →on_finalize(delivery cutoff stamped, monotone, I7).Published edge —
StageHandlers::on_publishis where every sink hangs: the delivery channel → Python’s result batch; the block-clock pipe → Python’s header clock; and the ADR-021 upstream RPC-disagreement verification (CompletenessDecision::Verify→assert_ws_block_completeinblock_pump.rs), which is the surviving kernel of that tripwire ADR — the in-process desync-detection half was retired because write confinement makes it unrepresentable.Rewind —
Rewind{to_epoch}may fire from any stage (I6, at most one in flight):Epoch::rewind_tobumpsseqexactly once,ReorgCoordinatorruns the per-pool journal restores, the ledger is relabeled, and stale contexts fail fast.
Cheap-read posture¶
Writers to StateLock<RwLock<BotState>> exist only in the Streaming stage of
the active epoch; Quiesced..Simulated read through cheap snapshots (spike
KWKEVV). With the detached cycle unconditional (WFF6MM cutover), engine-Mutex
holds on the solve path collapse to enqueue-length, so the solve is uncontended by construction
— there is no drain_lock and no FIFO, and the
drain_lock → engine Mutex → BotState RwLock lock-order narration of
ADR-037 is historical.
Evidence¶
Final-integration validation: the capture-replay regression sweep (zero divergences) and the live Jaeger soak A/B against the pre-epic operator baselines, recorded in the
PLRGINtask result; the percentile replay harness lives atscripts/soak_percentiles.py.Spike-derived bounds behind the stage table (write-burst window, state-lock p99, rewind-restore costs): the stateview feasibility doc §3/§5.1 (
docs/architecture/stateview-feasibility.md).Telemetry semantics (spans, metrics, force-close law): docs/telemetry-latency-playbook.md.
Legacy telemetry names (retired)¶
The proto-pump waterfall names are fully retired: degenbot.pump.block,
degenbot.pump.log_wait, and degenbot.pump.apply_stream no longer exist as
spans; the per-header beat became the degenbot.epoch.run root and the opaque
children became degenbot.stage.<stage> rows. The operational lesson they
encoded survives as the watchdog’s span force-close bound
(LOG_WAIT_MAX_AGE_SECS / STAGE_MAX_AGE_SECS).