Skip to main content

av_state/
store.rs

1//! The `StateStore` trait and the in-memory reference implementation.
2
3use dashmap::DashMap;
4use parking_lot::Mutex;
5use std::sync::atomic::{AtomicI64, Ordering};
6use std::sync::Arc;
7
8/// State-layer errors.
9///
10/// Deliberately NOT restructured to carry typed `#[source]` errors
11/// (unlike `FinalizeError`/`PipelineError` in av-harness): the derived
12/// `PartialEq`/`Eq` is a load-bearing public contract — the store
13/// contract tests (`redis_contract.rs`, `e2e_race_conditions.rs`) and
14/// budget rollback logic compare error values, and typed sources such
15/// as `std::io::Error`/`redis::RedisError` implement neither. Backends
16/// that swallow a typed error (e.g. `RedisStore`) must render it into
17/// the `Backend` payload at the failure site, where connection context
18/// is still available.
19#[derive(Debug, thiserror::Error, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum StateError {
22    /// Arithmetic would overflow.
23    #[error("counter overflow for key {0:?}")]
24    Overflow(String),
25    /// Backend failure or state-operation contract violation (network
26    /// stores; also API misuse such as duplicate keys in one batch).
27    #[error("state backend unavailable: {0}")]
28    Backend(String),
29}
30
31/// One key/amount/limit entry in an atomic multi-dimensional spend.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Spend {
34    /// Counter key.
35    pub key: String,
36    /// Amount to add.
37    pub amount: u64,
38    /// Maximum resulting counter value.
39    pub limit: u64,
40}
41
42/// One key/amount entry in an atomic multi-dimensional refund. Twin to
43/// [`Spend`]. Introduced in R67 to close R66 F2: the previous shape
44/// issued 3 independent `store.refund` calls (`total_calls`, per-tool,
45/// payout), each its own Redis round-trip. Crash/reconnect between
46/// steps left partial refund state; concurrent `try_tool_call`
47/// under `InMemoryStore` could observe partially-refunded state
48/// (fresh `total_calls` headroom but still-charged per-tool) — an
49/// ordering result impossible under a single-Lua refund and a
50/// cross-backend divergence with `RedisStore` (which has zero
51/// serialization between the 3 refunds).
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Refund {
54    /// Counter key.
55    pub key: String,
56    /// Amount to return.
57    pub amount: u64,
58}
59
60/// Outcome of an atomic multi-dimensional spend. Carries the post-
61/// commit min-headroom across the spent keys on success — a value
62/// that CANNOT race with a concurrent spend/refund because it is
63/// computed inside the same atomic Lua (RedisStore) / mutex-critical
64/// section (InMemoryStore) that commits the spend.
65///
66/// Introduced in R68 to close R66 F3: the previous shape returned
67/// `Result<Option<usize>, StateError>` (`None` = committed), forcing
68/// downstream `remaining_min` code to issue a SECOND set of GET
69/// round-trips to compute the reported headroom. Between the atomic
70/// spend and those GETs, a concurrent `remove_prefix` could DEL the
71/// counter — the GET returns 0, `remaining = cap.saturating_sub(0)`
72/// reports the full budget as available immediately after a debit
73/// that WAS committed. `BudgetDecision::Allowed { remaining }` is
74/// used as a flow-control signal (routes.rs, pipeline.rs), so wrong
75/// values reshape tail behaviour: bursts allowed through when they
76/// should be paced, or requests refused when they should be
77/// admitted. Not a security bug (the spend itself was atomic and
78/// cap-checked); a correctness bug on the "remaining headroom" API
79/// contract.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum TrySpendOutcome {
82    /// All spends committed atomically. `post_commit_min_remaining`
83    /// = min over spends of `spend.limit - post_commit_counter(spend.key)`,
84    /// where the post-commit counter is the value the same atomic
85    /// section wrote. `u64::MAX` for an empty spends slice.
86    Committed {
87        /// Min-headroom across the batch after commit. See enum
88        /// doc for full contract.
89        post_commit_min_remaining: u64,
90    },
91    /// The `index`-th spend would have exceeded its limit; nothing
92    /// was committed.
93    Refused {
94        /// Position in the original spends slice.
95        index: usize,
96    },
97}
98
99/// Atomic counter operations. Every mutation is atomic with respect to
100/// concurrent callers; `try_spend` is a single check-and-spend (never a
101/// read-then-write).
102///
103/// # Counter lifetime
104///
105/// Backends with native expiry apply a TTL to every counter they touch:
106/// `RedisStore` refreshes a 24 h TTL (`BUDGET_COUNTER_TTL_SECS` in
107/// `redis_store.rs`) on each spend/add/refund, so a session idle past
108/// that window has its budget counters silently reset. `InMemoryStore`
109/// never expires. Callers must bound session lifetimes to the backend
110/// TTL (or clean up explicitly via `remove`/`remove_prefix`) so the two
111/// backends stay behaviorally aligned across dev and prod.
112pub trait StateStore: Send + Sync {
113    /// Add `delta` to `key`, returning the new value.
114    fn add(&self, key: &str, delta: u64) -> Result<u64, StateError>;
115
116    /// Atomically retain the greater of the stored value and `value`, returning
117    /// the resulting maximum. Used for monotonic revocation cutoffs.
118    fn fetch_max(&self, _key: &str, _value: u64) -> Result<u64, StateError> {
119        Err(StateError::Backend(
120            "fetch_max is not supported by this state backend".into(),
121        ))
122    }
123
124    /// Backend counter TTL in seconds, if the backend expires counters
125    /// natively. `None` means counters live for the process lifetime
126    /// (in-memory). Exposing it makes the prod/dev divergence
127    /// a VALUE callers can inspect (startup logs it; ops docs cite it)
128    /// instead of a doc comment — a session active longer than this
129    /// window has its budget counters silently reset against the
130    /// expiring backend only.
131    fn counter_ttl_secs(&self) -> Option<u64> {
132        None
133    }
134
135    /// Read the current value of `key` (0 if absent).
136    fn get(&self, key: &str) -> Result<u64, StateError>;
137
138    /// Atomically spend `amount` from the remaining budget `limit - spent(key)`.
139    /// Returns `Ok(true)` and records the spend if the full `amount` fits,
140    /// `Ok(false)` (recording nothing) otherwise.
141    ///
142    /// Default impl routes through [`Self::try_spend_many`]; backends
143    /// override only if they can express single-key check-and-spend
144    /// more cheaply than the multi-key path.
145    fn try_spend(&self, key: &str, amount: u64, limit: u64) -> Result<bool, StateError> {
146        Ok(matches!(
147            self.try_spend_many(&[Spend {
148                key: key.to_owned(),
149                amount,
150                limit,
151            }])?,
152            TrySpendOutcome::Committed { .. }
153        ))
154    }
155
156    /// Atomically validate and commit every spend, or commit none.
157    ///
158    /// Every `Spend` in `spends` must carry a distinct `key`; two entries for
159    /// the same key would each observe the pre-commit value in the check
160    /// phase and pass their independent limit checks, then the commit phase
161    /// would sum them and blow through the cap. Duplicate keys return
162    /// `StateError::Backend` (not `Overflow`), matching the API-misuse class.
163    ///
164    /// On success returns [`TrySpendOutcome::Committed`] carrying the
165    /// post-commit min-headroom across the spent keys — computed
166    /// server-side in the SAME atomic section that committed the
167    /// spend, so it cannot race with a concurrent spend/refund/
168    /// remove_prefix on the same keys. Callers that previously
169    /// followed a successful spend with a `get`-per-dimension
170    /// `remaining_min` computation to build
171    /// `BudgetDecision::Allowed { remaining }` should use the
172    /// returned value directly (R66 F3).
173    ///
174    /// On refusal returns [`TrySpendOutcome::Refused`] with the
175    /// index of the offending spend.
176    fn try_spend_many(&self, spends: &[Spend]) -> Result<TrySpendOutcome, StateError>;
177
178    /// Remove a key (session cleanup).
179    fn remove(&self, key: &str);
180
181    /// Return a previously-spent `amount` to a counter. Saturating: if
182    /// the stored value is below `amount` (e.g. a concurrent
183    /// `remove_prefix` cleared it first, or another refund already
184    /// covered part of the debt) the counter clamps at 0 rather than
185    /// underflowing into "negative budget". Backends must never
186    /// propagate a refund error to the caller — the refund is
187    /// best-effort compensation on a lost-race path where the primary
188    /// verdict has already been decided. Default `remove_prefix`
189    /// semantics apply: backends with native TTL expiry may fold this
190    /// into their own cleanup if they prefer.
191    ///
192    /// Introduced to close a concurrent-MCP
193    /// budget double-spend. When two identical MCP requests race and
194    /// one loses the atomic `execution.claim()`, the sandbox-gate
195    /// spend is refunded so the budget counters reflect only the
196    /// admitted work.
197    fn refund(&self, key: &str, amount: u64) {
198        let _ = (key, amount);
199    }
200
201    /// Atomic multi-key refund. Twin to [`Self::try_spend_many`].
202    ///
203    /// Best-effort by contract (mirroring `refund`): backends
204    /// swallow errors internally so a compensation failure never
205    /// turns into a 5xx on the caller path. The atomicity guarantee
206    /// is: every backend implementation MUST either apply every
207    /// listed refund or none of them, so a concurrent
208    /// `try_tool_call` / `try_spend_many` cannot observe partially-
209    /// refunded state (fresh `total_calls` headroom but still-
210    /// charged per-tool, etc.). The trait's default falls back to
211    /// per-key `refund` calls — safe for the single-key case and
212    /// for backends that inherit the default, but atomicity of
213    /// downstream callers relies on backends overriding this with a
214    /// single-transaction implementation. `InMemoryStore` takes
215    /// its `transaction_lock` once around the whole batch;
216    /// `RedisStore` sends a single Lua script keyed on every
217    /// counter (all sharing the session's Redis Cluster hash tag,
218    /// so cross-slot fan-out is impossible).
219    ///
220    /// Duplicate keys in a single call are applied step-wise per
221    /// entry with the same saturate-at-zero clamp as the single-key
222    /// `refund`; the two backends match. In practice production
223    /// callers (currently only `refund_tool_call`) never produce
224    /// duplicate keys, so the step-wise-vs-summed distinction is
225    /// only relevant to a bespoke caller that intentionally batches
226    /// multiple entries against one counter.
227    fn refund_many(&self, refunds: &[Refund]) {
228        for r in refunds {
229            self.refund(&r.key, r.amount);
230        }
231    }
232
233    /// Remove every key beginning with `prefix` (whole-session cleanup at
234    /// finalization). Every backend must implement this: native expiry
235    /// (e.g. Redis TTLs) is NOT a substitute, because a finalized session
236    /// id can be recycled into a fresh session within the TTL window and
237    /// would inherit the prior incarnation's counters. In-process backends
238    /// additionally need it or session-keyed counters accumulate for the
239    /// process lifetime.
240    ///
241    /// CLUSTER-MODE HAZARD FOR FUTURE CALLERS.
242    /// The Redis Cluster implementation of `remove_prefix` routes SCAN
243    /// and DEL to a SINGLE hash slot — the one derived from the prefix.
244    /// This works only when every key under the prefix shares that
245    /// slot, i.e. the prefix contains a Redis Cluster hash tag
246    /// (`{...}`). Today `ActionBudget::session_prefix` wraps the digest
247    /// in a `{hash-tag}` (see `budget.rs`), so all its keys land in
248    /// the same slot and this is safe. A future caller that does NOT
249    /// hash-tag its prefix would silently leave keys behind in other
250    /// slots. Cross-slot SCAN is not supported by Redis Cluster —
251    /// there is no correct implementation for a non-tagged prefix, so
252    /// this remains a caller-side invariant.
253    fn remove_prefix(&self, prefix: &str) {
254        let _ = prefix;
255    }
256}
257
258/// Reject duplicate keys in one multi-spend batch (shared here so the
259/// check is not written verbatim in both backends). Every backend's
260/// check phase reads the pre-commit value once per entry — two spends
261/// on the same key would each pass their independent limit checks and
262/// the commit phase would sum them, silently blowing through the cap.
263pub(crate) fn refuse_duplicate_spend_keys(spends: &[Spend]) -> Result<(), StateError> {
264    let mut seen = std::collections::HashSet::with_capacity(spends.len());
265    for spend in spends {
266        if !seen.insert(spend.key.as_str()) {
267            return Err(StateError::Backend(format!(
268                "try_spend_many received duplicate key {:?}",
269                spend.key,
270            )));
271        }
272    }
273    Ok(())
274}
275
276/// Single-node in-memory store: atomic counters behind a short transaction
277/// mutex that serializes check-and-spend so multi-key spends stay atomic.
278#[derive(Debug, Default)]
279pub struct InMemoryStore {
280    counters: DashMap<String, Arc<AtomicI64>>,
281    transaction_lock: Mutex<()>,
282}
283
284impl InMemoryStore {
285    /// Create an empty store.
286    pub fn new() -> Self {
287        Self::default()
288    }
289
290    fn cell(&self, key: &str) -> Arc<AtomicI64> {
291        self.counters
292            .entry(key.to_owned())
293            .or_insert_with(|| Arc::new(AtomicI64::new(0)))
294            .clone()
295    }
296}
297
298/// Shared counter ceiling. Both `InMemoryStore` and
299/// `RedisStore::add_on` MUST use this exact value, otherwise the
300/// same trait call succeeds on the in-memory dev/test backend and
301/// silently fails with `StateError::Overflow` in production against
302/// Redis. `av_core::error::JCS_SAFE_MAX = 2^53` is the tightest
303/// bound (imposed by JCS canonicalization — integers past that
304/// point lose precision in receipt bodies), so both backends align
305/// with the eventually-signed representation.
306pub(crate) const COUNTER_MAX: i64 = av_core::error::JCS_SAFE_MAX as i64;
307
308impl StateStore for InMemoryStore {
309    fn fetch_max(&self, key: &str, value: u64) -> Result<u64, StateError> {
310        if value > av_core::error::JCS_SAFE_MAX {
311            return Err(StateError::Overflow(key.to_owned()));
312        }
313        let _transaction = self.transaction_lock.lock();
314        let cell = self.cell(key);
315        let current = cell.load(Ordering::Acquire);
316        if !(0..=COUNTER_MAX).contains(&current) {
317            return Err(StateError::Overflow(key.to_owned()));
318        }
319        let value = i64::try_from(value).map_err(|_| StateError::Overflow(key.to_owned()))?;
320        let prior = cell.fetch_max(value, Ordering::AcqRel);
321        u64::try_from(prior.max(value)).map_err(|_| StateError::Overflow(key.to_owned()))
322    }
323
324    fn add(&self, key: &str, delta: u64) -> Result<u64, StateError> {
325        let _transaction = self.transaction_lock.lock();
326        let delta = i64::try_from(delta).map_err(|_| StateError::Overflow(key.to_owned()))?;
327        let cell = self.cell(key);
328        let prev = cell.load(Ordering::Acquire);
329        let new = prev
330            .checked_add(delta)
331            .filter(|v| *v <= COUNTER_MAX)
332            .ok_or_else(|| StateError::Overflow(key.to_owned()))?;
333        // Only write after confirming no overflow — no transient negative visible to readers.
334        cell.store(new, Ordering::Release);
335        // A negative counter is unreachable through this API (spends check
336        // limits, refunds clamp at 0), but if one ever appears the silent
337        // `unwrap_or(0)` below would mask the corruption; surface it like
338        // `get` does.
339        u64::try_from(new).map_err(|_| StateError::Overflow(key.to_owned()))
340    }
341
342    fn get(&self, key: &str) -> Result<u64, StateError> {
343        match self.counters.get(key) {
344            None => Ok(0),
345            Some(cell) => {
346                let raw = cell.load(Ordering::Acquire);
347                if raw < 0 {
348                    return Err(StateError::Overflow(key.to_owned()));
349                }
350                u64::try_from(raw).map_err(|_| StateError::Overflow(key.to_owned()))
351            }
352        }
353    }
354
355    fn try_spend_many(&self, spends: &[Spend]) -> Result<TrySpendOutcome, StateError> {
356        let _transaction = self.transaction_lock.lock();
357        refuse_duplicate_spend_keys(spends)?;
358        let mut prepared = Vec::with_capacity(spends.len());
359        for (index, spend) in spends.iter().enumerate() {
360            // Match RedisStore's Overflow-reject
361            // discipline instead of silently clamping `limit` down
362            // to COUNTER_MAX. A caller with a config typo
363            // (`max_payout_usd_micros` with one extra zero) would
364            // otherwise succeed on the InMemoryStore dev/test path
365            // and fail with `Overflow` in Redis prod — the exact
366            // cross-backend divergence class already closed
367            // for `add`.
368            if spend.amount > av_core::error::JCS_SAFE_MAX {
369                return Err(StateError::Overflow(spend.key.clone()));
370            }
371            if spend.limit > av_core::error::JCS_SAFE_MAX {
372                return Err(StateError::Overflow(spend.key.clone()));
373            }
374            let amount = i64::try_from(spend.amount).map_err(|_| StateError::Overflow(spend.key.clone()))?;
375            let limit = i64::try_from(spend.limit).map_err(|_| StateError::Overflow(spend.key.clone()))?;
376            // Validation phase must NOT materialize cells (`self.cell`
377            // inserts a fresh 0). A refused batch would otherwise leave
378            // permanent zero counters for every key validated before the
379            // refusal — principal/tool ledger keys are attacker-chosen
380            // and never session-cleaned, so that's unbounded memory
381            // growth (the same class `refund`'s no-resurrect rule closed).
382            // Redis validates GET-only; match it: read existing cells,
383            // treat missing as 0, and create only in the commit phase.
384            let current = self
385                .counters
386                .get(&spend.key)
387                .map(|cell| cell.load(Ordering::Acquire))
388                .unwrap_or(0);
389            let next = current
390                .checked_add(amount)
391                .ok_or_else(|| StateError::Overflow(spend.key.clone()))?;
392            if next > limit {
393                return Ok(TrySpendOutcome::Refused { index });
394            }
395            prepared.push((&spend.key, amount, limit, next));
396        }
397        // R66 F3: compute post-commit min-headroom under the SAME
398        // transaction_lock that commits the spends. A subsequent
399        // `get`-per-key read would race with a concurrent
400        // remove_prefix/spend/refund; this value is the exact
401        // headroom at the instant of commit.
402        let mut min_remaining: u64 = u64::MAX;
403        for (key, amount, limit, next) in prepared {
404            // Every mutator holds `transaction_lock`, so the value read
405            // during validation is still current: fetch_add lands
406            // exactly on `next` (a fresh cell starts at 0 == `current`).
407            let cell = self.cell(key);
408            cell.fetch_add(amount, Ordering::AcqRel);
409            let remaining = u64::try_from(limit - next).unwrap_or(0);
410            min_remaining = min_remaining.min(remaining);
411        }
412        Ok(TrySpendOutcome::Committed {
413            post_commit_min_remaining: min_remaining,
414        })
415    }
416
417    fn remove(&self, key: &str) {
418        let _transaction = self.transaction_lock.lock();
419        self.counters.remove(key);
420    }
421
422    /// Saturating refund. `saturating_sub` on i64 keeps
423    /// the value non-negative even under concurrent `remove_prefix`
424    /// or a duplicate refund; the transaction lock keeps the
425    /// load/store pair atomic with respect to other spend / add
426    /// operations on the same key.
427    ///
428    /// NEVER resurrect a cell that a concurrent
429    /// `remove_prefix` already dropped. The prior implementation
430    /// used `self.cell(key)` which materialises a fresh `0`
431    /// AtomicI64 in the DashMap via `entry().or_insert_with(...)`.
432    /// Under the lost-claim-plus-idle-close ordering
433    /// (mcp_call's sandbox-gate debit races with the reconciler's
434    /// clear_budget_state), the refund path would create a
435    /// permanent 0-cell for a sealed session that no future
436    /// remove_prefix would ever collect — attacker-driven memory
437    /// growth. Skip the refund silently when the cell is gone;
438    /// the "budget spent" state is already whatever the caller
439    /// wanted (probably 0) and there's nothing to compensate.
440    fn refund(&self, key: &str, amount: u64) {
441        let _transaction = self.transaction_lock.lock();
442        let Some(cell) = self.counters.get(key).map(|entry| Arc::clone(entry.value())) else {
443            return;
444        };
445        let prev = cell.load(Ordering::Acquire);
446        let amount = i64::try_from(amount).unwrap_or(i64::MAX);
447        let next = prev.saturating_sub(amount).max(0);
448        cell.store(next, Ordering::Release);
449    }
450
451    /// R66 F2: fold ALL listed refunds under a single acquisition
452    /// of `transaction_lock` so a concurrent `try_spend_many` (which
453    /// also holds this lock) cannot observe partial state. Under
454    /// the old shape (per-key `refund`), a concurrent `try_tool_call`
455    /// running between refund step 1 and step 2 could pass
456    /// `total_calls` (freshly refunded) but be refused by the per-
457    /// tool cap (not yet refunded) — an ordering result impossible
458    /// under the single-Lua RedisStore path. Now the two backends
459    /// serialise the batch identically.
460    fn refund_many(&self, refunds: &[Refund]) {
461        if refunds.is_empty() {
462            return;
463        }
464        let _transaction = self.transaction_lock.lock();
465        for r in refunds {
466            let Some(cell) = self.counters.get(&r.key).map(|entry| Arc::clone(entry.value())) else {
467                continue;
468            };
469            let prev = cell.load(Ordering::Acquire);
470            let amount = i64::try_from(r.amount).unwrap_or(i64::MAX);
471            let next = prev.saturating_sub(amount).max(0);
472            cell.store(next, Ordering::Release);
473        }
474    }
475
476    fn remove_prefix(&self, prefix: &str) {
477        let _transaction = self.transaction_lock.lock();
478        self.counters.retain(|key, _| !key.starts_with(prefix));
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
485
486    use super::*;
487
488    /// The ONE shared backend contract, mirrored by
489    /// `redis_contract.rs::redis_satisfies_the_shared_state_store_contract`
490    /// so the two backends cannot silently drift on semantics again.
491    #[test]
492    fn fetch_max_is_atomic_monotonic_and_bounded() {
493        let store = Arc::new(InMemoryStore::new());
494        let joins: Vec<_> = (0..32)
495            .map(|value| {
496                let store = store.clone();
497                std::thread::spawn(move || store.fetch_max("cutoff", value).unwrap())
498            })
499            .collect();
500        for join in joins {
501            join.join().unwrap();
502        }
503        assert_eq!(store.get("cutoff").unwrap(), 31);
504        assert_eq!(store.fetch_max("cutoff", 10).unwrap(), 31);
505        assert!(matches!(
506            store.fetch_max("cutoff", av_core::error::JCS_SAFE_MAX + 1),
507            Err(StateError::Overflow(_))
508        ));
509        assert_eq!(store.get("cutoff").unwrap(), 31);
510        let cell = store.cell("bad");
511        cell.store(-1, Ordering::Release);
512        assert!(matches!(store.fetch_max("bad", 1), Err(StateError::Overflow(_))));
513        assert_eq!(cell.load(Ordering::Acquire), -1);
514    }
515
516    #[test]
517    fn fetch_max_unsupported_backend_fails_closed() {
518        struct Unsupported;
519        impl StateStore for Unsupported {
520            fn add(&self, _: &str, _: u64) -> Result<u64, StateError> {
521                Ok(0)
522            }
523            fn get(&self, _: &str) -> Result<u64, StateError> {
524                Ok(0)
525            }
526            fn try_spend_many(&self, _: &[Spend]) -> Result<TrySpendOutcome, StateError> {
527                Ok(TrySpendOutcome::Refused { index: 0 })
528            }
529            fn remove(&self, _: &str) {}
530        }
531        assert!(matches!(
532            Unsupported.fetch_max("cutoff", 1),
533            Err(StateError::Backend(_))
534        ));
535    }
536
537    #[test]
538    fn in_memory_satisfies_the_shared_state_store_contract() {
539        let store = InMemoryStore::new();
540        crate::state_store_contract(&store, "contract-tag");
541    }
542
543    /// Mutation-run hardening: the in-memory backend reports NO native
544    /// counter expiry — startup logging and the ops docs cite this value
545    /// to explain the prod/dev budget-window divergence, so a mutant
546    /// answering `Some(0)`/`Some(1)` must not survive.
547    #[test]
548    fn in_memory_counters_report_no_native_ttl() {
549        assert_eq!(InMemoryStore::new().counter_ttl_secs(), None);
550    }
551
552    #[test]
553    fn remove_prefix_drops_only_matching_keys() {
554        let store = InMemoryStore::new();
555        store.add("budget:{aaaa}:tokens", 5).unwrap();
556        store.add("budget:{aaaa}:tool:db_write", 1).unwrap();
557        store.add("budget:{bbbb}:tokens", 7).unwrap();
558        store.remove_prefix("budget:{aaaa}:");
559        assert_eq!(store.get("budget:{aaaa}:tokens").unwrap(), 0);
560        assert_eq!(store.get("budget:{aaaa}:tool:db_write").unwrap(), 0);
561        assert_eq!(
562            store.get("budget:{bbbb}:tokens").unwrap(),
563            7,
564            "other sessions' counters must survive a prefix removal",
565        );
566        assert_eq!(store.counters.len(), 1, "removed cells must actually be freed");
567    }
568
569    #[test]
570    fn add_overflow_rollback_is_never_visible_to_concurrent_get() {
571        // Before the fix, add() did fetch_add + fetch_sub to roll back, and
572        // get() held no lock — so a concurrent get() could see a transiently
573        // negative counter and return Err(Overflow) spuriously.
574        // The fix uses store() only after confirming no overflow, so no
575        // negative value ever hits the cell.
576        use std::sync::{Arc, Barrier};
577        use std::thread;
578
579        let store = Arc::new(InMemoryStore::new());
580        store.add("k", 5).unwrap();
581        let barrier = Arc::new(Barrier::new(3));
582
583        // Writer: push add() to overflow (delta = i64::MAX triggers Overflow).
584        let s1 = Arc::clone(&store);
585        let b1 = Arc::clone(&barrier);
586        let writer = thread::spawn(move || {
587            b1.wait();
588            for _ in 0..500 {
589                let _ = s1.add("k", u64::MAX / 2); // will overflow
590            }
591        });
592
593        // Reader: must never see Err(Overflow) from a spurious negative.
594        let s2 = Arc::clone(&store);
595        let b2 = Arc::clone(&barrier);
596        let reader1 = thread::spawn(move || {
597            b2.wait();
598            for _ in 0..2_000 {
599                let v = s2.get("k");
600                assert!(v.is_ok(), "spurious Overflow from concurrent get: {v:?}");
601            }
602        });
603
604        let s3 = Arc::clone(&store);
605        let b3 = Arc::clone(&barrier);
606        let reader2 = thread::spawn(move || {
607            b3.wait();
608            for _ in 0..2_000 {
609                let v = s3.get("k");
610                assert!(v.is_ok(), "spurious Overflow from concurrent get: {v:?}");
611            }
612        });
613
614        writer.join().unwrap();
615        reader1.join().unwrap();
616        reader2.join().unwrap();
617    }
618
619    #[test]
620    fn poisoned_negative_value_surfaces_as_overflow_not_silent_zero() {
621        // Direct cell manipulation simulates a wire-format corruption or a bug
622        // that leaves the counter negative. `get` must surface an Overflow
623        // error, not silently zero — the previous behavior would let a
624        // corrupted account get a free reset.
625        let s = InMemoryStore::new();
626        let cell = s.cell("k");
627        cell.store(-1, Ordering::Release);
628        match s.get("k") {
629            Err(StateError::Overflow(_)) => (),
630            other => panic!("expected Overflow on negative counter, got {other:?}"),
631        }
632    }
633
634    #[test]
635    fn add_and_get() {
636        let s = InMemoryStore::new();
637        assert_eq!(s.get("k").unwrap(), 0);
638        assert_eq!(s.add("k", 5).unwrap(), 5);
639        assert_eq!(s.add("k", 3).unwrap(), 8);
640        assert_eq!(s.get("k").unwrap(), 8);
641        s.remove("k");
642        assert_eq!(s.get("k").unwrap(), 0);
643    }
644
645    #[test]
646    fn try_spend_respects_limit_exactly() {
647        let s = InMemoryStore::new();
648        assert!(s.try_spend("b", 3, 3).unwrap()); // exactly to the limit: OK
649        assert!(!s.try_spend("b", 1, 3).unwrap()); // over: refused
650        assert_eq!(s.get("b").unwrap(), 3, "refused spend must not record");
651    }
652
653    #[test]
654    fn zero_amount_spend_is_free() {
655        let s = InMemoryStore::new();
656        assert!(s.try_spend("z", 0, 0).unwrap());
657        assert_eq!(s.get("z").unwrap(), 0);
658    }
659
660    #[test]
661    fn overflow_is_loud_not_wrapping() {
662        let s = InMemoryStore::new();
663        assert!(matches!(s.add("o", u64::MAX), Err(StateError::Overflow(_))));
664    }
665
666    /// Silent-error D13.9: 64 threads × 1000 attempts against a 10_000 budget —
667    /// exactly 10_000 must be spent, never more.
668    #[test]
669    fn concurrent_spend_never_exceeds_budget() {
670        let s = Arc::new(InMemoryStore::new());
671        let limit = 10_000u64;
672        let mut handles = Vec::new();
673        for _ in 0..64 {
674            let s = Arc::clone(&s);
675            handles.push(std::thread::spawn(move || {
676                let mut granted = 0u64;
677                for _ in 0..1000 {
678                    if s.try_spend("shared", 1, limit).unwrap() {
679                        granted += 1;
680                    }
681                }
682                granted
683            }));
684        }
685        let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
686        assert_eq!(total, limit, "grants must equal the budget exactly");
687        assert_eq!(s.get("shared").unwrap(), limit);
688    }
689
690    /// Mixed amounts race: partial spends must never let the sum exceed the cap.
691    #[test]
692    fn concurrent_mixed_amounts_never_over_cap() {
693        let s = Arc::new(InMemoryStore::new());
694        let limit = 5_000u64;
695        let mut handles = Vec::new();
696        for t in 0..32 {
697            let s = Arc::clone(&s);
698            handles.push(std::thread::spawn(move || {
699                let mut spent = 0u64;
700                let amount = (t % 7) + 1;
701                for _ in 0..500 {
702                    if s.try_spend("cap", amount, limit).unwrap() {
703                        spent += amount;
704                    }
705                }
706                spent
707            }));
708        }
709        let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
710        assert!(total <= limit, "over-spend: {total} > {limit}");
711        assert_eq!(s.get("cap").unwrap(), total);
712    }
713
714    /// A refused batch must not leave zero-valued cells behind for the
715    /// keys validated before the refusal: principal/tool ledger keys
716    /// are attacker-chosen and never session-cleaned, so materialized
717    /// zeros are unbounded memory growth (Redis validates GET-only and
718    /// has no such residue). The counters map must stay untouched.
719    #[test]
720    fn try_spend_many_refusal_leaves_no_zero_cells() {
721        let s = InMemoryStore::new();
722        let outcome = s
723            .try_spend_many(&[
724                Spend {
725                    key: "session-budget".to_owned(),
726                    amount: 10,
727                    limit: 100,
728                },
729                Spend {
730                    key: "principal:attacker-chosen".to_owned(),
731                    amount: 60,
732                    limit: 50, // refused here
733                },
734            ])
735            .unwrap();
736        assert!(
737            matches!(outcome, TrySpendOutcome::Refused { index: 1 }),
738            "got {outcome:?}"
739        );
740        assert_eq!(
741            s.counters.len(),
742            0,
743            "refused batch materialized cells: {:?}",
744            s.counters.iter().map(|e| e.key().clone()).collect::<Vec<_>>()
745        );
746        // And a committed batch still lands both cells with values.
747        let outcome = s
748            .try_spend_many(&[
749                Spend {
750                    key: "session-budget".to_owned(),
751                    amount: 10,
752                    limit: 100,
753                },
754                Spend {
755                    key: "principal:attacker-chosen".to_owned(),
756                    amount: 40,
757                    limit: 50,
758                },
759            ])
760            .unwrap();
761        assert!(matches!(outcome, TrySpendOutcome::Committed { .. }));
762        assert_eq!(s.get("session-budget").unwrap(), 10);
763        assert_eq!(s.get("principal:attacker-chosen").unwrap(), 40);
764    }
765
766    /// Vicious double-spend bug: `try_spend_many` used to
767    /// validate each Spend against the pre-commit cell value and then commit
768    /// them all sequentially. When two Spends referenced the same key, both
769    /// passed their independent limit checks (each saw `current = 0`), then
770    /// the commit phase INCRBY'd both — silently blowing through the cap.
771    /// Duplicate keys must fail loudly with `Backend`, not silently double-spend.
772    #[test]
773    fn try_spend_many_refuses_duplicate_keys() {
774        let s = InMemoryStore::new();
775        let outcome = s.try_spend_many(&[
776            Spend {
777                key: "budget".to_owned(),
778                amount: 60,
779                limit: 100,
780            },
781            Spend {
782                key: "budget".to_owned(),
783                amount: 60,
784                limit: 100,
785            },
786        ]);
787        match outcome {
788            Err(StateError::Backend(reason)) => {
789                assert!(reason.contains("duplicate key"), "wrong reason: {reason}");
790            }
791            other => panic!("must reject duplicate keys, got {other:?}"),
792        }
793        assert_eq!(
794            s.get("budget").unwrap(),
795            0,
796            "no partial spend must have been committed",
797        );
798    }
799
800    /// Symmetric locking of the fix: legitimate distinct-key multi-spends must
801    /// still succeed exactly as before.
802    #[test]
803    fn try_spend_many_distinct_keys_still_commits_atomically() {
804        let s = InMemoryStore::new();
805        assert_eq!(
806            s.try_spend_many(&[
807                Spend {
808                    key: "a".to_owned(),
809                    amount: 3,
810                    limit: 10,
811                },
812                Spend {
813                    key: "b".to_owned(),
814                    amount: 4,
815                    limit: 10,
816                },
817            ])
818            .unwrap(),
819            TrySpendOutcome::Committed {
820                post_commit_min_remaining: 6,
821            },
822        );
823        assert_eq!(s.get("a").unwrap(), 3);
824        assert_eq!(s.get("b").unwrap(), 4);
825    }
826
827    /// cross-backend divergence closed for
828    /// `try_spend_many`. Historically InMemoryStore silently
829    /// clamped `limit` down to COUNTER_MAX while Redis rejected
830    /// the same call with `Overflow`. A caller with a config typo
831    /// used to succeed in dev/test and fail in Redis prod. Both
832    /// backends now match: reject Overflow on `amount` or `limit`
833    /// past JCS_SAFE_MAX.
834    #[test]
835    fn try_spend_many_rejects_limits_past_counter_max() {
836        let s = InMemoryStore::new();
837        let outcome = s.try_spend_many(&[Spend {
838            key: "a".to_owned(),
839            amount: 1,
840            limit: av_core::error::JCS_SAFE_MAX + 1,
841        }]);
842        assert!(
843            matches!(outcome, Err(StateError::Overflow(_))),
844            "expected Overflow rejection, got {outcome:?}"
845        );
846    }
847
848    #[test]
849    fn try_spend_many_rejects_amounts_past_counter_max() {
850        let s = InMemoryStore::new();
851        let outcome = s.try_spend_many(&[Spend {
852            key: "a".to_owned(),
853            amount: av_core::error::JCS_SAFE_MAX + 1,
854            limit: u64::MAX,
855        }]);
856        assert!(
857            matches!(outcome, Err(StateError::Overflow(_))),
858            "expected Overflow rejection, got {outcome:?}"
859        );
860    }
861
862    /// Refund must NEVER resurrect a cell that a prior
863    /// remove_prefix cleared. The original refund path used
864    /// `self.cell(key)` which materialises a fresh 0-entry via
865    /// `entry().or_insert_with(...)`. Under the lost-claim-plus-
866    /// idle-close ordering (mcp_call sandbox-gate debit races
867    /// reconciler's clear_budget_state), that refund on a swept
868    /// session would leave a permanent zero-valued cell that no
869    /// future remove_prefix could reap — attacker-choosable
870    /// memory growth against a sealed session id.
871    #[test]
872    fn refund_after_remove_prefix_does_not_resurrect_cells() {
873        let s = InMemoryStore::new();
874        s.add("budget:{aaaa}:tool:db_write", 1).unwrap();
875        s.add("budget:{aaaa}:total_calls", 1).unwrap();
876        s.add("budget:{aaaa}:payout", 500_000).unwrap();
877        // Simulate the reconciler's clear_budget_state sweeping the
878        // session between the sandbox debit and the harness refund.
879        s.remove_prefix("budget:{aaaa}:");
880        assert_eq!(s.counters.len(), 0, "prefix sweep must have cleared all");
881        // Now the harness's refund path fires on the same three
882        // keys after the sweep — it must be a silent no-op, NOT
883        // a materialize-then-zero.
884        s.refund("budget:{aaaa}:tool:db_write", 1);
885        s.refund("budget:{aaaa}:total_calls", 1);
886        s.refund("budget:{aaaa}:payout", 500_000);
887        assert_eq!(
888            s.counters.len(),
889            0,
890            "refund on a swept session must not resurrect counter cells (attacker-choosable growth)"
891        );
892    }
893
894    /// Refund on a live session (not swept) still
895    /// compensates the debit exactly. Ensures the no-resurrect
896    /// guard didn't break the happy path.
897    #[test]
898    fn refund_on_live_session_still_compensates_exactly() {
899        let s = InMemoryStore::new();
900        s.add("budget:{live}:tool:db_write", 3).unwrap();
901        s.refund("budget:{live}:tool:db_write", 1);
902        assert_eq!(s.get("budget:{live}:tool:db_write").unwrap(), 2);
903        // Over-refund saturates at 0.
904        s.refund("budget:{live}:tool:db_write", 10);
905        assert_eq!(s.get("budget:{live}:tool:db_write").unwrap(), 0);
906    }
907}