Skip to main content

av_state/
budget.rs

1//! Action budgets (Module B): stateful tool limits — per-tool caps via
2//! `max_tool_calls` (e.g. `db_write: 3`), payout ceilings via
3//! `max_payout_usd_micros` — plus per-session token ceilings.
4//!
5//! Money is tracked in integer micro-USD; a payout of $12.34 spends
6//! 12_340_000. Fractional-cent dust can therefore never accumulate invisibly.
7
8use crate::store::{Refund, Spend, StateError, StateStore, TrySpendOutcome};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12/// Declarative budget for one session/agent (config-file surface).
13///
14/// Unknown keys are rejected so `[budget]` typos fail loudly at startup.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(deny_unknown_fields)]
17pub struct BudgetSpec {
18    /// Max total tokens (prompt+completion) per session. `None` = unlimited.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub max_tokens: Option<u64>,
21    /// Max cumulative payout in micro-USD.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub max_payout_usd_micros: Option<u64>,
24    /// Per-tool invocation caps, e.g. `db_write: 3`.
25    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
26    pub max_tool_calls: BTreeMap<String, u64>,
27    /// Cap on *all* tool calls combined.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub max_total_tool_calls: Option<u64>,
30}
31
32impl BudgetSpec {
33    /// True when at least one dimension is configured — i.e. this spec
34    /// actually constrains something. A default/omitted `[budget]`
35    /// block parses to all-`None`, which enforces NOTHING: every
36    /// session runs with unlimited tokens, payout, and tool calls.
37    /// Diagnostics (`avctl doctor`) use this to distinguish "budget
38    /// configured" from the silent-unbounded footgun.
39    pub fn binds_anything(&self) -> bool {
40        self.max_tokens.is_some()
41            || self.max_payout_usd_micros.is_some()
42            || self.max_total_tool_calls.is_some()
43            || !self.max_tool_calls.is_empty()
44    }
45}
46
47/// Outcome of a budget check.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub enum BudgetDecision {
50    /// Spend recorded; remaining headroom for the tightest matching limit.
51    Allowed {
52        /// Remaining amount under the binding limit (min across dimensions).
53        remaining: u64,
54    },
55    /// Refused: which limit would be exceeded.
56    Refused {
57        /// Human-readable limit name (`max_tool_calls.db_write`, …).
58        limit: String,
59        /// The configured cap.
60        cap: u64,
61    },
62}
63
64impl BudgetDecision {
65    /// True when the action was allowed.
66    pub fn is_allowed(&self) -> bool {
67        matches!(self, Self::Allowed { .. })
68    }
69}
70
71/// Budget enforcement bound to an accounting scope (a session id, or a
72/// stable principal id) and a state store.
73///
74/// Historically this type was hard-coded to a session-scoped view, but a
75/// session-only budget can be
76/// bypassed by any caller that rotates the caller-chosen `X-AV-Session`
77/// header — every request would land on a virgin counter. Callers that
78/// want to catch that vector layer a principal-scoped `ActionBudget` on
79/// top of the session-scoped one; the anti-rotation property lives in the
80/// key prefix, not in the check logic.
81pub struct ActionBudget<'a> {
82    store: &'a dyn StateStore,
83    scope: Scope<'a>,
84    spec: &'a BudgetSpec,
85}
86
87/// Which accounting bucket a budget check writes to.
88///
89/// Two identical `try_spend_many` calls made under different scopes hit
90/// entirely disjoint keys in the state store; a session budget cannot
91/// exhaust a principal budget or vice versa.
92#[derive(Debug, Clone, Copy)]
93enum Scope<'a> {
94    Session(&'a str),
95    Principal(&'a str),
96}
97
98impl<'a> ActionBudget<'a> {
99    /// Bind a spec to a session id.
100    pub fn new(store: &'a dyn StateStore, session: &'a str, spec: &'a BudgetSpec) -> Self {
101        Self {
102            store,
103            scope: Scope::Session(session),
104            spec,
105        }
106    }
107
108    /// Bind a spec to a principal id (JWT `sub`/`instance_uid`, HMAC-KID,
109    /// or the sentinel `"anonymous"` when `require_identity = false`).
110    /// The resulting counters aggregate across every session belonging to
111    /// the same principal, which is what makes a header-rotation attack
112    /// visible — the token debit persists even after the client mints a
113    /// fresh `X-AV-Session`.
114    pub fn for_principal(store: &'a dyn StateStore, principal: &'a str, spec: &'a BudgetSpec) -> Self {
115        Self {
116            store,
117            scope: Scope::Principal(principal),
118            spec,
119        }
120    }
121
122    fn key(&self, dim: &str) -> String {
123        let prefix = match self.scope {
124            Scope::Session(id) => Self::session_prefix(id),
125            Scope::Principal(id) => Self::principal_prefix(id),
126        };
127        format!("{prefix}{dim}")
128    }
129
130    /// Common key prefix for every budget counter of `session`. Callers use
131    /// this with [`StateStore::remove_prefix`] to drop a finalized session's
132    /// counters (per-tool keys are dynamic, so single-key removal cannot
133    /// enumerate them).
134    pub fn session_prefix(session: &str) -> String {
135        let digest = av_core::digest::sha256_hex(session.as_bytes());
136        // 32 hex chars = 128 bits: collision-safe well beyond realistic session counts.
137        format!("budget:{{{}}}:", digest.get(..32).unwrap_or(&digest))
138    }
139
140    /// Common key prefix for every principal-scoped budget counter. Distinct
141    /// namespace from [`Self::session_prefix`] so a session cleanup on close does
142    /// not touch the principal ledger — the principal ledger persists across
143    /// session boundaries by design.
144    pub fn principal_prefix(principal: &str) -> String {
145        let digest = av_core::digest::sha256_hex(principal.as_bytes());
146        format!("budget:principal:{{{}}}:", digest.get(..32).unwrap_or(&digest))
147    }
148
149    /// Check-and-spend one invocation of `tool`, with an optional payout
150    /// amount in micro-USD carried by this call.
151    ///
152    /// A non-zero payout with NO configured `max_payout_usd_micros`
153    /// refuses first (fail-closed: an unbounded payout dimension must
154    /// never spend), citing `max_payout_usd_micros(unset)` — even when
155    /// another dimension is also at cap. The remaining dimensions are
156    /// then checked in a fixed order (total calls → per-tool → payout)
157    /// and committed atomically via `try_spend_many`: every dimension
158    /// is validated first and either all spends commit or none do, so
159    /// a refused call consumes nothing.
160    pub fn try_tool_call(&self, tool: &str, payout_usd_micros: u64) -> Result<BudgetDecision, StateError> {
161        // Parallel arrays keep spends and limit-names together without paying
162        // for a joined-tuple clone before hitting the state store. There are
163        // at most three dimensions (total, per-tool, payout).
164        let mut spends: Vec<Spend> = Vec::with_capacity(3);
165        let mut limit_names: Vec<String> = Vec::with_capacity(3);
166
167        if let Some(cap) = self.spec.max_total_tool_calls {
168            spends.push(Spend {
169                key: self.key("total_calls"),
170                amount: 1,
171                limit: cap,
172            });
173            limit_names.push("max_total_tool_calls".into());
174        }
175        if let Some(cap) = self.spec.max_tool_calls.get(tool).copied() {
176            spends.push(Spend {
177                key: self.key(&format!("tool:{tool}")),
178                amount: 1,
179                limit: cap,
180            });
181            limit_names.push(format!("max_tool_calls.{tool}"));
182        }
183        if payout_usd_micros > 0 {
184            match self.spec.max_payout_usd_micros {
185                Some(cap) => {
186                    spends.push(Spend {
187                        key: self.key("payout"),
188                        amount: payout_usd_micros,
189                        limit: cap,
190                    });
191                    limit_names.push("max_payout_usd_micros".into());
192                }
193                None => {
194                    return Ok(BudgetDecision::Refused {
195                        limit: "max_payout_usd_micros(unset)".into(),
196                        cap: 0,
197                    });
198                }
199            }
200        }
201
202        // R66 F3: previously called `try_spend_many` for the atomic
203        // commit and then `remaining_min` for the reported headroom
204        // — three additional GET round-trips that could race with a
205        // concurrent `remove_prefix` DEL and inflate the reported
206        // `remaining` to the full cap (a debit that WAS committed).
207        // Now the outcome carries the post-commit min-headroom
208        // computed inside the SAME atomic section that committed the
209        // spend, so `BudgetDecision::Allowed { remaining }` can be
210        // taken directly without a follow-up read.
211        match self.store.try_spend_many(&spends)? {
212            TrySpendOutcome::Refused { index } => {
213                let spend = spends
214                    .get(index)
215                    .ok_or_else(|| StateError::Backend(format!("invalid failed spend index {index}")))?;
216                let limit = limit_names
217                    .get(index)
218                    .ok_or_else(|| StateError::Backend(format!("invalid failed spend index {index}")))?;
219                Ok(BudgetDecision::Refused {
220                    limit: limit.clone(),
221                    cap: spend.limit,
222                })
223            }
224            TrySpendOutcome::Committed {
225                post_commit_min_remaining,
226            } => Ok(BudgetDecision::Allowed {
227                remaining: post_commit_min_remaining,
228            }),
229        }
230    }
231
232    /// Compensating refund for a previously-successful
233    /// [`Self::try_tool_call`]. Reverses the spend on exactly the same
234    /// dimensions that were debited (total_calls, per-tool, payout) so
235    /// a lost-race path in the caller (concurrent identical MCP
236    /// request loses `execution.claim()` after the sandbox gate has
237    /// already spent) does not double-charge the session budget.
238    /// Best-effort: any backend error is silently absorbed by the
239    /// underlying [`StateStore::refund`] contract — a Redis blip on
240    /// the compensation path must never turn a lost-race response
241    /// into a 5xx.
242    pub fn refund_tool_call(&self, tool: &str, payout_usd_micros: u64) {
243        // R66 F2: single atomic refund_many call so all three
244        // dimensions (total_calls / tool:{X} / payout) are refunded
245        // as one transaction under InMemoryStore's `transaction_lock`
246        // and one Lua EVAL under RedisStore. Prior shape was 3
247        // independent `store.refund` calls — a crash / connection
248        // drop between steps left partial refund state, and a
249        // concurrent `try_tool_call` on InMemoryStore could observe
250        // the mid-batch state (fresh `total_calls` headroom but
251        // still-charged per-tool cap), an ordering impossible on
252        // RedisStore's single-Lua path.
253        let mut refunds: Vec<Refund> = Vec::with_capacity(3);
254        if self.spec.max_total_tool_calls.is_some() {
255            refunds.push(Refund {
256                key: self.key("total_calls"),
257                amount: 1,
258            });
259        }
260        if self.spec.max_tool_calls.contains_key(tool) {
261            refunds.push(Refund {
262                key: self.key(&format!("tool:{tool}")),
263                amount: 1,
264            });
265        }
266        if payout_usd_micros > 0 && self.spec.max_payout_usd_micros.is_some() {
267            refunds.push(Refund {
268                key: self.key("payout"),
269                amount: payout_usd_micros,
270            });
271        }
272        if !refunds.is_empty() {
273            self.store.refund_many(&refunds);
274        }
275    }
276
277    /// Compensating refund for a previously-successful [`Self::try_tokens`]
278    /// on a request that provably never reached the provider (sanitize
279    /// block, capture failure, in-flight-marker write failure, connect
280    /// failure). Same rationale and best-effort contract as
281    /// [`Self::refund_tool_call`]: without it, each client retry against
282    /// a flaky-but-unreachable upstream re-debits `max_tokens` with zero
283    /// LLM work performed, draining the session budget.
284    pub fn refund_tokens(&self, tokens: u64) {
285        if tokens > 0 && self.spec.max_tokens.is_some() {
286            self.store.refund(&self.key("tokens"), tokens);
287        }
288    }
289
290    /// Check-and-spend `tokens` against `max_tokens`.
291    pub fn try_tokens(&self, tokens: u64) -> Result<BudgetDecision, StateError> {
292        match self.spec.max_tokens {
293            Some(cap) => {
294                // R66 F3: route through `try_spend_many` so the
295                // reported `remaining` comes from the same atomic
296                // section that committed the spend. The previous
297                // shape did `try_spend` + `get` — a 2-RTT window
298                // in which a concurrent `remove_prefix` could DEL
299                // the counter and make `get` return 0, inflating
300                // `remaining` to `cap`. See `TrySpendOutcome`.
301                match self.store.try_spend_many(&[Spend {
302                    key: self.key("tokens"),
303                    amount: tokens,
304                    limit: cap,
305                }])? {
306                    TrySpendOutcome::Committed {
307                        post_commit_min_remaining,
308                    } => Ok(BudgetDecision::Allowed {
309                        remaining: post_commit_min_remaining,
310                    }),
311                    TrySpendOutcome::Refused { .. } => Ok(BudgetDecision::Refused {
312                        limit: "max_tokens".into(),
313                        cap,
314                    }),
315                }
316            }
317            None => Ok(BudgetDecision::Allowed { remaining: u64::MAX }),
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
325
326    use super::*;
327    use crate::store::InMemoryStore;
328
329    fn spec() -> BudgetSpec {
330        BudgetSpec {
331            max_tokens: Some(1000),
332            max_payout_usd_micros: Some(50_000_000), // $50 (brief example)
333            max_tool_calls: BTreeMap::from([("db_write".to_owned(), 3)]), // brief example
334            max_total_tool_calls: Some(10),
335        }
336    }
337
338    /// Mutation-run hardening: nothing previously pinned the ledger
339    /// prefix shapes, so a mutant replacing `principal_prefix` with an
340    /// arbitrary string survived the suite. The shapes are load-bearing
341    /// twice over: the `{hash-tag}` keeps every key of one ledger in a
342    /// single Redis Cluster slot (remove_prefix routes SCAN/DEL to the
343    /// slot derived from the prefix), and the distinct `budget:` vs
344    /// `budget:principal:` namespaces are what keep a session cleanup on
345    /// close from touching the principal ledger.
346    #[test]
347    fn ledger_prefixes_are_namespaced_and_cluster_hash_tagged() {
348        let session = ActionBudget::session_prefix("same-id");
349        let principal = ActionBudget::principal_prefix("same-id");
350        assert!(session.starts_with("budget:{"), "{session}");
351        assert!(principal.starts_with("budget:principal:{"), "{principal}");
352        for prefix in [&session, &principal] {
353            let open = prefix.find('{').unwrap();
354            let close = prefix.find('}').unwrap();
355            assert!(close > open + 1, "empty hash tag in {prefix}");
356            assert!(prefix.ends_with("}:"), "{prefix}");
357        }
358        // The SAME id in both scopes must land in disjoint key spaces —
359        // neither prefix may be a prefix of the other, or remove_prefix
360        // on one ledger would sweep the other's counters.
361        assert!(!session.starts_with(&principal) && !principal.starts_with(&session));
362    }
363
364    #[test]
365    fn db_write_capped_at_three() {
366        let store = InMemoryStore::new();
367        let s = spec();
368        let b = ActionBudget::new(&store, "sess", &s);
369        for _ in 0..3 {
370            assert!(b.try_tool_call("db_write", 0).unwrap().is_allowed());
371        }
372        let refused = b.try_tool_call("db_write", 0).unwrap();
373        assert_eq!(
374            refused,
375            BudgetDecision::Refused {
376                limit: "max_tool_calls.db_write".into(),
377                cap: 3
378            }
379        );
380        // Other tools still work (total budget has room).
381        assert!(b.try_tool_call("search", 0).unwrap().is_allowed());
382    }
383
384    #[test]
385    fn payout_capped_at_fifty_dollars() {
386        let store = InMemoryStore::new();
387        let s = spec();
388        let b = ActionBudget::new(&store, "sess", &s);
389        assert!(b.try_tool_call("payout", 49_000_000).unwrap().is_allowed());
390        let refused = b.try_tool_call("payout", 2_000_000).unwrap(); // would total $51
391        assert!(!refused.is_allowed());
392        // The refused call must not have consumed its per-call slot either:
393        // spend $1 exactly — succeeds if rollback was complete.
394        assert!(b.try_tool_call("payout", 1_000_000).unwrap().is_allowed());
395    }
396
397    #[test]
398    fn payout_without_cap_fails_closed() {
399        let store = InMemoryStore::new();
400        let s = BudgetSpec::default();
401        let b = ActionBudget::new(&store, "sess", &s);
402        let d = b.try_tool_call("payout", 1).unwrap();
403        assert!(
404            !d.is_allowed(),
405            "uncapped payout must be refused, not silently allowed"
406        );
407    }
408
409    #[test]
410    fn refused_multi_dimension_spend_rolls_back() {
411        let store = InMemoryStore::new();
412        let s = spec();
413        let b = ActionBudget::new(&store, "sess", &s);
414        // Exhaust db_write (3 calls, 3 total slots used).
415        for _ in 0..3 {
416            assert!(b.try_tool_call("db_write", 0).unwrap().is_allowed());
417        }
418        // This refusal must roll back its total_calls spend:
419        assert!(!b.try_tool_call("db_write", 0).unwrap().is_allowed());
420        // 7 remaining total slots — all must be grantable.
421        for _ in 0..7 {
422            assert!(b.try_tool_call("other", 0).unwrap().is_allowed());
423        }
424        assert!(
425            !b.try_tool_call("other", 0).unwrap().is_allowed(),
426            "total cap must bind at 10"
427        );
428    }
429
430    #[test]
431    fn token_budget() {
432        let store = InMemoryStore::new();
433        let s = spec();
434        let b = ActionBudget::new(&store, "sess", &s);
435        assert!(b.try_tokens(900).unwrap().is_allowed());
436        assert!(b.try_tokens(100).unwrap().is_allowed()); // exactly at cap
437        assert!(!b.try_tokens(1).unwrap().is_allowed());
438    }
439
440    #[test]
441    fn sessions_are_isolated() {
442        let store = InMemoryStore::new();
443        let s = spec();
444        let a = ActionBudget::new(&store, "sess-a", &s);
445        let b = ActionBudget::new(&store, "sess-b", &s);
446        for _ in 0..3 {
447            assert!(a.try_tool_call("db_write", 0).unwrap().is_allowed());
448        }
449        assert!(!a.try_tool_call("db_write", 0).unwrap().is_allowed());
450        assert!(
451            b.try_tool_call("db_write", 0).unwrap().is_allowed(),
452            "session b unaffected"
453        );
454    }
455
456    #[test]
457    fn concurrent_multi_dimension_spends_commit_all_or_none() {
458        let store = std::sync::Arc::new(InMemoryStore::new());
459        let spec = std::sync::Arc::new(BudgetSpec {
460            max_tool_calls: BTreeMap::from([("db_write".to_owned(), 100)]),
461            max_total_tool_calls: Some(100),
462            ..BudgetSpec::default()
463        });
464        let mut handles = Vec::new();
465        for _ in 0..32 {
466            let store = std::sync::Arc::clone(&store);
467            let spec = std::sync::Arc::clone(&spec);
468            handles.push(std::thread::spawn(move || {
469                let budget = ActionBudget::new(store.as_ref(), "atomic", spec.as_ref());
470                (0..20)
471                    .filter(|_| budget.try_tool_call("db_write", 0).unwrap().is_allowed())
472                    .count()
473            }));
474        }
475        let allowed: usize = handles.into_iter().map(|handle| handle.join().unwrap()).sum();
476        assert_eq!(allowed, 100);
477        let budget = ActionBudget::new(store.as_ref(), "atomic", spec.as_ref());
478        assert_eq!(store.get(&budget.key("total_calls")).unwrap(), 100);
479        assert_eq!(store.get(&budget.key("tool:db_write")).unwrap(), 100);
480    }
481
482    #[test]
483    fn unlimited_spec_allows_everything() {
484        let store = InMemoryStore::new();
485        let s = BudgetSpec::default();
486        let b = ActionBudget::new(&store, "sess", &s);
487        for _ in 0..100 {
488            assert!(b.try_tool_call("anything", 0).unwrap().is_allowed());
489        }
490        assert!(b.try_tokens(u64::MAX / 4).unwrap().is_allowed());
491    }
492
493    #[test]
494    fn allowed_decision_reports_true_remaining_headroom() {
495        // Catches any stub of `remaining_min` (e.g. → Ok(0) / Ok(1)): with a
496        // cap of 10, 3 prior spends, and the measured 4th call spending one
497        // itself, `remaining` must be exactly 6.
498        let store = InMemoryStore::new();
499        let s = BudgetSpec {
500            max_total_tool_calls: Some(10),
501            ..BudgetSpec::default()
502        };
503        let b = ActionBudget::new(&store, "sess-rem", &s);
504        for _ in 0..3 {
505            assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
506        }
507        match b.try_tool_call("t", 0).unwrap() {
508            BudgetDecision::Allowed { remaining } => assert_eq!(remaining, 6),
509            other => panic!("expected Allowed with real remaining, got {other:?}"),
510        }
511    }
512
513    /// refund_tool_call compensates the exact dimensions
514    /// try_tool_call debited. Locks in the primary invariant needed
515    /// by the harness's lost-claim path — after refund, the same
516    /// call succeeds again against the same caps.
517    #[test]
518    fn refund_tool_call_reverses_the_spend_exactly() {
519        let store = InMemoryStore::new();
520        let s = BudgetSpec {
521            max_total_tool_calls: Some(2),
522            max_tool_calls: BTreeMap::from([("db_write".to_owned(), 1u64)]),
523            max_payout_usd_micros: Some(1_000_000),
524            ..BudgetSpec::default()
525        };
526        let b = ActionBudget::new(&store, "sess-refund", &s);
527        // Debit 1 total + 1 per-tool + 500k payout.
528        assert!(b.try_tool_call("db_write", 500_000).unwrap().is_allowed());
529        // Without refund, per-tool cap trips the next call.
530        assert!(matches!(
531            b.try_tool_call("db_write", 100).unwrap(),
532            BudgetDecision::Refused { .. }
533        ));
534        // Refund reverses exactly the spend.
535        b.refund_tool_call("db_write", 500_000);
536        // The same call now succeeds — proving all three dimensions
537        // were compensated.
538        assert!(b.try_tool_call("db_write", 500_000).unwrap().is_allowed());
539    }
540
541    /// `refund_tokens` compensates a `try_tokens` debit exactly, for
542    /// requests that provably never reached the provider (sanitize
543    /// block, capture failure, marker-write failure, connect failure).
544    #[test]
545    fn refund_tokens_reverses_the_spend_exactly() {
546        let store = InMemoryStore::new();
547        let s = BudgetSpec {
548            max_tokens: Some(100),
549            ..BudgetSpec::default()
550        };
551        let b = ActionBudget::new(&store, "sess-token-refund", &s);
552        assert!(b.try_tokens(80).unwrap().is_allowed());
553        // Without refund the next debit trips the cap.
554        assert!(matches!(
555            b.try_tokens(80).unwrap(),
556            BudgetDecision::Refused { .. }
557        ));
558        b.refund_tokens(80);
559        // The same debit now succeeds — the refund compensated exactly.
560        assert!(b.try_tokens(80).unwrap().is_allowed());
561        // With no cap configured, refund is a no-op (never panics or
562        // creates counters).
563        let uncapped_spec = BudgetSpec::default();
564        let uncapped = ActionBudget::new(&store, "sess-uncapped", &uncapped_spec);
565        uncapped.refund_tokens(50);
566    }
567
568    /// Refund saturates at 0 under a concurrent clear.
569    /// The compensating refund must never leave a negative "budget
570    /// spent" counter — that would give the next legit call a free
571    /// ride relative to its cap.
572    #[test]
573    fn refund_is_saturating() {
574        let store = InMemoryStore::new();
575        let s = BudgetSpec {
576            max_total_tool_calls: Some(10),
577            ..BudgetSpec::default()
578        };
579        let b = ActionBudget::new(&store, "sess-sat", &s);
580        assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
581        // Two refunds in a row: the second must clamp at 0, not
582        // underflow the counter.
583        b.refund_tool_call("t", 0);
584        b.refund_tool_call("t", 0);
585        // 10 successful calls remain — the counter is 0 (clamped).
586        for _ in 0..10 {
587            assert!(b.try_tool_call("t", 0).unwrap().is_allowed());
588        }
589        assert!(matches!(
590            b.try_tool_call("t", 0).unwrap(),
591            BudgetDecision::Refused { .. }
592        ));
593    }
594
595    #[test]
596    fn binds_anything_is_false_only_for_the_all_default_spec() {
597        assert!(!BudgetSpec::default().binds_anything());
598        assert!(BudgetSpec {
599            max_tokens: Some(1),
600            ..Default::default()
601        }
602        .binds_anything());
603        assert!(BudgetSpec {
604            max_payout_usd_micros: Some(1),
605            ..Default::default()
606        }
607        .binds_anything());
608        assert!(BudgetSpec {
609            max_total_tool_calls: Some(1),
610            ..Default::default()
611        }
612        .binds_anything());
613        let mut per_tool = BudgetSpec::default();
614        per_tool.max_tool_calls.insert("db_write".to_owned(), 1);
615        assert!(per_tool.binds_anything());
616    }
617}