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#[derive(Debug, thiserror::Error, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum StateError {
12    /// Arithmetic would overflow.
13    #[error("counter overflow for key {0:?}")]
14    Overflow(String),
15    /// Backend failure or state-operation contract violation (network
16    /// stores; also API misuse such as duplicate keys in one batch).
17    #[error("state backend unavailable: {0}")]
18    Backend(String),
19}
20
21/// One key/amount/limit entry in an atomic multi-dimensional spend.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Spend {
24    /// Counter key.
25    pub key: String,
26    /// Amount to add.
27    pub amount: u64,
28    /// Maximum resulting counter value.
29    pub limit: u64,
30}
31
32/// Atomic counter operations. Every mutation is atomic with respect to
33/// concurrent callers; `try_spend` is a single check-and-spend (never a
34/// read-then-write).
35pub trait StateStore: Send + Sync {
36    /// Add `delta` to `key`, returning the new value.
37    fn add(&self, key: &str, delta: u64) -> Result<u64, StateError>;
38
39    /// Read the current value of `key` (0 if absent).
40    fn get(&self, key: &str) -> Result<u64, StateError>;
41
42    /// Atomically spend `amount` from the remaining budget `limit - spent(key)`.
43    /// Returns `Ok(true)` and records the spend if the full `amount` fits,
44    /// `Ok(false)` (recording nothing) otherwise.
45    fn try_spend(&self, key: &str, amount: u64, limit: u64) -> Result<bool, StateError>;
46
47    /// Atomically validate and commit every spend, or commit none. Returns the
48    /// index of the first dimension that would exceed its limit.
49    ///
50    /// Every `Spend` in `spends` must carry a distinct `key`; two entries for
51    /// the same key would each observe the pre-commit value in the check
52    /// phase and pass their independent limit checks, then the commit phase
53    /// would sum them and blow through the cap. Duplicate keys return
54    /// `StateError::Backend` (not `Overflow`), matching the API-misuse class.
55    fn try_spend_many(&self, spends: &[Spend]) -> Result<Option<usize>, StateError>;
56
57    /// Remove a key (session cleanup).
58    fn remove(&self, key: &str);
59
60    /// Return a previously-spent `amount` to a counter. Saturating: if
61    /// the stored value is below `amount` (e.g. a concurrent
62    /// `remove_prefix` cleared it first, or another refund already
63    /// covered part of the debt) the counter clamps at 0 rather than
64    /// underflowing into "negative budget". Backends must never
65    /// propagate a refund error to the caller — the refund is
66    /// best-effort compensation on a lost-race path where the primary
67    /// verdict has already been decided. Default `remove_prefix`
68    /// semantics apply: backends with native TTL expiry may fold this
69    /// into their own cleanup if they prefer.
70    ///
71    /// Round-33 F1: introduced to close the round-32 F3 concurrent-MCP
72    /// budget double-spend. When two identical MCP requests race and
73    /// one loses the atomic `execution.claim()`, the sandbox-gate
74    /// spend is refunded so `payout_remaining` and per-tool counters
75    /// reflect only the admitted work.
76    fn refund(&self, key: &str, amount: u64) {
77        let _ = (key, amount);
78    }
79
80    /// Remove every key beginning with `prefix` (whole-session cleanup at
81    /// finalization). Backends with native expiry (e.g. Redis TTLs) may
82    /// leave this as the default no-op; in-process backends must implement
83    /// it or session-keyed counters accumulate for the process lifetime.
84    fn remove_prefix(&self, prefix: &str) {
85        let _ = prefix;
86    }
87}
88
89/// Single-node in-memory store: atomic counters behind a short transaction
90/// mutex that serializes check-and-spend so multi-key spends stay atomic.
91#[derive(Debug, Default)]
92pub struct InMemoryStore {
93    counters: DashMap<String, Arc<AtomicI64>>,
94    transaction_lock: Mutex<()>,
95}
96
97impl InMemoryStore {
98    /// Create an empty store.
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    fn cell(&self, key: &str) -> Arc<AtomicI64> {
104        self.counters
105            .entry(key.to_owned())
106            .or_insert_with(|| Arc::new(AtomicI64::new(0)))
107            .clone()
108    }
109}
110
111/// Shared counter ceiling. Round-20 F1/F7: both `InMemoryStore` and
112/// `RedisStore::add_on` MUST use this exact value, otherwise the
113/// same trait call succeeds on the in-memory dev/test backend and
114/// silently fails with `StateError::Overflow` in production against
115/// Redis. `av_core::error::JCS_SAFE_MAX = 2^53` is the tightest
116/// bound (imposed by JCS canonicalization — integers past that
117/// point lose precision in receipt bodies), so both backends align
118/// with the eventually-signed representation.
119pub(crate) const COUNTER_MAX: i64 = av_core::error::JCS_SAFE_MAX as i64;
120
121impl StateStore for InMemoryStore {
122    fn add(&self, key: &str, delta: u64) -> Result<u64, StateError> {
123        let _transaction = self.transaction_lock.lock();
124        let delta = i64::try_from(delta).map_err(|_| StateError::Overflow(key.to_owned()))?;
125        let cell = self.cell(key);
126        let prev = cell.load(Ordering::Acquire);
127        let new = prev
128            .checked_add(delta)
129            .filter(|v| *v <= COUNTER_MAX)
130            .ok_or_else(|| StateError::Overflow(key.to_owned()))?;
131        // Only write after confirming no overflow — no transient negative visible to readers.
132        cell.store(new, Ordering::Release);
133        Ok(u64::try_from(new).unwrap_or(0))
134    }
135
136    fn get(&self, key: &str) -> Result<u64, StateError> {
137        match self.counters.get(key) {
138            None => Ok(0),
139            Some(cell) => {
140                let raw = cell.load(Ordering::Acquire);
141                if raw < 0 {
142                    return Err(StateError::Overflow(key.to_owned()));
143                }
144                u64::try_from(raw).map_err(|_| StateError::Overflow(key.to_owned()))
145            }
146        }
147    }
148
149    fn try_spend(&self, key: &str, amount: u64, limit: u64) -> Result<bool, StateError> {
150        Ok(self
151            .try_spend_many(&[Spend {
152                key: key.to_owned(),
153                amount,
154                limit,
155            }])?
156            .is_none())
157    }
158
159    fn try_spend_many(&self, spends: &[Spend]) -> Result<Option<usize>, StateError> {
160        let _transaction = self.transaction_lock.lock();
161        // Reject duplicate keys: check phase reads current + adds amount per entry.
162        // Two spends on the same key would each see the pre-commit value and pass
163        // their independent limit checks, then the commit phase would sum them
164        // and silently blow through the cap.
165        let mut seen = std::collections::HashSet::with_capacity(spends.len());
166        for spend in spends {
167            if !seen.insert(spend.key.as_str()) {
168                return Err(StateError::Backend(format!(
169                    "try_spend_many received duplicate key {:?}",
170                    spend.key,
171                )));
172            }
173        }
174        let mut prepared = Vec::with_capacity(spends.len());
175        for (index, spend) in spends.iter().enumerate() {
176            // Round-21 F1: match RedisStore's Overflow-reject
177            // discipline instead of silently clamping `limit` down
178            // to COUNTER_MAX. A caller with a config typo
179            // (`max_payout_usd_micros` with one extra zero) would
180            // otherwise succeed on the InMemoryStore dev/test path
181            // and fail with `Overflow` in Redis prod — the exact
182            // cross-backend divergence class round-20 F1 closed
183            // for `add`.
184            if spend.amount > av_core::error::JCS_SAFE_MAX {
185                return Err(StateError::Overflow(spend.key.clone()));
186            }
187            if spend.limit > av_core::error::JCS_SAFE_MAX {
188                return Err(StateError::Overflow(spend.key.clone()));
189            }
190            let amount = i64::try_from(spend.amount).map_err(|_| StateError::Overflow(spend.key.clone()))?;
191            let limit = i64::try_from(spend.limit).map_err(|_| StateError::Overflow(spend.key.clone()))?;
192            let cell = self.cell(&spend.key);
193            let current = cell.load(Ordering::Acquire);
194            let next = current
195                .checked_add(amount)
196                .ok_or_else(|| StateError::Overflow(spend.key.clone()))?;
197            if next > limit {
198                return Ok(Some(index));
199            }
200            prepared.push((cell, amount));
201        }
202        for (cell, amount) in prepared {
203            cell.fetch_add(amount, Ordering::AcqRel);
204        }
205        Ok(None)
206    }
207
208    fn remove(&self, key: &str) {
209        let _transaction = self.transaction_lock.lock();
210        self.counters.remove(key);
211    }
212
213    /// Round-33 F1: saturating refund. `saturating_sub` on i64 keeps
214    /// the value non-negative even under concurrent `remove_prefix`
215    /// or a duplicate refund; the transaction lock keeps the
216    /// load/store pair atomic with respect to other spend / add
217    /// operations on the same key.
218    ///
219    /// Round-34 F1: NEVER resurrect a cell that a concurrent
220    /// `remove_prefix` already dropped. The prior implementation
221    /// used `self.cell(key)` which materialises a fresh `0`
222    /// AtomicI64 in the DashMap via `entry().or_insert_with(...)`.
223    /// Under the round-33 lost-claim-plus-idle-close ordering
224    /// (mcp_call's sandbox-gate debit races with the reconciler's
225    /// clear_budget_state), the refund path would create a
226    /// permanent 0-cell for a sealed session that no future
227    /// remove_prefix would ever collect — attacker-driven memory
228    /// growth. Skip the refund silently when the cell is gone;
229    /// the "budget spent" state is already whatever the caller
230    /// wanted (probably 0) and there's nothing to compensate.
231    fn refund(&self, key: &str, amount: u64) {
232        let _transaction = self.transaction_lock.lock();
233        let Some(cell) = self.counters.get(key).map(|entry| Arc::clone(entry.value())) else {
234            return;
235        };
236        let prev = cell.load(Ordering::Acquire);
237        let amount = i64::try_from(amount).unwrap_or(i64::MAX);
238        let next = prev.saturating_sub(amount).max(0);
239        cell.store(next, Ordering::Release);
240    }
241
242    fn remove_prefix(&self, prefix: &str) {
243        let _transaction = self.transaction_lock.lock();
244        self.counters.retain(|key, _| !key.starts_with(prefix));
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
251
252    use super::*;
253
254    #[test]
255    fn remove_prefix_drops_only_matching_keys() {
256        let store = InMemoryStore::new();
257        store.add("budget:{aaaa}:tokens", 5).unwrap();
258        store.add("budget:{aaaa}:tool:db_write", 1).unwrap();
259        store.add("budget:{bbbb}:tokens", 7).unwrap();
260        store.remove_prefix("budget:{aaaa}:");
261        assert_eq!(store.get("budget:{aaaa}:tokens").unwrap(), 0);
262        assert_eq!(store.get("budget:{aaaa}:tool:db_write").unwrap(), 0);
263        assert_eq!(
264            store.get("budget:{bbbb}:tokens").unwrap(),
265            7,
266            "other sessions' counters must survive a prefix removal",
267        );
268        assert_eq!(store.counters.len(), 1, "removed cells must actually be freed");
269    }
270
271    #[test]
272    fn add_overflow_rollback_is_never_visible_to_concurrent_get() {
273        // Before the fix, add() did fetch_add + fetch_sub to roll back, and
274        // get() held no lock — so a concurrent get() could see a transiently
275        // negative counter and return Err(Overflow) spuriously.
276        // The fix uses store() only after confirming no overflow, so no
277        // negative value ever hits the cell.
278        use std::sync::{Arc, Barrier};
279        use std::thread;
280
281        let store = Arc::new(InMemoryStore::new());
282        store.add("k", 5).unwrap();
283        let barrier = Arc::new(Barrier::new(3));
284
285        // Writer: push add() to overflow (delta = i64::MAX triggers Overflow).
286        let s1 = Arc::clone(&store);
287        let b1 = Arc::clone(&barrier);
288        let writer = thread::spawn(move || {
289            b1.wait();
290            for _ in 0..500 {
291                let _ = s1.add("k", u64::MAX / 2); // will overflow
292            }
293        });
294
295        // Reader: must never see Err(Overflow) from a spurious negative.
296        let s2 = Arc::clone(&store);
297        let b2 = Arc::clone(&barrier);
298        let reader1 = thread::spawn(move || {
299            b2.wait();
300            for _ in 0..2_000 {
301                let v = s2.get("k");
302                assert!(v.is_ok(), "spurious Overflow from concurrent get: {v:?}");
303            }
304        });
305
306        let s3 = Arc::clone(&store);
307        let b3 = Arc::clone(&barrier);
308        let reader2 = thread::spawn(move || {
309            b3.wait();
310            for _ in 0..2_000 {
311                let v = s3.get("k");
312                assert!(v.is_ok(), "spurious Overflow from concurrent get: {v:?}");
313            }
314        });
315
316        writer.join().unwrap();
317        reader1.join().unwrap();
318        reader2.join().unwrap();
319    }
320
321    #[test]
322    fn poisoned_negative_value_surfaces_as_overflow_not_silent_zero() {
323        // Direct cell manipulation simulates a wire-format corruption or a bug
324        // that leaves the counter negative. `get` must surface an Overflow
325        // error, not silently zero — the previous behavior would let a
326        // corrupted account get a free reset.
327        let s = InMemoryStore::new();
328        let cell = s.cell("k");
329        cell.store(-1, Ordering::Release);
330        match s.get("k") {
331            Err(StateError::Overflow(_)) => (),
332            other => panic!("expected Overflow on negative counter, got {other:?}"),
333        }
334    }
335
336    #[test]
337    fn add_and_get() {
338        let s = InMemoryStore::new();
339        assert_eq!(s.get("k").unwrap(), 0);
340        assert_eq!(s.add("k", 5).unwrap(), 5);
341        assert_eq!(s.add("k", 3).unwrap(), 8);
342        assert_eq!(s.get("k").unwrap(), 8);
343        s.remove("k");
344        assert_eq!(s.get("k").unwrap(), 0);
345    }
346
347    #[test]
348    fn try_spend_respects_limit_exactly() {
349        let s = InMemoryStore::new();
350        assert!(s.try_spend("b", 3, 3).unwrap()); // exactly to the limit: OK
351        assert!(!s.try_spend("b", 1, 3).unwrap()); // over: refused
352        assert_eq!(s.get("b").unwrap(), 3, "refused spend must not record");
353    }
354
355    #[test]
356    fn zero_amount_spend_is_free() {
357        let s = InMemoryStore::new();
358        assert!(s.try_spend("z", 0, 0).unwrap());
359        assert_eq!(s.get("z").unwrap(), 0);
360    }
361
362    #[test]
363    fn overflow_is_loud_not_wrapping() {
364        let s = InMemoryStore::new();
365        assert!(matches!(s.add("o", u64::MAX), Err(StateError::Overflow(_))));
366    }
367
368    /// Silent-error D13.9: 64 threads × 1000 attempts against a 10_000 budget —
369    /// exactly 10_000 must be spent, never more.
370    #[test]
371    fn concurrent_spend_never_exceeds_budget() {
372        let s = Arc::new(InMemoryStore::new());
373        let limit = 10_000u64;
374        let mut handles = Vec::new();
375        for _ in 0..64 {
376            let s = Arc::clone(&s);
377            handles.push(std::thread::spawn(move || {
378                let mut granted = 0u64;
379                for _ in 0..1000 {
380                    if s.try_spend("shared", 1, limit).unwrap() {
381                        granted += 1;
382                    }
383                }
384                granted
385            }));
386        }
387        let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
388        assert_eq!(total, limit, "grants must equal the budget exactly");
389        assert_eq!(s.get("shared").unwrap(), limit);
390    }
391
392    /// Mixed amounts race: partial spends must never let the sum exceed the cap.
393    #[test]
394    fn concurrent_mixed_amounts_never_over_cap() {
395        let s = Arc::new(InMemoryStore::new());
396        let limit = 5_000u64;
397        let mut handles = Vec::new();
398        for t in 0..32 {
399            let s = Arc::clone(&s);
400            handles.push(std::thread::spawn(move || {
401                let mut spent = 0u64;
402                let amount = (t % 7) + 1;
403                for _ in 0..500 {
404                    if s.try_spend("cap", amount, limit).unwrap() {
405                        spent += amount;
406                    }
407                }
408                spent
409            }));
410        }
411        let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
412        assert!(total <= limit, "over-spend: {total} > {limit}");
413        assert_eq!(s.get("cap").unwrap(), total);
414    }
415
416    /// Vicious bug caught in review round 16: `try_spend_many` used to
417    /// validate each Spend against the pre-commit cell value and then commit
418    /// them all sequentially. When two Spends referenced the same key, both
419    /// passed their independent limit checks (each saw `current = 0`), then
420    /// the commit phase INCRBY'd both — silently blowing through the cap.
421    /// Duplicate keys must fail loudly with `Backend`, not silently double-spend.
422    #[test]
423    fn try_spend_many_refuses_duplicate_keys() {
424        let s = InMemoryStore::new();
425        let outcome = s.try_spend_many(&[
426            Spend {
427                key: "budget".to_owned(),
428                amount: 60,
429                limit: 100,
430            },
431            Spend {
432                key: "budget".to_owned(),
433                amount: 60,
434                limit: 100,
435            },
436        ]);
437        match outcome {
438            Err(StateError::Backend(reason)) => {
439                assert!(reason.contains("duplicate key"), "wrong reason: {reason}");
440            }
441            other => panic!("must reject duplicate keys, got {other:?}"),
442        }
443        assert_eq!(
444            s.get("budget").unwrap(),
445            0,
446            "no partial spend must have been committed",
447        );
448    }
449
450    /// Symmetric locking of the fix: legitimate distinct-key multi-spends must
451    /// still succeed exactly as before.
452    #[test]
453    fn try_spend_many_distinct_keys_still_commits_atomically() {
454        let s = InMemoryStore::new();
455        assert_eq!(
456            s.try_spend_many(&[
457                Spend {
458                    key: "a".to_owned(),
459                    amount: 3,
460                    limit: 10,
461                },
462                Spend {
463                    key: "b".to_owned(),
464                    amount: 4,
465                    limit: 10,
466                },
467            ])
468            .unwrap(),
469            None,
470        );
471        assert_eq!(s.get("a").unwrap(), 3);
472        assert_eq!(s.get("b").unwrap(), 4);
473    }
474
475    /// Round-21 F1: cross-backend divergence closed for
476    /// `try_spend_many`. Historically InMemoryStore silently
477    /// clamped `limit` down to COUNTER_MAX while Redis rejected
478    /// the same call with `Overflow`. A caller with a config typo
479    /// used to succeed in dev/test and fail in Redis prod. Both
480    /// backends now match: reject Overflow on `amount` or `limit`
481    /// past JCS_SAFE_MAX.
482    #[test]
483    fn try_spend_many_rejects_limits_past_counter_max() {
484        let s = InMemoryStore::new();
485        let outcome = s.try_spend_many(&[Spend {
486            key: "a".to_owned(),
487            amount: 1,
488            limit: av_core::error::JCS_SAFE_MAX + 1,
489        }]);
490        assert!(
491            matches!(outcome, Err(StateError::Overflow(_))),
492            "expected Overflow rejection, got {outcome:?}"
493        );
494    }
495
496    #[test]
497    fn try_spend_many_rejects_amounts_past_counter_max() {
498        let s = InMemoryStore::new();
499        let outcome = s.try_spend_many(&[Spend {
500            key: "a".to_owned(),
501            amount: av_core::error::JCS_SAFE_MAX + 1,
502            limit: u64::MAX,
503        }]);
504        assert!(
505            matches!(outcome, Err(StateError::Overflow(_))),
506            "expected Overflow rejection, got {outcome:?}"
507        );
508    }
509
510    /// Round-34 F1: refund must NEVER resurrect a cell that a prior
511    /// remove_prefix cleared. The round-33 F1 refund path used
512    /// `self.cell(key)` which materialises a fresh 0-entry via
513    /// `entry().or_insert_with(...)`. Under the lost-claim-plus-
514    /// idle-close ordering (mcp_call sandbox-gate debit races
515    /// reconciler's clear_budget_state), that refund on a swept
516    /// session would leave a permanent zero-valued cell that no
517    /// future remove_prefix could reap — attacker-choosable
518    /// memory growth against a sealed session id.
519    #[test]
520    fn refund_after_remove_prefix_does_not_resurrect_cells() {
521        let s = InMemoryStore::new();
522        s.add("budget:{aaaa}:tool:db_write", 1).unwrap();
523        s.add("budget:{aaaa}:total_calls", 1).unwrap();
524        s.add("budget:{aaaa}:payout", 500_000).unwrap();
525        // Simulate the reconciler's clear_budget_state sweeping the
526        // session between the sandbox debit and the harness refund.
527        s.remove_prefix("budget:{aaaa}:");
528        assert_eq!(s.counters.len(), 0, "prefix sweep must have cleared all");
529        // Now the harness's refund path fires on the same three
530        // keys after the sweep — it must be a silent no-op, NOT
531        // a materialize-then-zero.
532        s.refund("budget:{aaaa}:tool:db_write", 1);
533        s.refund("budget:{aaaa}:total_calls", 1);
534        s.refund("budget:{aaaa}:payout", 500_000);
535        assert_eq!(
536            s.counters.len(),
537            0,
538            "refund on a swept session must not resurrect counter cells (attacker-choosable growth)"
539        );
540    }
541
542    /// Round-34 F1: refund on a live session (not swept) still
543    /// compensates the debit exactly. Ensures the no-resurrect
544    /// guard didn't break the happy path.
545    #[test]
546    fn refund_on_live_session_still_compensates_exactly() {
547        let s = InMemoryStore::new();
548        s.add("budget:{live}:tool:db_write", 3).unwrap();
549        s.refund("budget:{live}:tool:db_write", 1);
550        assert_eq!(s.get("budget:{live}:tool:db_write").unwrap(), 2);
551        // Over-refund saturates at 0.
552        s.refund("budget:{live}:tool:db_write", 10);
553        assert_eq!(s.get("budget:{live}:tool:db_write").unwrap(), 0);
554    }
555}