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(¤t) {
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}