Преглед изворни кода

Separate append-only value tables from disposable hot indexes

The primary goal is correctness. Value and audit data lives only in
append-only tables that are inserted into and never updated or deleted,
so no code path or credential can corrupt or lose history. `postings` is
the immutable record of every posting; `accounts` is the immutable log
of every account version.

Fast access is served by separate disposable tables that behave like
indexes over that truth and can be dropped and rebuilt from it:
`active_postings` and `reserved_postings` hold the spendable and
in-flight set, and `account_head` points at each account's current
version. A posting's lifecycle state and an account's current version
are derived from membership in these tables, not from a mutable column.

Every write is an INSERT or a DELETE; nothing issues an UPDATE. That
shrinks the margin for error and is enforceable with database grants:
the ledger role needs INSERT on the value tables, INSERT and DELETE on
the hot tables, and UPDATE on nothing.

The hot tables carry full row copies of the live set, so reads hit them
directly without joining back to the value tables, and the reserve,
release, and consume primitives move the whole input set with bounded
set-based statements instead of per-row loops.

See ADR-0016 and ADR-0017.
Cesar Rodas пре 3 дана
родитељ
комит
288392181a

+ 8 - 7
crates/kuatia-core/src/posting_selection.rs

@@ -42,7 +42,8 @@ impl std::error::Error for SelectionError {}
 
 /// Picks postings to cover `target`, using largest-first greedy to minimise
 /// the number of postings consumed (and therefore the number of change postings
