# Betex Architecture

_Last updated: 2026-03-20_

This document reflects the current implementation in `src/` for the `betex` crate.

## 1. System Overview

Betex is an event-sourced matching engine for exchange-odds and binary prediction markets.

At runtime, the core write path is:

1. Accept command (`EngineHandler::submit*`)
2. Validate and emit events (`EngineRoot::handle` -> `EngineState::handle_command`)
3. Apply events in memory (`EngineRoot::apply`)
4. Publish events to the ring (`disruptor::Envelope<BookEventEnvelope>`)
5. Persist to segmented WAL (`engine::journaler::JournalHandler`)
6. Fan out to downstream handlers (projections, response callbacks, etc.)

```mermaid
flowchart LR
    C[Client Command] --> H[EngineHandler]
    H --> L[Engine Thread]
    L --> R[EngineRoot handle/apply]
    R --> D[Disruptor Ring]
    D --> W[WAL Poller]
    W --> S[(Segmented LMDB WAL)]
    D --> X[Downstream Handlers]
```

## 2. Primary Components

| Component | Source | Responsibility |
| --- | --- | --- |
| Engine builder/runtime | `../../src/engine/core/mod.rs` | Thread startup, disruptor setup, command loop, event publish |
| Aggregate root | `../../src/engine/root.rs` | Versioned aggregate, tx-id tracking, snapshot queries |
| Engine state | `../../src/engine/state.rs` | Command validation, event creation, event application |
| Book facade | `../../src/book/mod.rs` | Dispatch to concrete book implementation |
| Book variants | `../../src/book/two_runner.rs`, `../../src/book/multi_runner.rs`, `../../src/book/binary_yes.rs` | Matching, cancellations, state transitions |
| Event types | `../../src/book/common/events.rs` | `BookEventEnvelope` + `BookEvent` taxonomy |
| WAL/journal writer | `../../src/engine/journaler.rs` | Segmented LMDB persistence, validation, replay scanner, WAL poller integration |
| WAL/journal reader | `../../src/engine/journal.rs` | Public read-only recovery API over the shared WAL scanner |
| Response consumer | `../../src/engine/consumers.rs` | Build tx-level `Response::Ok` from emitted trade/order events |

## 3. Runtime and Thread Model

Engine runtime threads:

- Engine thread: receives commands, updates aggregate, publishes event tx batches.
- WAL poller thread: consumes ring events and persists envelopes durably.
- Optional consumer threads: registered via `EngineBuilder::register_handler(handler, HandlerRecovery, ...)`.

Startup sequencing:

- `EngineBuilder` stores handlers declaratively; registration does not start consumer threads.
- `build()` opens the journal, replays recovery once, then attaches handlers to the disruptor and starts live threads.
- `HandlerRecovery::LiveOnly` skips startup replay.
- `HandlerRecovery::AfterSeq(seq)` replays only events with `event.seq > seq` during startup and may resume from the middle of a committed tx.

Durability gating:

- A dedicated gate is created for WAL progress.
- Downstream handlers are attached as dependents of that gate.
- This ensures successful-command callbacks can be emitted after WAL persistence (when using `ResponseCallbackHandler`).

Internal batch scheduler:

- The engine thread keeps a runtime-only FIFO worklist of `MarketId`s with active batch work.
- Startup recovery seeds that worklist from recovered market state; no scheduler metadata is persisted.
- When the engine is busy, it forces an internal burst after `engine_max_external_before_internal_burst` external commands.
- When the engine is idle, `recv_timeout(engine_poll_timeout_ms)` drives internal bursts without requiring external commands.
- Each internal burst is bounded by `engine_internal_burst_base_commands` and `engine_internal_burst_base_elapsed_ms`, scaled by idle streak (`1x`, `2x`, then `4x`).
- FIFO is strict during runtime: the head market keeps the turn until its batch completes, then the scheduler pops it and moves to the next queued market.

## 4. Domain Model

### 4.1 Market Config and Models

`MarketConfig` supports three shapes:

- `TwoRunner`
- `MultiRunner` (static runners or dynamic when empty)
- `BinaryYes`

