Skip to main content

axon/runtime/
budget_kernel.rs

1//! v2.28.0 — the RateLease budget kernel.
2//!
3//! The runtime for `budget { rate/max … on Tool(X) }` (v2.28.0). A [`RateLease`] is
4//! the **refilling generalization** of the [`lease_kernel`](crate::runtime::lease_kernel)'s
5//! τ-decay affine `LeaseToken`: where a `LeaseToken` is single-use and DECAYS to
6//! nothing, a `RateLease` is N-use and REFILLS — but the linearity invariant is
7//! the same, *a consumed token is gone until it is refilled*. This is what makes
8//! "no more than N external effects per period" a real linear contract rather
9//! than an advisory counter (the v2.28.0 doctrine `effects_are_linear`).
10//!
11//! Two quota kinds, both PURE functions of `(lease state, now)`:
12//!
13//!   * `rate:` → a **token bucket** of capacity `limit`, refilling continuously
14//!     at `limit / period` tokens per second (so it permits a burst up to
15//! `limit`, then a steady rate). The v2.28.0 default daemon starts full.
16//!   * `max:`  → a **fixed tumbling window**: at most `limit` consumptions per
17//!     `period`; the window rolls (counter resets) once `period` has elapsed
18//!     since it opened. No intra-window refill — a hard cap.
19//!
20//! Refill/roll is LAZY: every `try_acquire` brings the lease current from the
21//! elapsed wall-clock, so the decision never depends on a background tick's
22//! granularity. [`RateLeaseKernel::tick`] is housekeeping (keeps `available`
23//! queries fresh + reaps), the refilling analogue of the lease kernel's `sweep`
24//! / the reconcile loop's periodic pass.
25
26#![allow(dead_code)]
27
28use std::collections::HashMap;
29
30use chrono::{DateTime, Duration, Utc};
31
32use crate::ir_nodes::IRBudgetQuota;
33
34// ═══════════════════════════════════════════════════════════════════
35//  PERIOD — the closed catalog (mirrors axon-T832)
36// ═══════════════════════════════════════════════════════════════════
37
38/// A budget quota's renewal/window period. Closed catalog — the type checker
39/// (`axon-T832`) already rejected anything else at compile time.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum BudgetPeriod {
42    Second,
43    Minute,
44    Hour,
45    Day,
46}
47
48impl BudgetPeriod {
49    /// Parse the closed catalog spelling. `None` for an unknown period (the
50    /// type-checker guarantees this does not happen for compiled programs).
51    pub fn parse(s: &str) -> Option<Self> {
52        Some(match s {
53            "second" => BudgetPeriod::Second,
54            "minute" => BudgetPeriod::Minute,
55            "hour" => BudgetPeriod::Hour,
56            "day" => BudgetPeriod::Day,
57            _ => return None,
58        })
59    }
60
61    /// The period length in seconds.
62    pub fn as_secs(self) -> f64 {
63        match self {
64            BudgetPeriod::Second => 1.0,
65            BudgetPeriod::Minute => 60.0,
66            BudgetPeriod::Hour => 3600.0,
67            BudgetPeriod::Day => 86400.0,
68        }
69    }
70}
71
72// ═══════════════════════════════════════════════════════════════════
73//  ACQUIRE OUTCOME
74// ═══════════════════════════════════════════════════════════════════
75
76/// The result of attempting to consume one token from a [`RateLease`]. A pure
77/// function of the lease's state + `now`.
78#[derive(Debug, Clone, PartialEq)]
79pub enum AcquireOutcome {
80    /// A token was consumed — the budgeted effect MAY proceed.
81    Granted,
82    /// The quota is exhausted. `retry_at` is the earliest instant a token will
83    /// be available again (the input to `on_exhausted: defer`'s reschedule).
84    Denied { retry_at: DateTime<Utc> },
85}
86
87impl AcquireOutcome {
88    pub fn is_granted(&self) -> bool {
89        matches!(self, AcquireOutcome::Granted)
90    }
91}
92
93// ═══════════════════════════════════════════════════════════════════
94//  RATE LEASE
95// ═══════════════════════════════════════════════════════════════════
96
97#[derive(Debug, Clone)]
98enum RateState {
99    /// `rate:` — a refilling token bucket. `tokens` is fractional (continuous
100    /// refill); `last_refill` is the watermark elapsed-time is measured from.
101    Bucket { tokens: f64, last_refill: DateTime<Utc> },
102    /// `max:` — a fixed tumbling window. `consumed` resets when the window rolls.
103    Window { window_start: DateTime<Utc>, consumed: i64 },
104}
105
106/// One quota's live state: a refilling bucket (`rate:`) or a fixed window
107/// (`max:`). Construct via [`RateLease::rate`] / [`RateLease::max`] /
108/// [`RateLease::from_quota`]. Consume via [`RateLease::try_acquire`].
109#[derive(Debug, Clone)]
110pub struct RateLease {
111    /// The declared tool this quota governs (`on Tool(effect)`).
112    pub effect: String,
113    /// The token allowance per period (> 0).
114    pub limit: i64,
115    /// The renewal/window period.
116    pub period: BudgetPeriod,
117    state: RateState,
118}
119
120impl RateLease {
121    /// A `rate:` quota — a token bucket starting FULL (`limit` tokens), refilling
122    /// `limit` tokens per `period`.
123    pub fn rate(effect: impl Into<String>, limit: i64, period: BudgetPeriod, now: DateTime<Utc>) -> Self {
124        RateLease {
125            effect: effect.into(),
126            limit,
127            period,
128            state: RateState::Bucket { tokens: limit.max(0) as f64, last_refill: now },
129        }
130    }
131
132    /// A `max:` quota — a fixed window of `period`, starting empty (0 consumed).
133    pub fn max(effect: impl Into<String>, limit: i64, period: BudgetPeriod, now: DateTime<Utc>) -> Self {
134        RateLease {
135            effect: effect.into(),
136            limit,
137            period,
138            state: RateState::Window { window_start: now, consumed: 0 },
139        }
140    }
141
142    /// Build a lease from a compiled [`IRBudgetQuota`]. `None` if the period is
143    /// not in the closed catalog (the type checker prevents this for compiled
144    /// programs; the caller fail-closes defensively).
145    pub fn from_quota(q: &IRBudgetQuota, now: DateTime<Utc>) -> Option<Self> {
146        let period = BudgetPeriod::parse(&q.period)?;
147        Some(match q.kind.as_str() {
148            "max" => RateLease::max(q.effect.clone(), q.limit, period, now),
149            // "rate" (and defensively any other kind) → a refilling bucket.
150            _ => RateLease::rate(q.effect.clone(), q.limit, period, now),
151        })
152    }
153
154    /// The refill-per-second rate for a bucket (`limit / period_secs`).
155    fn refill_per_sec(&self) -> f64 {
156        self.limit.max(0) as f64 / self.period.as_secs()
157    }
158
159    /// Bring the lease current as of `now` (refill the bucket / roll the window).
160    /// Idempotent at a fixed `now`; pure given the prior state.
161    pub fn refill(&mut self, now: DateTime<Utc>) {
162        let rate = self.refill_per_sec();
163        let capacity = self.limit.max(0) as f64;
164        let period_secs = self.period.as_secs();
165        match &mut self.state {
166            RateState::Bucket { tokens, last_refill } => {
167                let elapsed = (now - *last_refill).num_milliseconds() as f64 / 1000.0;
168                if elapsed > 0.0 {
169                    *tokens = (*tokens + elapsed * rate).min(capacity);
170                    *last_refill = now;
171                }
172            }
173            RateState::Window { window_start, consumed } => {
174                let elapsed = (now - *window_start).num_milliseconds() as f64 / 1000.0;
175                if elapsed >= period_secs {
176                    *window_start = now;
177                    *consumed = 0;
178                }
179            }
180        }
181    }
182
183    /// Attempt to consume one token as of `now`. Refills/rolls first, then either
184    /// consumes (→ [`AcquireOutcome::Granted`]) or denies with the next-available
185    /// instant. PURE: same `(state, now)` ⇒ same outcome + same post-state.
186    pub fn try_acquire(&mut self, now: DateTime<Utc>) -> AcquireOutcome {
187        self.refill(now);
188        let rate = self.refill_per_sec();
189        let period_secs = self.period.as_secs();
190        match &mut self.state {
191            RateState::Bucket { tokens, .. } => {
192                if *tokens >= 1.0 {
193                    *tokens -= 1.0;
194                    AcquireOutcome::Granted
195                } else {
196                    // Time until the bucket accrues the missing fraction of a token.
197                    let deficit = 1.0 - *tokens;
198                    let wait_secs = if rate > 0.0 { deficit / rate } else { f64::INFINITY };
199                    let retry_at = now + secs_to_duration(wait_secs);
200                    AcquireOutcome::Denied { retry_at }
201                }
202            }
203            RateState::Window { window_start, consumed } => {
204                if *consumed < self.limit {
205                    *consumed += 1;
206                    AcquireOutcome::Granted
207                } else {
208                    let retry_at = *window_start + secs_to_duration(period_secs);
209                    AcquireOutcome::Denied { retry_at }
210                }
211            }
212        }
213    }
214
215    /// The number of tokens currently available (after refilling to `now`).
216    /// Whole tokens for a window; fractional for a bucket.
217    pub fn available(&self, now: DateTime<Utc>) -> f64 {
218        let mut probe = self.clone();
219        probe.refill(now);
220        match probe.state {
221            RateState::Bucket { tokens, .. } => tokens,
222            RateState::Window { consumed, .. } => (self.limit - consumed).max(0) as f64,
223        }
224    }
225
226    /// Whether a token is available at `now` WITHOUT consuming it. `Granted` if a
227    /// call would succeed; `Denied{retry_at}` otherwise. Used by the multi-quota
228    /// gate to test all-or-none before committing any consumption.
229    pub fn peek(&self, now: DateTime<Utc>) -> AcquireOutcome {
230        let mut probe = self.clone();
231        probe.try_acquire(now)
232    }
233
234    /// v2.28.0 — capture this lease's live STATE as a serializable snapshot
235    /// (epoch-millis, no chrono in the wire form). The enterprise daemon
236    /// supervisor persists it so a `max` window / `rate` bucket is cumulative
237    /// ACROSS ticks (a daily cap spans the day's ticks). The v2.4.0 fire-once claim
238    /// serializes a daemon's ticks, so load → run → save needs no lock.
239    pub fn snapshot(&self) -> RateLeaseSnapshot {
240        match &self.state {
241            RateState::Bucket { tokens, last_refill } => RateLeaseSnapshot {
242                kind: "rate".to_string(),
243                tokens: *tokens,
244                last_refill_ms: last_refill.timestamp_millis(),
245                window_start_ms: 0,
246                consumed: 0,
247            },
248            RateState::Window { window_start, consumed } => RateLeaseSnapshot {
249                kind: "max".to_string(),
250                tokens: 0.0,
251                last_refill_ms: 0,
252                window_start_ms: window_start.timestamp_millis(),
253                consumed: *consumed,
254            },
255        }
256    }
257
258    /// v2.28.0 — restore this lease's STATE from a snapshot (the inverse of
259    /// [`snapshot`](Self::snapshot)). A kind mismatch (a `rate` lease restored
260    /// from a `max` snapshot — e.g. the budget grammar changed between ticks) is
261    /// IGNORED, leaving the freshly-built state (fail-safe: a re-budgeted daemon
262    /// starts clean rather than mis-restoring).
263    pub fn restore(&mut self, snap: &RateLeaseSnapshot) {
264        match (&mut self.state, snap.kind.as_str()) {
265            (RateState::Bucket { tokens, last_refill }, "rate") => {
266                *tokens = snap.tokens.min(self.limit.max(0) as f64);
267                if let Some(t) = DateTime::from_timestamp_millis(snap.last_refill_ms) {
268                    *last_refill = t;
269                }
270            }
271            (RateState::Window { window_start, consumed }, "max") => {
272                *consumed = snap.consumed.clamp(0, self.limit.max(0));
273                if let Some(t) = DateTime::from_timestamp_millis(snap.window_start_ms) {
274                    *window_start = t;
275                }
276            }
277            _ => { /* kind mismatch ⇒ keep the fresh state */ }
278        }
279    }
280}
281
282/// v2.28.0 — a [`RateLease`]'s persistable state (epoch-millis wire form). The
283/// enterprise supervisor stores one per quota subject key so budgets are
284/// cumulative across a daemon's ticks. `kind` discriminates which fields are
285/// live (`rate` ⇒ `tokens`/`last_refill_ms`; `max` ⇒ `window_start_ms`/`consumed`).
286#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
287pub struct RateLeaseSnapshot {
288    pub kind: String,
289    pub tokens: f64,
290    pub last_refill_ms: i64,
291    pub window_start_ms: i64,
292    pub consumed: i64,
293}
294
295/// Convert fractional seconds to a chrono [`Duration`] (millisecond precision;
296/// non-finite / negative values clamp to zero).
297fn secs_to_duration(secs: f64) -> Duration {
298    if !secs.is_finite() || secs <= 0.0 {
299        return Duration::zero();
300    }
301    // Cap at ~100 days to avoid i64-millis overflow on an infinite-ish wait.
302    let capped = secs.min(8_640_000.0);
303    Duration::milliseconds((capped * 1000.0) as i64)
304}
305
306// ═══════════════════════════════════════════════════════════════════
307//  RATE LEASE KERNEL — the in-process registry (OSS single-replica)
308// ═══════════════════════════════════════════════════════════════════
309
310/// An in-process registry of [`RateLease`]s, keyed by an opaque subject string
311/// (the v2.28.0 dispatch gate composes the key from the budget's scope + the
312/// effect + the quota kind, e.g. `"daemon:Outbound:Tool(TelnyxCall):rate"`). This
313/// is the OSS single-replica reference; the v2.28.0 enterprise layer binds the
314/// per-tenant Redis `RateLimiter` for multi-replica enforcement.
315#[derive(Default)]
316pub struct RateLeaseKernel {
317    leases: HashMap<String, RateLease>,
318}
319
320impl RateLeaseKernel {
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    /// Register (or replace) the lease for `key`.
326    pub fn register(&mut self, key: impl Into<String>, lease: RateLease) {
327        self.leases.insert(key.into(), lease);
328    }
329
330    /// Whether a lease is registered for `key`.
331    pub fn contains(&self, key: &str) -> bool {
332        self.leases.contains_key(key)
333    }
334
335    /// Attempt to consume one token from the lease at `key`. A `key` with no
336    /// registered lease is **unbudgeted** ⇒ always [`AcquireOutcome::Granted`]
337    /// (an effect with no declared quota is not rate-limited).
338    pub fn try_acquire(&mut self, key: &str, now: DateTime<Utc>) -> AcquireOutcome {
339        match self.leases.get_mut(key) {
340            Some(lease) => lease.try_acquire(now),
341            None => AcquireOutcome::Granted,
342        }
343    }
344
345    /// Attempt to consume one token from EVERY lease in `keys`, **all-or-none**:
346    /// consumes from all only if all have a token (so a per-hour `rate` and a
347    /// per-day `max` on the same tool both gate the call without a partial
348    /// consumption when one is exhausted). Returns [`AcquireOutcome::Denied`] with
349    /// the LATEST `retry_at` among the exhausted leases (the binding constraint —
350    /// you must wait for the slowest). An empty `keys` (an unbudgeted effect) is
351    /// granted. Unknown keys are skipped (treated as unbudgeted).
352    pub fn try_acquire_all(&mut self, keys: &[String], now: DateTime<Utc>) -> AcquireOutcome {
353        // Phase 1 — peek every lease; collect the binding retry_at on any denial.
354        let mut latest_retry: Option<DateTime<Utc>> = None;
355        for key in keys {
356            if let Some(lease) = self.leases.get(key) {
357                if let AcquireOutcome::Denied { retry_at } = lease.peek(now) {
358                    latest_retry = Some(match latest_retry {
359                        Some(prev) if prev >= retry_at => prev,
360                        _ => retry_at,
361                    });
362                }
363            }
364        }
365        if let Some(retry_at) = latest_retry {
366            return AcquireOutcome::Denied { retry_at };
367        }
368        // Phase 2 — all available ⇒ consume from each (cannot deny now).
369        for key in keys {
370            if let Some(lease) = self.leases.get_mut(key) {
371                let _ = lease.try_acquire(now);
372            }
373        }
374        AcquireOutcome::Granted
375    }
376
377    /// Tokens currently available at `key` (`None` if unregistered).
378    pub fn available(&self, key: &str, now: DateTime<Utc>) -> Option<f64> {
379        self.leases.get(key).map(|l| l.available(now))
380    }
381
382    /// Housekeeping pass — refill/roll every registered lease to `now` so
383    /// `available` snapshots are fresh. The refilling analogue of the lease
384    /// kernel's `sweep`. Acquisition does not depend on this (refill is lazy).
385    pub fn tick(&mut self, now: DateTime<Utc>) {
386        for lease in self.leases.values_mut() {
387            lease.refill(now);
388        }
389    }
390
391    /// v2.28.0 — snapshot every lease's state as `(key, snapshot)` pairs for
392    /// persistence. Deterministic order (sorted by key).
393    pub fn snapshot(&self) -> Vec<(String, RateLeaseSnapshot)> {
394        let mut out: Vec<(String, RateLeaseSnapshot)> =
395            self.leases.iter().map(|(k, l)| (k.clone(), l.snapshot())).collect();
396        out.sort_by(|a, b| a.0.cmp(&b.0));
397        out
398    }
399
400    /// v2.28.0 — restore lease states from `(key, snapshot)` pairs. Keys with
401    /// no registered lease are skipped (a quota dropped from a re-budgeted daemon).
402    pub fn restore(&mut self, snaps: &[(String, RateLeaseSnapshot)]) {
403        for (key, snap) in snaps {
404            if let Some(lease) = self.leases.get_mut(key) {
405                lease.restore(snap);
406            }
407        }
408    }
409}
410
411// ═══════════════════════════════════════════════════════════════════
412// BUDGET GATE — the v2.28.0 dispatch-site decision over a daemon's budget
413// ═══════════════════════════════════════════════════════════════════
414
415/// The dispatch gate's verdict for one budgeted effect emission.
416#[derive(Debug, Clone, PartialEq)]
417pub enum GateDecision {
418    /// A token was consumed from every quota on the effect — the call proceeds.
419    Allow,
420    /// At least one quota is exhausted. The caller applies `on_exhausted`:
421    /// `block` (fail the step), `defer` (reschedule to `retry_at`, v2.28.0), or
422    /// `shed` (skip the call, v2.28.0).
423    Deny {
424        retry_at: DateTime<Utc>,
425        on_exhausted: String,
426    },
427}
428
429/// v2.28.0 — a daemon's compiled `budget { … }` as a runnable gate. Holds one
430/// [`RateLease`] per quota (keyed by effect + kind), the `on_exhausted` policy,
431/// and an effect→keys index so the dispatch site can gate a tool emission by
432/// name. Built once when a budgeted daemon starts running its flow; the OSS
433/// reference is single-process (the v2.28.0 enterprise layer swaps the in-process
434/// kernel for the per-tenant Redis `RateLimiter` behind the same `gate` shape).
435pub struct BudgetGate {
436    kernel: RateLeaseKernel,
437    on_exhausted: String,
438    /// effect (tool name) → the subject keys of its quotas.
439    by_effect: HashMap<String, Vec<String>>,
440}
441
442impl BudgetGate {
443    /// Build a gate from a compiled [`crate::ir_nodes::IRBudget`]. `scope` is an
444    /// opaque prefix (e.g. the daemon name) that namespaces the subject keys.
445    /// An invalid-period quota (the type checker prevents this) is skipped.
446    pub fn from_ir(budget: &crate::ir_nodes::IRBudget, scope: &str, now: DateTime<Utc>) -> Self {
447        let mut kernel = RateLeaseKernel::new();
448        let mut by_effect: HashMap<String, Vec<String>> = HashMap::new();
449        for (i, quota) in budget.quotas.iter().enumerate() {
450            let Some(lease) = RateLease::from_quota(quota, now) else {
451                continue;
452            };
453            let key = format!("{scope}:Tool({}):{}:{i}", quota.effect, quota.kind);
454            kernel.register(key.clone(), lease);
455            by_effect.entry(quota.effect.clone()).or_default().push(key);
456        }
457        BudgetGate {
458            kernel,
459            on_exhausted: if budget.on_exhausted.is_empty() {
460                "block".to_string()
461            } else {
462                budget.on_exhausted.clone()
463            },
464            by_effect,
465        }
466    }
467
468    /// v2.69.0 — fold another gate into this one.
469    ///
470    /// A program may declare several top-level `budget`s. They compose into one
471    /// gate, and the composition **may only ever tighten**:
472    ///
473    /// - **Quotas accumulate.** Subject keys are namespaced by scope, so they
474    ///   cannot collide. Two budgets over the *same* tool means **both** must
475    ///   grant — `gate()` is all-or-none over an effect's quotas. You cannot
476    ///   satisfy one quota by ignoring another.
477    /// - **The STRICTEST `on_exhausted` wins** (`block` > `defer` > `shed`).
478    ///
479    /// That second rule is the load-bearing one. If a lax budget could soften a
480    /// strict one, then **adding a budget could widen what the program is allowed
481    /// to do** — and a quota whose presence increases your permissions is not a
482    /// quota. Merging must never be a way to buy leniency.
483    pub fn merged_with(mut self, other: BudgetGate) -> BudgetGate {
484        for (key, lease) in other.kernel.leases {
485            self.kernel.leases.insert(key, lease);
486        }
487        for (effect, keys) in other.by_effect {
488            self.by_effect.entry(effect).or_default().extend(keys);
489        }
490        // Strictest wins. `block` fails closed; `defer` reschedules; `shed` skips.
491        let strictness = |p: &str| match p {
492            "block" => 2,
493            "defer" => 1,
494            _ => 0, // `shed` — and any unknown policy, which must never be the winner
495        };
496        if strictness(&other.on_exhausted) > strictness(&self.on_exhausted) {
497            self.on_exhausted = other.on_exhausted;
498        }
499        self
500    }
501
502    /// Gate one emission of `effect` (a tool name) at `now`. An effect with no
503    /// quota is [`GateDecision::Allow`] (unbudgeted). Otherwise all of its quotas
504    /// must grant (all-or-none); on exhaustion the daemon's `on_exhausted` policy
505    /// rides on the [`GateDecision::Deny`].
506    pub fn gate(&mut self, effect: &str, now: DateTime<Utc>) -> GateDecision {
507        let Some(keys) = self.by_effect.get(effect) else {
508            return GateDecision::Allow;
509        };
510        let keys = keys.clone();
511        match self.kernel.try_acquire_all(&keys, now) {
512            AcquireOutcome::Granted => GateDecision::Allow,
513            AcquireOutcome::Denied { retry_at } => GateDecision::Deny {
514                retry_at,
515                on_exhausted: self.on_exhausted.clone(),
516            },
517        }
518    }
519
520    /// The exhaustion policy (`block` | `defer` | `shed`).
521    pub fn on_exhausted(&self) -> &str {
522        &self.on_exhausted
523    }
524
525    /// Whether `effect` has any quota under this budget.
526    pub fn governs(&self, effect: &str) -> bool {
527        self.by_effect.contains_key(effect)
528    }
529
530    /// v2.28.0 — snapshot the gate's cumulative state for persistence (the
531    /// enterprise supervisor saves this after a tick + restores it before the
532    /// next, so a `max: 50 per day` spans the day's ticks).
533    pub fn snapshot(&self) -> Vec<(String, RateLeaseSnapshot)> {
534        self.kernel.snapshot()
535    }
536
537    /// v2.28.0 — restore the gate's state from a prior [`snapshot`](Self::snapshot).
538    pub fn restore(&mut self, snaps: &[(String, RateLeaseSnapshot)]) {
539        self.kernel.restore(snaps);
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    fn t0() -> DateTime<Utc> {
548        "2026-06-29T00:00:00Z".parse().unwrap()
549    }
550
551    fn quota(kind: &str, limit: i64, period: &str, effect: &str) -> IRBudgetQuota {
552        IRBudgetQuota {
553            kind: kind.into(),
554            limit,
555            period: period.into(),
556            effect: effect.into(),
557        }
558    }
559
560    fn ir_budget(quotas: Vec<IRBudgetQuota>, on_exhausted: &str) -> crate::ir_nodes::IRBudget {
561        crate::ir_nodes::IRBudget {
562            node_type: "budget",
563            source_line: 1,
564            source_column: 1,
565            name: String::new(),
566            quotas,
567            on_exhausted: on_exhausted.into(),
568        }
569    }
570
571    // ── BudgetPeriod ─────────────────────────────────────────────────────
572
573    #[test]
574    fn period_parses_and_maps_to_seconds() {
575        assert_eq!(BudgetPeriod::parse("hour"), Some(BudgetPeriod::Hour));
576        assert_eq!(BudgetPeriod::parse("fortnight"), None);
577        assert_eq!(BudgetPeriod::Second.as_secs(), 1.0);
578        assert_eq!(BudgetPeriod::Minute.as_secs(), 60.0);
579        assert_eq!(BudgetPeriod::Hour.as_secs(), 3600.0);
580        assert_eq!(BudgetPeriod::Day.as_secs(), 86400.0);
581    }
582
583    // ── rate: token bucket ───────────────────────────────────────────────
584
585    #[test]
586    fn bucket_starts_full_and_grants_up_to_capacity() {
587        let now = t0();
588        let mut l = RateLease::rate("Telnyx", 3, BudgetPeriod::Hour, now);
589        // A burst of 3 succeeds (starts full)...
590        assert!(l.try_acquire(now).is_granted());
591        assert!(l.try_acquire(now).is_granted());
592        assert!(l.try_acquire(now).is_granted());
593        // ...the 4th is denied at the same instant (bucket empty).
594        match l.try_acquire(now) {
595            AcquireOutcome::Denied { retry_at } => {
596                // 3/hour ⇒ one token every 1200s; deficit is a full token.
597                assert_eq!(retry_at, now + Duration::seconds(1200));
598            }
599            other => panic!("expected Denied, got {other:?}"),
600        }
601    }
602
603    #[test]
604    fn bucket_refills_over_time() {
605        let now = t0();
606        let mut l = RateLease::rate("Telnyx", 2, BudgetPeriod::Hour, now);
607        // Drain both tokens.
608        assert!(l.try_acquire(now).is_granted());
609        assert!(l.try_acquire(now).is_granted());
610        assert!(!l.try_acquire(now).is_granted());
611        // 2/hour ⇒ 1 token per 1800s. After 1800s, exactly one token is back.
612        let later = now + Duration::seconds(1800);
613        assert!(l.try_acquire(later).is_granted());
614        assert!(!l.try_acquire(later).is_granted());
615    }
616
617    #[test]
618    fn bucket_refill_is_capped_at_capacity() {
619        let now = t0();
620        let mut l = RateLease::rate("Telnyx", 5, BudgetPeriod::Minute, now);
621        // Drain one, then wait a full day — refill must NOT exceed capacity.
622        assert!(l.try_acquire(now).is_granted());
623        let way_later = now + Duration::days(1);
624        // Only `limit` (5) grants available, not days' worth.
625        for _ in 0..5 {
626            assert!(l.try_acquire(way_later).is_granted());
627        }
628        assert!(!l.try_acquire(way_later).is_granted(), "capped at capacity");
629    }
630
631    // ── max: fixed window ────────────────────────────────────────────────
632
633    #[test]
634    fn window_caps_at_limit_then_rolls() {
635        let now = t0();
636        let mut l = RateLease::max("Telnyx", 50, BudgetPeriod::Day, now);
637        // 50 calls in the window succeed.
638        for _ in 0..50 {
639            assert!(l.try_acquire(now).is_granted());
640        }
641        // The 51st is denied; retry at the window roll (24h later).
642        match l.try_acquire(now) {
643            AcquireOutcome::Denied { retry_at } => {
644                assert_eq!(retry_at, now + Duration::seconds(86400));
645            }
646            other => panic!("expected Denied, got {other:?}"),
647        }
648        // Just before the roll → still denied.
649        assert!(!l.try_acquire(now + Duration::seconds(86399)).is_granted());
650        // After the roll → the window resets, calls succeed again.
651        let next_day = now + Duration::seconds(86400);
652        assert!(l.try_acquire(next_day).is_granted());
653        assert_eq!(l.available(next_day), 49.0);
654    }
655
656    #[test]
657    fn window_has_no_intra_window_refill() {
658        let now = t0();
659        let mut l = RateLease::max("Telnyx", 2, BudgetPeriod::Hour, now);
660        assert!(l.try_acquire(now).is_granted());
661        assert!(l.try_acquire(now).is_granted());
662        // Halfway through the window, still capped (no continuous refill).
663        assert!(!l.try_acquire(now + Duration::seconds(1800)).is_granted());
664    }
665
666    // ── from_quota + available ───────────────────────────────────────────
667
668    #[test]
669    fn from_quota_builds_the_right_kind() {
670        let now = t0();
671        let rate_q = IRBudgetQuota {
672            kind: "rate".into(),
673            limit: 8,
674            period: "hour".into(),
675            effect: "Telnyx".into(),
676        };
677        let max_q = IRBudgetQuota {
678            kind: "max".into(),
679            limit: 50,
680            period: "day".into(),
681            effect: "Telnyx".into(),
682        };
683        let rate = RateLease::from_quota(&rate_q, now).unwrap();
684        assert_eq!(rate.available(now), 8.0, "a rate bucket starts full");
685        let maxl = RateLease::from_quota(&max_q, now).unwrap();
686        assert_eq!(maxl.available(now), 50.0, "a max window starts with the full allowance");
687        // An invalid period fails closed to None.
688        let bad = IRBudgetQuota { period: "fortnight".into(), ..rate_q };
689        assert!(RateLease::from_quota(&bad, now).is_none());
690    }
691
692    // ── kernel ───────────────────────────────────────────────────────────
693
694    #[test]
695    fn kernel_unregistered_key_is_unbudgeted() {
696        let mut k = RateLeaseKernel::new();
697        // No lease for the key ⇒ an effect with no quota is never limited.
698        assert!(k.try_acquire("daemon:X:Tool(Y):rate", t0()).is_granted());
699        assert_eq!(k.available("daemon:X:Tool(Y):rate", t0()), None);
700    }
701
702    #[test]
703    fn kernel_enforces_a_registered_lease() {
704        let now = t0();
705        let mut k = RateLeaseKernel::new();
706        k.register("d:Out:Tool(Telnyx):rate", RateLease::rate("Telnyx", 1, BudgetPeriod::Hour, now));
707        assert!(k.try_acquire("d:Out:Tool(Telnyx):rate", now).is_granted());
708        assert!(!k.try_acquire("d:Out:Tool(Telnyx):rate", now).is_granted());
709        // After a full hour, the single token is back.
710        let later = now + Duration::seconds(3600);
711        assert!(k.try_acquire("d:Out:Tool(Telnyx):rate", later).is_granted());
712    }
713
714    #[test]
715    fn kernel_tick_refreshes_available_without_consuming() {
716        let now = t0();
717        let mut k = RateLeaseKernel::new();
718        k.register("k", RateLease::rate("E", 4, BudgetPeriod::Minute, now));
719        // Drain to empty.
720        for _ in 0..4 {
721            assert!(k.try_acquire("k", now).is_granted());
722        }
723        assert_eq!(k.available("k", now), Some(0.0));
724        // 30s = half a minute ⇒ 4/min * 30s = 2 tokens refilled by tick.
725        let later = now + Duration::seconds(30);
726        k.tick(later);
727        assert_eq!(k.available("k", later), Some(2.0));
728    }
729
730    // ── try_acquire_all — the all-or-none multi-quota gate ───────────────
731
732    #[test]
733    fn acquire_all_is_all_or_none() {
734        let now = t0();
735        let mut k = RateLeaseKernel::new();
736        // rate: 5/hour (plenty) + max: 1/day (the binding constraint).
737        k.register("r", RateLease::rate("E", 5, BudgetPeriod::Hour, now));
738        k.register("m", RateLease::max("E", 1, BudgetPeriod::Day, now));
739        let keys = vec!["r".to_string(), "m".to_string()];
740        // First call: both grant.
741        assert!(k.try_acquire_all(&keys, now).is_granted());
742        // Second: max is exhausted → DENIED, and the rate token must NOT have
743        // been consumed (all-or-none) — 4 still available on the bucket.
744        match k.try_acquire_all(&keys, now) {
745            AcquireOutcome::Denied { retry_at } => {
746                assert_eq!(retry_at, now + Duration::seconds(86400), "binding = the daily max");
747            }
748            other => panic!("expected Denied, got {other:?}"),
749        }
750        assert_eq!(k.available("r", now), Some(4.0), "rate token not consumed on denial");
751    }
752
753    #[test]
754    fn acquire_all_empty_keys_is_granted() {
755        let mut k = RateLeaseKernel::new();
756        assert!(k.try_acquire_all(&[], t0()).is_granted());
757    }
758
759    // ── BudgetGate ───────────────────────────────────────────────────────
760
761    #[test]
762    fn gate_allows_unbudgeted_effects() {
763        let now = t0();
764        let b = ir_budget(vec![quota("rate", 1, "hour", "Telnyx")], "block");
765        let mut gate = BudgetGate::from_ir(&b, "daemon:Out", now);
766        // A tool with no quota is unbudgeted → always allowed.
767        assert_eq!(gate.gate("SomeOtherTool", now), GateDecision::Allow);
768        assert!(!gate.governs("SomeOtherTool"));
769        assert!(gate.governs("Telnyx"));
770    }
771
772    #[test]
773    fn gate_enforces_then_denies_with_policy() {
774        let now = t0();
775        let b = ir_budget(
776            vec![
777                quota("rate", 2, "hour", "Telnyx"),
778                quota("max", 3, "day", "Telnyx"),
779            ],
780            "defer",
781        );
782        let mut gate = BudgetGate::from_ir(&b, "daemon:Out", now);
783        // The rate bucket (2/hour) is the tighter constraint at t0.
784        assert_eq!(gate.gate("Telnyx", now), GateDecision::Allow);
785        assert_eq!(gate.gate("Telnyx", now), GateDecision::Allow);
786        match gate.gate("Telnyx", now) {
787            GateDecision::Deny { on_exhausted, retry_at } => {
788                assert_eq!(on_exhausted, "defer");
789                // 2/hour ⇒ next token in 1800s.
790                assert_eq!(retry_at, now + Duration::seconds(1800));
791            }
792            other => panic!("expected Deny, got {other:?}"),
793        }
794    }
795
796    #[test]
797    fn gate_omitted_policy_is_block() {
798        let now = t0();
799        let b = ir_budget(vec![quota("rate", 1, "hour", "E")], "");
800        let gate = BudgetGate::from_ir(&b, "d", now);
801        assert_eq!(gate.on_exhausted(), "block");
802    }
803
804    // ── v2.28.0 — snapshot / restore (cumulative across ticks) ─────────
805
806    #[test]
807    fn snapshot_restore_carries_max_window_across_ticks() {
808        let now = t0();
809        let b = ir_budget(vec![quota("max", 3, "day", "Telnyx")], "block");
810        // Tick 1: a fresh gate consumes 2 of 3.
811        let mut g1 = BudgetGate::from_ir(&b, "d", now);
812        assert_eq!(g1.gate("Telnyx", now), GateDecision::Allow);
813        assert_eq!(g1.gate("Telnyx", now), GateDecision::Allow);
814        let snap = g1.snapshot();
815
816        // Tick 2 (a later minute, possibly a different replica): a fresh gate
817        // RESTORED from the snapshot has consumed=2 → only 1 left, NOT a full 3.
818        let mut g2 = BudgetGate::from_ir(&b, "d", now + Duration::minutes(5));
819        g2.restore(&snap);
820        assert_eq!(g2.gate("Telnyx", now + Duration::minutes(5)), GateDecision::Allow);
821        // The 4th overall consumption (2 in tick 1 + 2 here) is denied — the
822        // daily cap is honoured ACROSS ticks.
823        match g2.gate("Telnyx", now + Duration::minutes(5)) {
824            GateDecision::Deny { .. } => {}
825            other => panic!("expected the daily cap to hold across ticks, got {other:?}"),
826        }
827    }
828
829    #[test]
830    fn snapshot_round_trips_a_bucket() {
831        let now = t0();
832        let mut l = RateLease::rate("E", 8, BudgetPeriod::Hour, now);
833        l.try_acquire(now); // tokens: 8 → 7
834        let snap = l.snapshot();
835        assert_eq!(snap.kind, "rate");
836        let mut l2 = RateLease::rate("E", 8, BudgetPeriod::Hour, now);
837        l2.restore(&snap);
838        assert_eq!(l2.available(now), 7.0, "restored bucket carries the consumed token");
839    }
840}