ADR-038: The CL event-routing FSM — one table decides every event’s fate

Status: Accepted Date: 2026-08-25 Task: FUWYUR / epic U5DDC4 (“CL event-routing FSM”)

Context

A live V3 Mint for pool 0x88e6A0c2…5640 (USDC/WETH 0.05%) landed at block 25834714 while crawl was mid-flight and never reached engine state. The tombstone-confirmed delivery was fine (no [WS-INVARIANT]), decode/apply was fine (unit-tested), the dual-buffer machinery was fine (quarantine suites were green) — yet the event vanished. Root cause: an event’s fate was decided in three places, each holding a different partial copy of the policy:

  1. LogDispatcher::dispatch’s APPLY-MISS funnel: unregistered ⇒ drop (inferred as a perf shortcut — “skip the exclusive write lock for work that mutates nothing”). False for liquidity events: buffering IS the work.

  2. process_backfill_logs: Mint/Burn → backfill buffer; Swap → dropped when unregistered (silent None).

  3. Inline quarantine/unregistered arms inside apply_v3/v4_* on BotState.

Rust enforces exhaustiveness within one match; it cannot enforce agreement across three scattered layers. The funnel’s inference was exactly the gap. Consequence chain: unregistered-window Mint → silently lost → late registration pinned a DB row whose freshness stamp (update_block = 25834714) outran its own content (the concurrent liquidity updater raced the Mint) → verify passed against an anchor sharing the blind spot → Live with permanent desync until ADR-021 tripped.

Decision

  1. One exhaustive routing table. bot_core/cl_route.rs::route_action maps (Phase::{Backfill,Live}, PoolPresence::{Unregistered, Quarantined,Live}, EventKind::{ScalarRefresh,TickMutation}) → RouteAction {ApplyDirect, Buffer(BufferKind), Drop(NoOpReason)} as a single match. Adding any axis variant is a compile error until every cell is filled — the compiler now polices what three layers used to negotiate informally. “Unregistered” is a first-class presence value, not an Option callers interpret themselves.

  2. Total outcomes. Routing returns ApplyOutcome {Applied(pool_id), Buffered(BufferKind), NoOp(NoOpReason)}. Dropping requires naming a reason; today only ScalarReseedAtRegistration exists, and only for scalar-refresh payloads (their payload IS re-seeded wholesale from the DB row at registration — a documented trust assumption, not an inference). Tick mutations are never droppable anywhere in the table (asserted by test): tick data cannot be retro-supplied.

  3. BotState owns execution; callers are transport. BotState::route_v3_event / route_v4_event resolve presence, consult the table, and execute. The dispatcher decodes, asks, executes the verdict, and reports telemetry — it holds no policy of its own. Its former fast path survives only as a verdict-only pre-check: it may skip the write lock solely when the table verdict is provably Drop (unregistered + scalar refresh), where skipping is the semantics. Perf patches may skip neutral work; they may never re-decide policy.

  4. Clock provenance. Engine-witnessed horizons (v3/v4_event_horizons, advanced only by routed events, never by imported stamps) plus the tombstone cutoff bound what pin stamps can corroborate. pin_provenance_verdict classifies every pin; SeedTrustOnly with a non-zero witnessed horizon — the exact re-seed-after-activity lie shape — warns loudly instead of passing silently.

  5. Composition is tested, not assumed. A seed-deterministic fuzz harness (bamkki_routing_fuzz_oracle_holds_across_lifecycle_roles) drives the real pump over randomized feeds × lifecycle roles with a replay oracle, so the whole bug family fails CI rather than production.

Rejected alternatives

  • Full typestate pools (encoding lifecycle in types per entry): the registry is a HashMap; typestate would force monomorphization sprawl and dynamic dispatch at every accessor without adding safety beyond the table.

  • Deleting the dispatcher fast path outright: unnecessary — the verdict-only form preserves the lock-skip where it is semantically free.

  • Fixing only the funnel (the minimal patch): leaves the other two policy copies to diverge again on the next family addition (Curve/Aave).

Consequences

  • New CL families extend ONE table; the compiler demands all rows.

  • Buffer staging is observable as a distinct outcome (Buffered) rather than conflated into None; telemetry counters keep their meaning.

  • The DB updater’s stamp-honesty race remains open separately (FUWYUR follow-up): provenance classification makes such lies visible, but the updater should eventually stamp truthfully.