
# Inventory Manager — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Build the `InventoryManager` — a single-owner component for capital and position state — running in parallel alongside the existing `ExecutionEngine` position tracking, with new `orders` and `mint_events` tables for durable in-flight tracking.

**Architecture:** The `InventoryManager` lives in `crates/execution/src/inventory.rs`. It owns an in-memory representation of USDC balances, token positions, and minted pairs, persisted via event sourcing through the `PersistenceLayer`. New `orders` and `mint_events` tables track in-flight operations for crash recovery. During this phase, both the existing `DashMap<PositionKey, PositionState>` and the new `InventoryManager` run side-by-side — discrepancies are logged but don't halt trading. The `DecisionContext` gains an optional `inventory: Option<InventoryState>` field populated every tick. Reconciliation against external APIs (CLOB, RPC) is deferred to Phase 2 of the overall arbitrage project when `CtfClient` exists; this plan covers fresh-session initialization and fill-replay-based position verification.

**Tech Stack:** Rust (rust_decimal, rusqlite, dashmap, uuid, chrono, serde), TypeScript (type declarations)

**Design doc:** `docs/plans/2026-03-08-arbitrage/02-inventory-manager.md`

---

## Task 1: Add InventoryState, TokenPosition, MintedPairBalance types

Add the three core inventory types to the shared types crate. These are `Clone + Serialize + Deserialize` value types passed to strategies via `DecisionContext`.

**Files:**
- Modify: `crates/types/src/lib.rs` (add after `PortfolioState` at line 347)
- Test: `crates/types/src/lib.rs` (add to existing `#[cfg(test)] mod tests`)

**Step 1: Add the Rust types**

Add after `PortfolioState` (line 347):

```rust
// ── Inventory types ─────────────────────────────────────────────────

/// Complete capital and position state for a session.
/// Passed to every strategy via DecisionContext on every tick.
/// This is a derived view — never persisted as a blob.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InventoryState {
    /// Total USDC owned by this session.
    pub total_usd: f64,
    /// USDC committed to pending operations (open buy orders, pending mints).
    pub in_flight_usd: f64,
    /// Spendable USDC: total_usd - in_flight_usd.
    pub accessible_usd: f64,

    /// Per-token position state, keyed by token_id.
    pub tokens: std::collections::HashMap<String, TokenPosition>,

    /// Pre-minted YES+NO pairs, keyed by condition_id.
    /// Empty for sessions that don't use mint/merge.
    pub minted_pairs: std::collections::HashMap<String, MintedPairBalance>,

    /// Whether exit has been triggered.
    pub exit_triggered: bool,
}

/// Position state for a single token (YES or NO in a specific market).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenPosition {
    pub token_id: String,
    pub market_id: String,
    pub outcome: Outcome,

    pub qty: u64,
    pub in_flight_qty: u64,
    pub accessible_qty: u64,

    #[serde(with = "rust_decimal::serde::float")]
    pub avg_entry_price: Decimal,
    pub total_cost: f64,

    pub current_price: f64,
    pub unrealized_pnl: f64,
    #[serde(with = "rust_decimal::serde::float")]
    pub realized_pnl: Decimal,
}

/// Minted YES+NO pair balance for a single condition (market).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MintedPairBalance {
    pub condition_id: String,
    pub available: u64,
    pub reserved: u64,
    pub total_minted: u64,
    pub total_sold: u64,
    pub total_merged: u64,
}
```

**Step 2: Write serde tests**

Add to the existing `#[cfg(test)] mod tests` block:

```rust
#[test]
fn inventory_state_serde_roundtrip() {
    let state = InventoryState {
        total_usd: 1000.0,
        in_flight_usd: 65.0,
        accessible_usd: 935.0,
        tokens: std::collections::HashMap::new(),
        minted_pairs: std::collections::HashMap::new(),
        exit_triggered: false,
    };
    let json = serde_json::to_string(&state).unwrap();
    assert!(json.contains(r#""totalUsd":1000"#), "camelCase: {json}");
    assert!(json.contains(r#""inFlightUsd":65"#), "camelCase: {json}");
    assert!(json.contains(r#""exitTriggered":false"#), "camelCase: {json}");
    let rt: InventoryState = serde_json::from_str(&json).unwrap();
    assert_eq!(state, rt);
}

#[test]
fn token_position_serde_roundtrip() {
    let pos = TokenPosition {
        token_id: "tok-1".into(),
        market_id: "mkt-1".into(),
        outcome: Outcome::Yes,
        qty: 100,
        in_flight_qty: 20,
        accessible_qty: 80,
        avg_entry_price: Decimal::new(65, 2), // 0.65
        total_cost: 65.0,
        current_price: 0.70,
        unrealized_pnl: 5.0,
        realized_pnl: Decimal::ZERO,
    };
    let json = serde_json::to_string(&pos).unwrap();
    assert!(json.contains(r#""tokenId":"tok-1""#), "camelCase: {json}");
    assert!(json.contains(r#""inFlightQty":20"#), "camelCase: {json}");
    assert!(json.contains(r#""accessibleQty":80"#), "camelCase: {json}");
    let rt: TokenPosition = serde_json::from_str(&json).unwrap();
    assert_eq!(pos, rt);
}

#[test]
fn minted_pair_balance_serde_roundtrip() {
    let pair = MintedPairBalance {
        condition_id: "cond-1".into(),
        available: 50,
        reserved: 10,
        total_minted: 100,
        total_sold: 30,
        total_merged: 10,
    };
    let json = serde_json::to_string(&pair).unwrap();
    assert!(json.contains(r#""conditionId":"cond-1""#), "camelCase: {json}");
    assert!(json.contains(r#""totalMinted":100"#), "camelCase: {json}");
    let rt: MintedPairBalance = serde_json::from_str(&json).unwrap();
    assert_eq!(pair, rt);
}
```

**Step 3: Run tests**

Run: `cargo test -p weatherman-types`
Expected: all existing tests pass + 3 new tests pass.

**Step 4: Commit**

```
feat(types): add InventoryState, TokenPosition, MintedPairBalance types
```

---

## Task 2: Add inventory field to DecisionContext

Add an optional `inventory` field to `DecisionContext`. Existing strategies see `undefined` in JS — backwards compatible.

**Files:**
- Modify: `crates/types/src/lib.rs:229-240` (DecisionContext struct)
- Test: `crates/types/src/lib.rs` (existing tests)

**Step 1: Add the field**

In the `DecisionContext` struct (line 231), add after the `portfolio` field:

```rust
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inventory: Option<InventoryState>,
```

**Step 2: Fix all DecisionContext construction sites**

Search the codebase for where `DecisionContext` is constructed and add `inventory: None` to each. Key locations:
- `crates/weatherman/src/session.rs` (weather tick and book update branches)
- Any test files constructing DecisionContext

**Step 3: Write test**

```rust
#[test]
fn decision_context_inventory_omitted_when_none() {
    let ctx = DecisionContext {
        trigger: Trigger::BookUpdate,
        tick_number: 1,
        timestamp: chrono::Utc::now(),
        session_id: "s1".into(),
        weather: /* minimal WeatherState */ ,
        markets: vec![],
        positions: vec![],
        portfolio: /* minimal PortfolioState */ ,
        inventory: None,
    };
    let json = serde_json::to_string(&ctx).unwrap();
    assert!(!json.contains("inventory"), "inventory should be omitted when None: {json}");
}
```

Note: Use the same test helper patterns as existing DecisionContext serde tests. If no minimal constructors exist, create a helper function.

**Step 4: Run tests**

Run: `cargo test -p weatherman-types && cargo test -p weatherman`
Expected: all pass. Compilation requires updating construction sites.

**Step 5: Commit**

```
feat(types): add optional inventory field to DecisionContext
```

---

## Task 3: Add orders and mint_events tables

Add the two new tables to the SQLite schema. These track in-flight operations for crash recovery.

**Files:**
- Modify: `crates/persistence/src/sqlite/schema.rs:76-87` (add after `markets` table, before closing `";`)
- Test: `crates/persistence/src/sqlite/schema.rs` (extend existing tests)

**Step 1: Write the failing test**

Add to the existing `mod tests` in schema.rs:

```rust
#[test]
fn test_initialize_creates_orders_and_mint_events_tables() {
    let conn = Connection::open_in_memory().unwrap();
    initialize(&conn).unwrap();

    let tables: Vec<String> = conn
        .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
        .unwrap()
        .query_map([], |row| row.get(0))
        .unwrap()
        .filter_map(|r| r.ok())
        .collect();

    assert!(tables.contains(&"orders".to_string()));
    assert!(tables.contains(&"mint_events".to_string()));
}

#[test]
fn test_orders_table_constraints() {
    let conn = Connection::open_in_memory().unwrap();
    initialize(&conn).unwrap();

    // Insert a session first (FK constraint)
    conn.execute(
        "INSERT INTO sessions (id, mode, strategy_id, config_json, started_at) VALUES ('s1', 'paper', 'strat', '{}', '2026-01-01T00:00:00Z')",
        [],
    ).unwrap();

    // Valid insert
    conn.execute(
        "INSERT INTO orders (local_id, session_id, market_id, token_id, side, qty, limit_px, tif, reserved_usd, submitted_at, updated_at) VALUES ('ord-1', 's1', 'mkt-1', 'tok-1', 'buy', 100, 65, 'GTC', 6500, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
        [],
    ).unwrap();

    // Duplicate local_id should fail
    let dup = conn.execute(
        "INSERT INTO orders (local_id, session_id, market_id, token_id, side, qty, limit_px, tif, reserved_usd, submitted_at, updated_at) VALUES ('ord-1', 's1', 'mkt-1', 'tok-1', 'buy', 100, 65, 'GTC', 6500, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
        [],
    );
    assert!(dup.is_err());

    // Invalid side should fail
    let bad_side = conn.execute(
        "INSERT INTO orders (local_id, session_id, market_id, token_id, side, qty, limit_px, tif, reserved_usd, submitted_at, updated_at) VALUES ('ord-2', 's1', 'mkt-1', 'tok-1', 'hold', 100, 65, 'GTC', 6500, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
        [],
    );
    assert!(bad_side.is_err());
}

#[test]
fn test_mint_events_table_constraints() {
    let conn = Connection::open_in_memory().unwrap();
    initialize(&conn).unwrap();

    conn.execute(
        "INSERT INTO sessions (id, mode, strategy_id, config_json, started_at) VALUES ('s1', 'paper', 'strat', '{}', '2026-01-01T00:00:00Z')",
        [],
    ).unwrap();

    // Valid insert with NULL tx_hash (crash before broadcast)
    conn.execute(
        "INSERT INTO mint_events (session_id, condition_id, operation, amount, submitted_at) VALUES ('s1', 'cond-1', 'MINT', 50000, '2026-01-01T00:00:00Z')",
        [],
    ).unwrap();

    // Valid insert with tx_hash
    conn.execute(
        "INSERT INTO mint_events (tx_hash, session_id, condition_id, operation, amount, submitted_at) VALUES ('0xabc', 's1', 'cond-1', 'MERGE', 25000, '2026-01-01T00:00:00Z')",
        [],
    ).unwrap();

    // Invalid operation should fail
    let bad_op = conn.execute(
        "INSERT INTO mint_events (session_id, condition_id, operation, amount, submitted_at) VALUES ('s1', 'cond-1', 'BURN', 50000, '2026-01-01T00:00:00Z')",
        [],
    );
    assert!(bad_op.is_err());
}
```

**Step 2: Run test to verify it fails**