-/// created). Only active, positive postings of the right asset are considered.
+/// created). Only positive postings of the right asset are considered; the
+/// caller supplies an already-active `available` set.
 pub fn select_postings(
     available: &[Posting],
     asset: AssetId,
@@ -52,7 +53,7 @@ pub fn select_postings(
 
     let mut candidates: Vec<&Posting> = available
         .iter()
-        .filter(|p| p.is_active() && p.asset == asset && p.value.is_positive())
+        .filter(|p| p.asset == asset && p.value.is_positive())
         .collect();
 
     // Largest first
@@ -137,16 +138,16 @@ mod tests {
     }
 
     #[test]
-    fn ignores_inactive_and_wrong_asset() {
-        let mut inactive = make_posting(0, 1000);
-        inactive.status = PostingStatus::Inactive;
-
+    fn ignores_wrong_asset() {
+        // Selection receives an already-active set (state lives in the store's
+        // index tables, not on the posting), so it only has to skip the wrong
+        // asset and negative values.
         let mut wrong_asset = make_posting(1, 1000);
         wrong_asset.asset = AssetId::new(2);
 
         let good = make_posting(2, 50);
 
-        let postings = vec![inactive, wrong_asset, good];
+        let postings = vec![wrong_asset, good];
         let result = select_postings(&postings, AssetId::new(1), Cent::from(50)).unwrap();
         assert_eq!(result.len(), 1);
         assert_eq!(result[0].index, 2);

+ 4 - 65
crates/kuatia-core/src/validate.rs

@@ -58,8 +58,6 @@ pub enum ValidationError {
     DuplicateConsumedPosting(PostingId),
     /// A consumed posting id does not exist in the store.
     PostingNotFound(PostingId),
-    /// A consumed posting has already been spent.
-    PostingAlreadyConsumed(PostingId),
     /// A consumed posting is not owned by the expected account.
     OwnershipViolation {
         /// The posting that failed the ownership check.
@@ -137,7 +135,6 @@ impl std::fmt::Display for ValidationError {
             Self::EmptyTransfer => write!(f, "transfer has no postings"),
             Self::DuplicateConsumedPosting(id) => write!(f, "duplicate consumed posting {id:?}"),
             Self::PostingNotFound(id) => write!(f, "posting not found: {id:?}"),
-            Self::PostingAlreadyConsumed(id) => write!(f, "posting already consumed: {id:?}"),
             Self::OwnershipViolation {
                 posting_id,
                 expected,
@@ -243,16 +240,13 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
     let consumed_by_id: HashMap<PostingId, &Posting> =
         input.consumed_postings.iter().map(|p| (p.id, p)).collect();
 
-    // 3 & 4. Every consumed posting exists, is active, and we note ownership
+    // 3. Every consumed posting exists (its lifecycle state is enforced by the
+    // reserve CAS and the finalize "all spent" guard, not here — a `Posting`
+    // carries no state).
     for pid in envelope.consumes() {
-        let posting = consumed_by_id
+        consumed_by_id
             .get(pid)
             .ok_or(ValidationError::PostingNotFound(*pid))?;
-        if posting.status != PostingStatus::Active
-            && posting.status != PostingStatus::PendingInactive
-        {
-            return Err(ValidationError::PostingAlreadyConsumed(*pid));
-        }
     }
 
     // 5. Every referenced account exists, not FROZEN, not CLOSED
@@ -624,51 +618,6 @@ mod tests {
     }
 
     #[test]
-    fn double_spend_rejected() {
-        let pid = PostingId {
-            transfer: EnvelopeId([1; 32]),
-            index: 0,
-        };
-        let posting = Posting {
-            id: pid,
-            owner: AccountId::new(1),
-            asset: AssetId::new(1),
-            value: Cent::from(100),
-            status: PostingStatus::Inactive, // already consumed
-            reservation: None,
-        };
-        let envelope = Envelope {
-            consumes: vec![pid],
-            creates: vec![NewPosting {
-                owner: AccountId::new(2),
-                asset: AssetId::new(1),
-                value: Cent::from(100),
-                payer: None,
-            }],
-            book: BookId(0),
-            account_snapshots: vec![],
-            metadata: BTreeMap::new(),
-        };
-        let accounts = accounts_map(vec![
-            make_account(1, AccountPolicy::NoOverdraft),
-            make_account(2, AccountPolicy::NoOverdraft),
-        ]);
-        let balances = HashMap::new();
-        let input = PlanInput {
-            envelope: &envelope,
-            consumed_postings: &[posting],
-            accounts: &accounts,
-            balances: &balances,
-            book: None,
-        };
-
-        assert_eq!(
-            validate_and_plan(input).unwrap_err(),
-            ValidationError::PostingAlreadyConsumed(pid)
-        );
-    }
-
-    #[test]
     fn account_frozen_rejected() {
         let envelope = deposit_envelope();
         let mut acc = make_account(1, AccountPolicy::NoOverdraft);
@@ -721,8 +670,6 @@ mod tests {
             owner: AccountId::new(1),
             asset: AssetId::new(1),
             value: Cent::from(50),
-            status: PostingStatus::Active,
-            reservation: None,
         };
         // Try to send 50 but create 100 for recipient (conservation will fail first,
         // but let's test overdraft with a valid conservation)
@@ -774,8 +721,6 @@ mod tests {
             owner: AccountId::new(1),
             asset: AssetId::new(1),
             value: Cent::from(100),
-            status: PostingStatus::Active,
-            reservation: None,
         };
         let envelope = Envelope {
             consumes: vec![pid],
@@ -826,8 +771,6 @@ mod tests {
             owner: AccountId::new(1),
             asset: AssetId::new(1),
             value: Cent::from(100),
-            status: PostingStatus::Active,
-            reservation: None,
         };
         let envelope = Envelope {
             consumes: vec![pid],
@@ -884,8 +827,6 @@ mod tests {
             owner: AccountId::new(1),
             asset: AssetId::new(1),
             value: Cent::from(100),
-            status: PostingStatus::Active,
-            reservation: None,
         };
         let envelope = Envelope {
             consumes: vec![pid],
@@ -961,8 +902,6 @@ mod tests {
             owner: AccountId::new(1),
             asset: AssetId::new(1),
             value: Cent::from(100),
-            status: PostingStatus::Active,
-            reservation: None,
         };
         let envelope = Envelope {
             consumes: vec![pid],

+ 14 - 4
crates/kuatia-dashboard/src/data.rs

@@ -11,7 +11,7 @@ use axum::{
     response::{IntoResponse, Response},
 };
 use kuatia::ledger::Ledger;
-use kuatia_core::{Account, AccountId, AccountPolicy, AssetId, Cent, PostingId};
+use kuatia_core::{Account, AccountId, AccountPolicy, AssetId, Cent, PostingId, PostingState};
 use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
 use kuatia_storage::store::{EnvelopeRecord, TransferQuery};
 use serde::Serialize;
@@ -278,6 +278,16 @@ pub async fn accounts(state: &AppState) -> Result<Vec<AccountDto>, ApiError> {
     Ok(out)
 }
 
+/// Human-readable label for a posting's derived lifecycle state.
+fn posting_state_label(state: &PostingState) -> &'static str {
+    match state {
+        PostingState::Active => "Active",
+        PostingState::Reserved(_) => "Reserved",
+        PostingState::Spent => "Spent",
+        PostingState::Missing => "Missing",
+    }
+}
+
 /// One account with its postings (largest first) and the transfers it took part
 /// in.
 pub async fn account_detail(state: &AppState, id: AccountId) -> Result<AccountDetailDto, ApiError> {
@@ -285,15 +295,15 @@ pub async fn account_detail(state: &AppState, id: AccountId) -> Result<AccountDe
 
     let mut postings: Vec<PostingDto> = state
         .ledger
-        .postings(&id)
+        .postings_with_state(&id)
         .await?
         .iter()
-        .map(|p| PostingDto {
+        .map(|(p, state)| PostingDto {
             id: posting_id(&p.id),
             owner: p.owner,
             asset: p.asset,
             value: p.value,
-            status: format!("{:?}", p.status),
+            status: posting_state_label(state).to_string(),
         })
         .collect();
     postings.sort_by_key(|p| std::cmp::Reverse(p.value));

+ 375 - 140
crates/kuatia-storage-sql/src/lib.rs

@@ -10,6 +10,7 @@
 //! store.migrate().await?;
 //! ```
 
+use std::collections::{HashMap, HashSet};
 use std::str::FromStr;
 use std::sync::atomic::{AtomicU8, Ordering};
 
@@ -104,6 +105,14 @@ impl SqlStore {
                 "003_drop_user_data",
                 include_str!("migrations/003_drop_user_data.sql"),
             ),
+            (
+                "004_index_tables",
+                include_str!("migrations/004_index_tables.sql"),
+            ),
+            (
+                "005_account_head",
+                include_str!("migrations/005_account_head.sql"),
+            ),
         ];
 
         for (name, sql) in migrations {
@@ -196,23 +205,6 @@ fn envelope_id_from_hex(s: &str) -> Result<EnvelopeId, StoreError> {
     Ok(EnvelopeId(arr))
 }
 
-fn status_to_i16(s: PostingStatus) -> i16 {
-    match s {
-        PostingStatus::Active => 0,
-        PostingStatus::PendingInactive => 1,
-        PostingStatus::Inactive => 2,
-    }
-}
-
-fn status_from_i16(v: i16) -> Result<PostingStatus, StoreError> {
-    match v {
-        0 => Ok(PostingStatus::Active),
-        1 => Ok(PostingStatus::PendingInactive),
-        2 => Ok(PostingStatus::Inactive),
-        _ => Err(StoreError::Internal(format!("bad posting status: {v}"))),
-    }
-}
-
 fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
     let id: i64 = row
         .try_get("id")
@@ -266,12 +258,6 @@ fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
         .try_get("value")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
     let value = Cent::from_str(&value).map_err(|e| StoreError::Internal(e.to_string()))?;
-    let status: i16 = row
-        .try_get("status")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let reservation: Option<i64> = row
-        .try_get("reservation")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
 
     Ok(Posting {
         id: PostingId {
@@ -281,11 +267,43 @@ fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
         owner: AccountId::with_sub(owner, subaccount),
         asset: AssetId::new(asset as u32),
         value,
-        status: status_from_i16(status)?,
-        reservation: reservation.map(ReservationId::new),
     })
 }
 
+/// The FROM source for a posting read of the given derived state. Each index
+/// table carries a full row copy, so the live-set reads target the index table
+/// directly with no merge back to the immutable `postings` record. `Live` is a
+/// `UNION ALL` of the two disjoint live sets (the shared 6 data columns), still
+/// with no join to history. Portable across SQLite and PostgreSQL.
+fn filter_source(filter: PostingFilter) -> &'static str {
+    match filter {
+        PostingFilter::Active => "active_postings",
+        PostingFilter::Reserved => "reserved_postings",
+        PostingFilter::All => "postings",
+        PostingFilter::Live => {
+            "(SELECT transfer_id, idx, owner, subaccount, asset, value FROM active_postings \
+             UNION ALL \
+             SELECT transfer_id, idx, owner, subaccount, asset, value FROM reserved_postings) AS live"
+        }
+    }
+}
+
+/// Build a portable predicate matching a set of posting ids:
+/// `(transfer_id = $s AND idx = $s+1) OR (transfer_id = $s+2 AND idx = $s+3) ...`
+/// starting at placeholder `$start`. Row-value `IN ((a, b), ...)` is not
+/// portable across SQLite and PostgreSQL; an `OR` of equality pairs is. The
+/// caller binds each id as `(hex(transfer), idx as i16)` in order, matching the
+/// placeholder sequence. `ids` must be non-empty.
+fn id_predicate(count: usize, start: u32) -> String {
+    (0..count)
+        .map(|i| {
+            let p = start + (i as u32) * 2;
+            format!("(transfer_id = ${} AND idx = ${})", p, p + 1)
+        })
+        .collect::<Vec<_>>()
+        .join(" OR ")
+}
+
 // ---------------------------------------------------------------------------
 // AccountStore
 // ---------------------------------------------------------------------------
@@ -293,8 +311,13 @@ fn row_to_posting(row: &sqlx::any::AnyRow) -> Result<Posting, StoreError> {
 #[async_trait]
 impl AccountStore for SqlStore {
     async fn get_account(&self, id: &AccountId) -> Result<Account, StoreError> {
+        // The head points at the current version, so this is a single indexed
+        // lookup into the immutable history — no scan of the version chain.
         let row = sqlx::query(
-            "SELECT * FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version DESC LIMIT 1",
+            "SELECT a.* FROM accounts a \
+             JOIN account_head h \
+             ON h.id = a.id AND h.subaccount = a.subaccount AND h.version = a.version \
+             WHERE h.id = $1 AND h.subaccount = $2",
         )
         .bind(id.id)
         .bind(id.sub)
@@ -314,10 +337,11 @@ impl AccountStore for SqlStore {
     }
 
     async fn create_account(&self, account: Account) -> Result<(), StoreError> {
-        // Pessimistic locking: inside one transaction, lock any existing row for
-        // this account with `SELECT ... FOR UPDATE` so a concurrent creator
-        // waits, then insert. `ON CONFLICT DO NOTHING` is the portable backstop
-        // (SQLite has no `FOR UPDATE`, and it turns a concurrent double-insert
+        // Pessimistic locking: inside one transaction, lock the account's head
+        // row with `SELECT ... FOR UPDATE` so a concurrent creator waits. The
+        // head is the single row per account; its `ON CONFLICT (id, subaccount)
+        // DO NOTHING` insert is the portable backstop that decides the winner
+        // (SQLite has no `FOR UPDATE`, and it turns a concurrent double-create
         // into a clean affected-row count instead of a unique violation).
         let lock = self.lock_clause().await?;
         let mut tx = self
@@ -327,7 +351,7 @@ impl AccountStore for SqlStore {
             .map_err(|e| StoreError::Internal(e.to_string()))?;
 
         let existing = sqlx::query(&format!(
-            "SELECT 1 FROM accounts WHERE id = $1 AND subaccount = $2 LIMIT 1{lock}"
+            "SELECT 1 FROM account_head WHERE id = $1 AND subaccount = $2 LIMIT 1{lock}"
         ))
         .bind(account.id.id)
         .bind(account.id.sub)
@@ -341,7 +365,8 @@ impl AccountStore for SqlStore {
             )));
         }
 
-        let res = sqlx::query(
+        // Append the immutable first version, then point the head at it.
+        sqlx::query(
             "INSERT INTO accounts (id, subaccount, version, policy, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id, subaccount, version) DO NOTHING"
         )
             .bind(account.id.id)
@@ -354,6 +379,16 @@ impl AccountStore for SqlStore {
             .execute(&mut *tx)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let res = sqlx::query(
+            "INSERT INTO account_head (id, subaccount, version) VALUES ($1, $2, $3) ON CONFLICT (id, subaccount) DO NOTHING",
+        )
+        .bind(account.id.id)
+        .bind(account.id.sub)
+        .bind(account.version as i64)
+        .execute(&mut *tx)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
         if res.rows_affected() == 0 {
             return Err(StoreError::AlreadyExists(format!(
                 "account {:?}",
@@ -368,11 +403,13 @@ impl AccountStore for SqlStore {
     }
 
     async fn append_account_version(&self, account: Account) -> Result<(), StoreError> {
-        // Pessimistic locking: inside one transaction, lock the account's latest
-        // version row with `SELECT ... FOR UPDATE` so a concurrent appender waits
-        // here until we commit, then check the version and insert. `ON CONFLICT`
-        // is the portable backstop (SQLite has no `FOR UPDATE`, and it covers the
-        // append phantom-insert a row lock does not).
+        // Pessimistic locking: inside one transaction, lock the account's head
+        // row with `SELECT ... FOR UPDATE` so a concurrent appender waits here
+        // until we commit, then check the version, append the new immutable row,
+        // and move the head. `ON CONFLICT` is the portable backstop (SQLite has
+        // no `FOR UPDATE`, and it covers the append phantom-insert a row lock
+        // does not). The head is maintained by delete + insert, never `UPDATE`,
+        // so the write path issues only inserts and deletes.
         let lock = self.lock_clause().await?;
         let mut tx = self
             .pool
@@ -381,7 +418,7 @@ impl AccountStore for SqlStore {
             .map_err(|e| StoreError::Internal(e.to_string()))?;
 
         let current = sqlx::query(&format!(
-            "SELECT version FROM accounts WHERE id = $1 AND subaccount = $2 ORDER BY version DESC LIMIT 1{lock}"
+            "SELECT version FROM account_head WHERE id = $1 AND subaccount = $2{lock}"
         ))
         .bind(account.id.id)
         .bind(account.id.sub)
@@ -426,6 +463,21 @@ impl AccountStore for SqlStore {
             });
         }
 
+        // Move the head to the new version (delete + insert, never update).
+        sqlx::query("DELETE FROM account_head WHERE id = $1 AND subaccount = $2")
+            .bind(account.id.id)
+            .bind(account.id.sub)
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        sqlx::query("INSERT INTO account_head (id, subaccount, version) VALUES ($1, $2, $3)")
+            .bind(account.id.id)
+            .bind(account.id.sub)
+            .bind(account.version as i64)
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
         tx.commit()
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -448,14 +500,16 @@ impl AccountStore for SqlStore {
     }
 
     async fn list_accounts(&self) -> Result<Vec<Account>, StoreError> {
-        let rows = sqlx::query("SELECT * FROM accounts ORDER BY id, subaccount, version DESC")
-            .fetch_all(&self.pool)
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        let mut accounts: Vec<Account> =
-            rows.iter().map(row_to_account).collect::<Result<_, _>>()?;
-        accounts.dedup_by_key(|a| a.id);
-        Ok(accounts)
+        // One row per account via the head; no read-all-versions + dedup.
+        let rows = sqlx::query(
+            "SELECT a.* FROM accounts a \
+             JOIN account_head h \
+             ON h.id = a.id AND h.subaccount = a.subaccount AND h.version = a.version",
+        )
+        .fetch_all(&self.pool)
+        .await
+        .map_err(|e| StoreError::Internal(e.to_string()))?;
+        rows.iter().map(row_to_account).collect()
     }
 }
 
@@ -466,16 +520,48 @@ impl AccountStore for SqlStore {
 #[async_trait]
 impl PostingStore for SqlStore {
     async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError> {
-        let mut result = Vec::with_capacity(ids.len());
+        if ids.is_empty() {
+            return Ok(Vec::new());
+        }
+
+        // One set-based query for the whole batch instead of one probe per id,
+        // reusing the portable `id_predicate` and binding each id in order as
+        // `(hex(transfer), idx as i16)`.
+        let sql = format!(
+            "SELECT * FROM postings WHERE {}",
+            id_predicate(ids.len(), 1)
+        );
+        let mut q = sqlx::query(&sql);
         for id in ids {
-            let row = sqlx::query("SELECT * FROM postings WHERE transfer_id = $1 AND idx = $2")
+            q = q
                 .bind(envelope_id_to_hex(&id.transfer))
-                .bind(id.index as i16)
-                .fetch_optional(&self.pool)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?
+                .bind(id.index as i16);
+        }
+        let rows = q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        // Index the fetched postings by the same `(hex, idx)` key that was bound.
+        let mut found: HashMap<(String, i16), Posting> = HashMap::with_capacity(rows.len());
+        for row in &rows {
+            let posting = row_to_posting(row)?;
+            let key = (
+                envelope_id_to_hex(&posting.id.transfer),
+                posting.id.index as i16,
+            );
+            found.insert(key, posting);
+        }
+
+        // Return in input order, erroring on the first id absent from the batch
+        // (matching the per-id lookup's `NotFound` semantics).
+        let mut result = Vec::with_capacity(ids.len());
+        for id in ids {
+            let key = (envelope_id_to_hex(&id.transfer), id.index as i16);
+            let posting = found
+                .get(&key)
                 .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
-            result.push(row_to_posting(&row)?);
+            result.push(posting.clone());
         }
         Ok(result)
     }
@@ -485,12 +571,13 @@ impl PostingStore for SqlStore {
         id: i64,
         sub: Option<i64>,
         asset: Option<&AssetId>,
-        status: Option<PostingStatus>,
+        filter: PostingFilter,
     ) -> Result<Vec<Posting>, StoreError> {
         // Build the predicate dynamically: `sub == None` spans every subaccount
         // of `id`, `Some(s)` restricts to one. The subaccount is compared only
-        // for equality, never as a magnitude.
-        let mut sql = String::from("SELECT * FROM postings WHERE owner = $1");
+        // for equality, never as a magnitude. The derived-state filter selects
+        // which table (index copy or immutable record) to read from directly.
+        let mut sql = format!("SELECT * FROM {} WHERE owner = $1", filter_source(filter));
         let mut placeholder = 2u32;
         if sub.is_some() {
             sql.push_str(&format!(" AND subaccount = ${placeholder}"));
@@ -498,10 +585,6 @@ impl PostingStore for SqlStore {
         }
         if asset.is_some() {
             sql.push_str(&format!(" AND asset = ${placeholder}"));
-            placeholder += 1;
-        }
-        if status.is_some() {
-            sql.push_str(&format!(" AND status = ${placeholder}"));
         }
 
         let mut q = sqlx::query(&sql).bind(id);
@@ -511,9 +594,6 @@ impl PostingStore for SqlStore {
         if let Some(a) = asset {
             q = q.bind(a.0 as i32);
         }
-        if let Some(s) = status {
-            q = q.bind(status_to_i16(s));
-        }
 
         let rows = q
             .fetch_all(&self.pool)
@@ -522,8 +602,104 @@ impl PostingStore for SqlStore {
         rows.iter().map(row_to_posting).collect()
     }
 
+    async fn get_posting_states(&self, ids: &[PostingId]) -> Result<Vec<PostingState>, StoreError> {
+        if ids.is_empty() {
+            return Ok(Vec::new());
+        }
+
+        // One set-based query per state table instead of up to three probes per
+        // id. Each reuses the portable `id_predicate` (an OR of equality pairs;
+        // row-value `IN` is not portable across SQLite and PostgreSQL) and binds
+        // every id in order as `(hex(transfer), idx as i16)`.
+        let predicate = id_predicate(ids.len(), 1);
+
+        let active_sql = format!("SELECT transfer_id, idx FROM active_postings WHERE {predicate}");
+        let mut active_q = sqlx::query(&active_sql);
+        for id in ids {
+            active_q = active_q
+                .bind(envelope_id_to_hex(&id.transfer))
+                .bind(id.index as i16);
+        }
+        let active_rows = active_q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let reserved_sql = format!(
+            "SELECT transfer_id, idx, reservation FROM reserved_postings WHERE {predicate}"
+        );
+        let mut reserved_q = sqlx::query(&reserved_sql);
+        for id in ids {
+            reserved_q = reserved_q
+                .bind(envelope_id_to_hex(&id.transfer))
+                .bind(id.index as i16);
+        }
+        let reserved_rows = reserved_q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let spent_sql = format!("SELECT transfer_id, idx FROM postings WHERE {predicate}");
+        let mut spent_q = sqlx::query(&spent_sql);
+        for id in ids {
+            spent_q = spent_q
+                .bind(envelope_id_to_hex(&id.transfer))
+                .bind(id.index as i16);
+        }
+        let spent_rows = spent_q
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        // Key membership by the same `(hex, idx)` values that were bound, so the
+        // per-id lookup below matches without decoding transfer ids back.
+        let row_key = |row: &sqlx::any::AnyRow| -> Result<(String, i16), StoreError> {
+            let transfer_id: String = row
+                .try_get("transfer_id")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            let idx: i16 = row
+                .try_get("idx")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            Ok((transfer_id, idx))
+        };
+
+        let mut active: HashSet<(String, i16)> = HashSet::with_capacity(active_rows.len());
+        for row in &active_rows {
+            active.insert(row_key(row)?);
+        }
+        let mut reserved: HashMap<(String, i16), i64> = HashMap::with_capacity(reserved_rows.len());
+        for row in &reserved_rows {
+            let rid: i64 = row
+                .try_get("reservation")
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+            reserved.insert(row_key(row)?, rid);
+        }
+        let mut spent: HashSet<(String, i16)> = HashSet::with_capacity(spent_rows.len());
+        for row in &spent_rows {
+            spent.insert(row_key(row)?);
+        }
+
+        // Reconstruct each id's state in input order, preserving the active >
+        // reserved > spent > missing precedence of the original probes.
+        let mut out = Vec::with_capacity(ids.len());
+        for id in ids {
+            let key = (envelope_id_to_hex(&id.transfer), id.index as i16);
+            out.push(if active.contains(&key) {
+                PostingState::Active
+            } else if let Some(rid) = reserved.get(&key) {
+                PostingState::Reserved(ReservationId::new(*rid))
+            } else if spent.contains(&key) {
+                PostingState::Spent
+            } else {
+                PostingState::Missing
+            });
+        }
+        Ok(out)
+    }
+
     async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, StoreError> {
         let (where_clause, count_clause) = {
+            let source = filter_source(query.filter);
             let mut w = String::from("WHERE owner = $1");
             let mut idx = 2u32;
             if query.sub.is_some() {
@@ -532,16 +708,12 @@ impl PostingStore for SqlStore {
             }
             if query.asset.is_some() {
                 w.push_str(&format!(" AND asset = ${idx}"));
-                idx += 1;
-            }
-            if query.status.is_some() {
-                w.push_str(&format!(" AND status = ${idx}"));
             }
-            let c = format!("SELECT COUNT(*) as cnt FROM postings {w}");
+            let c = format!("SELECT COUNT(*) as cnt FROM {source} {w}");
             let limit = query.limit.unwrap_or(u32::MAX);
             let offset = query.offset.unwrap_or(0);
             w.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
-            (format!("SELECT * FROM postings {w}"), c)
+            (format!("SELECT * FROM {source} {w}"), c)
         };
 
         // Build count query
@@ -552,9 +724,6 @@ impl PostingStore for SqlStore {
         if let Some(ref a) = query.asset {
             count_q = count_q.bind(a.0 as i32);
         }
-        if let Some(s) = query.status {
-            count_q = count_q.bind(status_to_i16(s));
-        }
         let count_row = count_q
             .fetch_one(&self.pool)
             .await
@@ -571,9 +740,6 @@ impl PostingStore for SqlStore {
         if let Some(ref a) = query.asset {
             data_q = data_q.bind(a.0 as i32);
         }
-        if let Some(s) = query.status {
-            data_q = data_q.bind(status_to_i16(s));
-        }
         let rows = data_q
             .fetch_all(&self.pool)
             .await
@@ -591,34 +757,59 @@ impl PostingStore for SqlStore {
         ids: &[PostingId],
         reservation: ReservationId,
     ) -> Result<u64, StoreError> {
-        // Dumb instruction: each id flips Active → PendingInactive (the status
-        // precondition is in the WHERE so it is atomic). Return the count of rows
-        // changed; the caller decides what a short count means.
+        // Dumb instruction over the whole id set, in two statements: copy the
+        // currently-active rows into the reserved index (sourced from
+        // `active_postings`, so only active ids move), then delete those same
+        // ids from `active_postings`. The DELETE's affected count is the number
+        // claimed, and by active/reserved disjointness it equals the INSERT's
+        // row count. Concurrent reserves serialize on the reserved-index primary
+        // key, so exactly one wins each contended id.
+        if ids.is_empty() {
+            return Ok(0);
+        }
         let mut tx = self
             .pool
             .begin()
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
-        let mut reserved: u64 = 0;
+
+        // Reservation is $1; each id pair follows starting at $2.
+        let insert_sql = format!(
+            "INSERT INTO reserved_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) \
+             SELECT transfer_id, idx, owner, subaccount, asset, value, $1 FROM active_postings WHERE {} \
+             ON CONFLICT (transfer_id, idx) DO NOTHING",
+            id_predicate(ids.len(), 2)
+        );
+        let mut insert_q = sqlx::query(&insert_sql).bind(reservation.0);
         for id in ids {
-            let res = sqlx::query(
-                "UPDATE postings SET status = $1, reservation = $2 WHERE transfer_id = $3 AND idx = $4 AND status = $5",
-            )
-            .bind(status_to_i16(PostingStatus::PendingInactive))
-            .bind(reservation.0)
-            .bind(envelope_id_to_hex(&id.transfer))
-            .bind(id.index as i16)
-            .bind(status_to_i16(PostingStatus::Active))
+            insert_q = insert_q
+                .bind(envelope_id_to_hex(&id.transfer))
+                .bind(id.index as i16);
+        }
+        insert_q
             .execute(&mut *tx)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
-            reserved += res.rows_affected();
+
+        let delete_sql = format!(
+            "DELETE FROM active_postings WHERE {}",
+            id_predicate(ids.len(), 1)
+        );
+        let mut delete_q = sqlx::query(&delete_sql);
+        for id in ids {
+            delete_q = delete_q
+                .bind(envelope_id_to_hex(&id.transfer))
+                .bind(id.index as i16);
         }
+        let del = delete_q
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
 
         tx.commit()
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(reserved)
+        Ok(del.rows_affected())
     }
 
     async fn release_postings(
@@ -626,32 +817,56 @@ impl PostingStore for SqlStore {
         ids: &[PostingId],
         reservation: ReservationId,
     ) -> Result<u64, StoreError> {
-        // Dumb instruction: each id reserved by `reservation` flips
-        // PendingInactive → Active. Return the count released; an already-Active
-        // or differently-owned posting simply does not count.
+        // Dumb instruction over the whole id set: copy the rows reserved by
+        // `reservation` back into the active index, then delete them from the
+        // reserved index. The DELETE's affected count is the number released; an
+        // id already active or reserved by another saga does not match.
+        if ids.is_empty() {
+            return Ok(0);
+        }
         let mut tx = self
             .pool
             .begin()
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
-        let mut released: u64 = 0;
+
+        // Reservation is $1; each id pair follows starting at $2.
+        let insert_sql = format!(
+            "INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) \
+             SELECT transfer_id, idx, owner, subaccount, asset, value FROM reserved_postings \
+             WHERE ({}) AND reservation = $1 ON CONFLICT (transfer_id, idx) DO NOTHING",
+            id_predicate(ids.len(), 2)
+        );
+        let mut insert_q = sqlx::query(&insert_sql).bind(reservation.0);
+        for id in ids {
+            insert_q = insert_q
+                .bind(envelope_id_to_hex(&id.transfer))
+                .bind(id.index as i16);
+        }
+        insert_q
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+
+        let delete_sql = format!(
+            "DELETE FROM reserved_postings WHERE ({}) AND reservation = $1",
+            id_predicate(ids.len(), 2)
+        );
+        let mut delete_q = sqlx::query(&delete_sql).bind(reservation.0);
         for id in ids {
-            let res = sqlx::query("UPDATE postings SET status = $1, reservation = NULL WHERE transfer_id = $2 AND idx = $3 AND status = $4 AND reservation = $5")
-                .bind(status_to_i16(PostingStatus::Active))
+            delete_q = delete_q
                 .bind(envelope_id_to_hex(&id.transfer))
-                .bind(id.index as i16)
-                .bind(status_to_i16(PostingStatus::PendingInactive))
-                .bind(reservation.0)
-                .execute(&mut *tx)
-                .await
-                .map_err(|e| StoreError::Internal(e.to_string()))?;
-            released += res.rows_affected();
+                .bind(id.index as i16);
         }
+        let del = delete_q
+            .execute(&mut *tx)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
 
         tx.commit()
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(released)
+        Ok(del.rows_affected())
     }
 
     async fn deactivate_postings(
@@ -659,46 +874,51 @@ impl PostingStore for SqlStore {
         ids: &[PostingId],
         reservation: Option<ReservationId>,
     ) -> Result<u64, StoreError> {
-        let mut tx = self
-            .pool
-            .begin()
-            .await
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-        let mut changed: u64 = 0;
+        // Dumb instruction over the whole id set: a single DELETE removes the
+        // ids from an index so they become spent (present only in the immutable
+        // table). `rows_affected` is the count; the caller interprets a shortfall.
+        if ids.is_empty() {
+            return Ok(0);
+        }
+        let (sql, rid) = match reservation {
+            // Raw path: remove from the active index.
+            None => (
+                format!(
+                    "DELETE FROM active_postings WHERE {}",
+                    id_predicate(ids.len(), 1)
+                ),
+                None,
+            ),
+            // Saga path: remove only the rows reserved by `rid`.
+            Some(rid) => (
+                format!(
+                    "DELETE FROM reserved_postings WHERE ({}) AND reservation = $1",
+                    id_predicate(ids.len(), 2)
+                ),
+                Some(rid),
+            ),
+        };
+        let mut q = sqlx::query(&sql);
+        if let Some(rid) = rid {
+            q = q.bind(rid.0);
+        }
         for id in ids {
-            // The precondition is the instruction; the count is the result. The
-            // caller decides what a short count means.
-            let res = match reservation {
-                None => {
-                    sqlx::query("UPDATE postings SET status = $1, reservation = NULL WHERE transfer_id = $2 AND idx = $3 AND status = $4")
-                        .bind(status_to_i16(PostingStatus::Inactive))
-                        .bind(envelope_id_to_hex(&id.transfer))
-                        .bind(id.index as i16)
-                        .bind(status_to_i16(PostingStatus::Active))
-                        .execute(&mut *tx)
-                        .await
-                }
-                Some(rid) => {
-                    sqlx::query("UPDATE postings SET status = $1, reservation = NULL WHERE transfer_id = $2 AND idx = $3 AND status = $4 AND reservation = $5")
-                        .bind(status_to_i16(PostingStatus::Inactive))
-                        .bind(envelope_id_to_hex(&id.transfer))
-                        .bind(id.index as i16)
-                        .bind(status_to_i16(PostingStatus::PendingInactive))
-                        .bind(rid.0)
-                        .execute(&mut *tx)
-                        .await
-                }
-            }
-            .map_err(|e| StoreError::Internal(e.to_string()))?;
-            changed += res.rows_affected();
+            q = q
+                .bind(envelope_id_to_hex(&id.transfer))
+                .bind(id.index as i16);
         }
-        tx.commit()
+        let res = q
+            .execute(&self.pool)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
-        Ok(changed)
+        Ok(res.rows_affected())
     }
 
     async fn insert_postings(&self, postings: &[Posting]) -> Result<u64, StoreError> {
+        // Dumb instruction: insert each posting into the immutable table and, only
+        // when the row was newly inserted, add its id to the active index. Return
+        // the count of immutable rows inserted. The newness gate stops a replayed
+        // finalize from re-activating a since-spent posting.
         let mut tx = self
             .pool
             .begin()
@@ -706,20 +926,35 @@ impl PostingStore for SqlStore {
             .map_err(|e| StoreError::Internal(e.to_string()))?;
         let mut inserted: u64 = 0;
         for posting in postings {
+            let hex = envelope_id_to_hex(&posting.id.transfer);
             let res = sqlx::query(
-                "INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value, status) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (transfer_id, idx) DO NOTHING"
+                "INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (transfer_id, idx) DO NOTHING"
             )
-                .bind(envelope_id_to_hex(&posting.id.transfer))
+                .bind(hex.clone())
                 .bind(posting.id.index as i16)
                 .bind(posting.owner.id)
                 .bind(posting.owner.sub)
                 .bind(posting.asset.0 as i32)
                 .bind(posting.value.to_string())
-                .bind(status_to_i16(posting.status))
                 .execute(&mut *tx)
                 .await
                 .map_err(|e| StoreError::Internal(e.to_string()))?;
-            inserted += res.rows_affected();
+            if res.rows_affected() == 1 {
+                // Activate a full copy so spendable reads never merge.
+                sqlx::query(
+                    "INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (transfer_id, idx) DO NOTHING",
+                )
+                .bind(hex)
+                .bind(posting.id.index as i16)
+                .bind(posting.owner.id)
+                .bind(posting.owner.sub)
+                .bind(posting.asset.0 as i32)
+                .bind(posting.value.to_string())
+                .execute(&mut *tx)
+                .await
+                .map_err(|e| StoreError::Internal(e.to_string()))?;
+                inserted += 1;
+            }
         }
         tx.commit()
             .await

+ 48 - 0
crates/kuatia-storage-sql/src/migrations/004_index_tables.sql

@@ -0,0 +1,48 @@
+CREATE TABLE IF NOT EXISTS active_postings (
+    transfer_id TEXT NOT NULL,
+    idx         SMALLINT NOT NULL,
+    owner       BIGINT NOT NULL,
+    subaccount  BIGINT NOT NULL DEFAULT 0,
+    asset       INTEGER NOT NULL,
+    value       TEXT NOT NULL,
+    PRIMARY KEY (transfer_id, idx)
+);
+
+CREATE TABLE IF NOT EXISTS reserved_postings (
+    transfer_id TEXT NOT NULL,
+    idx         SMALLINT NOT NULL,
+    owner       BIGINT NOT NULL,
+    subaccount  BIGINT NOT NULL DEFAULT 0,
+    asset       INTEGER NOT NULL,
+    value       TEXT NOT NULL,
+    reservation BIGINT NOT NULL,
+    PRIMARY KEY (transfer_id, idx)
+);
+
+INSERT INTO active_postings (transfer_id, idx, owner, subaccount, asset, value) SELECT transfer_id, idx, owner, subaccount, asset, value FROM postings WHERE status = 0;
+
+INSERT INTO reserved_postings (transfer_id, idx, owner, subaccount, asset, value, reservation) SELECT transfer_id, idx, owner, subaccount, asset, value, reservation FROM postings WHERE status = 1 AND reservation IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS idx_active_owner ON active_postings(owner, subaccount, asset);
+
+CREATE INDEX IF NOT EXISTS idx_reserved_owner ON reserved_postings(owner, subaccount, asset);
+
+DROP INDEX IF EXISTS idx_postings_owner;
+
+CREATE TABLE postings_new (
+    transfer_id TEXT NOT NULL,
+    idx         SMALLINT NOT NULL,
+    owner       BIGINT NOT NULL,
+    subaccount  BIGINT NOT NULL DEFAULT 0,
+    asset       INTEGER NOT NULL,
+    value       TEXT NOT NULL,
+    PRIMARY KEY (transfer_id, idx)
+);
+
+INSERT INTO postings_new (transfer_id, idx, owner, subaccount, asset, value) SELECT transfer_id, idx, owner, subaccount, asset, value FROM postings;
+
+DROP TABLE postings;
+
+ALTER TABLE postings_new RENAME TO postings;
+
+CREATE INDEX IF NOT EXISTS idx_postings_owner ON postings(owner, subaccount, asset);

+ 8 - 0
crates/kuatia-storage-sql/src/migrations/005_account_head.sql

@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS account_head (
+    id         BIGINT NOT NULL,
+    subaccount BIGINT NOT NULL DEFAULT 0,
+    version    BIGINT NOT NULL,
+    PRIMARY KEY (id, subaccount)
+);
+
+INSERT INTO account_head (id, subaccount, version) SELECT a.id, a.subaccount, a.version FROM accounts a WHERE NOT EXISTS (SELECT 1 FROM accounts b WHERE b.id = a.id AND b.subaccount = a.subaccount AND b.version > a.version);

+ 153 - 2
crates/kuatia-storage-sql/tests/sqlite.rs

@@ -107,13 +107,13 @@ async fn subaccount_columns_round_trip() {
 
     // Filtering by the subaccount returns it; the main account holds nothing.
     let by_sub = store
-        .get_postings_by_account(1, Some(7), None, None)
+        .get_postings_by_account(1, Some(7), None, PostingFilter::All)
         .await
         .unwrap();
     assert_eq!(by_sub.len(), 1);
     assert_eq!(by_sub[0].owner, sub);
     let by_main = store
-        .get_postings_by_account(1, Some(0), None, None)
+        .get_postings_by_account(1, Some(0), None, PostingFilter::All)
         .await
         .unwrap();
     assert!(by_main.is_empty());
@@ -185,6 +185,157 @@ async fn migration_upgrades_existing_rows_to_main_account() {
     assert!(got.id.is_main());
 }
 
+/// The 004 migration splits lifecycle state out of `postings` into the two
+/// id-only index tables: an active (status 0) posting is backfilled into
+/// `active_postings`, a reserved (status 1) posting into `reserved_postings`
+/// with its token, and the `status` column is dropped from `postings`.
+#[tokio::test]
+async fn migration_004_backfills_index_tables() {
+    let pool = new_pool().await;
+
+    // Pre-004 postings schema (the shape 001->003 leave it): status + reservation.
+    sqlx::query(
+        "CREATE TABLE postings (transfer_id TEXT NOT NULL, idx SMALLINT NOT NULL, owner BIGINT NOT NULL, subaccount BIGINT NOT NULL DEFAULT 0, asset INTEGER NOT NULL, value TEXT NOT NULL, status SMALLINT NOT NULL, reservation BIGINT, PRIMARY KEY (transfer_id, idx))",
+    )
+    .execute(&pool)
+    .await
+    .unwrap();
+    // One active (status 0) and one reserved (status 1, reservation 77) posting.
+    sqlx::query("INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value, status, reservation) VALUES ('aa', 0, 1, 0, 1, '100', 0, NULL)")
+        .execute(&pool)
+        .await
+        .unwrap();
+    sqlx::query("INSERT INTO postings (transfer_id, idx, owner, subaccount, asset, value, status, reservation) VALUES ('bb', 0, 1, 0, 1, '200', 1, 77)")
+        .execute(&pool)
+        .await
+        .unwrap();
+
+    // A post-003 DB also has the accounts table (empty here); migrate() will run
+    // 005 after 004, which backfills the account head from it.
+    sqlx::query(
+        "CREATE TABLE accounts (id BIGINT NOT NULL, subaccount BIGINT NOT NULL DEFAULT 0, version BIGINT NOT NULL, policy TEXT NOT NULL, flags INTEGER NOT NULL, book BIGINT NOT NULL, metadata TEXT NOT NULL, PRIMARY KEY (id, subaccount, version))",
+    )
+    .execute(&pool)
+    .await
+    .unwrap();
+
+    // Record 001-003 as applied so migrate() only runs 004 and 005.
+    sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
+        .execute(&pool)
+        .await
+        .unwrap();
+    for m in ["001_init", "002_subaccounts", "003_drop_user_data"] {
+        sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
+            .bind(m)
+            .execute(&pool)
+            .await
+            .unwrap();
+    }
+
+    let store = SqlStore::new(pool.clone());
+    store.migrate().await.unwrap();
+
+    // The active posting is now in active_postings, carrying a full row copy.
+    let active = sqlx::query("SELECT transfer_id, owner, asset, value FROM active_postings")
+        .fetch_all(&pool)
+        .await
+        .unwrap();
+    assert_eq!(active.len(), 1);
+    let a_tid: String = active[0].try_get("transfer_id").unwrap();
+    let a_owner: i64 = active[0].try_get("owner").unwrap();
+    let a_value: String = active[0].try_get("value").unwrap();
+    assert_eq!(a_tid, "aa");
+    assert_eq!(a_owner, 1);
+    assert_eq!(a_value, "100");
+
+    // The reserved posting is in reserved_postings with its data copy and token.
+    let reserved = sqlx::query("SELECT transfer_id, value, reservation FROM reserved_postings")
+        .fetch_all(&pool)
+        .await
+        .unwrap();
+    assert_eq!(reserved.len(), 1);
+    let r_tid: String = reserved[0].try_get("transfer_id").unwrap();
+    let r_value: String = reserved[0].try_get("value").unwrap();
+    let r_res: i64 = reserved[0].try_get("reservation").unwrap();
+    assert_eq!(r_tid, "bb");
+    assert_eq!(r_value, "200");
+    assert_eq!(r_res, 77);
+
+    // Both immutable rows survive; the status column is gone.
+    let all = sqlx::query("SELECT transfer_id FROM postings")
+        .fetch_all(&pool)
+        .await
+        .unwrap();
+    assert_eq!(all.len(), 2);
+    assert!(
+        sqlx::query("SELECT status FROM postings")
+            .fetch_all(&pool)
+            .await
+            .is_err()
+    );
+}
+
+/// The 005 migration backfills `account_head` with the current (highest)
+/// version of each account, so a subsequent read hits one row per account
+/// without scanning the version history.
+#[tokio::test]
+async fn migration_005_backfills_account_head() {
+    let pool = new_pool().await;
+
+    // Pre-005 accounts schema (post-004 shape) with a versioned history:
+    // account 1 has three versions, account 2 has one.
+    sqlx::query(
+        "CREATE TABLE accounts (id BIGINT NOT NULL, subaccount BIGINT NOT NULL DEFAULT 0, version BIGINT NOT NULL, policy TEXT NOT NULL, flags INTEGER NOT NULL, book BIGINT NOT NULL, metadata TEXT NOT NULL, PRIMARY KEY (id, subaccount, version))",
+    )
+    .execute(&pool)
+    .await
+    .unwrap();
+    for (id, version) in [(1, 1), (1, 2), (1, 3), (2, 1)] {
+        sqlx::query("INSERT INTO accounts (id, subaccount, version, policy, flags, book, metadata) VALUES ($1, 0, $2, '\"NoOverdraft\"', 0, 0, '{}')")
+            .bind(id as i64)
+            .bind(version as i64)
+            .execute(&pool)
+            .await
+            .unwrap();
+    }
+
+    // Record 001-004 as applied so migrate() only runs 005.
+    sqlx::query("CREATE TABLE _migrations (name TEXT PRIMARY KEY)")
+        .execute(&pool)
+        .await
+        .unwrap();
+    for m in [
+        "001_init",
+        "002_subaccounts",
+        "003_drop_user_data",
+        "004_index_tables",
+    ] {
+        sqlx::query("INSERT INTO _migrations (name) VALUES ($1)")
+            .bind(m)
+            .execute(&pool)
+            .await
+            .unwrap();
+    }
+
+    let store = SqlStore::new(pool.clone());
+    store.migrate().await.unwrap();
+
+    // One head per account, each pointing at the highest version.
+    let mut heads: Vec<(i64, i64)> = sqlx::query("SELECT id, version FROM account_head")
+        .fetch_all(&pool)
+        .await
+        .unwrap()
+        .iter()
+        .map(|r| (r.try_get("id").unwrap(), r.try_get("version").unwrap()))
+        .collect();
+    heads.sort();
+    assert_eq!(heads, vec![(1, 3), (2, 1)]);
+
+    // The store reads the current version through the head.
+    let acct = store.get_account(&AccountId::new(1)).await.unwrap();
+    assert_eq!(acct.version, 3);
+}
+
 /// migrate() is idempotent: running it repeatedly on the same DB is a no-op.
 #[tokio::test]
 async fn migrate_is_idempotent() {

+ 93 - 44
crates/kuatia-storage/src/mem_store.rs

@@ -8,8 +8,8 @@ use tokio::sync::RwLock;
 
 use kuatia_types::autoid::AutoId;
 use kuatia_types::{
-    Account, AccountId, AssetId, Book, BookId, EnvelopeId, Posting, PostingId, PostingStatus,
-    ReservationId,
+    Account, AccountId, AssetId, Book, BookId, EnvelopeId, Posting, PostingFilter, PostingId,
+    PostingState, ReservationId,
 };
 
 use crate::error::StoreError;
@@ -18,9 +18,23 @@ use crate::store::{
     AccountStore, BookStore, EnvelopeRecord, PostingStore, SagaStore, TransferStore,
 };
 
+/// Postings held as an immutable record table plus two index maps that carry
+/// full row copies of the live set, so a live-set read never merges back to the
+/// immutable record. Kept under one lock so the reserve claim (remove from
+/// active + insert into reserved) is atomic.
+#[derive(Default)]
+struct PostingTables {
+    /// The append-only record of every posting; never mutated or removed.
+    immutable: HashMap<PostingId, Posting>,
+    /// Full copies of spendable postings.
+    active: HashMap<PostingId, Posting>,
+    /// Full copies of reserved postings, each with its owning reservation.
+    reserved: HashMap<PostingId, (Posting, ReservationId)>,
+}
+
 /// In-memory [`Store`](crate::store::Store) implementation backed by `RwLock<HashMap>`.
 pub struct InMemoryStore {
-    postings: RwLock<HashMap<PostingId, Posting>>,
+    postings: RwLock<PostingTables>,
     accounts: RwLock<HashMap<AccountId, Vec<Account>>>,
     transfers: RwLock<HashMap<EnvelopeId, EnvelopeRecord>>,
     sagas: RwLock<HashMap<i64, Vec<u8>>>,
@@ -39,7 +53,7 @@ impl InMemoryStore {
     /// Create an empty in-memory store.
     pub fn new() -> Self {
         Self {
-            postings: RwLock::new(HashMap::new()),
+            postings: RwLock::new(PostingTables::default()),
             accounts: RwLock::new(HashMap::new()),
             transfers: RwLock::new(HashMap::new()),
             sagas: RwLock::new(HashMap::new()),
@@ -138,6 +152,7 @@ impl PostingStore for InMemoryStore {
         let mut result = Vec::with_capacity(ids.len());
         for id in ids {
             let posting = postings
+                .immutable
                 .get(id)
                 .ok_or_else(|| StoreError::NotFound(format!("posting {id:?}")))?;
             result.push(posting.clone());
@@ -150,18 +165,52 @@ impl PostingStore for InMemoryStore {
         id: i64,
         sub: Option<i64>,
         asset: Option<&AssetId>,
-        status: Option<PostingStatus>,
+        filter: PostingFilter,
     ) -> Result<Vec<Posting>, StoreError> {
         let postings = self.postings.read().await;
-        Ok(postings
-            .values()
-            .filter(|p| {
-                p.owner.id == id
-                    && sub.is_none_or(|s| p.owner.sub == s)
-                    && asset.is_none_or(|a| p.asset == *a)
-                    && status.is_none_or(|s| p.status == s)
+        let matches = |p: &&Posting| {
+            p.owner.id == id
+                && sub.is_none_or(|s| p.owner.sub == s)
+                && asset.is_none_or(|a| p.asset == *a)
+        };
+        // Read from the relevant set directly — no merge back to the immutable
+        // record. `Live` is the two disjoint live sets chained together.
+        let reserved = || postings.reserved.values().map(|(p, _)| p);
+        let result: Vec<Posting> = match filter {
+            PostingFilter::Active => postings.active.values().filter(matches).cloned().collect(),
+            PostingFilter::Reserved => reserved().filter(matches).cloned().collect(),
+            PostingFilter::Live => postings
+                .active
+                .values()
+                .chain(reserved())
+                .filter(matches)
+                .cloned()
+                .collect(),
+            PostingFilter::All => postings
+                .immutable
+                .values()
+                .filter(matches)
+                .cloned()
+                .collect(),
+        };
+        Ok(result)
+    }
+
+    async fn get_posting_states(&self, ids: &[PostingId]) -> Result<Vec<PostingState>, StoreError> {
+        let postings = self.postings.read().await;
+        Ok(ids
+            .iter()
+            .map(|id| {
+                if postings.active.contains_key(id) {
+                    PostingState::Active
+                } else if let Some((_, rid)) = postings.reserved.get(id) {
+                    PostingState::Reserved(*rid)
+                } else if postings.immutable.contains_key(id) {
+                    PostingState::Spent
+                } else {
+                    PostingState::Missing
+                }
             })
-            .cloned()
             .collect())
     }
 
@@ -173,12 +222,11 @@ impl PostingStore for InMemoryStore {
         let mut postings = self.postings.write().await;
         let mut reserved: u64 = 0;
         for id in ids {
-            let Some(posting) = postings.get_mut(id) else {
-                continue; // dumb: a missing row just doesn't count
-            };
-            if posting.status == PostingStatus::Active {
-                posting.status = PostingStatus::PendingInactive;
-                posting.reservation = Some(reservation);
+            // Removing the active copy is the atomic claim: only one caller can
+            // remove a given id, and only then is the copy moved to the reserved
+            // set (carrying its data, so reserved reads never merge).
+            if let Some(p) = postings.active.remove(id) {
+                postings.reserved.insert(*id, (p, reservation));
                 reserved += 1;
             }
         }
@@ -193,15 +241,11 @@ impl PostingStore for InMemoryStore {
         let mut postings = self.postings.write().await;
         let mut released: u64 = 0;
         for id in ids {
-            let Some(posting) = postings.get_mut(id) else {
-                continue;
-            };
-            if posting.status == PostingStatus::PendingInactive
-                && posting.reservation == Some(reservation)
-            {
-                posting.status = PostingStatus::Active;
-                posting.reservation = None;
-                released += 1;
+            if postings.reserved.get(id).map(|(_, r)| *r) == Some(reservation) {
+                if let Some((p, _)) = postings.reserved.remove(id) {
+                    postings.active.insert(*id, p);
+                    released += 1;
+                }
             }
         }
         Ok(released)
@@ -215,19 +259,18 @@ impl PostingStore for InMemoryStore {
         let mut postings = self.postings.write().await;
         let mut changed: u64 = 0;
         for id in ids {
-            let Some(posting) = postings.get_mut(id) else {
-                continue; // dumb: a missing row just doesn't count
-            };
-            let matches = match reservation {
-                None => posting.status == PostingStatus::Active,
+            let removed = match reservation {
+                None => postings.active.remove(id).is_some(),
                 Some(rid) => {
-                    posting.status == PostingStatus::PendingInactive
-                        && posting.reservation == Some(rid)
+                    if postings.reserved.get(id).map(|(_, r)| *r) == Some(rid) {
+                        postings.reserved.remove(id);
+                        true
+                    } else {
+                        false
+                    }
                 }
             };
-            if matches {
-                posting.status = PostingStatus::Inactive;
-                posting.reservation = None;
+            if removed {
                 changed += 1;
             }
         }
@@ -238,8 +281,13 @@ impl PostingStore for InMemoryStore {
         let mut store = self.postings.write().await;
         let mut inserted: u64 = 0;
         for posting in postings {
-            if let std::collections::hash_map::Entry::Vacant(e) = store.entry(posting.id) {
+            if let std::collections::hash_map::Entry::Vacant(e) = store.immutable.entry(posting.id)
+            {
                 e.insert(posting.clone());
+                // Only newly-inserted postings are activated; a since-spent
+                // posting is not re-activated on a replayed insert. The active
+                // set carries a full copy so spendable reads never merge.
+                store.active.insert(posting.id, posting.clone());
                 inserted += 1;
             }
         }
@@ -293,11 +341,12 @@ impl TransferStore for InMemoryStore {
                     .creates()
                     .iter()
                     .any(|np| matches(&np.owner))
-                    || record
-                        .envelope
-                        .consumes()
-                        .iter()
-                        .any(|pid| postings.get(pid).is_some_and(|p| matches(&p.owner)))
+                    || record.envelope.consumes().iter().any(|pid| {
+                        postings
+                            .immutable
+                            .get(pid)
+                            .is_some_and(|p| matches(&p.owner))
+                    })
             })
             .cloned()
             .collect();

+ 45 - 31
crates/kuatia-storage/src/store.rs

@@ -13,8 +13,8 @@
 
 use async_trait::async_trait;
 use kuatia_types::{
-    Account, AccountId, AssetId, Book, BookId, Envelope, EnvelopeId, Posting, PostingId,
-    PostingStatus, Receipt, ReservationId,
+    Account, AccountId, AssetId, Book, BookId, Envelope, EnvelopeId, Posting, PostingFilter,
+    PostingId, PostingState, Receipt, ReservationId,
 };
 
 use crate::error::StoreError;
@@ -40,8 +40,8 @@ pub struct PostingQuery {
     pub sub: Option<i64>,
     /// Filter by asset.
     pub asset: Option<AssetId>,
-    /// Filter by posting status.
-    pub status: Option<PostingStatus>,
+    /// Filter by derived lifecycle state.
+    pub filter: PostingFilter,
     /// Max results to return.
     pub limit: Option<u32>,
     /// Number of results to skip.
@@ -97,64 +97,78 @@ pub trait AccountStore: Send + Sync {
     async fn list_accounts(&self) -> Result<Vec<Account>, StoreError>;
 }
 
-/// Posting persistence: reads and lifecycle transitions.
+/// Posting persistence: an immutable posting table plus two id-only index
+/// tables (active, reserved) whose membership expresses lifecycle state.
+///
+/// A posting is written once into the immutable table and never updated. Its
+/// state is derived from which index it is in: active → spendable, reserved →
+/// claimed by a saga, neither → spent. All lifecycle transitions are inserts
+/// and deletes on the index tables; the posting row itself never changes.
 #[async_trait]
 pub trait PostingStore: Send + Sync {
-    /// Fetch postings by their ids.
+    /// Fetch postings by their ids from the immutable table. Consumed (spent)
+    /// postings still resolve here — immutable rows persist forever.
     async fn get_postings(&self, ids: &[PostingId]) -> Result<Vec<Posting>, StoreError>;
-    /// Return postings owned by a base account, optionally filtered by
-    /// subaccount, asset, and/or status. `sub == None` spans every subaccount
-    /// of `id`; `sub == Some(s)` restricts to that one subaccount.
+    /// Return postings owned by a base account, filtered by subaccount, asset,
+    /// and derived lifecycle state (see [`PostingFilter`]). `sub == None` spans
+    /// every subaccount of `id`; `sub == Some(s)` restricts to that one.
     async fn get_postings_by_account(
         &self,
         id: i64,
         sub: Option<i64>,
         asset: Option<&AssetId>,
-        status: Option<PostingStatus>,
+        filter: PostingFilter,
     ) -> Result<Vec<Posting>, StoreError>;
-    /// Reserve postings: `Active → PendingInactive`, stamping `reservation` as
-    /// the owner token. A dumb instruction — each id flips only if still `Active`;
-    /// returns the **number of rows reserved** (0 ≤ n ≤ ids.len()). It does not
-    /// error on a short count; the caller (saga) interprets it.
+    /// Return the derived [`PostingState`] of each id, aligned to the input
+    /// order. This is the single membership probe used by the saga verifier and
+    /// the finalize guards. An id absent from the immutable table is `Missing`.
+    async fn get_posting_states(&self, ids: &[PostingId]) -> Result<Vec<PostingState>, StoreError>;
+    /// Reserve postings: move each id from the active index into the reserved
+    /// index under `reservation`. A dumb instruction — an id moves only if it
+    /// was in the active index (the delete-returns-one is the atomic
+    /// single-winner claim under contention); returns the **number of rows
+    /// reserved** (0 ≤ n ≤ ids.len()). The caller (saga) interprets a short
+    /// count.
     async fn reserve_postings(
         &self,
         ids: &[PostingId],
         reservation: ReservationId,
     ) -> Result<u64, StoreError>;
-    /// Release postings: `PendingInactive` owned by `reservation` → `Active`,
-    /// clearing the owner. A dumb instruction — only postings reserved by this
-    /// `reservation` flip; returns the **number of rows released**. Releasing an
-    /// `Active` (already released) or differently-owned posting simply does not
-    /// count. The caller interprets the result.
+    /// Release postings: move each id reserved by `reservation` back into the
+    /// active index. A dumb instruction — only ids reserved by this
+    /// `reservation` move; returns the **number of rows released**. Releasing an
+    /// id that is already active or reserved by another saga does not count.
     async fn release_postings(
         &self,
         ids: &[PostingId],
         reservation: ReservationId,
     ) -> Result<u64, StoreError>;
 
-    /// Deactivate postings: flip to `Inactive`. A dumb instruction — it applies
-    /// the conditional update and returns the **number of rows changed**; it does
-    /// not decide whether that count is correct. The caller (saga) interprets it.
-    /// - `reservation == None` (raw): only postings still `Active` flip.
-    /// - `reservation == Some(rid)`: only postings `PendingInactive` owned by
-    ///   `rid` flip.
-    /// Returns the count of postings actually transitioned (0 ≤ n ≤ ids.len()).
+    /// Deactivate postings: remove each id from an index so it becomes spent
+    /// (present only in the immutable table). A dumb instruction — it applies
+    /// the conditional delete and returns the **number of rows removed**.
+    /// - `reservation == None` (raw): remove ids from the active index.
+    /// - `reservation == Some(rid)`: remove ids from the reserved index that are
+    ///   owned by `rid`.
+    /// Returns the count actually removed (0 ≤ n ≤ ids.len()).
     async fn deactivate_postings(
         &self,
         ids: &[PostingId],
         reservation: Option<ReservationId>,
     ) -> Result<u64, StoreError>;
 
-    /// Insert postings if absent (idempotent). A dumb instruction — inserts each
-    /// posting unless one with the same id already exists, and returns the
-    /// **number of rows inserted** (already-present postings contribute 0). The
-    /// caller decides what a short count means.
+    /// Insert postings if absent, and add each newly-inserted id to the active
+    /// index. A dumb instruction — inserts each posting into the immutable table
+    /// unless one with the same id already exists, activating only the rows that
+    /// were newly inserted, and returns the **number of immutable rows
+    /// inserted** (already-present postings contribute 0, and do not get
+    /// re-activated). The caller decides what a short count means.
     async fn insert_postings(&self, postings: &[Posting]) -> Result<u64, StoreError>;
 
     /// Query postings with filtering and pagination.
     async fn query_postings(&self, query: &PostingQuery) -> Result<Page<Posting>, StoreError> {
         let all = self
-            .get_postings_by_account(query.account, query.sub, query.asset.as_ref(), query.status)
+            .get_postings_by_account(query.account, query.sub, query.asset.as_ref(), query.filter)
             .await?;
         let total = all.len() as u64;
         let offset = query.offset.unwrap_or(0) as usize;

+ 162 - 49
crates/kuatia-storage/src/store_tests.rs

@@ -120,6 +120,11 @@ async fn seed_active(store: &(impl Store + 'static), _tag: u8, create: &[Posting
     store.insert_postings(create).await.unwrap();
 }
 
+/// Fetch the derived [`PostingState`] of a single posting.
+async fn state_of(store: &(impl Store + 'static), id: PostingId) -> PostingState {
+    store.get_posting_states(&[id]).await.unwrap()[0]
+}
+
 /// Persist `envelope` as a committed transfer, deriving its created postings the
 /// way the ledger does (`PostingId { transfer: tid, index }`) and indexing the
 /// created owners — the same shape the saga produces.
@@ -321,20 +326,20 @@ pub async fn get_postings_by_account_filters(store: &(impl Store + 'static)) {
     seed_active(store, 200, &[p1, p2, p3]).await;
 
     let all = store
-        .get_postings_by_account(1, None, None, None)
+        .get_postings_by_account(1, None, None, PostingFilter::All)
         .await
         .unwrap();
     assert_eq!(all.len(), 2);
 
     let filtered = store
-        .get_postings_by_account(1, None, Some(&AssetId::new(1)), None)
+        .get_postings_by_account(1, None, Some(&AssetId::new(1)), PostingFilter::All)
         .await
         .unwrap();
     assert_eq!(filtered.len(), 1);
     assert_eq!(filtered[0].value, Cent::from(100));
 
     let active = store
-        .get_postings_by_account(1, None, None, Some(PostingStatus::Active))
+        .get_postings_by_account(1, None, None, PostingFilter::Active)
         .await
         .unwrap();
     assert_eq!(active.len(), 2);
@@ -352,14 +357,14 @@ pub async fn get_postings_by_subaccount(store: &(impl Store + 'static)) {
 
     // sub = None spans every subaccount of base id 1.
     let all = store
-        .get_postings_by_account(1, None, Some(&AssetId::new(1)), None)
+        .get_postings_by_account(1, None, Some(&AssetId::new(1)), PostingFilter::All)
         .await
         .unwrap();
     assert_eq!(all.len(), 3);
 
     // sub = Some(0) is the main account only.
     let main_only = store
-        .get_postings_by_account(1, Some(0), Some(&AssetId::new(1)), None)
+        .get_postings_by_account(1, Some(0), Some(&AssetId::new(1)), PostingFilter::All)
         .await
         .unwrap();
     assert_eq!(main_only.len(), 1);
@@ -369,7 +374,7 @@ pub async fn get_postings_by_subaccount(store: &(impl Store + 'static)) {
     // sub = Some(7) is that subaccount only; its two postings are never folded
     // into the main account's figure.
     let sub_only = store
-        .get_postings_by_account(1, Some(7), Some(&AssetId::new(1)), None)
+        .get_postings_by_account(1, Some(7), Some(&AssetId::new(1)), PostingFilter::All)
         .await
         .unwrap();
     assert_eq!(sub_only.len(), 2);
@@ -381,7 +386,7 @@ pub async fn get_postings_by_subaccount(store: &(impl Store + 'static)) {
 
     // A subaccount that was never used returns nothing.
     let empty = store
-        .get_postings_by_account(1, Some(9), None, None)
+        .get_postings_by_account(1, Some(9), None, PostingFilter::All)
         .await
         .unwrap();
     assert!(empty.is_empty());
@@ -401,7 +406,7 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
             account: 1,
             sub: None,
             asset: None,
-            status: None,
+            filter: PostingFilter::All,
             limit: Some(2),
             offset: Some(0),
         })
@@ -416,7 +421,7 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
             account: 1,
             sub: None,
             asset: None,
-            status: None,
+            filter: PostingFilter::All,
             limit: Some(2),
             offset: Some(2),
         })
@@ -431,7 +436,7 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
             account: 1,
             sub: None,
             asset: None,
-            status: None,
+            filter: PostingFilter::All,
             limit: Some(2),
             offset: Some(4),
         })
@@ -446,7 +451,7 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
             account: 1,
             sub: None,
             asset: Some(AssetId::new(1)),
-            status: None,
+            filter: PostingFilter::All,
             limit: Some(10),
             offset: None,
         })
@@ -456,22 +461,17 @@ pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
     assert_eq!(filtered.items.len(), 5);
 }
 
-/// Reserve a batch of postings: Active → PendingInactive.
+/// Reserve a batch of postings: active index → reserved index.
 pub async fn reserve_postings_batch(store: &(impl Store + 'static)) {
     let p1 = make_posting([1; 32], 0, 1, 1, 100);
     let p2 = make_posting([1; 32], 1, 1, 1, 200);
     seed_active(store, 200, &[p1.clone(), p2.clone()]).await;
 
-    store
-        .reserve_postings(&[p1.id, p2.id], ReservationId::new(1))
-        .await
-        .unwrap();
+    let rid = ReservationId::new(1);
+    store.reserve_postings(&[p1.id, p2.id], rid).await.unwrap();
 
-    let got = store.get_postings(&[p1.id, p2.id]).await.unwrap();
-    assert!(
-        got.iter()
-            .all(|p| p.status == PostingStatus::PendingInactive)
-    );
+    let states = store.get_posting_states(&[p1.id, p2.id]).await.unwrap();
+    assert!(states.iter().all(|s| *s == PostingState::Reserved(rid)));
 }
 
 /// Reserve only flips the still-Active postings and reports that count; an
@@ -490,7 +490,7 @@ pub async fn reserve_skips_non_active(store: &(impl Store + 'static)) {
         1
     );
 
-    // p1 already PendingInactive → only p2 (still Active) reserves.
+    // p1 already reserved → only p2 (still active) reserves.
     assert_eq!(
         store
             .reserve_postings(&[p1.id, p2.id], ReservationId::new(1))
@@ -499,8 +499,8 @@ pub async fn reserve_skips_non_active(store: &(impl Store + 'static)) {
         1
     );
     assert_eq!(
-        store.get_postings(&[p2.id]).await.unwrap()[0].status,
-        PostingStatus::PendingInactive
+        state_of(store, p2.id).await,
+        PostingState::Reserved(ReservationId::new(1))
     );
 }
 
@@ -518,8 +518,7 @@ pub async fn release_postings_batch(store: &(impl Store + 'static)) {
         .await
         .unwrap();
 
-    let got = store.get_postings(&[p1.id]).await.unwrap();
-    assert_eq!(got[0].status, PostingStatus::Active);
+    assert_eq!(state_of(store, p1.id).await, PostingState::Active);
 }
 
 /// Releasing an Active posting is a no-op (succeeds silently).
@@ -532,16 +531,15 @@ pub async fn release_active_is_noop(store: &(impl Store + 'static)) {
         .await
         .unwrap();
 
-    let got = store.get_postings(&[p1.id]).await.unwrap();
-    assert_eq!(got[0].status, PostingStatus::Active);
+    assert_eq!(state_of(store, p1.id).await, PostingState::Active);
 }
 
-/// Releasing an Inactive (void) posting is a no-op: zero rows released.
+/// Releasing a spent posting is a no-op: zero rows released.
 pub async fn release_inactive_zero(store: &(impl Store + 'static)) {
     let p1 = make_posting([1; 32], 0, 1, 1, 100);
     seed_active(store, 200, std::slice::from_ref(&p1)).await;
 
-    // Deactivate p1 (raw path: still Active) so the release sees a void posting.
+    // Deactivate p1 (raw path: still active) so the release sees a spent posting.
     assert_eq!(store.deactivate_postings(&[p1.id], None).await.unwrap(), 1);
 
     assert_eq!(
@@ -551,14 +549,12 @@ pub async fn release_inactive_zero(store: &(impl Store + 'static)) {
             .unwrap(),
         0
     );
-    assert_eq!(
-        store.get_postings(&[p1.id]).await.unwrap()[0].status,
-        PostingStatus::Inactive
-    );
+    assert_eq!(state_of(store, p1.id).await, PostingState::Spent);
 }
 
-/// Deactivating a reserved posting (saga path) transitions it
-/// PendingInactive → Inactive while a separate insert adds the created posting.
+/// Deactivating a reserved posting (saga path) removes it from the reserved
+/// index (→ spent) while a separate insert adds and activates the created
+/// posting.
 pub async fn commit_deactivates_postings(store: &(impl Store + 'static)) {
     let p1 = make_posting([1; 32], 0, 1, 1, 100);
     seed_active(store, 200, std::slice::from_ref(&p1)).await;
@@ -568,7 +564,7 @@ pub async fn commit_deactivates_postings(store: &(impl Store + 'static)) {
         .unwrap();
 
     let p2 = make_posting([2; 32], 0, 1, 1, 100);
-    // Saga path: p1 is PendingInactive owned by reservation 1.
+    // Saga path: p1 is reserved by reservation 1.
     assert_eq!(
         store
             .deactivate_postings(&[p1.id], Some(ReservationId::new(1)))
@@ -581,11 +577,8 @@ pub async fn commit_deactivates_postings(store: &(impl Store + 'static)) {
         .await
         .unwrap();
 
-    let got = store.get_postings(&[p1.id]).await.unwrap();
-    assert_eq!(got[0].status, PostingStatus::Inactive);
-
-    let got2 = store.get_postings(&[p2.id]).await.unwrap();
-    assert_eq!(got2[0].status, PostingStatus::Active);
+    assert_eq!(state_of(store, p1.id).await, PostingState::Spent);
+    assert_eq!(state_of(store, p2.id).await, PostingState::Active);
 }
 
 // ---------------------------------------------------------------------------
@@ -616,8 +609,8 @@ pub async fn insert_postings_counts(store: &(impl Store + 'static)) {
     assert_eq!(store.insert_postings(&[p1, p2]).await.unwrap(), 0);
 }
 
-/// `deactivate_postings` (raw path) flips Active→Inactive and reports the count;
-/// a replay over already-Inactive postings reports zero.
+/// `deactivate_postings` (raw path) removes active ids (→ spent) and reports the
+/// count; a replay over already-spent postings reports zero.
 pub async fn deactivate_postings_counts(store: &(impl Store + 'static)) {
     let p1 = make_posting([4; 32], 0, 1, 1, 100);
     let p2 = make_posting([4; 32], 1, 1, 1, 200);
@@ -633,7 +626,7 @@ pub async fn deactivate_postings_counts(store: &(impl Store + 'static)) {
             .unwrap(),
         2
     );
-    // replay: already Inactive → 0
+    // replay: already spent → 0
     assert_eq!(
         store
             .deactivate_postings(&[p1.id, p2.id], None)
@@ -641,10 +634,7 @@ pub async fn deactivate_postings_counts(store: &(impl Store + 'static)) {
             .unwrap(),
         0
     );
-    assert_eq!(
-        store.get_postings(&[p1.id]).await.unwrap()[0].status,
-        PostingStatus::Inactive
-    );
+    assert_eq!(state_of(store, p1.id).await, PostingState::Spent);
 }
 
 /// `deactivate_postings` (saga path) only flips postings reserved by the given
@@ -678,6 +668,125 @@ pub async fn deactivate_postings_saga_path(store: &(impl Store + 'static)) {
     );
 }
 
+/// `get_posting_states` reflects index membership across every derived state:
+/// active, reserved (with its owner), spent, and missing.
+pub async fn get_posting_states_reflect_membership(store: &(impl Store + 'static)) {
+    let active = make_posting([9; 32], 0, 1, 1, 100);
+    let reserved = make_posting([9; 32], 1, 1, 1, 200);
+    let spent = make_posting([9; 32], 2, 1, 1, 300);
+    let missing = PostingId {
+        transfer: EnvelopeId([0xEE; 32]),
+        index: 0,
+    };
+    store
+        .insert_postings(&[active.clone(), reserved.clone(), spent.clone()])
+        .await
+        .unwrap();
+
+    let rid = ReservationId::new(3);
+    store.reserve_postings(&[reserved.id], rid).await.unwrap();
+    store.deactivate_postings(&[spent.id], None).await.unwrap();
+
+    let states = store
+        .get_posting_states(&[active.id, reserved.id, spent.id, missing])
+        .await
+        .unwrap();
+    assert_eq!(
+        states,
+        vec![
+            PostingState::Active,
+            PostingState::Reserved(rid),
+            PostingState::Spent,
+            PostingState::Missing,
+        ]
+    );
+}
+
+/// `get_posting_states` stays aligned to input order for a larger batch, with
+/// ids interleaved out of insertion order and repeated. This guards the batched
+/// SQL path, which fetches each state table as a set and must reconstruct the
+/// per-id state positionally (duplicates resolve to the same state).
+pub async fn get_posting_states_batch_preserves_order(store: &(impl Store + 'static)) {
+    let active = make_posting([7; 32], 0, 1, 1, 100);
+    let reserved = make_posting([7; 32], 1, 1, 1, 200);
+    let spent = make_posting([7; 32], 2, 1, 1, 300);
+    let missing = PostingId {
+        transfer: EnvelopeId([0xAB; 32]),
+        index: 5,
+    };
+    store
+        .insert_postings(&[active.clone(), reserved.clone(), spent.clone()])
+        .await
+        .unwrap();
+
+    let rid = ReservationId::new(7);
+    store.reserve_postings(&[reserved.id], rid).await.unwrap();
+    store.deactivate_postings(&[spent.id], None).await.unwrap();
+
+    // Interleaved, out of insertion order, with `active` and `missing` repeated.
+    let query = [
+        missing,
+        spent.id,
+        active.id,
+        reserved.id,
+        active.id,
+        missing,
+    ];
+    let states = store.get_posting_states(&query).await.unwrap();
+    assert_eq!(
+        states,
+        vec![
+            PostingState::Missing,
+            PostingState::Spent,
+            PostingState::Active,
+            PostingState::Reserved(rid),
+            PostingState::Active,
+            PostingState::Missing,
+        ]
+    );
+
+    // Empty input returns an empty vec (the batched path guards this).
+    assert!(store.get_posting_states(&[]).await.unwrap().is_empty());
+}
+
+/// A consumed posting is removed from the indexes but stays in the immutable
+/// table forever: `get_postings` still returns it while its state is `Spent`.
+pub async fn spent_posting_remains_in_immutable_table(store: &(impl Store + 'static)) {
+    let p = make_posting([0xA1; 32], 0, 1, 1, 100);
+    seed_active(store, 0, std::slice::from_ref(&p)).await;
+    store.deactivate_postings(&[p.id], None).await.unwrap();
+
+    let got = store.get_postings(&[p.id]).await.unwrap();
+    assert_eq!(got.len(), 1);
+    assert_eq!(got[0].value, Cent::from(100));
+    assert_eq!(state_of(store, p.id).await, PostingState::Spent);
+}
+
+/// The `Live` filter returns active and reserved postings but excludes spent
+/// ones (the replacement for the old "not Inactive" balance-bearing set).
+pub async fn get_postings_by_account_live_filter(store: &(impl Store + 'static)) {
+    let active = make_posting([0xB2; 32], 0, 1, 1, 100);
+    let reserved = make_posting([0xB2; 32], 1, 1, 1, 200);
+    let spent = make_posting([0xB2; 32], 2, 1, 1, 300);
+    store
+        .insert_postings(&[active.clone(), reserved.clone(), spent.clone()])
+        .await
+        .unwrap();
+    store
+        .reserve_postings(&[reserved.id], ReservationId::new(1))
+        .await
+        .unwrap();
+    store.deactivate_postings(&[spent.id], None).await.unwrap();
+
+    let live = store
+        .get_postings_by_account(1, None, None, PostingFilter::Live)
+        .await
+        .unwrap();
+    let mut ids: Vec<PostingId> = live.iter().map(|p| p.id).collect();
+    ids.sort_by_key(|id| id.index);
+    assert_eq!(ids, vec![active.id, reserved.id]);
+}
+
 /// `store_transfer` returns 1 when the record is newly inserted, 0 on replay,
 /// and indexes the involved accounts.
 pub async fn store_transfer_counts(store: &(impl Store + 'static)) {
@@ -1063,6 +1172,10 @@ macro_rules! store_tests {
             insert_postings_counts,
             deactivate_postings_counts,
             deactivate_postings_saga_path,
+            get_posting_states_reflect_membership,
+            get_posting_states_batch_preserves_order,
+            spent_posting_remains_in_immutable_table,
+            get_postings_by_account_live_filter,
             store_transfer_counts,
             // Reservation / double-spend regressions
             reserve_twice_second_zero,

+ 5 - 5
crates/kuatia-storage/tests/concurrency.rs

@@ -3,7 +3,7 @@
 //! The generated conformance suite drives the store through a single `&store`,
 //! so it never exercises two callers racing on the same rows. `reserve_postings`
 //! is the primitive the saga relies on to make double-spends impossible: it must
-//! flip each `Active` posting to `PendingInactive` for exactly one caller, even
+//! move each active posting into the reserved index for exactly one caller, even
 //! when many callers target the same postings at once.
 
 #![allow(missing_docs)]
@@ -29,7 +29,7 @@ fn posting(index: u16) -> Posting {
 /// Many tasks concurrently reserve the same set of postings, each with its own
 /// reservation id. Reservation is a claim, so each posting may be reserved by
 /// exactly one task: the per-task counts sum to the number of postings, and
-/// every posting ends `PendingInactive`.
+/// every posting ends reserved.
 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
 async fn concurrent_reserve_claims_each_posting_once() {
     const POSTINGS: u16 = 32;
@@ -63,11 +63,11 @@ async fn concurrent_reserve_claims_each_posting_once() {
         "each posting is reserved by exactly one task"
     );
 
-    let final_postings = store.get_postings(&ids).await.unwrap();
+    let states = store.get_posting_states(&ids).await.unwrap();
     assert!(
-        final_postings
+        states
             .iter()
-            .all(|p| p.status == PostingStatus::PendingInactive),
+            .all(|s| matches!(s, PostingState::Reserved(_))),
         "every posting ends reserved"
     );
 }

+ 47 - 38
crates/kuatia-types/src/lib.rs

@@ -547,8 +547,9 @@ impl BookId {
     }
 }
 
-/// Identifies a reservation — the owner token stamped on a posting while it is
-/// `PendingInactive`, so only the saga that reserved it may finalize or release it.
+/// Identifies a reservation — the owner token recorded in the reserved index
+/// while a posting is claimed, so only the saga that reserved it may finalize
+/// or release it.
 #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct ReservationId(pub i64);
 
@@ -661,29 +662,51 @@ impl BookBuilder {
 // Posting
 // ---------------------------------------------------------------------------
 
-/// Lifecycle state of a [`Posting`].
+/// Read filter over the derived lifecycle state of postings.
+///
+/// A posting's state is no longer stored on the posting itself; it is derived
+/// from index-table membership. This filter selects which postings a read
+/// returns:
+///
+/// - `Active` — spendable (present in the active index).
+/// - `Reserved` — claimed by an in-flight saga (present in the reserved index).
+/// - `Live` — `Active ∪ Reserved`; everything that still counts toward balance.
+/// - `All` — every posting in the immutable table, including spent ones.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum PostingFilter {
+    /// Spendable postings only.
+    Active,
+    /// Reserved (in-flight) postings only.
+    Reserved,
+    /// Active or reserved — the balance-bearing set (the old "not Inactive").
+    Live,
+    /// Every posting ever created, including spent ones.
+    All,
+}
+
+/// The derived lifecycle state of a single [`Posting`], computed from
+/// index-table membership rather than stored on the posting.
 ///
 /// ```text
-/// Active ──reserve──▶ PendingInactive ──finalize──▶ Inactive (void)
-///   ▲  ▲                    │
-///   │  └─── release (no-op) ┘
-///   └────── release ────────┘  (compensation)
+/// Active ──reserve──▶ Reserved(rid) ──consume──▶ Spent
+///   ▲  ▲                   │
+///   │  └── release ────────┘  (compensation)
+///   └── (id in active index)
 /// ```
 ///
-/// `reserve_postings` and `release_postings` are batch operations:
-/// - **reserve**: all postings must be Active, otherwise the batch fails.
-/// - **release**: Active is a no-op, PendingInactive reverts to Active,
-///   Inactive (void) fails the batch.
+/// `Reserved` carries the owning [`ReservationId`] so a saga can confirm it
+/// still holds a posting before finalizing or releasing it. `Missing` means the
+/// id is not present in the immutable postings table at all.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
-pub enum PostingStatus {
-    /// Available for consumption and counted in balance.
+pub enum PostingState {
+    /// Present in the active index — spendable, counts toward balance.
     Active,
-    /// Reserved for a transfer; not available for other consumption.
-    /// Reverts to `Active` on compensation via `release_postings`.
-    PendingInactive,
-    /// Consumed by a committed transfer. Kept for audit trail (void).
-    /// Cannot be released.
-    Inactive,
+    /// Present in the reserved index, claimed by the given reservation.
+    Reserved(ReservationId),
+    /// Present only in the immutable table — consumed by a committed transfer.
+    Spent,
+    /// Not present in the immutable table.
+    Missing,
 }
 
 /// A signed amount of one asset, owned by exactly one account.
@@ -691,6 +714,10 @@ pub enum PostingStatus {
 /// A positive posting is value controlled by the account; a negative posting is
 /// an offset position (issuance, external flow, overdraft, or system balancing).
 /// Negative postings are allowed on every policy except `NoOverdraft`.
+///
+/// A `Posting` is an immutable record: once created it is never updated. Its
+/// lifecycle state is not a field here; it is derived from index-table
+/// membership (see [`PostingState`]).
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct Posting {
     /// Unique identifier derived from the creating transfer.
@@ -701,31 +728,18 @@ pub struct Posting {
     pub asset: AssetId,
     /// Signed: positive = value controlled by the account, negative = offset position.
     pub value: Cent,
-    /// Lifecycle state — only `Active` postings count toward balance.
-    pub status: PostingStatus,
-    /// Owner token while `PendingInactive`. `Some(rid)` iff reserved by saga
-    /// `rid`; `None` when `Active` or `Inactive`. Only the holder of a matching
-    /// `ReservationId` may finalize or release a reserved posting.
-    pub reservation: Option<ReservationId>,
 }
 
 impl Posting {
-    /// Construct an `Active`, unreserved posting.
+    /// Construct a posting record.
     pub fn new(id: PostingId, owner: AccountId, asset: AssetId, value: Cent) -> Self {
         Self {
             id,
             owner,
             asset,
             value,
-            status: PostingStatus::Active,
-            reservation: None,
         }
     }
-
-    /// Returns `true` if this posting's status is [`PostingStatus::Active`].
-    pub fn is_active(&self) -> bool {
-        self.status == PostingStatus::Active
-    }
 }
 
 /// A posting to be created — carries no id yet because the [`PostingId`] depends
@@ -1200,11 +1214,6 @@ impl ToBytes for Posting {
         buf.extend(self.owner.to_bytes());
         buf.extend_from_slice(&self.asset.0.to_be_bytes());
         buf.extend(self.value.to_bytes());
-        buf.push(match self.status {
-            PostingStatus::Active => 0,
-            PostingStatus::PendingInactive => 1,
-            PostingStatus::Inactive => 2,
-        });
         buf
     }
 }

+ 6 - 7
crates/kuatia/src/inflight.rs

@@ -21,7 +21,7 @@ use kuatia_core::{
 };
 use kuatia_storage::error::StoreError;
 use kuatia_storage::store::EnvelopeRecord;
-use kuatia_types::PostingStatus;
+use kuatia_types::PostingFilter;
 use serde::{Deserialize, Serialize};
 
 use crate::error::LedgerError;
@@ -531,15 +531,14 @@ impl Ledger {
         self.commit(tx).await
     }
 
-    /// Close a holding account once it has no live (Active/PendingInactive)
-    /// postings left. No-op if already closed or still holding funds.
+    /// Close a holding account once it has no live (active or reserved) postings
+    /// left. No-op if already closed or still holding funds.
     async fn close_if_drained(&self, hold: &AccountId) -> Result<(), LedgerError> {
-        let live = self
+        let live = !self
             .store()
-            .get_postings_by_account(hold.id, Some(hold.sub), None, None)
+            .get_postings_by_account(hold.id, Some(hold.sub), None, PostingFilter::Live)
             .await?
-            .into_iter()
-            .any(|p| p.status != PostingStatus::Inactive);
+            .is_empty();
         if live {
             return Ok(());
         }

+ 61 - 43
crates/kuatia/src/ledger.rs

@@ -8,8 +8,9 @@ use tracing::instrument;
 
 use kuatia_core::{
     AccountId, AccountPolicy, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope,
-    EnvelopeBuilder, EnvelopeId, NewPosting, PlanInput, Posting, PostingId, PostingStatus, Receipt,
-    SelectionError, Transfer, account_snapshot_id, envelope_id, select_postings, validate_and_plan,
+    EnvelopeBuilder, EnvelopeId, NewPosting, PlanInput, Posting, PostingFilter, PostingId,
+    PostingState, Receipt, SelectionError, Transfer, account_snapshot_id, envelope_id,
+    select_postings, validate_and_plan,
 };
 
 use crate::error::LedgerError;
@@ -40,8 +41,8 @@ enum SagaPhase {
     /// re-reserve and re-validate before it can commit.
     Reserving,
     /// Saved at the start of finalize — after validation passed and just before
-    /// the consumed postings begin turning `Inactive` (the point of no return).
-    /// Recovery rolls forward without re-validating.
+    /// the consumed postings begin being removed from the reserved index (the
+    /// point of no return). Recovery rolls forward without re-validating.
     Finalizing,
 }
 
@@ -195,7 +196,7 @@ impl Ledger {
                     account.id,
                     Some(account.sub),
                     Some(asset),
-                    Some(PostingStatus::Active),
+                    PostingFilter::Active,
                 )
                 .await?;
             let total_positive = Cent::checked_sum(
@@ -435,13 +436,13 @@ impl Ledger {
     /// Idempotently finalize `envelope` to its committed state, **verifying every
     /// step's end-state**. Used by the saga's finalize step and by recovery.
     ///
-    /// When the consumed postings are still pre-deactivation it re-validates
-    /// against current state (the last-step floor / freeze-close guard) and then
-    /// marks the saga `Finalizing` (the point of no return). Once any consumed
-    /// posting is already `Inactive` — a prior attempt or recovery passed that
-    /// point — it rolls forward without re-validating (validation rejects
-    /// `Inactive`). It never creates or stores anything unless **all** consumed
-    /// postings are confirmed `Inactive`, which is the double-spend guard.
+    /// When the consumed postings are still reserved it re-validates against
+    /// current state (the last-step floor / freeze-close guard) and then marks
+    /// the saga `Finalizing` (the point of no return). Once any consumed posting
+    /// is already spent — a prior attempt or recovery passed that point — it
+    /// rolls forward without re-validating. It never creates or stores anything
+    /// unless **all** consumed postings are confirmed spent, which is the
+    /// double-spend guard.
     pub(crate) async fn finalize_envelope(
         &self,
         envelope: &Envelope,
@@ -458,39 +459,45 @@ impl Ledger {
         }
         let consumes = envelope.consumes();
 
-        // Read consumed postings (also captures their owners for indexing).
+        // Read consumed postings (immutable rows, kept for owner indexing) and
+        // their derived states.
         let consumed = if consumes.is_empty() {
             Vec::new()
         } else {
             self.store.get_postings(consumes).await?
         };
-        let past_no_return = consumed.iter().any(|p| p.status == PostingStatus::Inactive);
+        let states = if consumes.is_empty() {
+            Vec::new()
+        } else {
+            self.store.get_posting_states(consumes).await?
+        };
+        let past_no_return = states.contains(&PostingState::Spent);
 
         // Last-step boundary re-check: re-validate floor + freeze/close + snapshots
         // against current state, but only while it is still safe (validation
-        // rejects already-`Inactive` consumed postings).
+        // rejects a consumed posting that is no longer live).
         if !past_no_return {
             let loaded = self.load(envelope).await?;
             self.plan(envelope, &loaded)?;
         }
 
-        // Point of no return: record Finalizing before any posting turns Inactive.
+        // Point of no return: record Finalizing before any posting is consumed.
         self.save_pending(envelope, reservation, SagaPhase::Finalizing)
             .await?;
 
-        // Deactivate consumed postings (PendingInactive owned by us → Inactive),
-        // then assert ALL consumed postings are Inactive. This is the double-spend
-        // guard: do not create/store unless the inputs were really consumed by us.
+        // Consume our reserved postings (remove from the reserved index → spent),
+        // then assert ALL consumed postings are spent. This is the double-spend
+        // guard: `deactivate_postings(Some(rid))` only removes rows we reserved,
+        // so any consumed id still active or reserved by another saga leaves the
+        // "all spent" check failing.
         self.store
             .deactivate_postings(consumes, Some(reservation))
             .await?;
         if !consumes.is_empty() {
-            let after = self.store.get_postings(consumes).await?;
-            if after.len() != consumes.len()
-                || after.iter().any(|p| p.status != PostingStatus::Inactive)
-            {
+            let after = self.store.get_posting_states(consumes).await?;
+            if after.len() != consumes.len() || after.iter().any(|s| *s != PostingState::Spent) {
                 return Err(LedgerError::Store(StoreError::Internal(
-                    "finalize: consumed postings not all inactive (contended or not reserved by this saga)".into(),
+                    "finalize: consumed postings not all spent (contended or not reserved by this saga)".into(),
                 )));
             }
         }
@@ -651,7 +658,8 @@ impl Ledger {
     // Internal: resolve account snapshots
     // -----------------------------------------------------------------------
 
-    /// Compute balance from non-Inactive postings for an account/asset pair.
+    /// Compute balance from the live (active or reserved) postings for an
+    /// account/asset pair.
     async fn compute_balance(
         &self,
         account: &AccountId,
@@ -659,14 +667,14 @@ impl Ledger {
     ) -> Result<Cent, LedgerError> {
         let postings = self
             .store
-            .get_postings_by_account(account.id, Some(account.sub), Some(asset), None)
+            .get_postings_by_account(
+                account.id,
+                Some(account.sub),
+                Some(asset),
+                PostingFilter::Live,
+            )
             .await?;
-        Ok(Cent::checked_sum(
-            postings
-                .iter()
-                .filter(|p| p.status != PostingStatus::Inactive)
-                .map(|p| p.value),
-        )?)
+        Ok(Cent::checked_sum(postings.iter().map(|p| p.value))?)
     }
 
     async fn resolve_snapshots(
@@ -742,15 +750,13 @@ impl Ledger {
         if current.is_closed() {
             return Err(LedgerError::AccountAlreadyClosed(*id));
         }
-        // Reject if any posting is still live — Active or PendingInactive
-        // (reserved, i.e. a transfer in flight). Only fully Inactive postings
-        // (or none) permit a close.
-        let blocking = self
+        // Reject if any posting is still live — active or reserved (a transfer
+        // in flight). Only spent postings (or none) permit a close.
+        let blocking = !self
             .store
-            .get_postings_by_account(id.id, Some(id.sub), None, None)
+            .get_postings_by_account(id.id, Some(id.sub), None, PostingFilter::Live)
             .await?
-            .into_iter()
-            .any(|p| p.status != PostingStatus::Inactive);
+            .is_empty();
         if blocking {
             return Err(LedgerError::AccountNotEmpty(*id));
         }
@@ -863,17 +869,29 @@ impl Ledger {
         Ok(self.store.query_transfers(query).await?)
     }
 
-    /// Return all postings (any status) for the given account.
+    /// Return all postings (any state) for the given account.
     pub async fn postings(
         &self,
         account: &AccountId,
     ) -> Result<Vec<kuatia_core::Posting>, LedgerError> {
         Ok(self
             .store
-            .get_postings_by_account(account.id, Some(account.sub), None, None)
+            .get_postings_by_account(account.id, Some(account.sub), None, PostingFilter::All)
             .await?)
     }
 
+    /// Return all postings for the given account paired with their derived
+    /// lifecycle state (active, reserved, or spent).
+    pub async fn postings_with_state(
+        &self,
+        account: &AccountId,
+    ) -> Result<Vec<(kuatia_core::Posting, PostingState)>, LedgerError> {
+        let postings = self.postings(account).await?;
+        let ids: Vec<PostingId> = postings.iter().map(|p| p.id).collect();
+        let states = self.store.get_posting_states(&ids).await?;
+        Ok(postings.into_iter().zip(states).collect())
+    }
+
     /// Query postings with filtering and pagination.
     pub async fn query_postings(
         &self,
@@ -1046,7 +1064,7 @@ mod recovery_tests {
     }
 
     /// A commit that crashed mid-finalize (phase Finalizing; the consumed posting
-    /// is already Inactive) is rolled forward by `recover()`.
+    /// is already spent) is rolled forward by `recover()`.
     #[tokio::test]
     async fn recover_completes_partial_finalize() {
         let ledger = funded_ledger().await;
@@ -1214,7 +1232,7 @@ mod recovery_tests {
         );
         let active = ledger
             .store()
-            .get_postings_by_account(1, None, Some(&AssetId::new(1)), Some(PostingStatus::Active))
+            .get_postings_by_account(1, None, Some(&AssetId::new(1)), PostingFilter::Active)
             .await
             .unwrap();
         assert_eq!(active.len(), 1); // back to Active

+ 9 - 8
crates/kuatia/src/saga.rs

@@ -9,7 +9,7 @@
 //! A commit is two saga steps over a pre-resolved [`Envelope`] (resolution runs
 //! before the saga, in `Ledger::commit`):
 //!
-//! 1. **ReservePostingsStep** -- `reserve_postings`: Active → PendingInactive, stamped with the saga's `ReservationId`; interprets the count via `verify_postings`.
+//! 1. **ReservePostingsStep** -- `reserve_postings`: move each consumed posting from the active index into the reserved index under the saga's `ReservationId`; interprets the count via `verify_postings`.
 //! 2. **FinalizeTransferStep** -- delegates to `Ledger::finalize_envelope`, which re-validates against current state (the last-step floor / freeze-close guard), marks the saga `Finalizing`, then runs the dumb primitives (`deactivate_postings` → `insert_postings` → `store_transfer` → `append_event`) verifying every end-state.
 //!
 //! The `EnvelopeSaga` is defined via `legend!` in `ledger.rs` and driven by
@@ -31,7 +31,7 @@ use serde::{Deserialize, Serialize};
 use tracing::Instrument;
 
 use kuatia_core::{
-    AccountId, AssetId, Cent, Envelope, Posting, PostingId, PostingStatus, Receipt, ReservationId,
+    AccountId, AssetId, Cent, Envelope, PostingId, PostingState, Receipt, ReservationId,
     TransferBuilder,
 };
 
@@ -49,17 +49,17 @@ async fn verify_postings(
     store: &dyn Store,
     ids: &[PostingId],
     count: u64,
-    ok: impl Fn(&Posting) -> bool,
+    ok: impl Fn(&PostingState) -> bool,
     what: &str,
 ) -> Result<(), SagaError> {
     if count == ids.len() as u64 {
         return Ok(());
     }
-    let postings = store
-        .get_postings(ids)
+    let states = store
+        .get_posting_states(ids)
         .await
         .map_err(|e| SagaError::from(LedgerError::Store(e)))?;
-    if postings.len() == ids.len() && postings.iter().all(&ok) {
+    if states.len() == ids.len() && states.iter().all(&ok) {
         return Ok(());
     }
     Err(SagaError {
@@ -192,7 +192,8 @@ impl LedgerCtx {
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct ReserveInput;
 
-/// Reserves consumed postings by CAS: Active to PendingInactive.
+/// Reserves consumed postings by CAS: move each from the active index to the
+/// reserved index (the delete-returns-one picks a single winner).
 ///
 /// Gets the posting ids from the resolved envelope in the context.
 /// Compensation releases all reserved postings back to Active.
@@ -226,7 +227,7 @@ impl Step<LedgerCtx, SagaError> for ReservePostingsStep {
                 store,
                 &posting_ids,
                 reserved,
-                |p| p.status == PostingStatus::PendingInactive && p.reservation == Some(rid),
+                |s| matches!(s, PostingState::Reserved(r) if *r == rid),
                 "reserve",
             )
             .await?;

+ 3 - 2
crates/kuatia/tests/concurrency.rs

@@ -74,8 +74,9 @@ async fn deposit(ledger: &Arc<Ledger>, to: AccountId, amount: Cent) {
 
 /// Many transfers concurrently try to spend the *same* funded posting to
 /// different recipients. Exactly one may win: the winner's `reserve_postings`
-/// flips the single Active posting to `PendingInactive`, and every other saga's
-/// reserve returns zero for a fresh reservation, so it fails and compensates.
+/// claims the single active posting into the reserved index, and every other
+/// saga's reserve returns zero for a fresh reservation, so it fails and
+/// compensates.
 /// The ledger stays conserved: the payer ends at zero and exactly one recipient
 /// receives the full amount.
 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]

+ 13 - 8
crates/kuatia/tests/integration.rs

@@ -409,12 +409,12 @@ async fn fx_trade_via_market_account() {
     // Build the atomic envelope manually since it spans two assets
     let a1_usd_postings = ledger
         .store()
-        .get_postings_by_account(1, None, Some(&usd()), Some(PostingStatus::Active))
+        .get_postings_by_account(1, None, Some(&usd()), PostingFilter::Active)
         .await
         .unwrap();
     let fx_eur_postings = ledger
         .store()
-        .get_postings_by_account(50, None, Some(&eur()), Some(PostingStatus::Active))
+        .get_postings_by_account(50, None, Some(&eur()), PostingFilter::Active)
         .await
         .unwrap();
 
@@ -535,10 +535,11 @@ async fn close_rejects_reserved_postings() {
 
     deposit(&ledger, account(1), usd(), Cent::from(100), external()).await;
 
-    // Reserve the account's only posting (a transfer in flight): Active → PendingInactive.
+    // Reserve the account's only posting (a transfer in flight): move it from
+    // the active index into the reserved index.
     let postings = ledger
         .store()
-        .get_postings_by_account(1, None, Some(&usd()), Some(PostingStatus::Active))
+        .get_postings_by_account(1, None, Some(&usd()), PostingFilter::Active)
         .await
         .unwrap();
     ledger
@@ -547,7 +548,7 @@ async fn close_rejects_reserved_postings() {
         .await
         .unwrap();
 
-    // Close must reject: the posting is live (PendingInactive), not Inactive.
+    // Close must reject: the posting is live (reserved), not spent.
     let result = ledger.close(&account(1)).await;
     assert!(result.is_err());
 }
@@ -594,9 +595,13 @@ async fn postings_returns_all_postings() {
     // Original 100 posting (now consumed) + 40 change posting (active)
     assert_eq!(posts.len(), 2);
 
-    let active: Vec<_> = posts.iter().filter(|p| p.is_active()).collect();
+    let with_state = ledger.postings_with_state(&account(1)).await.unwrap();
+    let active: Vec<_> = with_state
+        .iter()
+        .filter(|(_, s)| *s == PostingState::Active)
+        .collect();
     assert_eq!(active.len(), 1);
-    assert_eq!(active[0].value, Cent::from(40));
+    assert_eq!(active[0].0.value, Cent::from(40));
 }
 
 #[tokio::test]
@@ -777,7 +782,7 @@ async fn capped_overdraft_creates_negative_posting() {
     // A negative posting now backs the overdraft.
     let postings = ledger
         .store()
-        .get_postings_by_account(10, None, Some(&usd()), Some(PostingStatus::Active))
+        .get_postings_by_account(10, None, Some(&usd()), PostingFilter::Active)
         .await
         .unwrap();
     assert!(postings.iter().any(|p| p.value == Cent::from(-50)));

+ 186 - 0
doc/adr/0016-immutable-postings-index-tables.md

@@ -0,0 +1,186 @@
+# Immutable postings with active/reserved index tables
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-11
+* Targeted modules: `kuatia-types` (`Posting`, `PostingState`,
+  `PostingFilter`), `kuatia-storage` (`PostingStore`, `InMemoryStore`),
+  `kuatia-storage-sql` (schema, migration 004), `kuatia` (`ledger`, `saga`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+ADR-0006 gave every posting a mutable `status` column
+(`Active → PendingInactive → Inactive`) plus a nullable `reservation` token,
+both flipped in place with `UPDATE`. Consumed postings stayed in the table as
+`Inactive` tombstones. That works, but it makes the postings table a
+mutate-in-place table: the commit path needs `UPDATE` rights on it, a
+historical posting's value or state can be rewritten by any code path (or any
+compromised credential) that can issue an `UPDATE`, and every "what can this
+account spend" read scans the full postings history filtered by a partial index
+on `status`, a set that only grows.
+
+The reservation protocol of ADR-0006 does not actually require the state to live
+on the posting. It requires an atomic single-winner claim, durable recoverable
+ownership, and count-returning primitives (ADR-0003). Can we keep those
+guarantees while making a posting an append-only record that is written once and
+never changed?
+
+## Decision Drivers
+
+* **Append-only integrity.** A posting is a fact about a committed transfer.
+  Once written it should never change, matching the append-only stance of
+  ADR-0001 (value as immutable signed postings) and ADR-0007 (undo by
+  compensation, never mutation). The postings table becomes the immutable
+  record; the transfer + event logs already record every create and consume.
+* **Least privilege / tamper surface.** If postings are insert-only, the role
+  that commits transfers needs only `INSERT` on `postings`, never `UPDATE` or
+  `DELETE`. No path can rewrite a historical row. Lifecycle churn is confined to
+  small index tables that hold ids, not monetary values.
+* **Read performance by segregation, not by partial index.** "Spendable" and
+  "live" are hot reads. Rather than scan a growing history and filter by
+  `status` (a partial index over cold and hot rows mixed together), keep the
+  working set in its own physically separate table. The index is the table.
+* **Preserve ADR-0006 semantics.** Unconditional, lock-free double-spend safety;
+  durable ownership that survives the multi-step saga and a crash; expressible
+  as atomic, count-returning dumb-storage instructions (ADR-0003).
+
+## Considered Options
+
+#### Option 1: Keep ADR-0006 (mutable `status` column + partial index)
+
+Leave postings as a mutate-in-place table with `status` + `reservation`.
+
+**Pros:**
+
+* Good, because a posting's state is co-located with its data (one row, no
+  join).
+* Good, because it is already implemented and conformance-tested.
+
+**Cons:**
+
+* Bad, because the commit path requires `UPDATE` on the value-bearing table, so
+  a bug or a compromised credential can rewrite historical postings.
+* Bad, because spendable/live reads scan the full, ever-growing history filtered
+  by `status`; the hot working set is never physically separated from cold
+  tombstones.
+* Bad, because the postings table is not append-only, at odds with ADR-0001/0007.
+
+#### Option 2: Single active table, reservation only in the write-ahead record
+
+Delete a posting id from a single `active_postings` table to reserve it; keep
+the reservation solely in the saga's write-ahead `PendingSaga` blob (ADR-0003).
+
+**Pros:**
+
+* Good, because the schema is minimal (one index table).
+
+**Cons:**
+
+* Bad, because reserved (in-flight) postings are no longer observable in storage,
+  so balance (`Active + Reserved`) and `close` (blocks on live) change behavior,
+  and recovery cannot read "reserved by rid" from a row.
+* Bad, because it silently alters the observable semantics ADR-0006 fixed.
+
+#### Option 3: Immutable postings + two id-only index tables
+
+`postings` becomes insert-only and immutable:
+`(transfer_id, idx, owner, subaccount, asset, value)`, no `status`, no
+`reservation`. Two index tables hold only ids: `active_postings` (membership =
+spendable) and `reserved_postings` (`(id, reservation)`, membership = claimed by
+a saga). A posting's state is derived from membership: in active → `Active`, in
+reserved → `Reserved(rid)`, in neither → `Spent`. Every transition is an
+insert/delete on an index table; the posting row never changes:
+
+* Create (finalize): `INSERT` into `postings`, then `INSERT` id into
+  `active_postings`.
+* Reserve: `DELETE` id from `active_postings`; if it removed a row, `INSERT`
+  id + rid into `reserved_postings`. The delete-returns-one is the atomic
+  single-winner claim.
+* Consume (finalize): `DELETE` id from `reserved_postings` where reservation =
+  rid. The posting stays in `postings` forever, now in neither index = spent.
+* Release (compensation): `DELETE` from `reserved_postings`, `INSERT` back into
+  `active_postings`.
+
+**Pros:**
+
+* Good, because `postings` is append-only. The commit role needs only `INSERT`
+  on it; no code path or credential can rewrite a historical posting. The
+  immutable table is the audit trail, with history also reconstructable from the
+  transfer + event logs.
+* Good, because the active and reserved working sets are physically separate,
+  small, and id-only. Spendable/live reads hit a dedicated table instead of
+  scanning history behind a partial index.
+* Good, because reservation is still a single atomic claim (the delete-CAS picks
+  one winner; the loser sees zero rows), and ownership is still durable and
+  observable: `reserved_postings.reservation` records who holds a posting, so
+  recovery and finalize target only their own rows exactly as in ADR-0006.
+* Good, because it stays within dumb storage: each primitive is one conditional
+  insert/delete returning an affected-row count; the saga interprets it
+  (ADR-0003).
+* Good, because balance (`Active ∪ Reserved`) and `close` (blocks on any live)
+  keep their exact prior semantics, now expressed as index membership instead of
+  a `status` filter.
+
+**Cons:**
+
+* Bad, because a posting's state is no longer co-located with its data: reading
+  state is a membership probe across two tables (`get_posting_states`), and
+  three tables replace one.
+* Bad, because consuming an already-spent posting no longer surfaces as a
+  plan-time validation error; a spent posting is simply absent from the indexes,
+  so the abort moves to the reserve claim / finalize guard (same safety,
+  different surface).
+* Bad, because the schema change is forward-only (migration 004 rebuilds
+  `postings` without the `status`/`reservation` columns).
+
+## Decision Outcome
+
+Chosen option: **Option 3, immutable postings with active/reserved index
+tables**, because it keeps every guarantee of ADR-0006 (lock-free double-spend
+safety, durable recoverable ownership, count-returning primitives) while making
+the value-bearing table append-only. That buys least-privilege security (the
+commit role needs no `UPDATE` on postings, so historical rows are
+tamper-evident by construction) and read performance by segregation (the hot
+active/reserved working set lives in its own id-only tables rather than behind a
+partial index over ever-growing history).
+
+This supersedes the storage representation of ADR-0006. The reservation protocol
+of ADR-0006 stands; only its physical encoding changes, from an in-place
+`status` transition to index-table membership.
+
+### Positive Consequences
+
+* `postings` is insert-only: grant `INSERT`, withhold `UPDATE`/`DELETE`.
+* Reserve is `DELETE FROM active_postings` as the concurrency gate; the saga
+  reads the affected-row count to know it won (ADR-0003).
+* Recovery still distinguishes "reserved by this saga" (row present in
+  `reserved_postings` with our rid) from "spent" (absent from both indexes) and
+  "taken by another" (reserved by a different rid), which is what makes
+  phase-tracked roll-forward safe.
+* The immutable postings table and the append-only logs can be archived or
+  pruned independently of the live working set, which partly addresses the
+  deferred retention question (README "Recommended future ADRs").
+
+### Negative Consequences
+
+* Reading a posting's state is a probe across `active_postings` /
+  `reserved_postings` / `postings` rather than one column.
+* Three tables instead of one; ports of the store must keep the reserve claim
+  (delete-from-active + insert-into-reserved) atomic.
+* No `Inactive` tombstone: a spent posting's state is "present in `postings`,
+  absent from the indexes." Consumers that read per-posting state use the
+  derived `PostingState`, not a stored column.
+
+## Links
+
+* Supersedes the storage representation of
+  [ADR-0006](0006-reservation-protocol-posting-lifecycle.md) (the reservation
+  protocol itself is unchanged).
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md) (immutable signed
+  postings) and the dumb-storage recovery of
+  [ADR-0003](0003-dumb-storage-saga-recovery.md).
+* Extended by the conformance suite of
+  [ADR-0008](0008-conformance-tested-storage.md) (membership-based transition
+  tests).
+* Background: [architecture.md](../architecture.md) ("Posting Lifecycle").

+ 149 - 0
doc/adr/0017-correctness-first-append-only-hot-indexes.md

@@ -0,0 +1,149 @@
+# Correctness-first storage: append-only value tables, disposable hot indexes
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-11
+* Targeted modules: `kuatia-storage`, `kuatia-storage-sql`, and the storage
+  schema (postings, accounts, and their hot tables)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+The ledger's correctness rests on never losing or corrupting value and audit
+data. Earlier decisions evolved the storage piecemeal: value as immutable signed
+postings (ADR-0001), the reservation lifecycle (ADR-0006), and moving a posting's
+lifecycle state out of a mutable column into separate tables (ADR-0016).
+Accounts followed the same append-only shape, and the separate tables later
+gained full row copies for direct reads.
+
+This ADR states the principle those changes converged on, so the *why* is
+explicit rather than implied across three ADRs: how should the schema be shaped
+so that the correctness of the ledger does not depend on trusting every write
+path to touch mutable state correctly?
+
+## Decision Drivers
+
+* **Correctness (primary).** The source of truth must not be corruptible or
+  loseable by a bug or a mistaken write. A mistake in a fast-access structure
+  should be recoverable, not a data-loss event.
+* **Least privilege / defense in depth.** The database itself should be able to
+  reject the dangerous operations, not just the application. This is only
+  possible if the write patterns map to a small, grantable set of operations.
+* **Read performance.** The common "what is spendable / what is the current
+  account" reads should hit a small, dedicated structure, not scan history.
+
+## Considered Options
+
+#### Option 1: Mutable-in-place columns
+
+Keep one table per entity and mutate a `status` / current-version column in
+place with `UPDATE`.
+
+**Pros:**
+
+* Good, because state is co-located with the row (one table, no duplication).
+
+**Cons:**
+
+* Bad, because the value and audit data lives in a table that is updated in
+  place, so a bug or a compromised credential can silently rewrite or lose
+  history. Correctness depends entirely on every write path being correct.
+* Bad, because the commit role must hold `UPDATE` on the value-bearing table,
+  which cannot then be withheld as a safety net.
+
+#### Option 2: One table plus a partial index on the mutable column
+
+Keep the single mutable table but add a partial index over the "live" rows.
+
+**Pros:**
+
+* Good, because hot reads can use the partial index.
+
+**Cons:**
+
+* Bad, because the table is still mutated in place (same correctness and
+  privilege problems as Option 1); the hot and cold rows still share one
+  ever-growing, mutable table.
+
+#### Option 3: Append-only value tables plus disposable hot indexes
+
+Split every entity into two kinds of table:
+
+* An **append-only value / audit table** that is only ever inserted into and
+  never updated or deleted. This is the immutable source of truth: `postings`
+  (every posting ever created), `accounts` (every account version).
+* Zero or more **disposable hot tables** that index the current / live subset for
+  fast access, maintained only by `INSERT` and `DELETE` (never `UPDATE`), and
+  fully rebuildable from the value tables: `active_postings` and
+  `reserved_postings` (the spendable / in-flight set), `account_head` (each
+  account's current version).
+
+Derived state (a posting's lifecycle, an account's current version) is read from
+hot-table membership, not from a mutable column. The hot tables carry full row
+copies of the live set, so reads hit them directly without joining back to the
+value tables.
+
+**Pros:**
+
+* Good, because the source of truth is append-only and therefore cannot be
+  corrupted or lost by any write path. Correctness no longer depends on trusting
+  updates to be right.
+* Good, because a bug in a disposable hot table is recoverable: the hot tables
+  are derivable from the value tables and can be dropped and rebuilt.
+* Good, because the entire write path reduces to `INSERT` and `DELETE`, which the
+  database can enforce with grants: the ledger role gets `INSERT` on the value
+  tables, `INSERT` + `DELETE` on the hot tables, and `UPDATE` on nothing. The
+  margin for error is enforced below the application.
+* Good, because the hot tables are small and directly readable, so the common
+  reads are fast.
+
+**Cons:**
+
+* Bad, because live rows are duplicated between a value table and its hot table.
+* Bad, because the write primitives must keep the hot tables consistent with the
+  value tables (reserve/release/consume move rows between hot tables).
+* Bad, because per-row state is a membership probe across tables rather than a
+  single column read.
+
+## Decision Outcome
+
+Chosen option: **Option 3, append-only value tables plus disposable hot
+indexes.** Correctness is the deciding driver: making the source of truth
+append-only means no write can corrupt or lose it, and confining all mutation to
+`INSERT` and `DELETE` lets the database enforce that guarantee through grants
+rather than trusting the application. The performance and least-privilege
+benefits follow from the same shape.
+
+The rule going forward: value and audit data lives only in append-only tables;
+anything mutable is a disposable hot table that indexes the current or live
+subset, is maintained by `INSERT` and `DELETE` only, and can be rebuilt from the
+value tables.
+
+### Positive Consequences
+
+* The source of truth (`postings`, `accounts`) is append-only and tamper-evident
+  by construction.
+* The write path is grantable as `INSERT` + `DELETE` with no `UPDATE` anywhere,
+  so the database rejects the dangerous operation independently of the code.
+* Hot tables are disposable and rebuildable, so a defect there is recoverable
+  rather than a loss of truth.
+* Hot reads hit small, dedicated, directly-readable tables.
+
+### Negative Consequences
+
+* Live rows are stored twice (value table + hot table).
+* The move primitives (reserve/release/consume) own keeping the hot tables
+  consistent with the value tables.
+* Reading a single entity's state is a membership probe, not one column.
+
+## Links
+
+* Generalizes and refines
+  [ADR-0016](0016-immutable-postings-index-tables.md), and supersedes its
+  "id-only" hot-table detail: the hot tables now carry full row copies so reads
+  do not merge back to the value table.
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md) (value as immutable
+  signed postings) and [ADR-0006](0006-reservation-protocol-posting-lifecycle.md)
+  (the reservation lifecycle the hot tables encode).
+* Covers `account_head` (the current-version hot table for accounts) and the
+  set-based reserve/release/consume primitives that move rows between hot tables.

+ 12 - 8
doc/adr/README.md

@@ -15,7 +15,7 @@ to reverse a decision. Instead, a new ADR supersedes it.
 | [0003](0003-dumb-storage-saga-recovery.md) | Dumb storage + durable saga recovery | accepted | Storage returns affected-row counts and makes no decisions; the saga owns interpretation/idempotency; crash-safety is phase-tracked write-ahead + roll-forward. Refines 0002. |
 | [0004](0004-account-policies-overdraft-model.md) | Account policies & overdraft model | accepted | A closed `AccountPolicy` enum per account gates negative postings + floor; intent is explicit, illegal states unrepresentable. Refines 0001. |
 | [0005](0005-intent-api-movements-vs-envelopes.md) | Intent API: movements vs. envelopes | accepted | Callers express `Movement`/`Transfer` intent; `resolve()` produces the concrete `Envelope`. UTXO mechanics stay internal; idempotency keys on the resolved id. |
-| [0006](0006-reservation-protocol-posting-lifecycle.md) | Reservation protocol & posting lifecycle | accepted | `Active → PendingInactive → Inactive` + a durable `ReservationId` give lock-free, recoverable, exclusive ownership of inputs. The primitive behind 0002/0003. |
+| [0006](0006-reservation-protocol-posting-lifecycle.md) | Reservation protocol & posting lifecycle | accepted (storage representation superseded by 0016) | `Active → PendingInactive → Inactive` + a durable `ReservationId` give lock-free, recoverable, exclusive ownership of inputs. The primitive behind 0002/0003. |
 | [0007](0007-reversal-via-compensating-transfers.md) | Reversal via compensating transfers | accepted | Undo is an inverse envelope committed through the normal path (never deletion/mutation), preserving the append-only audit log. |
 | [0008](0008-conformance-tested-storage.md) | Conformance-tested storage | accepted | One `store_tests!` suite every backend must pass, with `InMemoryStore` as the executable reference; enforces the equal count semantics 0003 relies on. |
 | [0009](0009-monetary-representation-integer-minor-units.md) | Monetary amounts as integer minor units | accepted | `Cent` is an `i64` newtype of minor units with only checked arithmetic; scale lives in the presentation-only `Amount`, not on the stored value or asset. Makes 0001's conservation exact. |
@@ -25,6 +25,8 @@ to reverse a decision. Instead, a new ADR supersedes it.
 | [0013](0013-journaling-model.md) | Journaling model: transfer as journal entry | accepted | A committed `Transfer`/`Envelope` is a (compound) journal entry; the transfer log is the accounting journal; `Book` is a policy scope, not the journal. Frames 0001/0005/0010 in accounting terms. |
 | [0014](0014-inflight-holds-via-holding-accounts.md) | Inflight holds via per-destination holding accounts | accepted | A hold is a subaccount of its destination; committing routes funds through the holding subaccount so pending value stays visible and reconcilable until settle or cancel. |
 | [0015](0015-fixed-width-account-code.md) | Fixed-width 20-character account code | accepted | The IBAN-style code becomes a fixed 20 chars (18-char body + 2 trailing check digits, five groups of four) by packing id (63 bits) and subaccount (30 bits) into one permuted value. Presentation-only; caps the subaccount at `SUB_BITS`. Supersedes the code section of 0012. |
+| [0016](0016-immutable-postings-index-tables.md) | Immutable postings with active/reserved index tables | accepted (hot-table representation refined by 0017) | Postings become an insert-only immutable table; lifecycle state moves to two index tables (`active_postings`, `reserved_postings`). Append-only integrity + least privilege (no `UPDATE`) + hot working set by segregation. Supersedes the storage representation of 0006. |
+| [0017](0017-correctness-first-append-only-hot-indexes.md) | Correctness-first storage: append-only value tables, disposable hot indexes | accepted | The guiding principle: value/audit tables (`postings`, `accounts`) are strictly append-only source of truth; disposable hot tables (`active_postings`, `reserved_postings`, `account_head`) index the live subset by `INSERT`/`DELETE` only and are rebuildable. Correctness first; no `UPDATE` anywhere, enforceable by DB grants. Generalizes 0016 (hot tables now hold full row copies). |
 
 ## Recommended future ADRs
 
@@ -51,11 +53,13 @@ captured as an ADR, roughly in priority order:
    selection (ADR-0001/0005) fragments balances into ever-smaller change
    postings; whether and how to consolidate, and what to do with dust, is
    undecided.
-7. **Retention / pruning of `Inactive` postings and the append-only
-   logs**: both the transfer log and the derived event stream (ADR-0010)
-   grow without bound; archival/retention is deferred and currently a
-   conscious omission.
+7. **Retention / pruning of spent postings and the append-only logs**: the
+   immutable postings table (ADR-0016), the transfer log, and the derived
+   event stream (ADR-0010) all grow without bound; archival/retention is
+   deferred and currently a conscious omission. ADR-0016 makes the spent
+   history physically separable from the live working set, which is where
+   pruning would apply.
 8. **Read/projection consistency model**: a balance is a non-transactional
-   sum over `Active` postings, so a read concurrent with a commit is
-   eventually consistent; the read-side guarantee is implied but never
-   stated.
+   sum over the live (active or reserved) postings, so a read concurrent
+   with a commit is eventually consistent; the read-side guarantee is
+   implied but never stated.