Преглед на файлове

Add expiry to inflight holds so abandoned authorizations self-release

An inflight authorization parked its funders' balance until someone confirmed
or voided it. Nothing bounded that window, so a trade that was never settled
kept the funds out of reach indefinitely, unlike every real payment rail, which
times out an authorization.

An authorization can now carry an optional deadline. Funds still held past it
are returned to their funders automatically. The deadline is recorded in the
authorize transfer's metadata, next to the leg table, so nothing mutable is
stored and the value is content-addressed into the handle like the rest of the
payload. Holds written before this change decode as never-expiring.

The ledger keeps an in-memory BTreeMap keyed by deadline that drives a
background reaper: it sleeps until the earliest deadline rather than polling,
and returns due holds via the ordinary void path, so idempotency, conservation,
and crash recovery are inherited unchanged. The map is a derived cache, rebuilt
from the durable metadata by recover() on startup, so a restart loses no
deadline.

New API: authorize_with_expiry, spawn_expiry_reaper (returns a ReaperHandle),
expire_due for manual sweeps, and rebuild_expiry_index. InflightState::Expired
and expires_at are surfaced on Authorization and InflightStatus. Rationale in
ADR-0016.
Cesar Rodas преди 2 седмици
родител
ревизия
c3ffab4cc9

+ 170 - 0
crates/kuatia/src/expiry.rs

@@ -0,0 +1,170 @@
+//! Hold expiry: an in-memory deadline index and the background reaper that
+//! auto-voids inflight holds once their deadline passes.
+//!
+//! The deadline itself is a durable fact recorded in each authorize transfer's
+//! metadata (see [`crate::inflight`]). This module keeps a derived
+//! `BTreeMap<deadline, {handle}>` on the [`Ledger`], rebuilt from that metadata
+//! on [`Ledger::recover`], and runs a task that sleeps until the earliest
+//! deadline and returns due holds to their funders via the ordinary
+//! [`Ledger::void`] path. See `doc/adr/0016-hold-expiry-and-reaper.md`.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use kuatia_core::EnvelopeId;
+use tokio::task::JoinHandle;
+use tracing::warn;
+
+use crate::error::LedgerError;
+use crate::ledger::{Ledger, now_millis};
+
+/// Handle to a spawned expiry reaper. Dropping it aborts the task, so hold it for
+/// as long as the ledger should auto-void expired holds.
+#[derive(Debug)]
+pub struct ReaperHandle {
+    task: JoinHandle<()>,
+}
+
+impl ReaperHandle {
+    /// Stop the reaper. Equivalent to dropping the handle.
+    pub fn stop(self) {
+        self.task.abort();
+    }
+}
+
+impl Drop for ReaperHandle {
+    fn drop(&mut self) {
+        self.task.abort();
+    }
+}
+
+impl Ledger {
+    /// Record `inflight`'s auto-void deadline in the in-memory index and wake the
+    /// reaper (in case this deadline is earlier than the one it is sleeping on).
+    pub(crate) fn register_expiry(&self, inflight: EnvelopeId, expires_at: i64) {
+        // The map is only ever locked for these tiny, await-free critical
+        // sections, so a poisoned lock is unreachable in practice; fall back to
+        // the inner value rather than panicking if it ever happens.
+        let mut idx = self
+            .expiry
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        idx.entry(expires_at).or_default().insert(inflight);
+        drop(idx);
+        self.reaper_wake.notify_one();
+    }
+
+    /// Drop `inflight` from the deadline index. Called when a hold settles
+    /// (confirm_all / void) so the reaper skips it. A missing entry is a no-op; a
+    /// stale entry left behind is harmless (the reaper's void would be a no-op).
+    pub(crate) fn deregister_expiry(&self, inflight: &EnvelopeId) {
+        let mut idx = self
+            .expiry
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        idx.retain(|_, handles| {
+            handles.remove(inflight);
+            !handles.is_empty()
+        });
+    }
+
+    /// Rebuild the deadline index from the durable authorize metadata, replacing
+    /// whatever was there. Called by [`recover`](Ledger::recover) on startup so
+    /// deadlines set before a restart still drive the reaper.
+    pub async fn rebuild_expiry_index(self: &Arc<Self>) -> Result<(), LedgerError> {
+        let open = self.open_inflights_with_expiry().await?;
+        {
+            let mut idx = self
+                .expiry
+                .lock()
+                .unwrap_or_else(std::sync::PoisonError::into_inner);
+            idx.clear();
+            for (handle, at) in open {
+                idx.entry(at).or_default().insert(handle);
+            }
+        }
+        self.reaper_wake.notify_one();
+        Ok(())
+    }
+
+    /// The earliest registered deadline, if any.
+    fn next_deadline(&self) -> Option<i64> {
+        self.expiry
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner)
+            .keys()
+            .next()
+            .copied()
+    }
+
+    /// Remove and return every handle whose deadline is at or before `now`.
+    fn take_due(&self, now: i64) -> Vec<EnvelopeId> {
+        let mut idx = self
+            .expiry
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        // Split off everything strictly after `now`; keep the due remainder.
+        let future = idx.split_off(&(now + 1));
+        let due: Vec<EnvelopeId> = idx.values().flat_map(|s| s.iter().copied()).collect();
+        *idx = future;
+        due
+    }
+
+    /// Void every hold whose deadline has passed, returning how many were
+    /// processed. Each is removed from the index first (so a failing void does not
+    /// spin the reaper), then voided via the ordinary path. A void that fails
+    /// (e.g. a funder was closed) is logged; the hold reappears on the next
+    /// [`rebuild_expiry_index`](Ledger::rebuild_expiry_index) if still open.
+    pub async fn expire_due(self: &Arc<Self>, now: i64) -> usize {
+        let due = self.take_due(now);
+        for handle in &due {
+            if let Err(e) = self.void(handle).await {
+                warn!(inflight = ?handle, error = %e, "expiry reaper failed to void hold");
+            }
+        }
+        due.len()
+    }
+
+    /// Spawn the background reaper. It sleeps until the earliest deadline, voids
+    /// the holds due at that point, and repeats, waking early whenever a newer,
+    /// earlier deadline is registered. Returns a [`ReaperHandle`]; drop it (or call
+    /// [`ReaperHandle::stop`]) to end the task.
+    ///
+    /// Run at most one reaper per store. Two racing reapers are safe (void is
+    /// idempotent; the loser settles nothing) but redundant.
+    pub fn spawn_expiry_reaper(self: &Arc<Self>) -> ReaperHandle {
+        let ledger = Arc::clone(self);
+        let task = tokio::spawn(async move {
+            loop {
+                match ledger.next_deadline() {
+                    // Nothing scheduled: wait until a deadline is registered.
+                    None => ledger.reaper_wake.notified().await,
+                    Some(at) => {
+                        let now = match now_millis() {
+                            Ok(n) => n,
+                            // Unreachable in practice (clock before the Unix
+                            // epoch); wait for a wake rather than busy-looping.
+                            Err(_) => {
+                                ledger.reaper_wake.notified().await;
+                                continue;
+                            }
+                        };
+                        if at <= now {
+                            ledger.expire_due(now).await;
+                        } else {
+                            // Sleep until the deadline, but wake early if a newer,
+                            // earlier deadline arrives. A registration that lands in
+                            // this gap stores a Notify permit, so it is not lost.
+                            let wait = Duration::from_millis((at - now) as u64);
+                            tokio::select! {
+                                _ = tokio::time::sleep(wait) => {}
+                                _ = ledger.reaper_wake.notified() => {}
+                            }
+                        }
+                    }
+                }
+            }
+        });
+        ReaperHandle { task }
+    }
+}