Each variant carries a required human-readable market `name`.

Market pricing/matching model (`MarketModel`):

- `ExchangeOdds`
- `BinaryYes { max_price_ticks }`

### 4.2 Book Implementations

| Book | Used for | Notes |
| --- | --- | --- |
| `TwoRunnerBook` | 2-runner exchange markets | Direct + implied matching |
| `MultiRunnerBook` | 3+ runner exchange markets | Direct matching; supports dynamic runners |
| `BinaryYesBook` | Prediction markets | Tick/share based ladder (`price_ticks`, `qty_shares`) |

### 4.3 Market Lifecycle State

Book-local market lifecycle state (`BookMarketState`) tracks whether the market is tradable,
paused, halted, or terminal:

| State | Matchable | Terminal |
| --- | --- | --- |
| `Open` | Yes | No |
| `Suspended` | No | No |
| `Halted` | No | No |
| `Closed` | No | Yes |
| `Voided` | No | Yes |
| `Settled` | No | Yes |

Command-level `MarketState` accepts `Open`, `Suspended`, and `Closed` and maps into those lifecycle transitions.

### 4.4 Market Phase

Trading phase is modeled separately as `MarketPhase`:

| Phase | Meaning |
| --- | --- |
| `Pre` | Pre-event trading |
| `Live` | Live / in-play trading |

An `Open` market may be either `Pre` or `Live`.
`SetMarketPhase` currently supports the runtime transition `Pre -> Live` for `MarketKind::InPlayCapable`.
Halts and resumes no longer need to restore a special "live-open" lifecycle state; they preserve phase independently.

## 5. Command Surface (Current)

Major command groups from `book::protocol::command::CommandKind`:

- Market lifecycle:
  `CreateMarket`, `SetMarketState`, `SetMarketPhase`, `CloseMarket`, `ContinueCloseMarket`, `VoidMarket`, `ContinueVoidOrders`, `SettleMarket`, `RemoveMarket`
- Administrative:
  `HaltMarket`, `ResumeMarket`, `RemoveRunner`, `VoidTradesFromTime`
- Order flow:
  `PlaceOrder`, `PlaceBinaryOrder`, `CancelOrder`, `ReplaceOrder`, `ReplaceBinaryOrder`
- Batch cancellation:
  `BatchCancelOrders`, `ContinueBatchCancelOrders`, `ContinueLapseOrders` (book-internal)

Notes:

- `CreateMarket` is handled at engine-state level (before per-market dispatch).
- `CreateMarket` carries both `market_kind` and `market_phase`.
- `RemoveMarket` requires terminal market state.
- `SetMarketPhase` changes pre/live trading phase without changing lifecycle state.
- `SetMarketState::Closed` routes to the same bounded close flow as `CloseMarket`.
- Batch close/cancel/void flows emit bounded chunks for deterministic progress.

## 6. Event and Envelope Model

Two envelope levels are used:

1. Domain envelope: `BookEventEnvelope`
   - `market_id`, `market_name`, `market_seq`, `timestamp`, `metadata`, `event`
2. Transport/durability envelope: `disruptor::Envelope<T>`
   - `seq`, `payload`, `tx_id`, `tx_len`, `tx_ix`, `response_cb`

Sequence semantics:

- `seq`: global monotonic event sequence.
- `market_seq`: per-market monotonic sequence.
- `tx_id`: monotonic transaction id assigned per accepted command.

`response_cb` is runtime-only (not serialized to WAL) and is attached to the final event in a tx batch.
The engine validates batch publish slot/payload length alignment before acknowledging success.

## 7. Durability and Recovery

WAL characteristics (`engine::journaler`):

- Segmented LMDB layout under a root directory (`seg_<start_seq>` per segment).
- Configurable segment map size and max events per segment.
- Optional startup pruning (`prune_before_seq_on_startup`) and trash cleanup for the mutable journal handler, applied explicitly after startup recovery.
- Startup validation of segment contiguity and schema version.

Recovery path:

1. `EngineBuilder::build()` opens the journal handler, computes `min(root.version(), all handler recovery cursors)`, and runs a quick validation pass over that suffix before applying any recovery actions.
2. The validator returns the validated replay bound plus any truncated tail tx metadata; startup reuses that single result to reject cursors that land inside the truncated tail and then replays only the previously validated suffix.
3. The scanner trims an incomplete tail transaction only when replay starts before that transaction begins.
4. If an exact startup cursor falls inside the truncated tail transaction, engine build fails with `WalError::IncompleteTxAfterCursor`.
5. `EngineRoot` only applies events where `event.seq > root.version()`.
6. Recovery-enabled handlers only receive events where `event.seq > handler_cursor`.
7. Recovery-enabled handler cursors may be newer than the engine snapshot, but they cannot exceed the recoverable startup head (`max(snapshot head, validated WAL head)`).
8. If a recovery-enabled handler cursor predates the snapshot head, startup requires enough retained WAL to replay through that snapshot head or engine build fails.
9. After replay, configured startup maintenance (prune/trash cleanup) runs and then the live disruptor pipeline is built.

If a sequence gap is detected, recovery panics (corruption detection by design).

Public read-only recovery:

- `engine::journal::JournalReader` exposes `head_seq()`, `recover_from(...)`, and `recover_range(...)`.
- It emits owned `JournalEvent = Envelope<BookEventEnvelope>` values.
- `recover_from(Some(seq))` accepts any event cursor and resumes at `seq + 1`, even if that lands inside a committed tx.
- Recovery trims a truncated tail tx only when `seq` is before that tx starts.
- Recovery fails if the exact cursor lands inside a tx that the retained WAL cannot complete.
- `recover_range(...)` is the permissive slicing API and may skip a partial left-edge transaction.
- Recovered events always have `response_cb = None`.
- `JournalReader::open(...)` is side-effect free: it does not prune segments, clear trash, rotate, or write.
- `JournalHandler::open(...)` runs `apply_startup_maintenance(...)` (segment pruning and trash cleanup) before optional segment validation, ensuring obsolete segments are removed before contiguity checks.

## 8. Read Path and Query Artifacts

Read-side structures:

- `EngineRoot::market_snapshot(market_id, depth)` returns `MarketSnapshot`.
- `snapshot::MarketSnapshot` contains market name, lifecycle `state`, trading `phase`, optional downstream metadata, plus an adjacently tagged `book` payload (`kind` + `data`) for exchange depth or binary depth.
- Downstream handlers consume `Envelope<BookEventEnvelope>` directly; there is no separate in-tree stream wire type.

In examples (`examples/live`), projection handlers consume engine events and materialize API-facing snapshots.

## 9. Responses and Acknowledgements

`submit_with_response` behavior:

- Immediate validation failures can respond directly with `Response::Rejected`.
- Successful commands emit responses via callback consumers (typically `ResponseCallbackHandler`).
- If no callback consumer is registered, caller-side response receivers may not resolve for successful tx commands.

## 10. Idempotency Layer

`src/idempotency.rs` provides an optional wrapper around command submission:

- Fingerprint-based dedupe
- In-flight conflict handling
- Replay of previously completed responses
- Default in-memory implementation: `DashMapIdempotencyStore`
- Keys/correlation identifiers are represented as string newtypes in `src/types/ids.rs`

This layer is outside the core matching loop and can be adopted by gateways/services.

## 11. Cross-Matching Status

`src/cross_match/` includes a 3-runner cross-match engine with configurable risk tolerance.

Current status in architecture terms:

- Implemented as a module above `Book`
- Not automatically invoked by the core `Engine` command loop
- Intended for orchestrated use where multi-leg execution policy is explicitly controlled
- Uses compensation via `VoidTradesFromTime` when a hedge leg fails after partial execution

## 12. Core Guarantees

The current implementation is built around these invariants:

- Deterministic command handling and event emission for identical inputs.
- Strictly monotonic global and per-market sequencing.
- Event-sourced state transitions (`handle` emits, `apply` mutates).
- Durable WAL-first downstream fan-out via durability gating.
- Crash recovery via replay from journal (optionally after snapshot/root version).