Run: `cargo test -p weatherman-persistence -- test_initialize_creates_orders`
Expected: FAIL (tables don't exist yet)

**Step 3: Add the DDL**

In `schema.rs`, inside the `execute_batch` string, add after the `markets` table (before the closing `";`):

```sql
        CREATE TABLE IF NOT EXISTS orders (
            local_id          TEXT PRIMARY KEY,
            exchange_order_id TEXT UNIQUE,
            session_id        TEXT NOT NULL REFERENCES sessions(id),
            market_id         TEXT NOT NULL,
            token_id          TEXT NOT NULL,
            side              TEXT NOT NULL CHECK(side IN ('buy', 'sell')),
            qty               INTEGER NOT NULL CHECK(qty > 0),
            limit_px          INTEGER NOT NULL CHECK(limit_px > 0 AND limit_px <= 100),
            tif               TEXT NOT NULL CHECK(tif IN ('GTC', 'GTD', 'FOK')),
            group_id          TEXT,
            status            TEXT NOT NULL DEFAULT 'SUBMITTED'
                              CHECK(status IN ('SUBMITTED', 'PARTIALLY_FILLED', 'FILLED', 'CANCELLED', 'EXPIRED')),
            filled_qty        INTEGER NOT NULL DEFAULT 0 CHECK(filled_qty >= 0),
            reserved_usd      INTEGER NOT NULL DEFAULT 0 CHECK(reserved_usd >= 0),
            reserved_qty      INTEGER NOT NULL DEFAULT 0 CHECK(reserved_qty >= 0),
            submitted_at      TEXT NOT NULL,
            updated_at        TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_orders_session ON orders(session_id, status);
        CREATE INDEX IF NOT EXISTS idx_orders_market ON orders(market_id, status);
        CREATE INDEX IF NOT EXISTS idx_orders_exchange ON orders(exchange_order_id) WHERE exchange_order_id IS NOT NULL;

        CREATE TABLE IF NOT EXISTS mint_events (
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
            tx_hash       TEXT UNIQUE,
            session_id    TEXT NOT NULL REFERENCES sessions(id),
            condition_id  TEXT NOT NULL,
            operation     TEXT NOT NULL CHECK(operation IN ('MINT', 'MERGE')),
            amount        INTEGER NOT NULL CHECK(amount > 0),
            status        TEXT NOT NULL DEFAULT 'PENDING'
                          CHECK(status IN ('PENDING', 'CONFIRMED', 'FAILED')),
            gas_cost      INTEGER NOT NULL DEFAULT 0 CHECK(gas_cost >= 0),
            submitted_at  TEXT NOT NULL,
            confirmed_at  TEXT
        );
        CREATE INDEX IF NOT EXISTS idx_mint_session ON mint_events(session_id, status);
        CREATE INDEX IF NOT EXISTS idx_mint_tx ON mint_events(tx_hash) WHERE tx_hash IS NOT NULL;
```

**Step 4: Update existing table test**

Add `"orders"` and `"mint_events"` to the assertion in `test_initialize_creates_all_tables`.

**Step 5: Run tests**

Run: `cargo test -p weatherman-persistence -- schema`
Expected: all pass.

**Step 6: Commit**

```
feat(persistence): add orders and mint_events tables for in-flight tracking
```

---

## Task 4: Add order DB operations

Add CRUD operations for the `orders` table.

**Files:**
- Modify: `crates/persistence/src/sqlite/ops.rs` (add functions after `insert_market`)
- Modify: `crates/persistence/src/layer.rs` (add PersistenceLayer wrappers)
- Test: `crates/persistence/src/sqlite/ops.rs` (add to existing test module)

**Step 1: Write tests**

Add to `ops.rs` test module:

```rust
#[test]
fn test_insert_order() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();

    insert_order(
        &conn, "ord-1", "sess-1", "mkt-1", "tok-1", "buy", 100, 65, "GTC",
        None, 6500, 0, &now,
    ).unwrap();

    let (status, qty, reserved): (String, i64, i64) = conn
        .query_row(
            "SELECT status, qty, reserved_usd FROM orders WHERE local_id = ?1",
            ["ord-1"],
            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
        ).unwrap();
    assert_eq!(status, "SUBMITTED");
    assert_eq!(qty, 100);
    assert_eq!(reserved, 6500);
}

#[test]
fn test_update_order_exchange_id() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();
    insert_order(
        &conn, "ord-1", "sess-1", "mkt-1", "tok-1", "buy", 100, 65, "GTC",
        None, 6500, 0, &now,
    ).unwrap();

    update_order_exchange_id(&conn, "ord-1", "exch-abc").unwrap();

    let eid: Option<String> = conn
        .query_row(
            "SELECT exchange_order_id FROM orders WHERE local_id = ?1",
            ["ord-1"],
            |row| row.get(0),
        ).unwrap();
    assert_eq!(eid.as_deref(), Some("exch-abc"));
}

#[test]
fn test_update_order_fill() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();
    insert_order(
        &conn, "ord-1", "sess-1", "mkt-1", "tok-1", "buy", 100, 65, "GTC",
        None, 6500, 0, &now,
    ).unwrap();

    // Partial fill
    update_order_fill(&conn, "ord-1", 50, "PARTIALLY_FILLED", &now).unwrap();
    let (filled, status): (i64, String) = conn
        .query_row(
            "SELECT filled_qty, status FROM orders WHERE local_id = ?1",
            ["ord-1"],
            |row| Ok((row.get(0)?, row.get(1)?)),
        ).unwrap();
    assert_eq!(filled, 50);
    assert_eq!(status, "PARTIALLY_FILLED");

    // Full fill
    update_order_fill(&conn, "ord-1", 100, "FILLED", &now).unwrap();
    let (filled, status): (i64, String) = conn
        .query_row(
            "SELECT filled_qty, status FROM orders WHERE local_id = ?1",
            ["ord-1"],
            |row| Ok((row.get(0)?, row.get(1)?)),
        ).unwrap();
    assert_eq!(filled, 100);
    assert_eq!(status, "FILLED");
}

#[test]
fn test_update_order_status() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();
    insert_order(
        &conn, "ord-1", "sess-1", "mkt-1", "tok-1", "buy", 100, 65, "GTC",
        None, 6500, 0, &now,
    ).unwrap();

    update_order_status(&conn, "ord-1", "CANCELLED", &now).unwrap();
    let status: String = conn
        .query_row(
            "SELECT status FROM orders WHERE local_id = ?1",
            ["ord-1"],
            |row| row.get(0),
        ).unwrap();
    assert_eq!(status, "CANCELLED");
}

#[test]
fn test_load_inflight_orders() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();

    insert_order(&conn, "o1", "sess-1", "mkt-1", "t1", "buy", 100, 65, "GTC", None, 6500, 0, &now).unwrap();
    insert_order(&conn, "o2", "sess-1", "mkt-1", "t1", "sell", 50, 70, "FOK", None, 0, 50, &now).unwrap();
    insert_order(&conn, "o3", "sess-1", "mkt-1", "t1", "buy", 200, 60, "GTC", None, 12000, 0, &now).unwrap();
    update_order_status(&conn, "o3", "FILLED", &now).unwrap();

    let orders = load_inflight_orders(&conn, "sess-1").unwrap();
    assert_eq!(orders.len(), 2); // o1 and o2 (o3 is FILLED)
}
```

**Step 2: Run tests to verify they fail**

Run: `cargo test -p weatherman-persistence -- test_insert_order`
Expected: FAIL (function doesn't exist)

**Step 3: Implement the operations**

Add to `ops.rs`:

```rust
pub fn insert_order(
    conn: &Connection,
    local_id: &str,
    session_id: &str,
    market_id: &str,
    token_id: &str,
    side: &str,
    qty: i64,
    limit_px: i64,
    tif: &str,
    group_id: Option<&str>,
    reserved_usd: i64,
    reserved_qty: i64,
    submitted_at: &DateTime<Utc>,
) -> Result<(), PersistenceError> {
    let now = submitted_at.to_rfc3339();
    conn.execute(
        "INSERT INTO orders (local_id, session_id, market_id, token_id, side, qty, limit_px, tif, group_id, reserved_usd, reserved_qty, submitted_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
        rusqlite::params![local_id, session_id, market_id, token_id, side, qty, limit_px, tif, group_id, reserved_usd, reserved_qty, now, now],
    )?;
    Ok(())
}

pub fn update_order_exchange_id(
    conn: &Connection,
    local_id: &str,
    exchange_order_id: &str,
) -> Result<(), PersistenceError> {
    conn.execute(
        "UPDATE orders SET exchange_order_id = ?1 WHERE local_id = ?2",
        rusqlite::params![exchange_order_id, local_id],
    )?;
    Ok(())
}

pub fn update_order_fill(
    conn: &Connection,
    local_id: &str,
    filled_qty: i64,
    status: &str,
    updated_at: &DateTime<Utc>,
) -> Result<(), PersistenceError> {
    conn.execute(
        "UPDATE orders SET filled_qty = ?1, status = ?2, updated_at = ?3 WHERE local_id = ?4",
        rusqlite::params![filled_qty, status, updated_at.to_rfc3339(), local_id],
    )?;
    Ok(())
}

pub fn update_order_status(
    conn: &Connection,
    local_id: &str,
    status: &str,
    updated_at: &DateTime<Utc>,
) -> Result<(), PersistenceError> {
    conn.execute(
        "UPDATE orders SET status = ?1, updated_at = ?2 WHERE local_id = ?3",
        rusqlite::params![status, updated_at.to_rfc3339(), local_id],
    )?;
    Ok(())
}

/// Row returned by load_inflight_orders.
pub struct OrderRow {
    pub local_id: String,
    pub exchange_order_id: Option<String>,
    pub market_id: String,
    pub token_id: String,
    pub side: String,
    pub qty: i64,
    pub limit_px: i64,
    pub tif: String,
    pub group_id: Option<String>,
    pub status: String,
    pub filled_qty: i64,
    pub reserved_usd: i64,
    pub reserved_qty: i64,
}

pub fn load_inflight_orders(
    conn: &Connection,
    session_id: &str,
) -> Result<Vec<OrderRow>, PersistenceError> {
    let mut stmt = conn.prepare(
        "SELECT local_id, exchange_order_id, market_id, token_id, side, qty, limit_px, tif, group_id, status, filled_qty, reserved_usd, reserved_qty FROM orders WHERE session_id = ?1 AND status IN ('SUBMITTED', 'PARTIALLY_FILLED')"
    )?;
    let rows = stmt.query_map(rusqlite::params![session_id], |row| {
        Ok(OrderRow {
            local_id: row.get(0)?,
            exchange_order_id: row.get(1)?,
            market_id: row.get(2)?,
            token_id: row.get(3)?,
            side: row.get(4)?,
            qty: row.get(5)?,
            limit_px: row.get(6)?,
            tif: row.get(7)?,
            group_id: row.get(8)?,
            status: row.get(9)?,
            filled_qty: row.get(10)?,
            reserved_usd: row.get(11)?,
            reserved_qty: row.get(12)?,
        })
    })?;
    rows.collect::<Result<Vec<_>, _>>().map_err(PersistenceError::from)
}
```

**Step 4: Add PersistenceLayer wrappers**

Add to `layer.rs` after the existing `insert_market` method:

```rust
pub fn insert_order(
    &self,
    local_id: &str, session_id: &str, market_id: &str, token_id: &str,
    side: &str, qty: i64, limit_px: i64, tif: &str, group_id: Option<&str>,
    reserved_usd: i64, reserved_qty: i64, submitted_at: &DateTime<Utc>,
) -> Result<(), PersistenceError> {
    let conn = self.db.lock().unwrap();
    ops::insert_order(&conn, local_id, session_id, market_id, token_id, side, qty, limit_px, tif, group_id, reserved_usd, reserved_qty, submitted_at)
}

pub fn update_order_exchange_id(&self, local_id: &str, exchange_order_id: &str) -> Result<(), PersistenceError> {
    let conn = self.db.lock().unwrap();
    ops::update_order_exchange_id(&conn, local_id, exchange_order_id)
}

pub fn update_order_fill(&self, local_id: &str, filled_qty: i64, status: &str, updated_at: &DateTime<Utc>) -> Result<(), PersistenceError> {
    let conn = self.db.lock().unwrap();
    ops::update_order_fill(&conn, local_id, filled_qty, status, updated_at)
}

pub fn update_order_status(&self, local_id: &str, status: &str, updated_at: &DateTime<Utc>) -> Result<(), PersistenceError> {
    let conn = self.db.lock().unwrap();
    ops::update_order_status(&conn, local_id, status, updated_at)
}

pub fn load_inflight_orders(&self, session_id: &str) -> Result<Vec<ops::OrderRow>, PersistenceError> {
    let conn = self.db.lock().unwrap();
    ops::load_inflight_orders(&conn, session_id)
}
```

**Step 5: Run tests**

Run: `cargo test -p weatherman-persistence`
Expected: all pass.

**Step 6: Commit**

```
feat(persistence): add order CRUD operations for in-flight tracking
```

---

## Task 5: Add mint_events DB operations

Add CRUD operations for the `mint_events` table.

**Files:**
- Modify: `crates/persistence/src/sqlite/ops.rs`
- Modify: `crates/persistence/src/layer.rs`
- Test: `crates/persistence/src/sqlite/ops.rs`

**Step 1: Write tests**

```rust
#[test]
fn test_insert_mint_event() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();

    insert_mint_event(&conn, Some("0xabc"), "sess-1", "cond-1", "MINT", 50000, &now).unwrap();

    let (status, amount): (String, i64) = conn
        .query_row(
            "SELECT status, amount FROM mint_events WHERE tx_hash = ?1",
            ["0xabc"],
            |row| Ok((row.get(0)?, row.get(1)?)),
        ).unwrap();
    assert_eq!(status, "PENDING");
    assert_eq!(amount, 50000);
}

#[test]
fn test_insert_mint_event_null_tx_hash() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();

    // Crash before broadcast — tx_hash is NULL
    let id = insert_mint_event(&conn, None, "sess-1", "cond-1", "MINT", 50000, &now).unwrap();
    assert!(id > 0);
}

#[test]
fn test_update_mint_event_status() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();
    insert_mint_event(&conn, Some("0xabc"), "sess-1", "cond-1", "MINT", 50000, &now).unwrap();

    update_mint_event_status(&conn, "0xabc", "CONFIRMED", 1500000, Some(&now)).unwrap();

    let (status, gas, confirmed): (String, i64, Option<String>) = conn
        .query_row(
            "SELECT status, gas_cost, confirmed_at FROM mint_events WHERE tx_hash = ?1",
            ["0xabc"],
            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
        ).unwrap();
    assert_eq!(status, "CONFIRMED");
    assert_eq!(gas, 1500000);
    assert!(confirmed.is_some());
}

#[test]
fn test_load_pending_mints() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();

    insert_mint_event(&conn, Some("0xabc"), "sess-1", "cond-1", "MINT", 50000, &now).unwrap();
    insert_mint_event(&conn, Some("0xdef"), "sess-1", "cond-1", "MERGE", 25000, &now).unwrap();
    insert_mint_event(&conn, Some("0x123"), "sess-1", "cond-2", "MINT", 30000, &now).unwrap();
    update_mint_event_status(&conn, "0x123", "CONFIRMED", 0, Some(&now)).unwrap();

    let pending = load_pending_mints(&conn, "sess-1").unwrap();
    assert_eq!(pending.len(), 2); // 0xabc and 0xdef (0x123 is CONFIRMED)
}
```

**Step 2: Run tests to verify failure**

Run: `cargo test -p weatherman-persistence -- test_insert_mint_event`
Expected: FAIL

**Step 3: Implement**

Add to `ops.rs`:

```rust
/// Insert a mint/merge event. Returns the auto-generated row ID.
pub fn insert_mint_event(
    conn: &Connection,
    tx_hash: Option<&str>,
    session_id: &str,
    condition_id: &str,
    operation: &str,
    amount: i64,
    submitted_at: &DateTime<Utc>,
) -> Result<i64, PersistenceError> {
    conn.execute(
        "INSERT INTO mint_events (tx_hash, session_id, condition_id, operation, amount, submitted_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
        rusqlite::params![tx_hash, session_id, condition_id, operation, amount, submitted_at.to_rfc3339()],
    )?;
    Ok(conn.last_insert_rowid())
}

pub fn update_mint_event_status(
    conn: &Connection,
    tx_hash: &str,
    status: &str,
    gas_cost: i64,
    confirmed_at: Option<&DateTime<Utc>>,
) -> Result<(), PersistenceError> {
    conn.execute(
        "UPDATE mint_events SET status = ?1, gas_cost = ?2, confirmed_at = ?3 WHERE tx_hash = ?4",
        rusqlite::params![status, gas_cost, confirmed_at.map(|t| t.to_rfc3339()), tx_hash],
    )?;
    Ok(())
}

pub struct MintEventRow {
    pub id: i64,
    pub tx_hash: Option<String>,
    pub condition_id: String,
    pub operation: String,
    pub amount: i64,
    pub status: String,
}

pub fn load_pending_mints(
    conn: &Connection,
    session_id: &str,
) -> Result<Vec<MintEventRow>, PersistenceError> {
    let mut stmt = conn.prepare(
        "SELECT id, tx_hash, condition_id, operation, amount, status FROM mint_events WHERE session_id = ?1 AND status = 'PENDING'"
    )?;
    let rows = stmt.query_map(rusqlite::params![session_id], |row| {
        Ok(MintEventRow {
            id: row.get(0)?,
            tx_hash: row.get(1)?,
            condition_id: row.get(2)?,
            operation: row.get(3)?,
            amount: row.get(4)?,
            status: row.get(5)?,
        })
    })?;
    rows.collect::<Result<Vec<_>, _>>().map_err(PersistenceError::from)
}
```

**Step 4: Add PersistenceLayer wrappers**

Same pattern as Task 4 — delegate to `ops::` functions with `self.db.lock().unwrap()`.

**Step 5: Run tests**

Run: `cargo test -p weatherman-persistence`
Expected: all pass.

**Step 6: Commit**

```
feat(persistence): add mint_events CRUD operations
```

---

## Task 6: Atomic fill + position persistence

Wrap `insert_fill` + `upsert_position` in a single SQLite transaction. This prevents the case where a crash between the two leaves the position cache stale.

**Files:**
- Modify: `crates/persistence/src/sqlite/ops.rs`
- Modify: `crates/persistence/src/layer.rs`
- Test: `crates/persistence/src/sqlite/ops.rs`

**Step 1: Write test**

```rust
#[test]
fn test_insert_fill_and_position_atomic() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();

    insert_fill_and_position(
        &conn,
        // fill params
        "fill-1", "ord-1", "sess-1", "mkt-1", "tok-1", "buy", 100, 6500, 50, &now,
        // position params
        "mkt-1", "tok-1", "sess-1", "buy", 100, 6500, 0, 0, &now,
    ).unwrap();

    // Both should exist
    let fill_count: i64 = conn
        .query_row("SELECT COUNT(*) FROM fills WHERE fill_id = 'fill-1'", [], |row| row.get(0))
        .unwrap();
    let pos_qty: i64 = conn
        .query_row(
            "SELECT qty FROM positions WHERE market_id = 'mkt-1' AND token_id = 'tok-1' AND session_id = 'sess-1'",
            [], |row| row.get(0),
        ).unwrap();
    assert_eq!(fill_count, 1);
    assert_eq!(pos_qty, 100);
}

#[test]
fn test_insert_fill_and_position_duplicate_fill_is_idempotent() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();

    insert_fill_and_position(
        &conn,
        "fill-1", "ord-1", "sess-1", "mkt-1", "tok-1", "buy", 100, 6500, 50, &now,
        "mkt-1", "tok-1", "sess-1", "buy", 100, 6500, 0, 0, &now,
    ).unwrap();

    // Second call with same fill_id should be idempotent (INSERT OR IGNORE)
    let result = insert_fill_and_position(
        &conn,
        "fill-1", "ord-1", "sess-1", "mkt-1", "tok-1", "buy", 100, 6500, 50, &now,
        "mkt-1", "tok-1", "sess-1", "buy", 200, 6600, 0, 0, &now,
    );
    // Should either succeed (ignore dup fill, still upsert position) or error on fill uniqueness.
    // We choose: fail on duplicate fill_id to detect replay bugs explicitly.
    assert!(result.is_err());
}
```

**Step 2: Implement**

Add to `ops.rs`:

```rust
/// Atomically insert a fill and upsert the position in a single transaction.
/// Fails on duplicate fill_id (UNIQUE constraint) — caller should dedup.
pub fn insert_fill_and_position(
    conn: &Connection,
    // fill params
    fill_id: &str, order_id: &str, session_id: &str,
    fill_market_id: &str, fill_token_id: &str, fill_side: &str,
    fill_qty: i64, fill_price: i64, fill_fee: i64, fill_timestamp: &DateTime<Utc>,
    // position params
    pos_market_id: &str, pos_token_id: &str, pos_session_id: &str,
    pos_side: &str, pos_qty: i64, pos_avg_price: i64,
    pos_realized_pnl: i64, pos_unrealized_pnl: i64, pos_updated_at: &DateTime<Utc>,
) -> Result<(), PersistenceError> {
    let tx = conn.unchecked_transaction()?;
    tx.execute(
        "INSERT INTO fills (fill_id, order_id, session_id, market_id, token_id, side, qty, price, fee, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
        rusqlite::params![fill_id, order_id, session_id, fill_market_id, fill_token_id, fill_side, fill_qty, fill_price, fill_fee, fill_timestamp.to_rfc3339()],
    )?;
    tx.execute(
        "INSERT INTO positions (market_id, token_id, session_id, side, qty, avg_price, realized_pnl, unrealized_pnl, updated_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
         ON CONFLICT(market_id, token_id, session_id) DO UPDATE SET
            side = excluded.side, qty = excluded.qty, avg_price = excluded.avg_price,
            realized_pnl = excluded.realized_pnl, unrealized_pnl = excluded.unrealized_pnl,
            updated_at = excluded.updated_at",
        rusqlite::params![pos_market_id, pos_token_id, pos_session_id, pos_side, pos_qty, pos_avg_price, pos_realized_pnl, pos_unrealized_pnl, pos_updated_at.to_rfc3339()],
    )?;
    tx.commit()?;
    Ok(())
}
```

Note: Uses `unchecked_transaction()` because we have `&Connection` not `&mut Connection` (the mutex gives us exclusive access). This is the same pattern SQLite uses when WAL mode guarantees single-writer semantics.

**Step 3: Add PersistenceLayer wrapper**

```rust
pub fn insert_fill_and_position(
    &self,
    fill_id: &str, order_id: &str, session_id: &str,
    fill_market_id: &str, fill_token_id: &str, fill_side: &str,
    fill_qty: i64, fill_price: i64, fill_fee: i64, fill_timestamp: &DateTime<Utc>,
    pos_market_id: &str, pos_token_id: &str, pos_session_id: &str,
    pos_side: &str, pos_qty: i64, pos_avg_price: i64,
    pos_realized_pnl: i64, pos_unrealized_pnl: i64, pos_updated_at: &DateTime<Utc>,
) -> Result<(), PersistenceError> {
    let conn = self.db.lock().unwrap();
    ops::insert_fill_and_position(
        &conn,
        fill_id, order_id, session_id, fill_market_id, fill_token_id, fill_side,
        fill_qty, fill_price, fill_fee, fill_timestamp,
        pos_market_id, pos_token_id, pos_session_id,
        pos_side, pos_qty, pos_avg_price, pos_realized_pnl, pos_unrealized_pnl, pos_updated_at,
    )
}
```

**Step 4: Run tests**

Run: `cargo test -p weatherman-persistence`
Expected: all pass.

**Step 5: Commit**

```
feat(persistence): add atomic fill + position transaction
```

---

## Task 7: Update TypeScript type declarations

Add inventory types to the TypeScript declarations so strategies get IDE support.

**Files:**
- Modify: `strategies/lib/types.d.ts`

**Step 1: Add interfaces**

Add after `PortfolioState` interface (after line 94):

```typescript
interface InventoryState {
  /** Total USDC owned by this session. */
  totalUsd: number;
  /** USDC committed to pending operations. */
  inFlightUsd: number;
  /** Spendable USDC (totalUsd - inFlightUsd). */
  accessibleUsd: number;
  /** Per-token position state, keyed by tokenId. */
  tokens: Record<string, TokenPositionState>;
  /** Minted YES+NO pairs, keyed by conditionId. Empty if no minting. */
  mintedPairs: Record<string, MintedPairBalance>;
  /** Whether exit has been triggered. */
  exitTriggered: boolean;
}

interface TokenPositionState {
  tokenId: string;
  marketId: string;
  outcome: "YES" | "NO";
  qty: number;
  inFlightQty: number;
  accessibleQty: number;
  avgEntryPrice: number;
  totalCost: number;
  currentPrice: number;
  unrealizedPnl: number;
  realizedPnl: number;
}

interface MintedPairBalance {
  conditionId: string;
  available: number;
  reserved: number;
  totalMinted: number;
  totalSold: number;
  totalMerged: number;
}
```

**Step 2: Add inventory to DecisionContext**

Update the `DecisionContext` interface to include the optional field:

```typescript
interface DecisionContext {
  trigger: Trigger;
  tickNumber: number;
  timestamp: string;
  sessionId: string;

  weather: WeatherState;
  markets: MarketState[];
  positions: PositionState[];
  portfolio: PortfolioState;
  /** Inventory state. Present when InventoryManager is active. */
  inventory?: InventoryState;
}
```

**Step 3: Commit**

```
feat(types): add InventoryState TypeScript declarations
```

---

## Task 8: Create InventoryManager with core state management

Create the `InventoryManager` struct and its initialization/snapshot logic.

**Files:**
- Create: `crates/execution/src/inventory.rs`
- Modify: `crates/execution/src/lib.rs` (add `pub mod inventory;`)
- Modify: `crates/execution/Cargo.toml` (add `rusqlite` dep if not present)
- Test: `crates/execution/src/inventory.rs`

**Step 1: Write tests for core functionality**

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    fn make_manager(initial_capital_usd: f64) -> InventoryManager {
        InventoryManager::new(initial_capital_usd)
    }

    #[test]
    fn fresh_manager_has_full_capital_accessible() {
        let mgr = make_manager(1000.0);
        let snap = mgr.snapshot();
        assert_eq!(snap.total_usd, 1000.0);
        assert_eq!(snap.in_flight_usd, 0.0);
        assert_eq!(snap.accessible_usd, 1000.0);
        assert!(snap.tokens.is_empty());
        assert!(snap.minted_pairs.is_empty());
        assert!(!snap.exit_triggered);
    }

    #[test]
    fn can_buy_within_budget() {
        let mgr = make_manager(1000.0);
        // 100 contracts at $0.65 = $65
        assert!(mgr.can_buy(100, 65));
        // 2000 contracts at $0.65 = $1300 > $1000
        assert!(!mgr.can_buy(2000, 65));
    }

    #[test]
    fn can_sell_with_no_position() {
        let mgr = make_manager(1000.0);
        assert!(!mgr.can_sell("tok-1", 10));
    }

    #[test]
    fn can_mint_within_budget() {
        let mgr = make_manager(1000.0);
        assert!(mgr.can_mint(500.0));
        assert!(!mgr.can_mint(1500.0));
    }
}
```

**Step 2: Implement InventoryManager struct**

Create `crates/execution/src/inventory.rs`:

```rust
use std::collections::HashMap;

use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use weatherman_types::{InventoryState, MintedPairBalance, Outcome, TokenPosition};

use crate::convert::centibps_to_decimal;

/// Internal position state tracked by the InventoryManager.
/// Richer than the snapshot type — includes Decimal precision fields.
#[derive(Debug, Clone)]
struct InternalPosition {
    token_id: String,
    market_id: String,
    outcome: Outcome,
    qty: u64,
    in_flight_qty: u64,
    avg_entry_price: Decimal,
    total_cost: Decimal,
    current_price: Decimal,
    realized_pnl: Decimal,
}

/// The InventoryManager is the single authority for capital and position state.
/// It takes `&mut self` — not thread-safe by design. Runs within the session's
/// sequential tick loop.
pub struct InventoryManager {
    /// Total USDC owned by this session.
    total_usd: Decimal,
    /// USDC committed to in-flight buy orders and pending mints.
    in_flight_usd: Decimal,

    /// Per-token positions, keyed by token_id.
    positions: HashMap<String, InternalPosition>,

    /// Minted pair balances, keyed by condition_id.
    minted_pairs: HashMap<String, MintedPairBalance>,

    /// Whether exit has been triggered.
    exit_triggered: bool,
}

impl InventoryManager {
    /// Create a fresh InventoryManager for a new session.
    pub fn new(initial_capital_usd: f64) -> Self {
        Self {
            total_usd: Decimal::try_from(initial_capital_usd).unwrap_or(Decimal::ZERO),
            in_flight_usd: Decimal::ZERO,
            positions: HashMap::new(),
            minted_pairs: HashMap::new(),
            exit_triggered: false,
        }
    }

    /// Build an immutable InventoryState snapshot for the strategy.
    pub fn snapshot(&self) -> InventoryState {
        let accessible = self.total_usd - self.in_flight_usd;

        let tokens: HashMap<String, TokenPosition> = self.positions.iter().map(|(id, p)| {
            let accessible_qty = p.qty.saturating_sub(p.in_flight_qty);
            let unrealized_pnl = if p.qty > 0 {
                (p.current_price - p.avg_entry_price) * Decimal::from(p.qty)
            } else {
                Decimal::ZERO
            };

            (id.clone(), TokenPosition {
                token_id: p.token_id.clone(),
                market_id: p.market_id.clone(),
                outcome: p.outcome,
                qty: p.qty,
                in_flight_qty: p.in_flight_qty,
                accessible_qty,
                avg_entry_price: p.avg_entry_price,
                total_cost: p.total_cost.to_f64().unwrap_or(0.0),
                current_price: p.current_price.to_f64().unwrap_or(0.0),
                unrealized_pnl: unrealized_pnl.to_f64().unwrap_or(0.0),
                realized_pnl: p.realized_pnl,
            })
        }).collect();

        InventoryState {
            total_usd: self.total_usd.to_f64().unwrap_or(0.0),
            in_flight_usd: self.in_flight_usd.to_f64().unwrap_or(0.0),
            accessible_usd: accessible.to_f64().unwrap_or(0.0),
            tokens,
            minted_pairs: self.minted_pairs.clone(),
            exit_triggered: self.exit_triggered,
        }
    }

    /// Update current prices for all positions from live book data.
    /// `prices` maps token_id → mid_price as centibps (0-100).
    pub fn update_prices(&mut self, prices: &HashMap<String, u64>) {
        for (token_id, pos) in &mut self.positions {
            if let Some(&mid_centibps) = prices.get(token_id) {
                pos.current_price = centibps_to_decimal(mid_centibps);
            }
        }
    }

    /// Set exit triggered.
    pub fn trigger_exit(&mut self) {
        self.exit_triggered = true;
    }

    // ── Risk checks ────────────────────────────────────────────

    /// Check if a buy order can be funded.
    /// cost = qty * limit_px centibps-dollars, converted to USD.
    pub fn can_buy(&self, qty: u64, limit_px: u64) -> bool {
        let cost = Decimal::from(qty) * Decimal::from(limit_px) / Decimal::from(100u64);
        let accessible = self.total_usd - self.in_flight_usd;
        accessible >= cost
    }

    /// Check if a sell order can be filled from inventory.
    pub fn can_sell(&self, token_id: &str, qty: u64) -> bool {
        self.positions
            .get(token_id)
            .map_or(false, |p| p.qty.saturating_sub(p.in_flight_qty) >= qty)
    }

    /// Check if a mint can be funded.
    pub fn can_mint(&self, amount_usd: f64) -> bool {
        let amount = Decimal::try_from(amount_usd).unwrap_or(Decimal::MAX);
        let accessible = self.total_usd - self.in_flight_usd;
        accessible >= amount
    }
}
```

**Step 3: Register the module**

In `crates/execution/src/lib.rs`, add:

```rust
pub mod inventory;
```

And add to the pub use block:

```rust
pub use inventory::InventoryManager;
```

**Step 4: Run tests**

Run: `cargo test -p weatherman-execution -- inventory`
Expected: all pass.

**Step 5: Commit**

```
feat(execution): add InventoryManager with core state and risk checks
```

---

## Task 9: InventoryManager order lifecycle handlers

Add methods for order submission, acceptance, fill, and cancellation.

**Files:**
- Modify: `crates/execution/src/inventory.rs`

**Step 1: Write tests**

```rust
#[test]
fn order_submitted_reserves_usd_for_buy() {
    let mut mgr = make_manager(1000.0);
    // Buy 100 at $0.65 = $65 reserved
    mgr.on_order_submitted_buy("ord-1", "mkt-1", "tok-1", Outcome::Yes, 100, 65);
    let snap = mgr.snapshot();
    assert_eq!(snap.in_flight_usd, 65.0);
    assert_eq!(snap.accessible_usd, 935.0);
}

#[test]
fn order_submitted_reserves_qty_for_sell() {
    let mut mgr = make_manager(1000.0);
    // Manually add a position first
    mgr.add_position("tok-1", "mkt-1", Outcome::Yes, 100, dec!(0.65));
    mgr.on_order_submitted_sell("ord-1", "tok-1", 50);
    let snap = mgr.snapshot();
    let pos = snap.tokens.get("tok-1").unwrap();
    assert_eq!(pos.in_flight_qty, 50);
    assert_eq!(pos.accessible_qty, 50);
}

#[test]
fn fill_buy_updates_position_and_releases_reserve() {
    let mut mgr = make_manager(1000.0);
    mgr.on_order_submitted_buy("ord-1", "mkt-1", "tok-1", Outcome::Yes, 100, 65);
    // Full fill at $0.65
    mgr.on_fill_buy("ord-1", "tok-1", "mkt-1", Outcome::Yes, 100, dec!(0.65), 100);
    let snap = mgr.snapshot();
    assert_eq!(snap.in_flight_usd, 0.0);
    let pos = snap.tokens.get("tok-1").unwrap();
    assert_eq!(pos.qty, 100);
    assert!((pos.avg_entry_price - dec!(0.65)).abs() < dec!(0.001));
}

#[test]
fn fill_sell_realizes_pnl() {
    let mut mgr = make_manager(1000.0);
    mgr.add_position("tok-1", "mkt-1", Outcome::Yes, 100, dec!(0.65));
    mgr.on_order_submitted_sell("ord-1", "tok-1", 50);
    // Sell 50 at $0.75 → realized PnL = (0.75 - 0.65) * 50 = $5.00
    mgr.on_fill_sell("ord-1", "tok-1", 50, dec!(0.75), 50);
    let snap = mgr.snapshot();
    let pos = snap.tokens.get("tok-1").unwrap();
    assert_eq!(pos.qty, 50);
    assert!((pos.realized_pnl - dec!(5.0)).abs() < dec!(0.01));
}

#[test]
fn order_cancelled_releases_reserve() {
    let mut mgr = make_manager(1000.0);
    mgr.on_order_submitted_buy("ord-1", "mkt-1", "tok-1", Outcome::Yes, 100, 65);
    assert_eq!(mgr.snapshot().in_flight_usd, 65.0);
    mgr.on_order_cancelled_buy("ord-1", 100, 65);
    assert_eq!(mgr.snapshot().in_flight_usd, 0.0);
    assert_eq!(mgr.snapshot().accessible_usd, 1000.0);
}

#[test]
fn partial_fill_then_cancel_releases_remainder() {
    let mut mgr = make_manager(1000.0);
    mgr.on_order_submitted_buy("ord-1", "mkt-1", "tok-1", Outcome::Yes, 100, 65);
    // Partial fill: 60 of 100
    mgr.on_fill_buy("ord-1", "tok-1", "mkt-1", Outcome::Yes, 60, dec!(0.65), 100);
    let snap = mgr.snapshot();
    // Released: 60 * 65 / 100 = $39, remaining in-flight: $26
    assert!((snap.in_flight_usd - 26.0).abs() < 0.01);
    // Cancel remainder
    mgr.on_order_cancelled_buy("ord-1", 40, 65);
    assert!((mgr.snapshot().in_flight_usd).abs() < 0.01);
}

#[test]
fn vwap_across_multiple_fills() {
    let mut mgr = make_manager(1000.0);
    mgr.on_order_submitted_buy("o1", "mkt-1", "tok-1", Outcome::Yes, 100, 60);
    mgr.on_fill_buy("o1", "tok-1", "mkt-1", Outcome::Yes, 100, dec!(0.60), 100);
    mgr.on_order_submitted_buy("o2", "mkt-1", "tok-1", Outcome::Yes, 100, 70);
    mgr.on_fill_buy("o2", "tok-1", "mkt-1", Outcome::Yes, 100, dec!(0.70), 100);
    let snap = mgr.snapshot();
    let pos = snap.tokens.get("tok-1").unwrap();
    assert_eq!(pos.qty, 200);
    // VWAP: (100*0.60 + 100*0.70) / 200 = 0.65
    assert!((pos.avg_entry_price - dec!(0.65)).abs() < dec!(0.001));
}
```

**Step 2: Implement order lifecycle methods**

Add to `InventoryManager` impl block:

```rust
    // ── Internal helpers (for testing) ─────────────────────────

    /// Add a position directly (for testing and recovery).
    #[cfg(test)]
    pub fn add_position(&mut self, token_id: &str, market_id: &str, outcome: Outcome, qty: u64, avg_price: Decimal) {
        let total_cost = avg_price * Decimal::from(qty);
        self.positions.insert(token_id.to_string(), InternalPosition {
            token_id: token_id.to_string(),
            market_id: market_id.to_string(),
            outcome,
            qty,
            in_flight_qty: 0,
            avg_entry_price: avg_price,
            total_cost,
            current_price: avg_price,
            realized_pnl: Decimal::ZERO,
        });
    }

    // ── Order event handlers ───────────────────────────────────

    /// Record a buy order submission. Reserves USDC.
    pub fn on_order_submitted_buy(
        &mut self, _local_id: &str, _market_id: &str, _token_id: &str,
        _outcome: Outcome, qty: u64, limit_px: u64,
    ) {
        let reserved = Decimal::from(qty) * Decimal::from(limit_px) / Decimal::from(100u64);
        self.in_flight_usd += reserved;
    }

    /// Record a sell order submission. Reserves token quantity.
    pub fn on_order_submitted_sell(&mut self, _local_id: &str, token_id: &str, qty: u64) {
        if let Some(pos) = self.positions.get_mut(token_id) {
            pos.in_flight_qty += qty;
        }
    }

    /// Record a buy fill. Updates position with VWAP, releases proportional reserve.
    pub fn on_fill_buy(
        &mut self, _local_id: &str, token_id: &str, market_id: &str,
        outcome: Outcome, fill_qty: u64, fill_price: Decimal, order_qty: u64,
    ) {
        // Update position (VWAP)
        let pos = self.positions.entry(token_id.to_string()).or_insert_with(|| {
            InternalPosition {
                token_id: token_id.to_string(),
                market_id: market_id.to_string(),
                outcome,
                qty: 0,
                in_flight_qty: 0,
                avg_entry_price: Decimal::ZERO,
                total_cost: Decimal::ZERO,
                current_price: Decimal::ZERO,
                realized_pnl: Decimal::ZERO,
            }
        });

        let old_qty = Decimal::from(pos.qty);
        let new_qty = old_qty + Decimal::from(fill_qty);
        pos.avg_entry_price = if new_qty.is_zero() {
            Decimal::ZERO
        } else {
            (pos.avg_entry_price * old_qty + fill_price * Decimal::from(fill_qty)) / new_qty
        };
        pos.qty += fill_qty;
        pos.total_cost = pos.avg_entry_price * Decimal::from(pos.qty);

        // Debit capital
        let cost = fill_price * Decimal::from(fill_qty);
        self.total_usd -= cost;

        // Release proportional in-flight reserve
        // The order reserved (order_qty * limit_px / 100). We release fill_qty's share.
        // But we don't know the original limit_px here — we release based on actual cost.
        // Simpler: release the cost of the fill from in_flight.
        self.in_flight_usd = (self.in_flight_usd - cost).max(Decimal::ZERO);
    }

    /// Record a sell fill. Realizes PnL, releases token reserve.
    pub fn on_fill_sell(
        &mut self, _local_id: &str, token_id: &str, fill_qty: u64,
        fill_price: Decimal, _order_qty: u64,
    ) {
        if let Some(pos) = self.positions.get_mut(token_id) {
            let sell_qty = std::cmp::min(fill_qty, pos.qty);
            let realized = (fill_price - pos.avg_entry_price) * Decimal::from(sell_qty);
            pos.realized_pnl += realized;
            pos.qty -= sell_qty;
            pos.in_flight_qty = pos.in_flight_qty.saturating_sub(fill_qty);

            // Credit capital
            let proceeds = fill_price * Decimal::from(sell_qty);
            self.total_usd += proceeds;

            if pos.qty == 0 {
                pos.avg_entry_price = Decimal::ZERO;
                pos.total_cost = Decimal::ZERO;
            } else {
                pos.total_cost = pos.avg_entry_price * Decimal::from(pos.qty);
            }
        }
    }

    /// Cancel/reject a buy order. Releases the remaining reserved USDC.
    pub fn on_order_cancelled_buy(&mut self, _local_id: &str, remaining_qty: u64, limit_px: u64) {
        let released = Decimal::from(remaining_qty) * Decimal::from(limit_px) / Decimal::from(100u64);
        self.in_flight_usd = (self.in_flight_usd - released).max(Decimal::ZERO);
    }

    /// Cancel/reject a sell order. Releases the remaining reserved quantity.
    pub fn on_order_cancelled_sell(&mut self, _local_id: &str, token_id: &str, remaining_qty: u64) {
        if let Some(pos) = self.positions.get_mut(token_id) {
            pos.in_flight_qty = pos.in_flight_qty.saturating_sub(remaining_qty);
        }
    }
```

Note: The `_local_id` parameters are accepted but unused in the in-memory logic — they're needed for the DB persistence path that the session integration (Task 12) will wire up.

**Step 3: Run tests**

Run: `cargo test -p weatherman-execution -- inventory`
Expected: all pass.

**Step 4: Commit**

```
feat(execution): add InventoryManager order lifecycle handlers
```

---

## Task 10: InventoryManager mint/merge lifecycle handlers

Add methods for mint/merge submission, confirmation, and failure.

**Files:**
- Modify: `crates/execution/src/inventory.rs`

**Step 1: Write tests**

```rust
#[test]
fn mint_submitted_reserves_usd() {
    let mut mgr = make_manager(1000.0);
    mgr.on_mint_submitted("cond-1", 500.0);
    let snap = mgr.snapshot();
    assert_eq!(snap.in_flight_usd, 500.0);
    assert_eq!(snap.accessible_usd, 500.0);
}

#[test]
fn mint_confirmed_creates_positions_and_pairs() {
    let mut mgr = make_manager(1000.0);
    mgr.on_mint_submitted("cond-1", 500.0);
    // Mint creates 500 YES+NO pairs (each at $0.50 cost basis)
    mgr.on_mint_confirmed("cond-1", 500, "tok-yes", "tok-no", "mkt-1");
    let snap = mgr.snapshot();
    assert_eq!(snap.in_flight_usd, 0.0);
    // total_usd should be reduced by mint cost
    assert!((snap.total_usd - 500.0).abs() < 0.01);

    let pair = snap.minted_pairs.get("cond-1").unwrap();
    assert_eq!(pair.available, 500);
    assert_eq!(pair.total_minted, 500);

    // YES and NO positions created at $0.50 each
    let yes = snap.tokens.get("tok-yes").unwrap();
    assert_eq!(yes.qty, 500);
    assert!((yes.avg_entry_price - dec!(0.50)).abs() < dec!(0.01));

    let no = snap.tokens.get("tok-no").unwrap();
    assert_eq!(no.qty, 500);
}

#[test]
fn mint_failed_releases_reserve() {
    let mut mgr = make_manager(1000.0);
    mgr.on_mint_submitted("cond-1", 500.0);
    mgr.on_mint_failed("cond-1", 500.0);
    let snap = mgr.snapshot();
    assert_eq!(snap.in_flight_usd, 0.0);
    assert_eq!(snap.total_usd, 1000.0);
}

#[test]
fn merge_confirmed_credits_usd_and_removes_pairs() {
    let mut mgr = make_manager(1000.0);
    // Setup: mint first
    mgr.on_mint_submitted("cond-1", 500.0);
    mgr.on_mint_confirmed("cond-1", 500, "tok-yes", "tok-no", "mkt-1");

    // Merge 200 pairs back to USDC
    mgr.on_merge_submitted("cond-1", 200);
    let snap = mgr.snapshot();
    let pair = snap.minted_pairs.get("cond-1").unwrap();
    assert_eq!(pair.available, 300); // 500 - 200 reserved
    assert_eq!(pair.reserved, 200);

    mgr.on_merge_confirmed("cond-1", 200, "tok-yes", "tok-no");
    let snap = mgr.snapshot();
    let pair = snap.minted_pairs.get("cond-1").unwrap();
    assert_eq!(pair.available, 300);
    assert_eq!(pair.reserved, 0);
    assert_eq!(pair.total_merged, 200);

    // YES and NO positions reduced
    let yes = snap.tokens.get("tok-yes").unwrap();
    assert_eq!(yes.qty, 300);
}
```

**Step 2: Implement mint/merge handlers**

Add to `InventoryManager` impl:

```rust
    // ── Mint/Merge event handlers ──────────────────────────────

    /// Record a mint submission. Reserves USDC.
    pub fn on_mint_submitted(&mut self, _condition_id: &str, amount_usd: f64) {
        let amount = Decimal::try_from(amount_usd).unwrap_or(Decimal::ZERO);
        self.in_flight_usd += amount;
    }

    /// Record a mint confirmation. Creates YES+NO positions and minted pair balance.
    pub fn on_mint_confirmed(
        &mut self, condition_id: &str, pair_count: u64,
        yes_token_id: &str, no_token_id: &str, market_id: &str,
    ) {
        let amount = Decimal::from(pair_count) * Decimal::from(100u64) / Decimal::from(100u64);
        // Each pair costs $1.00 (YES + NO = $1), so pair_count pairs = pair_count USD
        // But from the caller's perspective, `amount_usd` was reserved at submission.
        // Release the in-flight reserve.
        let mint_cost = Decimal::from(pair_count);
        self.in_flight_usd = (self.in_flight_usd - mint_cost).max(Decimal::ZERO);
        self.total_usd -= mint_cost;

        // Create/update YES position at $0.50 cost basis
        let half = Decimal::new(5, 1); // 0.50
        for (token_id, outcome) in [
            (yes_token_id, Outcome::Yes),
            (no_token_id, Outcome::No),
        ] {
            let pos = self.positions.entry(token_id.to_string()).or_insert_with(|| {
                InternalPosition {
                    token_id: token_id.to_string(),
                    market_id: market_id.to_string(),
                    outcome,
                    qty: 0,
                    in_flight_qty: 0,
                    avg_entry_price: Decimal::ZERO,
                    total_cost: Decimal::ZERO,
                    current_price: half,
                    realized_pnl: Decimal::ZERO,
                }
            });
            let old_qty = Decimal::from(pos.qty);
            let new_qty = old_qty + Decimal::from(pair_count);
            pos.avg_entry_price = if new_qty.is_zero() {
                Decimal::ZERO
            } else {
                (pos.avg_entry_price * old_qty + half * Decimal::from(pair_count)) / new_qty
            };
            pos.qty += pair_count;
            pos.total_cost = pos.avg_entry_price * Decimal::from(pos.qty);
        }

        // Update minted pair balance
        let pair = self.minted_pairs.entry(condition_id.to_string()).or_insert_with(|| {
            MintedPairBalance {
                condition_id: condition_id.to_string(),
                available: 0,
                reserved: 0,
                total_minted: 0,
                total_sold: 0,
                total_merged: 0,
            }
        });
        pair.available += pair_count;
        pair.total_minted += pair_count;
    }

    /// Record a mint failure. Release reserved USDC.
    pub fn on_mint_failed(&mut self, _condition_id: &str, amount_usd: f64) {
        let amount = Decimal::try_from(amount_usd).unwrap_or(Decimal::ZERO);
        self.in_flight_usd = (self.in_flight_usd - amount).max(Decimal::ZERO);
    }

    /// Record a merge submission. Reserves minted pairs.
    pub fn on_merge_submitted(&mut self, condition_id: &str, pair_count: u64) {
        if let Some(pair) = self.minted_pairs.get_mut(condition_id) {
            let reserve = std::cmp::min(pair_count, pair.available);
            pair.available -= reserve;
            pair.reserved += reserve;
        }
    }

    /// Record a merge confirmation. Credits USDC, removes pairs and token positions.
    pub fn on_merge_confirmed(
        &mut self, condition_id: &str, pair_count: u64,
        yes_token_id: &str, no_token_id: &str,
    ) {
        // Credit USDC
        self.total_usd += Decimal::from(pair_count);

        // Update pair balance
        if let Some(pair) = self.minted_pairs.get_mut(condition_id) {
            pair.reserved = pair.reserved.saturating_sub(pair_count);
            pair.total_merged += pair_count;
        }

        // Reduce token positions
        for token_id in [yes_token_id, no_token_id] {
            if let Some(pos) = self.positions.get_mut(token_id) {
                pos.qty = pos.qty.saturating_sub(pair_count);
                if pos.qty == 0 {
                    pos.avg_entry_price = Decimal::ZERO;
                    pos.total_cost = Decimal::ZERO;
                } else {
                    pos.total_cost = pos.avg_entry_price * Decimal::from(pos.qty);
                }
            }
        }
    }

    /// Record a merge failure. Unreserve pairs.
    pub fn on_merge_failed(&mut self, condition_id: &str, pair_count: u64) {
        if let Some(pair) = self.minted_pairs.get_mut(condition_id) {
            let unreserve = std::cmp::min(pair_count, pair.reserved);
            pair.reserved -= unreserve;
            pair.available += unreserve;
        }
    }
```

**Step 3: Run tests**

Run: `cargo test -p weatherman-execution -- inventory`
Expected: all pass.

**Step 4: Commit**

```
feat(execution): add InventoryManager mint/merge lifecycle handlers
```

---

## Task 11: Load positions from DB for recovery

Add a method to initialize `InventoryManager` state from persisted positions (the "fast-start cache"). This handles session resume without full reconciliation.

**Files:**
- Modify: `crates/persistence/src/sqlite/ops.rs` (add `load_positions`)
- Modify: `crates/persistence/src/layer.rs` (add wrapper)
- Modify: `crates/execution/src/inventory.rs` (add `load_positions`)

**Step 1: Write DB test**

```rust
#[test]
fn test_load_positions() {
    let conn = setup();
    let now = Utc::now();
    insert_session(&conn, "sess-1", "paper", "strat-v1", "{}", &now).unwrap();
    upsert_position(&conn, "mkt-1", "tok-1", "sess-1", "buy", 100, 6500, 300, 0, &now).unwrap();
    upsert_position(&conn, "mkt-1", "tok-2", "sess-1", "buy", 50, 4000, 0, 100, &now).unwrap();

    let positions = load_positions(&conn, "sess-1").unwrap();
    assert_eq!(positions.len(), 2);
    assert_eq!(positions[0].qty, 100);
}
```

**Step 2: Implement load_positions**

Add to `ops.rs`:

```rust
pub struct PositionRow {
    pub market_id: String,
    pub token_id: String,
    pub side: String,
    pub qty: i64,
    pub avg_price: i64,
    pub realized_pnl: i64,
    pub unrealized_pnl: i64,
}

pub fn load_positions(
    conn: &Connection,
    session_id: &str,
) -> Result<Vec<PositionRow>, PersistenceError> {
    let mut stmt = conn.prepare(
        "SELECT market_id, token_id, side, qty, avg_price, realized_pnl, unrealized_pnl FROM positions WHERE session_id = ?1"
    )?;
    let rows = stmt.query_map(rusqlite::params![session_id], |row| {
        Ok(PositionRow {
            market_id: row.get(0)?,
            token_id: row.get(1)?,
            side: row.get(2)?,
            qty: row.get(3)?,
            avg_price: row.get(4)?,
            realized_pnl: row.get(5)?,
            unrealized_pnl: row.get(6)?,
        })
    })?;
    rows.collect::<Result<Vec<_>, _>>().map_err(PersistenceError::from)
}
```

**Step 3: Add InventoryManager::load_positions method**

Add to `inventory.rs`:

```rust
    /// Load positions from DB rows (fast-start cache on recovery).
    /// token_id → outcome mapping must be provided by the caller (from MarketMeta).
    pub fn load_positions(
        &mut self,
        rows: &[(String, String, String, u64, Decimal, Decimal)],
        // (token_id, market_id, side_str, qty, avg_price, realized_pnl)
    ) {
        for (token_id, market_id, _side, qty, avg_price, realized_pnl) in rows {
            self.positions.insert(token_id.clone(), InternalPosition {
                token_id: token_id.clone(),
                market_id: market_id.clone(),
                outcome: Outcome::Yes, // Caller resolves from MarketMeta
                qty: *qty,
                in_flight_qty: 0,
                avg_entry_price: *avg_price,
                total_cost: *avg_price * Decimal::from(*qty),
                current_price: *avg_price, // Will be updated on first tick
                realized_pnl: *realized_pnl,
            });
        }
    }
```

**Step 4: Add PersistenceLayer wrapper and run tests**

Run: `cargo test -p weatherman-persistence -- test_load_positions && cargo test -p weatherman-execution -- inventory`
Expected: all pass.

**Step 5: Commit**

```
feat: add position loading for InventoryManager recovery
```

---

## Task 12: Wire InventoryManager into session (parallel system)

Integrate `InventoryManager` into the session tick loop so that `ctx.inventory` is populated on every tick, while the existing `DashMap` position tracking continues to run.

**Files:**
- Modify: `crates/weatherman/src/session.rs` (session initialization and tick loop)
- Modify: `crates/weatherman/src/providers.rs` (add to SessionDeps if needed)

**Step 1: Add InventoryManager to session state**

In `session.rs`, where session dependencies are set up (around line 376-404), create an `InventoryManager`:

```rust
use weatherman_execution::InventoryManager;

// After existing initialization, before tick loop:
let mut inventory_mgr = InventoryManager::new(session.initial_capital_usd);
```

**Step 2: Populate ctx.inventory on weather ticks**

In the weather timer branch (around lines 503-520), where `DecisionContext` is built, add:

```rust
// After building current_positions and portfolio:
// Update inventory prices from current book data
let price_map: HashMap<String, u64> = /* build from book data */;
inventory_mgr.update_prices(&price_map);

let ctx = DecisionContext {
    trigger: Trigger::WeatherRefresh,
    tick_number,
    timestamp: chrono::Utc::now(),
    session_id: session_id.clone(),
    weather: weather_state,
    markets: market_states,
    positions: current_positions,
    portfolio,
    inventory: Some(inventory_mgr.snapshot()),
};
```

**Step 3: Populate ctx.inventory on book updates**

Same pattern in the book update branch (around lines 555-576).

**Step 4: Update InventoryManager on fills**

In `run_strategy_tick()` (around lines 231-294), after each fill is processed by the existing engine, also update the `InventoryManager`:

```rust
for fill in &batch_report.fills {
    // Existing persistence logic stays...

    // Also update InventoryManager (parallel tracking)
    match fill.side {
        Side::Buy => {
            inventory_mgr.on_fill_buy(
                &fill.order_id, &fill.token_id, &fill.market_id,
                /* outcome from market meta */, fill.qty, fill.price, fill.qty,
            );
        }
        Side::Sell => {
            inventory_mgr.on_fill_sell(
                &fill.order_id, &fill.token_id, fill.qty, fill.price, fill.qty,
            );
        }
    }
}
```

**Step 5: Log discrepancies between old and new systems**

After building the snapshot, compare key fields:

```rust
// Compare old portfolio vs new inventory for discrepancy detection
if let Some(ref inv) = ctx.inventory {
    let old_available = ctx.portfolio.available_capital_usd;
    let new_accessible = inv.accessible_usd;
    if (old_available - new_accessible).abs() > 1.0 {
        tracing::warn!(
            old_available, new_accessible,
            "inventory/portfolio discrepancy detected"
        );
    }
}
```

**Step 6: Run tests**

Run: `cargo test -p weatherman`
Expected: existing session tests pass. `ctx.inventory` is now `Some(...)`.

**Step 7: Commit**

```
feat(session): wire InventoryManager as parallel tracking system
```

---

## Task 13: Persist orders via InventoryManager in fill path

Wire the order DB operations into the existing fill processing path so that order lifecycle is durably tracked.

**Files:**
- Modify: `crates/weatherman/src/session.rs` (run_strategy_tick)

**Step 1: Record order submissions before execution**

In `run_strategy_tick`, before `deps.engine.execute_batch(intents)`:

```rust
// Record each order intent in the orders table BEFORE execution
let mut local_ids: Vec<String> = Vec::new();
for intent in &intents {
    if let Intent::Order(order) = intent {
        let local_id = uuid::Uuid::new_v4().to_string();
        let token_id = /* resolve from market meta */;
        let (reserved_usd, reserved_qty) = match order.side {
            Side::Buy => ((order.qty as i64) * (order.limit_px as i64), 0i64),
            Side::Sell => (0i64, order.qty as i64),
        };
        deps.persistence.insert_order(
            &local_id, &session_id, &order.market_id, &token_id,
            &order.side.to_string(), order.qty as i64, order.limit_px as i64,
            &format!("{:?}", order.tif), order.group_id.as_deref(),
            reserved_usd, reserved_qty, &chrono::Utc::now(),
        ).ok(); // Log but don't halt on persistence failure
        local_ids.push(local_id);
    }
}
```

**Step 2: Update order status on fill**

After each fill, update the order status:

```rust
if let Some(local_id) = local_ids.get(idx) {
    deps.persistence.update_order_fill(
        local_id, fill.qty as i64, "FILLED", &chrono::Utc::now(),
    ).ok();
}
```

**Step 3: Run tests**

Run: `cargo test -p weatherman`
Expected: all pass.

**Step 4: Commit**

```
feat(session): persist order lifecycle to orders table
```

---

## Post-Implementation Notes

### What this plan builds
- `InventoryState`, `TokenPosition`, `MintedPairBalance` types (Rust + TypeScript)
- `orders` and `mint_events` DB tables with full CRUD
- Atomic fill + position persistence
- `InventoryManager` with order and mint/merge lifecycle tracking
- Position loading from DB for session recovery
- Parallel tracking wired into the session tick loop

### What is deferred
- **Full reconciliation against CLOB API and Polygon RPC** — requires `CtfClient` (Phase 2 of arbitrage project). The position-from-fills replay is implemented; external API reconciliation (steps 3-4 of the startup reconciliation in the design doc) is deferred.
- **Cutover from DashMap to InventoryManager** (Migration Phase 3 in design doc) — will be a separate plan after parallel validation confirms correctness.
- **Remove compatibility shim** (Migration Phase 4) — after cutover is stable.
- **PRAGMA synchronous = FULL for live mode** — will be added when live mode is activated.

### Verification approach
After implementation, run `cargo test --workspace` to verify all tests pass. Then run a paper trading session and inspect logs for any inventory/portfolio discrepancy warnings.