+ 104 - 14
crates/kuatia/src/inflight.rs

@@ -53,6 +53,10 @@ pub struct Authorization {
     pub inflight: EnvelopeId,
     /// The legs, one per original movement.
     pub legs: Vec<InflightLeg>,
+    /// Deadline (Unix milliseconds) after which the reaper auto-voids any funds
+    /// still held. `None` for [`authorize`](Ledger::authorize): the hold never
+    /// expires and must be settled explicitly.
+    pub expires_at: Option<i64>,
 }
 
 impl Authorization {
@@ -78,6 +82,10 @@ pub enum InflightState {
     Voided,
     /// Fully settled, but a mix of confirmed and voided legs.
     Mixed,
+    /// Funds are still held and the deadline has passed, but the reaper has not
+    /// voided them yet. A transient state resolved to `Voided` (or `Mixed`) once
+    /// the reaper runs.
+    Expired,
 }
 
 /// Per-(destination, asset) status line.
@@ -106,6 +114,9 @@ pub struct InflightStatus {
     pub inflight: EnvelopeId,
     /// One entry per (destination, asset).
     pub legs: Vec<InflightLegStatus>,
+    /// Deadline (Unix milliseconds) after which held funds are auto-voided, or
+    /// `None` if the hold never expires.
+    pub expires_at: Option<i64>,
     /// Overall state.
     pub state: InflightState,
 }
@@ -119,8 +130,14 @@ pub struct InflightStatus {
 /// whole lifecycle is self-describing and read back, not inferred.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 enum InflightMeta {
-    /// Tags the authorize transfer and carries its leg table.
-    Authorize { legs: Vec<InflightLeg> },
+    /// Tags the authorize transfer and carries its leg table. `expires_at` is the
+    /// optional auto-void deadline (Unix ms); `#[serde(default)]` decodes holds
+    /// written before expiry existed as `None` (never expire).
+    Authorize {
+        legs: Vec<InflightLeg>,
+        #[serde(default)]
+        expires_at: Option<i64>,
+    },
     /// Tags a per-destination holding subaccount.
     Hold { destination: AccountId },
     /// Tags a settling transfer that delivers to a destination.
@@ -186,6 +203,28 @@ impl Ledger {
         self: &Arc<Self>,
         transfer: Transfer,
     ) -> Result<Authorization, LedgerError> {
+        self.authorize_inner(transfer, None).await
+    }
+
+    /// Authorize a trade with an auto-void deadline. Identical to
+    /// [`authorize`](Self::authorize), but funds still held at `expires_at` (Unix
+    /// milliseconds) are returned to their funders by the expiry reaper (see
+    /// [`spawn_expiry_reaper`](Self::spawn_expiry_reaper)). A deadline already in
+    /// the past reaps on the next reaper pass. The hold remains manually
+    /// confirmable or voidable before the deadline.
+    pub async fn authorize_with_expiry(
+        self: &Arc<Self>,
+        transfer: Transfer,
+        expires_at: i64,
+    ) -> Result<Authorization, LedgerError> {
+        self.authorize_inner(transfer, Some(expires_at)).await
+    }
+
+    async fn authorize_inner(
+        self: &Arc<Self>,
+        transfer: Transfer,
+        expires_at: Option<i64>,
+    ) -> Result<Authorization, LedgerError> {
         // All holds of this inflight share one subaccount, derived from the
         // submitted trade so it is stable and known before the holds are created
         // (the authorize transfer's own id cannot be used: it is a hash of the
@@ -237,14 +276,22 @@ impl Ledger {
         let mut md = transfer.metadata.clone();
         md.insert(
             K_INFLIGHT.to_string(),
-            encode_meta(&InflightMeta::Authorize { legs: legs.clone() })?,
+            encode_meta(&InflightMeta::Authorize {
+                legs: legs.clone(),
+                expires_at,
+            })?,
         );
         let rewritten = builder.metadata(md).build();
 
         let receipt = self.commit(rewritten).await?;
+        // Register the deadline so the reaper auto-voids it (no-op when None).
+        if let Some(at) = expires_at {
+            self.register_expiry(receipt.transfer_id, at);
+        }
         Ok(Authorization {
             inflight: receipt.transfer_id,
             legs,
+            expires_at,
         })
     }
 
@@ -258,7 +305,7 @@ impl Ledger {
         self: &Arc<Self>,
         inflight: &EnvelopeId,
     ) -> Result<Vec<Receipt>, LedgerError> {
-        let (record, legs) = self.load_inflight(inflight).await?;
+        let (record, legs, _expires_at) = self.load_inflight(inflight).await?;
         let book = record.envelope.book();
         let mut receipts = Vec::new();
         for hold in holds_of(&legs) {
@@ -283,6 +330,8 @@ impl Ledger {
             }
             self.close_if_drained(&hold).await?;
         }
+        // Terminal: drop any pending deadline so the reaper skips it.
+        self.deregister_expiry(inflight);
         Ok(receipts)
     }
 
@@ -304,7 +353,7 @@ impl Ledger {
         inflight: &EnvelopeId,
         confirms: Transfer,
     ) -> Result<Vec<Receipt>, LedgerError> {
-        let (record, legs) = self.load_inflight(inflight).await?;
+        let (record, legs, _expires_at) = self.load_inflight(inflight).await?;
         let book = record.envelope.book();
         let mut receipts = Vec::new();
         let mut touched: BTreeSet<AccountId> = BTreeSet::new();
@@ -354,7 +403,7 @@ impl Ledger {
         self: &Arc<Self>,
         inflight: &EnvelopeId,
     ) -> Result<Vec<Receipt>, LedgerError> {
-        let (record, legs) = self.load_inflight(inflight).await?;
+        let (record, legs, _expires_at) = self.load_inflight(inflight).await?;
         let book = record.envelope.book();
         let mut receipts = Vec::new();
         for hold in holds_of(&legs) {
@@ -398,6 +447,8 @@ impl Ledger {
             }
             self.close_if_drained(&hold).await?;
         }
+        // Terminal: drop any pending deadline so the reaper skips it.
+        self.deregister_expiry(inflight);
         Ok(receipts)
     }
 
@@ -412,7 +463,7 @@ impl Ledger {
         &self,
         inflight: &EnvelopeId,
     ) -> Result<InflightStatus, LedgerError> {
-        let (_record, legs) = self.load_inflight(inflight).await?;
+        let (_record, legs, expires_at) = self.load_inflight(inflight).await?;
 
         // Authorized per (hold, asset).
         let mut authorized: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
@@ -459,10 +510,18 @@ impl Ledger {
             });
         }
 
-        let state = overall_state(&lines);
+        // Report `Expired` when funds are still held past the deadline but the
+        // reaper has not voided them yet. Time is read once, here, so status is a
+        // faithful snapshot rather than an inferred flag.
+        let expired = match expires_at {
+            Some(at) => crate::ledger::now_millis()? >= at,
+            None => false,
+        };
+        let state = overall_state(&lines, expired);
         Ok(InflightStatus {
             inflight: *inflight,
             legs: lines,
+            expires_at,
             state,
         })
     }
@@ -479,25 +538,50 @@ impl Ledger {
             .collect())
     }
 
+    /// Every currently open inflight that carries an auto-void deadline, as
+    /// `(handle, expires_at)`. Reads the deadline straight from each authorize
+    /// transfer's metadata, so it reflects the durable record. Used to rebuild the
+    /// in-memory expiry index on startup (see
+    /// [`rebuild_expiry_index`](Self::rebuild_expiry_index)).
+    pub(crate) async fn open_inflights_with_expiry(
+        &self,
+    ) -> Result<Vec<(EnvelopeId, i64)>, LedgerError> {
+        let mut out: BTreeMap<EnvelopeId, i64> = BTreeMap::new();
+        for hold in self.list_open_inflights().await? {
+            // The authorize transfer creates this hold's postings, so it is in the
+            // hold's history. One authorize funds several holds; the map dedups.
+            for record in self.history(&hold).await? {
+                if let Some(InflightMeta::Authorize {
+                    expires_at: Some(at),
+                    ..
+                }) = read_meta(record.envelope.metadata())
+                {
+                    out.insert(record.receipt.transfer_id, at);
+                }
+            }
+        }
+        Ok(out.into_iter().collect())
+    }
+
     // -----------------------------------------------------------------------
     // Internal helpers
     // -----------------------------------------------------------------------
 
-    /// Load the authorize transfer and decode its leg table.
+    /// Load the authorize transfer and decode its leg table and deadline.
     async fn load_inflight(
         &self,
         inflight: &EnvelopeId,
-    ) -> Result<(EnvelopeRecord, Vec<InflightLeg>), LedgerError> {
+    ) -> Result<(EnvelopeRecord, Vec<InflightLeg>, Option<i64>), LedgerError> {
         let record = self
             .store()
             .get_transfer(inflight)
             .await?
             .ok_or(LedgerError::InflightNotFound(*inflight))?;
-        let legs = match read_meta(record.envelope.metadata()) {
-            Some(InflightMeta::Authorize { legs }) => legs,
+        let (legs, expires_at) = match read_meta(record.envelope.metadata()) {
+            Some(InflightMeta::Authorize { legs, expires_at }) => (legs, expires_at),
             _ => return Err(LedgerError::NotInflightTransaction(*inflight)),
         };
-        Ok((record, legs))
+        Ok((record, legs, expires_at))
     }
 
     /// Commit a `hold -> target` settling transfer tagged with the inflight role.
@@ -590,7 +674,7 @@ fn destination_of(
         .ok_or_else(|| malformed(inflight))
 }
 
-fn overall_state(lines: &[InflightLegStatus]) -> InflightState {
+fn overall_state(lines: &[InflightLegStatus], expired: bool) -> InflightState {
     let mut any_held = false;
     let mut any_confirmed = false;
     let mut any_voided = false;
@@ -605,6 +689,12 @@ fn overall_state(lines: &[InflightLegStatus]) -> InflightState {
             any_voided = true;
         }
     }
+    // A past deadline with funds still held is reported as Expired regardless of
+    // what has settled so far; once the reaper voids the remainder it reads as
+    // Voided or Mixed.
+    if any_held && expired {
+        return InflightState::Expired;
+    }
     match (any_held, any_confirmed, any_voided) {
         (true, false, false) => InflightState::Held,
         (true, _, _) => InflightState::PartiallyConfirmed,

+ 17 - 2
crates/kuatia/src/ledger.rs

@@ -1,9 +1,10 @@
 //! The async ledger resource -- the primary entry point for callers.
 
-use std::collections::HashMap;
-use std::sync::Arc;
+use std::collections::{BTreeMap, BTreeSet, HashMap};
+use std::sync::{Arc, Mutex};
 
 use legend::{ExecutionResult, legend};
+use tokio::sync::Notify;
 use tracing::instrument;
 
 use kuatia_core::{
@@ -68,6 +69,15 @@ pub struct SubAccountBalance {
 /// Async ledger resource composing the commit pipeline.
 pub struct Ledger {
     store: Arc<dyn Store>,
+    /// In-memory index of inflight deadlines: `expires_at` (Unix ms) -> the
+    /// inflight handles due at that time. A derived cache, rebuilt from the
+    /// authorize metadata by [`recover`](Self::recover); it drives the expiry
+    /// reaper (ADR-0016). The durable source of truth is the deadline recorded in
+    /// each authorize transfer's metadata, never this map.
+    pub(crate) expiry: Mutex<BTreeMap<i64, BTreeSet<EnvelopeId>>>,
+    /// Wakes the reaper when a newly authorized hold has an earlier deadline than
+    /// the one it is currently sleeping on.
+    pub(crate) reaper_wake: Notify,
 }
 
 impl Ledger {
@@ -75,6 +85,8 @@ impl Ledger {
     pub fn new(store: impl Store + 'static) -> Self {
         Self {
             store: Arc::new(store),
+            expiry: Mutex::new(BTreeMap::new()),
+            reaper_wake: Notify::new(),
         }
     }
 
@@ -429,6 +441,9 @@ impl Ledger {
                 }
             }
         }
+        // Rebuild the in-memory expiry index from the durable authorize metadata,
+        // so deadlines set before a crash still drive the reaper (ADR-0016).
+        self.rebuild_expiry_index().await?;
         Ok(count)
     }
 

+ 1 - 0
crates/kuatia/src/lib.rs

@@ -5,6 +5,7 @@
 //! commit pipeline (load → plan → apply) behind a convenient async API.
 
 pub mod error;
+pub mod expiry;
 pub mod inflight;
 pub mod ledger;
 pub mod saga;

+ 1 - 0
crates/kuatia/src/prelude.rs

@@ -8,6 +8,7 @@
 pub use kuatia_core::*;
 
 pub use crate::error::LedgerError;
+pub use crate::expiry::ReaperHandle;
 pub use crate::inflight::{
     Authorization, InflightLeg, InflightLegStatus, InflightState, InflightStatus,
 };

+ 243 - 0
crates/kuatia/tests/expiry.rs

@@ -0,0 +1,243 @@
+//! Integration tests for inflight hold expiry: the `expires_at` deadline, the
+//! derived `Expired` state, `expire_due`, the background reaper, and rebuilding
+//! the deadline index from durable metadata.
+
+use std::collections::BTreeMap;
+use std::sync::Arc;
+use std::time::Duration;
+
+use kuatia::prelude::*;
+
+fn usd() -> AssetId {
+    AssetId::new(1)
+}
+fn payer() -> AccountId {
+    AccountId::new(1)
+}
+fn merchant() -> AccountId {
+    AccountId::new(2)
+}
+fn ext() -> AccountId {
+    AccountId::new(99)
+}
+
+fn now_ms() -> i64 {
+    std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .unwrap()
+        .as_millis() as i64
+}
+
+fn make_account(id: i64, policy: AccountPolicy) -> Account {
+    Account {
+        id: AccountId::new(id),
+        version: 1,
+        policy,
+        flags: AccountFlags::empty(),
+        book: BookId(0),
+        metadata: BTreeMap::new(),
+    }
+}
+
+async fn deposit(ledger: &Arc<Ledger>, to: AccountId, asset: AssetId, amount: i64) {
+    let t = TransferBuilder::new()
+        .deposit(to, asset, Cent::from(amount), ext())
+        .unwrap()
+        .build();
+    ledger.commit(t).await.unwrap();
+}
+
+/// A ledger with a payer holding 100 USD, a merchant, and an external account.
+async fn setup() -> Arc<Ledger> {
+    let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+    for id in [1, 2] {
+        ledger
+            .store()
+            .create_account(make_account(id, AccountPolicy::NoOverdraft))
+            .await
+            .unwrap();
+    }
+    ledger
+        .store()
+        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .await
+        .unwrap();
+    deposit(&ledger, payer(), usd(), 100).await;
+    ledger
+}
+
+/// A single-leg trade: payer -> merchant for `amount` USD.
+fn trade(amount: i64) -> Transfer {
+    TransferBuilder::new()
+        .pay(payer(), merchant(), usd(), Cent::from(amount))
+        .build()
+}
+
+async fn bal(ledger: &Arc<Ledger>, account: AccountId, asset: AssetId) -> Cent {
+    ledger.balance(&account, &asset).await.unwrap()
+}
+
+/// The deadline is surfaced on the authorization and on the derived status.
+#[tokio::test]
+async fn deadline_is_surfaced() {
+    let ledger = setup().await;
+    let deadline = now_ms() + 60_000;
+    let auth = ledger
+        .authorize_with_expiry(trade(40), deadline)
+        .await
+        .unwrap();
+    assert_eq!(auth.expires_at, Some(deadline));
+
+    let status = ledger.inflight_status(&auth.inflight).await.unwrap();
+    assert_eq!(status.expires_at, Some(deadline));
+    // Still in the future, funds parked: Held, not Expired.
+    assert_eq!(status.state, InflightState::Held);
+
+    // Plain authorize carries no deadline and never reports Expired.
+    let plain = ledger.authorize(trade(10)).await.unwrap();
+    assert_eq!(plain.expires_at, None);
+    assert_eq!(
+        ledger
+            .inflight_status(&plain.inflight)
+            .await
+            .unwrap()
+            .expires_at,
+        None
+    );
+}
+
+/// A past deadline with funds still held is reported as Expired, before any
+/// reaper runs.
+#[tokio::test]
+async fn past_deadline_reads_expired() {
+    let ledger = setup().await;
+    let auth = ledger
+        .authorize_with_expiry(trade(40), now_ms() - 1)
+        .await
+        .unwrap();
+    let status = ledger.inflight_status(&auth.inflight).await.unwrap();
+    assert_eq!(status.state, InflightState::Expired);
+    // The funds are still held; nothing has moved yet.
+    assert_eq!(bal(&ledger, payer(), usd()).await, Cent::from(60));
+    assert_eq!(bal(&ledger, merchant(), usd()).await, Cent::ZERO);
+}
+
+/// `expire_due` returns every past-deadline hold to its funder, closes the hold,
+/// and is idempotent on a second pass.
+#[tokio::test]
+async fn expire_due_voids_and_returns_funds() {
+    let ledger = setup().await;
+    let auth = ledger
+        .authorize_with_expiry(trade(40), now_ms() - 1)
+        .await
+        .unwrap();
+    assert_eq!(bal(&ledger, payer(), usd()).await, Cent::from(60));
+
+    let reaped = ledger.expire_due(now_ms()).await;
+    assert_eq!(reaped, 1);
+
+    // Funds are back with the payer, nothing reached the merchant, hold closed.
+    assert_eq!(bal(&ledger, payer(), usd()).await, Cent::from(100));
+    assert_eq!(bal(&ledger, merchant(), usd()).await, Cent::ZERO);
+    assert!(ledger.list_open_inflights().await.unwrap().is_empty());
+    let status = ledger.inflight_status(&auth.inflight).await.unwrap();
+    assert_eq!(status.state, InflightState::Voided);
+
+    // Nothing left due: a second sweep is a no-op.
+    assert_eq!(ledger.expire_due(now_ms()).await, 0);
+}
+
+/// A deadline still in the future is left alone by `expire_due`.
+#[tokio::test]
+async fn future_deadline_is_not_reaped() {
+    let ledger = setup().await;
+    let auth = ledger
+        .authorize_with_expiry(trade(40), now_ms() + 60_000)
+        .await
+        .unwrap();
+    assert_eq!(ledger.expire_due(now_ms()).await, 0);
+    assert_eq!(
+        ledger.inflight_status(&auth.inflight).await.unwrap().state,
+        InflightState::Held
+    );
+    assert_eq!(bal(&ledger, payer(), usd()).await, Cent::from(60));
+}
+
+/// The background reaper auto-voids a hold shortly after its deadline passes,
+/// with no manual sweep.
+#[tokio::test]
+async fn reaper_auto_voids_after_deadline() {
+    let ledger = setup().await;
+    let auth = ledger
+        .authorize_with_expiry(trade(40), now_ms() + 150)
+        .await
+        .unwrap();
+    let _reaper = ledger.spawn_expiry_reaper();
+
+    // Poll until the reaper returns the funds, up to a generous timeout.
+    let mut returned = false;
+    for _ in 0..40 {
+        if bal(&ledger, payer(), usd()).await == Cent::from(100) {
+            returned = true;
+            break;
+        }
+        tokio::time::sleep(Duration::from_millis(50)).await;
+    }
+    assert!(returned, "reaper did not void the expired hold in time");
+    assert_eq!(
+        ledger.inflight_status(&auth.inflight).await.unwrap().state,
+        InflightState::Voided
+    );
+    assert!(ledger.list_open_inflights().await.unwrap().is_empty());
+}
+
+/// Confirming before the deadline settles to the destination; a later sweep sees
+/// nothing to reap (the terminal op deregistered the deadline, and the hold is
+/// closed regardless).
+#[tokio::test]
+async fn confirmed_hold_is_not_reaped() {
+    let ledger = setup().await;
+    let auth = ledger
+        .authorize_with_expiry(trade(40), now_ms() - 1)
+        .await
+        .unwrap();
+    ledger.confirm_all(&auth.inflight).await.unwrap();
+    assert_eq!(bal(&ledger, merchant(), usd()).await, Cent::from(40));
+
+    // Even with a past deadline, the settled hold is not touched.
+    assert_eq!(ledger.expire_due(now_ms()).await, 0);
+    assert_eq!(bal(&ledger, merchant(), usd()).await, Cent::from(40));
+    assert_eq!(
+        ledger.inflight_status(&auth.inflight).await.unwrap().state,
+        InflightState::Confirmed
+    );
+}
+
+/// `rebuild_expiry_index` reconstructs the index from durable metadata: it picks
+/// up open holds with deadlines and ignores ones already settled.
+#[tokio::test]
+async fn rebuild_index_reflects_open_holds() {
+    let ledger = setup().await;
+    // One open expiring hold, and one already-voided expiring hold.
+    let open = ledger
+        .authorize_with_expiry(trade(40), now_ms() - 1)
+        .await
+        .unwrap();
+    let closed = ledger
+        .authorize_with_expiry(trade(20), now_ms() - 1)
+        .await
+        .unwrap();
+    ledger.void(&closed.inflight).await.unwrap();
+
+    // Rebuild from scratch: the index now comes only from durable metadata of
+    // still-open holds, so exactly the one open hold is due.
+    ledger.rebuild_expiry_index().await.unwrap();
+    assert_eq!(ledger.expire_due(now_ms()).await, 1);
+
+    // The surviving hold was the open one; funds are fully back with the payer.
+    assert_eq!(bal(&ledger, payer(), usd()).await, Cent::from(100));
+    assert_eq!(
+        ledger.inflight_status(&open.inflight).await.unwrap().state,
+        InflightState::Voided
+    );
+}

+ 173 - 0
doc/adr/0016-hold-expiry-and-reaper.md

@@ -0,0 +1,173 @@
+# Hold expiry via deadline metadata and an in-memory reaper
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-10
+* Targeted modules: `kuatia` (`ledger`, `inflight`, `expiry`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+Inflight holds ([ADR-0014](0014-inflight-holds-via-holding-accounts.md)) park
+funds until a caller confirms or voids them. Nothing bounds that window. An
+authorization that is never settled parks its funders' balance forever: the
+holding subaccounts stay open, the funds stay out of reach, and only a manual
+`void` releases them. This is the authorize/capture pattern with no
+authorization timeout, which every real payment rail has.
+
+We want an authorization to carry an optional deadline, and for the ledger to
+return expired holds to their funders on its own once that deadline passes, with
+no operator action.
+
+Two constraints pull against a naive fix:
+
+* **Derive, don't store.** The rest of the inflight design keeps every fact in
+  the authorize transfer's metadata and derives live state from balances. A
+  deadline should live there too, not in a new mutable "expiry" table that a
+  background job mutates.
+* **No polling storm.** A ledger can hold many open authorizations. Waking on a
+  timer to scan every open hold on every tick wastes work and still reacts late.
+  Expiry should fire close to the deadline without a tight poll loop.
+
+How do we record a per-authorization deadline durably, auto-release on time
+without a mutable expiry store, and drive it efficiently rather than by polling?
+
+## Decision Drivers
+
+* **Reuse the commit path.** Auto-release must be the existing `void`, so
+  idempotency, conservation, and crash recovery are inherited unchanged. No
+  second settlement mechanism.
+* **Durable fact, derived state.** The deadline is recorded once in the authorize
+  transfer's metadata (the same CBOR payload that already carries the leg table).
+  Nothing about expiry is stored mutably.
+* **Rebuildable index.** Any in-memory structure that drives timing must be
+  reconstructable from the durable metadata on startup, so a crash loses no
+  deadline.
+* **Fire near the deadline, not on a poll.** The reaper should sleep until the
+  earliest known deadline, not wake on a fixed interval.
+* **Opt-in and backward compatible.** Existing `authorize` callers and existing
+  stored holds (no deadline) keep never expiring.
+
+## Considered Options
+
+#### Option 1: A `SUM`-free periodic scan of open holds
+
+On a fixed interval, list open inflights, read each deadline from metadata, and
+void the ones past due.
+
+**Pros:**
+
+* Good, because it needs no new field beyond the deadline and no in-memory index:
+  the durable metadata is the only source.
+* Good, because it is trivially correct after a crash: the next scan sees the same
+  open holds.
+
+**Cons:**
+
+* Bad, because it reacts up to one interval late, and tightening the interval
+  turns it into a busy poll over a growing account set.
+* Bad, because every tick re-lists and re-decodes all open holds even when nothing
+  is due, work proportional to open holds rather than to expiries.
+
+#### Option 2: A persisted expiry queue (new store table / sub-trait)
+
+Add a durable table keyed by deadline, written on authorize and deleted on
+settle, that a worker pops from.
+
+**Pros:**
+
+* Good, because pop-until-due is efficient and survives restarts with no rebuild.
+
+**Cons:**
+
+* Bad, because it adds mutable state and a new `Store` sub-trait plus a migration,
+  the opposite of ADR-0014's "existing storage only, derive don't store."
+* Bad, because it duplicates a fact already implied by the authorize metadata,
+  introducing a second source of truth that can drift from the holds it tracks.
+
+#### Option 3: Deadline in metadata, in-memory `BTreeMap` index, reaper task (chosen)
+
+Record an optional `expires_at` (Unix milliseconds) in the existing
+`InflightMeta::Authorize` payload. Keep an in-memory
+`BTreeMap<deadline, {inflight handles}>` on the `Ledger`, populated on
+`authorize_with_expiry` and rebuilt on `recover()` by scanning open inflights and
+reading their deadlines back from metadata. A background reaper task sleeps until
+the map's earliest key, then voids every handle due at or before now via the
+ordinary `void` path and drops them from the map. A `tokio::sync::Notify` wakes
+the reaper when a newly authorized hold has an earlier deadline than it is
+currently sleeping on.
+
+**Pros:**
+
+* Good, because the deadline is one more field in a payload that is already
+  written, hashed, and recovered. No new store, no migration, no mutable table.
+* Good, because the index is a pure cache: it is rebuilt from durable metadata on
+  startup, so a crash loses no deadline, and a stale entry only causes a harmless
+  no-op `void` (a fully settled inflight has zero held balance, so nothing moves).
+* Good, because `BTreeMap` gives the earliest deadline in `O(log n)`, so the
+  reaper sleeps exactly until the next expiry instead of polling.
+* Good, because auto-release is the existing `void`: crash-safe, idempotent,
+  conservation-preserving, and self-describing in the audit trail (an expiry is
+  indistinguishable in the ledger from a manual void, which is correct — the funds
+  went back to the funder either way).
+
+**Cons:**
+
+* Bad, because the index is process-local: the reaper only fires while a ledger
+  instance is running with a spawned reaper. A ledger that is down past a deadline
+  reaps on next startup (rebuild + immediate due sweep), not at the wall-clock
+  instant. Acceptable: the deadline is a floor on when funds *may* return, and the
+  hold is still manually voidable meanwhile.
+* Bad, because two instances against one store could both run reapers and race to
+  void the same hold. Safe by construction — `void` is idempotent and the loser
+  settles nothing — but redundant. Running a single reaper per store is the
+  intended deployment.
+
+## Decision Outcome
+
+Chosen option: **Option 3**, because it adds expiry without a mutable store or a
+second source of truth, reuses `void` and `recover()` wholesale, and drives
+timing off the deadline rather than a poll. Concretely:
+
+* **Deadline in the authorize payload.** `InflightMeta::Authorize` gains
+  `expires_at: Option<i64>` (Unix ms), decoded with `#[serde(default)]` so holds
+  written before this change decode as `None` (never expire).
+* **Opt-in API.** `authorize` keeps its signature and records no deadline.
+  `authorize_with_expiry(transfer, expires_at)` records the deadline and registers
+  the handle in the index. `Authorization` and `InflightStatus` surface the
+  deadline; `InflightStatus` reports `InflightState::Expired` when funds are still
+  held past the deadline but not yet reaped.
+* **In-memory index on the Ledger.** A `Mutex<BTreeMap<i64, BTreeSet<EnvelopeId>>>`
+  keyed by deadline, plus a `Notify`. `authorize_with_expiry` inserts and notifies;
+  `void` / `confirm_all` deregister (a stale entry is harmless regardless).
+* **Rebuild on recover.** `recover()` calls `rebuild_expiry_index()`, which scans
+  open inflights, reads each `expires_at` from the authorize metadata, and repopulates
+  the map. The durable metadata is the source of truth; the map is a derived cache.
+* **Reaper task.** `spawn_expiry_reaper(self: &Arc<Ledger>)` spawns a task that
+  loops: read the earliest deadline; if due, void it and remove it; else sleep
+  until it, waking early if `Notify` signals a newer, earlier deadline; if the map
+  is empty, wait on `Notify`. Dropping the returned handle aborts the task.
+
+### Positive Consequences
+
+* Expiry is a thin layer over `void`, `list_open_inflights`, and the existing
+  metadata. Crash recovery and idempotency come for free; no schema change.
+* Deadlines survive restarts (rebuilt from metadata) and fire promptly (BTree +
+  Notify), without a poll loop or a mutable expiry table.
+* An expired hold's release is a normal void in the audit trail, so no new event
+  kind or reconciliation is needed.
+
+### Negative Consequences
+
+* The reaper is process-local and at-least-once across instances; a single reaper
+  per store is the intended deployment, and redundant reaps are safe no-ops.
+* While the ledger is down, deadlines do not fire; they are swept on next startup
+  after the rebuild, not at the exact wall-clock instant.
+
+## Links
+
+* Extends [ADR-0014](0014-inflight-holds-via-holding-accounts.md) (inflight holds)
+  and reuses [ADR-0003](0003-dumb-storage-saga-recovery.md) (dumb storage, saga
+  recovery) unchanged. Builds on [ADR-0012](0012-subaccounts.md) (holds as
+  subaccounts).
+* Usage and API in [doc/inflight.md](../inflight.md).

+ 1 - 0
doc/adr/README.md

@@ -25,6 +25,7 @@ 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-hold-expiry-and-reaper.md) | Hold expiry via deadline metadata and an in-memory reaper | accepted | An authorization carries an optional `expires_at` in its metadata; an in-memory `BTreeMap` deadline index (rebuilt on recover) drives a background reaper that voids due holds via the ordinary `void` path. No mutable expiry store. Extends 0014. |
 
 ## Recommended future ADRs
 

+ 52 - 1
doc/inflight.md

@@ -41,6 +41,8 @@ stateDiagram-v2
     Held --> Held: confirm (partial)
     Held --> Confirmed: confirm_all / drained
     Held --> Voided: void
+    Held --> Expired: deadline passes
+    Expired --> Voided: reaper voids
     Confirmed --> [*]
     Voided --> [*]
 ```
@@ -48,6 +50,10 @@ stateDiagram-v2
 Every operation is an ordinary `commit`, so idempotency, conservation, and crash
 recovery are inherited unchanged. A hold closes automatically once drained.
 
+`Expired` is a derived, transient state: a hold whose deadline has passed but
+whose funds are still held. The reaper resolves it to `Voided` by returning the
+funds to their funders. See [Expiry](#expiry) below.
+
 ## API
 
 All methods hang off `Ledger`.
@@ -84,9 +90,51 @@ let status = ledger.inflight_status(&auth.inflight).await?;
 let open = ledger.list_open_inflights().await?;
 ```
 
-`authorize` returns an `Authorization { inflight, receipt, legs }`. The
+`authorize` returns an `Authorization { inflight, legs, expires_at }`. The
 `inflight` field (an `EnvelopeId`) is the handle passed to every other call.
 
+## Expiry
+
+A hold can carry a deadline. Funds still held past it are returned to their
+funders automatically, so an abandoned authorization does not park a payer's
+balance forever.
+
+```rust
+// Authorize with an auto-void deadline (Unix milliseconds).
+let deadline = now_millis + 30 * 60 * 1000; // 30 minutes from now
+let auth = ledger.authorize_with_expiry(trade, deadline).await?;
+
+// Run once per process: a background task that voids holds as they expire.
+// Keep the handle alive for as long as the ledger should auto-void; dropping it
+// stops the task.
+let reaper = ledger.spawn_expiry_reaper();
+
+// Or drive expiry manually (e.g. from your own scheduler): void everything due
+// at or before `now`.
+let reaped = ledger.expire_due(now_millis).await;
+```
+
+Plain `authorize` records no deadline: the hold never expires and must be
+settled explicitly.
+
+How it works (see [adr/0016-hold-expiry-and-reaper.md](adr/0016-hold-expiry-and-reaper.md)):
+
+- The deadline is stored in the authorize transfer's metadata, next to the leg
+  table. Nothing mutable is added, and the deadline is content-addressed into the
+  handle like the rest of the payload.
+- The ledger keeps an in-memory `BTreeMap<deadline, {handle}>` that drives the
+  reaper: it sleeps until the earliest deadline rather than polling. The map is a
+  cache, rebuilt from the durable metadata by `Ledger::recover()` on startup, so
+  a restart loses no deadline.
+- Auto-void is the ordinary `void`, so it is crash-safe, idempotent, and appears
+  in the audit trail as a normal void (the funds went back to the funder either
+  way). `inflight_status` reports `InflightState::Expired` for a past-deadline
+  hold that the reaper has not yet reached.
+
+Run at most one reaper per store. Two racing reapers are safe (the loser voids
+nothing) but redundant. While the ledger is down, deadlines do not fire; they are
+swept on the next startup after `recover()` rebuilds the index.
+
 ## Guarantees
 
 - **Over-confirmation is impossible.** A hold is `NoOverdraft`, so confirming
@@ -128,7 +176,10 @@ summed.
 ## Where it lives
 
 - `crates/kuatia/src/inflight.rs` — the API and metadata schema.
+- `crates/kuatia/src/expiry.rs` — the deadline index and the reaper task.
 - `crates/kuatia/tests/inflight.rs` — authorize, confirm, partial confirm, void,
   over-confirm rejection, concurrent inflights per account, segregated balances,
   and status tests.
+- `crates/kuatia/tests/expiry.rs` — deadline surfacing, the `Expired` state,
+  `expire_due`, the reaper, and index rebuild.
 - `AccountFlags::INFLIGHT` — `crates/kuatia-types/src/lib.rs`.