Skip to main content

lean_ctx/proxy/
policy_gate.rs

1//! Org-policy gateway gate (enterprise#25) — model ceiling + hard budgets,
2//! enforced in the forward path **only** under a signed, trusted,
3//! `enforced = true` org policy ([`crate::core::policy::org`]).
4//!
5//! Three governance controls, all from the policy's new sections (Doc 08 §4.3):
6//!
7//! 1. **Model ceiling** (`[routing].allowed_models`) — a request whose
8//!    requested model matches no allowlist pattern is refused with 403 before
9//!    it leaves the gateway.
10//! 2. **Hard budgets** (`[budgets]`) — measured spend per person/UTC-day and
11//!    per project/UTC-month; a breached cap refuses further requests with 429
12//!    until the window rolls over.
13//! 3. **Per-person rate limit** (`[budgets].max_requests_per_minute_per_person`,
14//!    enterprise#66) — accepted requests per person per UTC minute; the
15//!    excess is refused with 429 + `Retry-After` until the minute rolls.
16//!    Counted in-process (per replica), which is the right blast-radius
17//!    control against a single runaway agent without cross-replica chatter.
18//!
19//! Spend accounting feeds from the same choke-point as all metering
20//! ([`super::usage_meter::record`]) and is seeded from the central usage
21//! store when the gateway runs with Postgres, so budgets survive restarts and
22//! cover multi-replica deployments to the seeding interval's precision.
23//!
24//! Design guarantees:
25//! - **Local-free invariant:** without an installed + pinned + enforced org
26//!   policy this module is a no-op — a solo user's traffic is never gated.
27//! - **Fail-open on infrastructure:** seeding errors only degrade precision
28//!   (in-process counting continues); they never block traffic.
29//! - **O(1) per request:** the policy snapshot is cached with a short TTL;
30//!   budget lookups are two hash-map reads.
31
32use std::collections::HashMap;
33use std::sync::{Mutex, OnceLock, RwLock};
34use std::time::{Duration, Instant};
35
36use axum::http::StatusCode;
37use axum::response::{IntoResponse, Response};
38
39use crate::core::policy::{BudgetRules, RoutingPolicyRules};
40
41/// How long a loaded org-policy snapshot stays valid before the gate re-reads
42/// (and re-verifies) the installed artifact. Policy rollout latency, not a
43/// hot-path cost: within the TTL every request uses the cached snapshot.
44const SNAPSHOT_TTL: Duration = Duration::from_mins(1);
45
46/// The governance subset of the active org policy the gate enforces.
47#[derive(Debug, Clone, Default, PartialEq)]
48pub struct GateRules {
49    pub allowed_models: Vec<String>,
50    pub forbid_downgrade_for: Vec<String>,
51    pub max_cost_usd_per_person_per_day: Option<f64>,
52    pub max_cost_usd_per_project_per_month: Option<f64>,
53    pub max_requests_per_minute_per_person: Option<u32>,
54}
55
56impl GateRules {
57    fn from_policy(routing: &RoutingPolicyRules, budgets: &BudgetRules) -> Option<Self> {
58        if routing.is_empty() && budgets.is_empty() {
59            return None;
60        }
61        Some(Self {
62            allowed_models: routing.allowed_models.clone(),
63            forbid_downgrade_for: routing.forbid_downgrade_for.clone(),
64            max_cost_usd_per_person_per_day: budgets.max_cost_usd_per_person_per_day,
65            max_cost_usd_per_project_per_month: budgets.max_cost_usd_per_project_per_month,
66            max_requests_per_minute_per_person: budgets.max_requests_per_minute_per_person,
67        })
68    }
69}
70
71struct CachedSnapshot {
72    rules: Option<GateRules>,
73    loaded_at: Instant,
74}
75
76static SNAPSHOT: RwLock<Option<CachedSnapshot>> = RwLock::new(None);
77
78/// Test hook: pin the gate rules directly, bypassing the org-policy store.
79/// Distinguishes "no override active" from "override pinned to no-rules".
80#[cfg(test)]
81#[derive(Clone, Default)]
82enum TestOverride {
83    #[default]
84    Unset,
85    Pinned(Option<GateRules>),
86}
87
88#[cfg(test)]
89static TEST_OVERRIDE: Mutex<TestOverride> = Mutex::new(TestOverride::Unset);
90
91/// The active governance rules, from cache or a fresh policy load.
92/// `None` = no enforced org governance → the gate is a no-op.
93#[must_use]
94pub fn active_rules() -> Option<GateRules> {
95    #[cfg(test)]
96    if let TestOverride::Pinned(pinned) = TEST_OVERRIDE
97        .lock()
98        .unwrap_or_else(std::sync::PoisonError::into_inner)
99        .clone()
100    {
101        return pinned;
102    }
103    {
104        let guard = SNAPSHOT
105            .read()
106            .unwrap_or_else(std::sync::PoisonError::into_inner);
107        if let Some(cached) = guard.as_ref()
108            && cached.loaded_at.elapsed() < SNAPSHOT_TTL
109        {
110            return cached.rules.clone();
111        }
112    }
113    let rules = crate::core::policy::org::active_resolved()
114        .and_then(|p| GateRules::from_policy(&p.routing, &p.budgets));
115    let mut guard = SNAPSHOT
116        .write()
117        .unwrap_or_else(std::sync::PoisonError::into_inner);
118    *guard = Some(CachedSnapshot {
119        rules: rules.clone(),
120        loaded_at: Instant::now(),
121    });
122    rules
123}
124
125/// Pin (or clear) the gate rules for a test, bypassing disk + signatures.
126#[cfg(test)]
127pub fn test_set_rules(rules: Option<GateRules>) {
128    *TEST_OVERRIDE
129        .lock()
130        .unwrap_or_else(std::sync::PoisonError::into_inner) = TestOverride::Pinned(rules);
131}
132
133#[cfg(test)]
134pub fn test_clear_rules() {
135    *TEST_OVERRIDE
136        .lock()
137        .unwrap_or_else(std::sync::PoisonError::into_inner) = TestOverride::Unset;
138}
139
140// ── Model ceiling ────────────────────────────────────────────────────────────
141
142/// Glob-lite match: `*` matches any run of characters; everything else is
143/// literal (models are flat names — no need for full glob semantics).
144fn pattern_matches(pattern: &str, model: &str) -> bool {
145    fn rec(p: &[u8], m: &[u8]) -> bool {
146        match p.first() {
147            None => m.is_empty(),
148            Some(b'*') => {
149                // Try every possible consumption length (bounded: model names
150                // are short) — classic backtracking glob.
151                (0..=m.len()).any(|k| rec(&p[1..], &m[k..]))
152            }
153            Some(&c) => m.first() == Some(&c) && rec(&p[1..], &m[1..]),
154        }
155    }
156    rec(pattern.trim().as_bytes(), model.trim().as_bytes())
157}
158
159/// Whether the requested model passes the ceiling. An empty allowlist means
160/// "no restriction".
161#[must_use]
162pub fn model_allowed(rules: &GateRules, model: &str) -> bool {
163    rules.allowed_models.is_empty()
164        || rules
165            .allowed_models
166            .iter()
167            .any(|p| pattern_matches(p, model))
168}
169
170/// Whether the router must not downgrade this project's requests.
171#[must_use]
172pub fn downgrade_forbidden(rules: &GateRules, project: Option<&str>) -> bool {
173    project.is_some_and(|p| rules.forbid_downgrade_for.iter().any(|f| f == p))
174}
175
176// ── Budget ledger ────────────────────────────────────────────────────────────
177
178/// UTC day (`yyyymmdd`) and month (`yyyymm`) window keys.
179fn window_keys_at(now: chrono::DateTime<chrono::Utc>) -> (u32, u32) {
180    use chrono::Datelike;
181    let day = now.year() as u32 * 10_000 + now.month() * 100 + now.day();
182    let month = now.year() as u32 * 100 + now.month();
183    (day, month)
184}
185
186fn window_keys() -> (u32, u32) {
187    window_keys_at(chrono::Utc::now())
188}
189
190/// In-memory measured-spend accumulators for the two budget windows.
191///
192/// `baseline` holds sums seeded from the central usage store (authoritative
193/// across restarts/replicas); `live` accumulates events recorded by *this*
194/// process since the last seed. A seed replaces the baseline and clears the
195/// live delta — the store query already contains those events.
196#[derive(Default)]
197struct BudgetLedger {
198    day_key: u32,
199    month_key: u32,
200    baseline_person_day: HashMap<String, f64>,
201    live_person_day: HashMap<String, f64>,
202    baseline_project_month: HashMap<String, f64>,
203    live_project_month: HashMap<String, f64>,
204}
205
206impl BudgetLedger {
207    /// Drop accumulators whose window rolled over.
208    fn roll(&mut self, day: u32, month: u32) {
209        if self.day_key != day {
210            self.day_key = day;
211            self.baseline_person_day.clear();
212            self.live_person_day.clear();
213        }
214        if self.month_key != month {
215            self.month_key = month;
216            self.baseline_project_month.clear();
217            self.live_project_month.clear();
218        }
219    }
220
221    fn add(&mut self, person: Option<&str>, project: Option<&str>, cost_usd: f64) {
222        let (day, month) = window_keys();
223        self.roll(day, month);
224        if let Some(p) = person {
225            *self.live_person_day.entry(p.to_string()).or_default() += cost_usd;
226        }
227        if let Some(p) = project {
228            *self.live_project_month.entry(p.to_string()).or_default() += cost_usd;
229        }
230    }
231
232    fn person_day_spend(&self, person: &str) -> f64 {
233        self.baseline_person_day.get(person).copied().unwrap_or(0.0)
234            + self.live_person_day.get(person).copied().unwrap_or(0.0)
235    }
236
237    fn project_month_spend(&self, project: &str) -> f64 {
238        self.baseline_project_month
239            .get(project)
240            .copied()
241            .unwrap_or(0.0)
242            + self.live_project_month.get(project).copied().unwrap_or(0.0)
243    }
244}
245
246fn ledger() -> &'static Mutex<BudgetLedger> {
247    static LEDGER: OnceLock<Mutex<BudgetLedger>> = OnceLock::new();
248    LEDGER.get_or_init(|| Mutex::new(BudgetLedger::default()))
249}
250
251// ── Rate ledger (enterprise#66) ──────────────────────────────────────────────
252
253/// Accepted-request counts per person for one UTC minute. Only requests that
254/// pass every gate are counted, so a blocked burst does not extend its own
255/// block. Memory stays bounded: the map resets each minute and holds at most
256/// one entry per active person.
257#[derive(Default)]
258struct RateLedger {
259    minute_key: u64,
260    accepted: HashMap<String, u32>,
261}
262
263impl RateLedger {
264    /// Count one accepted request; `false` = the person's limit is already
265    /// exhausted for this minute (the request must be refused, not counted).
266    fn try_accept(&mut self, person: &str, limit: u32, minute: u64) -> bool {
267        if self.minute_key != minute {
268            self.minute_key = minute;
269            self.accepted.clear();
270        }
271        let count = self.accepted.entry(person.to_string()).or_default();
272        if *count >= limit {
273            return false;
274        }
275        *count += 1;
276        true
277    }
278}
279
280fn rate_ledger() -> &'static Mutex<RateLedger> {
281    static RATE: OnceLock<Mutex<RateLedger>> = OnceLock::new();
282    RATE.get_or_init(|| Mutex::new(RateLedger::default()))
283}
284
285/// Unix minute + seconds left until it rolls (the honest `Retry-After`).
286fn minute_now() -> (u64, u64) {
287    let secs = std::time::SystemTime::now()
288        .duration_since(std::time::UNIX_EPOCH)
289        .map_or(0, |d| d.as_secs());
290    (secs / 60, 60 - (secs % 60))
291}
292
293/// Records one measured turn's cost against the budget windows. Called from
294/// the metering choke-point; cheap (two hash-map bumps) and never blocking.
295pub fn record_spend(person: Option<&str>, project: Option<&str>, cost_usd: f64) {
296    if cost_usd <= 0.0 || (person.is_none() && project.is_none()) {
297        return;
298    }
299    ledger()
300        .lock()
301        .unwrap_or_else(std::sync::PoisonError::into_inner)
302        .add(person, project, cost_usd);
303}
304
305/// Replaces the seeded baselines with fresh sums from the central usage store
306/// (gateway-server mode). The live deltas reset — the store query already
307/// includes everything this process pushed through the usage sink.
308pub fn seed_from_store(person_day: HashMap<String, f64>, project_month: HashMap<String, f64>) {
309    let (day, month) = window_keys();
310    let mut guard = ledger()
311        .lock()
312        .unwrap_or_else(std::sync::PoisonError::into_inner);
313    guard.roll(day, month);
314    guard.baseline_person_day = person_day;
315    guard.live_person_day.clear();
316    guard.baseline_project_month = project_month;
317    guard.live_project_month.clear();
318}
319
320#[cfg(test)]
321fn test_reset_ledger() {
322    let mut guard = ledger()
323        .lock()
324        .unwrap_or_else(std::sync::PoisonError::into_inner);
325    *guard = BudgetLedger::default();
326}
327
328// ── Enforcement ──────────────────────────────────────────────────────────────
329
330/// Why the gate refused a request.
331#[derive(Debug, Clone, PartialEq)]
332pub enum Refusal {
333    ModelNotAllowed {
334        model: String,
335    },
336    PersonBudgetExceeded {
337        person: String,
338        cap_usd: f64,
339        spent_usd: f64,
340    },
341    ProjectBudgetExceeded {
342        project: String,
343        cap_usd: f64,
344        spent_usd: f64,
345    },
346    PersonRateLimited {
347        person: String,
348        limit_rpm: u32,
349        retry_after_secs: u64,
350    },
351}
352
353/// Blocked-request counters for `/metrics` (enterprise#34).
354static BLOCKED_MODEL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
355static BLOCKED_BUDGET: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
356static BLOCKED_RATE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
357
358/// (model-ceiling blocks, budget blocks, rate-limit blocks) since process start.
359#[must_use]
360pub fn blocked_counters() -> (u64, u64, u64) {
361    (
362        BLOCKED_MODEL.load(std::sync::atomic::Ordering::Relaxed),
363        BLOCKED_BUDGET.load(std::sync::atomic::Ordering::Relaxed),
364        BLOCKED_RATE.load(std::sync::atomic::Ordering::Relaxed),
365    )
366}
367
368/// The full gate: model ceiling, then budgets, then the per-person rate
369/// limit. `Ok(())` forwards (and counts the request against the rate window);
370/// a refusal carries everything needed to render the wire-shape error.
371pub fn enforce(
372    rules: &GateRules,
373    requested_model: Option<&str>,
374    tags: &super::gateway_identity::GatewayTags,
375) -> Result<(), Refusal> {
376    if let Some(model) = requested_model
377        && !model_allowed(rules, model)
378    {
379        BLOCKED_MODEL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
380        return Err(Refusal::ModelNotAllowed {
381            model: model.to_string(),
382        });
383    }
384
385    {
386        let guard = ledger()
387            .lock()
388            .unwrap_or_else(std::sync::PoisonError::into_inner);
389        if let (Some(cap), Some(person)) = (
390            rules.max_cost_usd_per_person_per_day,
391            tags.person.as_deref(),
392        ) {
393            let spent = guard.person_day_spend(person);
394            if spent >= cap {
395                BLOCKED_BUDGET.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
396                return Err(Refusal::PersonBudgetExceeded {
397                    person: person.to_string(),
398                    cap_usd: cap,
399                    spent_usd: spent,
400                });
401            }
402        }
403        if let (Some(cap), Some(project)) = (
404            rules.max_cost_usd_per_project_per_month,
405            tags.project.as_deref(),
406        ) {
407            let spent = guard.project_month_spend(project);
408            if spent >= cap {
409                BLOCKED_BUDGET.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
410                return Err(Refusal::ProjectBudgetExceeded {
411                    project: project.to_string(),
412                    cap_usd: cap,
413                    spent_usd: spent,
414                });
415            }
416        }
417    }
418
419    // Last gate: rate. Counting only requests that passed everything above
420    // keeps refused traffic from extending its own block.
421    if let (Some(limit), Some(person)) = (
422        rules.max_requests_per_minute_per_person,
423        tags.person.as_deref(),
424    ) {
425        let (minute, secs_left) = minute_now();
426        let accepted = rate_ledger()
427            .lock()
428            .unwrap_or_else(std::sync::PoisonError::into_inner)
429            .try_accept(person, limit, minute);
430        if !accepted {
431            BLOCKED_RATE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
432            return Err(Refusal::PersonRateLimited {
433                person: person.to_string(),
434                limit_rpm: limit,
435                retry_after_secs: secs_left,
436            });
437        }
438    }
439    Ok(())
440}
441
442/// Renders a refusal as the wire-shape error the client's SDK understands.
443/// Model blocks → 403, budget blocks → 429 with `Retry-After`.
444#[must_use]
445pub fn refusal_response(refusal: &Refusal, provider_label: &str) -> Response {
446    let openai_shape = matches!(provider_label, "OpenAI" | "ChatGPT");
447    let (status, code, message) = match refusal {
448        Refusal::ModelNotAllowed { model } => (
449            StatusCode::FORBIDDEN,
450            "org_policy_model_blocked",
451            format!(
452                "model '{model}' is not allowed by your organization's AI gateway policy — \
453                 choose an approved model or contact your gateway admin"
454            ),
455        ),
456        Refusal::PersonBudgetExceeded {
457            person,
458            cap_usd,
459            spent_usd,
460        } => (
461            StatusCode::TOO_MANY_REQUESTS,
462            "org_budget_exceeded",
463            format!(
464                "daily AI budget exhausted for '{person}' \
465                 (${spent_usd:.2} of ${cap_usd:.2} spent) — resets at midnight UTC"
466            ),
467        ),
468        Refusal::ProjectBudgetExceeded {
469            project,
470            cap_usd,
471            spent_usd,
472        } => (
473            StatusCode::TOO_MANY_REQUESTS,
474            "org_budget_exceeded",
475            format!(
476                "monthly AI budget exhausted for project '{project}' \
477                 (${spent_usd:.2} of ${cap_usd:.2} spent) — resets on the 1st (UTC)"
478            ),
479        ),
480        Refusal::PersonRateLimited {
481            person, limit_rpm, ..
482        } => (
483            StatusCode::TOO_MANY_REQUESTS,
484            "org_policy_rate_limited",
485            format!(
486                "request rate limit reached for '{person}' \
487                 ({limit_rpm} requests/minute by org policy) — retry shortly"
488            ),
489        ),
490    };
491
492    let body = if openai_shape {
493        serde_json::json!({
494            "error": {
495                "message": message,
496                "type": if status == StatusCode::FORBIDDEN {
497                    "invalid_request_error"
498                } else {
499                    "insufficient_quota"
500                },
501                "code": code,
502            }
503        })
504    } else {
505        // Anthropic error envelope (also what Gemini SDKs tolerate for
506        // non-2xx JSON: they surface `message`).
507        serde_json::json!({
508            "type": "error",
509            "error": {
510                "type": if status == StatusCode::FORBIDDEN {
511                    "permission_error"
512                } else {
513                    "rate_limit_error"
514                },
515                "message": message,
516            }
517        })
518    };
519
520    let mut response = (status, axum::Json(body)).into_response();
521    if status == StatusCode::TOO_MANY_REQUESTS {
522        // Rate limits roll within the minute — say exactly when. Budgets roll
523        // at window boundaries; the coarse hour mainly stops naive SDK
524        // hot-retry loops.
525        let retry_after = match refusal {
526            Refusal::PersonRateLimited {
527                retry_after_secs, ..
528            } => (*retry_after_secs).clamp(1, 60).to_string(),
529            _ => "3600".to_string(),
530        };
531        if let Ok(value) = retry_after.parse() {
532            response
533                .headers_mut()
534                .insert(axum::http::header::RETRY_AFTER, value);
535        }
536    }
537    response
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::proxy::gateway_identity::GatewayTags;
544
545    fn tags(person: &str, project: &str) -> GatewayTags {
546        GatewayTags {
547            person: Some(person.to_string()),
548            team: None,
549            project: Some(project.to_string()),
550        }
551    }
552
553    fn rules() -> GateRules {
554        GateRules {
555            allowed_models: vec!["claude-*".into(), "gpt-4o-mini".into()],
556            forbid_downgrade_for: vec!["prod".into()],
557            max_cost_usd_per_person_per_day: Some(50.0),
558            max_cost_usd_per_project_per_month: Some(1000.0),
559            max_requests_per_minute_per_person: None,
560        }
561    }
562
563    fn test_reset_rate_ledger() {
564        let mut guard = rate_ledger()
565            .lock()
566            .unwrap_or_else(std::sync::PoisonError::into_inner);
567        *guard = RateLedger::default();
568    }
569
570    #[test]
571    fn glob_lite_matches_prefix_and_exact() {
572        assert!(pattern_matches("claude-*", "claude-sonnet-4-5"));
573        assert!(pattern_matches("gpt-4o-mini", "gpt-4o-mini"));
574        assert!(pattern_matches("*", "anything"));
575        assert!(!pattern_matches("claude-*", "gpt-5.2"));
576        assert!(!pattern_matches("gpt-4o-mini", "gpt-4o"));
577        assert!(pattern_matches("*sonnet*", "claude-sonnet-4-5"));
578    }
579
580    #[test]
581    fn empty_allowlist_means_no_restriction() {
582        let r = GateRules::default();
583        assert!(model_allowed(&r, "any-model"));
584    }
585
586    #[test]
587    #[serial_test::serial(policy_gate_ledger)]
588    fn model_ceiling_blocks_unlisted_model() {
589        test_reset_ledger();
590        let err = enforce(&rules(), Some("o3-pro"), &tags("a", "p")).unwrap_err();
591        assert_eq!(
592            err,
593            Refusal::ModelNotAllowed {
594                model: "o3-pro".into()
595            }
596        );
597        // Allowed models pass.
598        assert!(enforce(&rules(), Some("claude-haiku-4-5"), &tags("a", "p")).is_ok());
599    }
600
601    #[test]
602    #[serial_test::serial(policy_gate_ledger)]
603    fn person_day_budget_blocks_after_cap() {
604        test_reset_ledger();
605        record_spend(Some("mara"), Some("web"), 49.0);
606        assert!(enforce(&rules(), Some("claude-x"), &tags("mara", "web")).is_ok());
607        record_spend(Some("mara"), Some("web"), 2.0);
608        let err = enforce(&rules(), Some("claude-x"), &tags("mara", "web")).unwrap_err();
609        match err {
610            Refusal::PersonBudgetExceeded {
611                person,
612                cap_usd,
613                spent_usd,
614            } => {
615                assert_eq!(person, "mara");
616                assert!((cap_usd - 50.0).abs() < f64::EPSILON);
617                assert!(spent_usd >= 51.0 - 1e-9);
618            }
619            other => panic!("expected person budget refusal, got {other:?}"),
620        }
621        test_reset_ledger();
622    }
623
624    #[test]
625    #[serial_test::serial(policy_gate_ledger)]
626    fn project_month_budget_blocks_after_cap() {
627        test_reset_ledger();
628        record_spend(Some("a"), Some("ml-pipeline"), 600.0);
629        record_spend(Some("b"), Some("ml-pipeline"), 500.0);
630        let err = enforce(&rules(), Some("claude-x"), &tags("c", "ml-pipeline")).unwrap_err();
631        assert!(matches!(err, Refusal::ProjectBudgetExceeded { .. }));
632        // Another project is unaffected.
633        assert!(enforce(&rules(), Some("claude-x"), &tags("c", "other")).is_ok());
634        test_reset_ledger();
635    }
636
637    #[test]
638    #[serial_test::serial(policy_gate_ledger)]
639    fn seeding_replaces_baseline_and_clears_live() {
640        test_reset_ledger();
641        record_spend(Some("mara"), Some("web"), 10.0);
642        seed_from_store(HashMap::from([("mara".to_string(), 49.5)]), HashMap::new());
643        // 49.5 seeded (live 10.0 discarded — the store already contains it).
644        assert!(enforce(&rules(), Some("claude-x"), &tags("mara", "web")).is_ok());
645        record_spend(Some("mara"), Some("web"), 0.6);
646        assert!(enforce(&rules(), Some("claude-x"), &tags("mara", "web")).is_err());
647        test_reset_ledger();
648    }
649
650    #[test]
651    fn windows_roll_over() {
652        let mut l = BudgetLedger::default();
653        let (d1, m1) = (20_260_701, 202_607);
654        l.roll(d1, m1);
655        l.live_person_day.insert("a".into(), 100.0);
656        l.live_project_month.insert("p".into(), 100.0);
657        // Next day, same month: person resets, project persists.
658        l.roll(20_260_702, m1);
659        assert_eq!(l.person_day_spend("a"), 0.0);
660        assert_eq!(l.project_month_spend("p"), 100.0);
661        // Next month: project resets too.
662        l.roll(20_260_801, 202_608);
663        assert_eq!(l.project_month_spend("p"), 0.0);
664    }
665
666    #[test]
667    #[serial_test::serial(policy_gate_ledger)]
668    fn anonymous_requests_pass_budget_checks() {
669        test_reset_ledger();
670        // No tags → no person/project to attribute → budgets cannot apply.
671        let err = enforce(&rules(), Some("claude-x"), &GatewayTags::default());
672        assert!(err.is_ok());
673    }
674
675    #[test]
676    fn downgrade_exemption_matches_project() {
677        let r = rules();
678        assert!(downgrade_forbidden(&r, Some("prod")));
679        assert!(!downgrade_forbidden(&r, Some("web")));
680        assert!(!downgrade_forbidden(&r, None));
681    }
682
683    #[test]
684    fn refusal_bodies_match_wire_shape() {
685        let model_block = Refusal::ModelNotAllowed { model: "o3".into() };
686        let resp = refusal_response(&model_block, "Anthropic");
687        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
688
689        let budget_block = Refusal::PersonBudgetExceeded {
690            person: "a".into(),
691            cap_usd: 50.0,
692            spent_usd: 51.0,
693        };
694        let resp = refusal_response(&budget_block, "OpenAI");
695        assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
696        assert!(resp.headers().contains_key(axum::http::header::RETRY_AFTER));
697    }
698
699    #[test]
700    #[serial_test::serial(policy_gate_rate)]
701    fn rate_limit_blocks_after_n_accepted_requests() {
702        let mut r = rules();
703        r.max_requests_per_minute_per_person = Some(3);
704
705        // Retry once if the UTC minute rolls mid-test (rare but real): the
706        // whole sequence must land inside one window to be meaningful.
707        let err = (0..2)
708            .find_map(|_| {
709                test_reset_rate_ledger();
710                for _ in 0..3 {
711                    assert!(enforce(&r, Some("claude-x"), &tags("mara", "web")).is_ok());
712                }
713                enforce(&r, Some("claude-x"), &tags("mara", "web")).err()
714            })
715            .expect("4th request within one minute window must be refused");
716        match err {
717            Refusal::PersonRateLimited {
718                person,
719                limit_rpm,
720                retry_after_secs,
721            } => {
722                assert_eq!(person, "mara");
723                assert_eq!(limit_rpm, 3);
724                assert!((1..=60).contains(&retry_after_secs), "{retry_after_secs}");
725            }
726            other => panic!("expected rate refusal, got {other:?}"),
727        }
728        // Another person is unaffected — the window is per person.
729        assert!(enforce(&r, Some("claude-x"), &tags("erik", "web")).is_ok());
730        test_reset_rate_ledger();
731    }
732
733    #[test]
734    #[serial_test::serial(policy_gate_rate)]
735    fn rate_window_rolls_per_minute() {
736        test_reset_rate_ledger();
737        let mut l = RateLedger::default();
738        assert!(l.try_accept("mara", 2, 100));
739        assert!(l.try_accept("mara", 2, 100));
740        assert!(!l.try_accept("mara", 2, 100), "limit reached");
741        // Next minute: fresh window.
742        assert!(l.try_accept("mara", 2, 101));
743        test_reset_rate_ledger();
744    }
745
746    #[test]
747    #[serial_test::serial(policy_gate_rate)]
748    fn rate_limit_absent_or_anonymous_is_noop() {
749        test_reset_rate_ledger();
750        // No limit configured → unlimited.
751        for _ in 0..50 {
752            assert!(enforce(&rules(), Some("claude-x"), &tags("mara", "web")).is_ok());
753        }
754        // Limit set but request is anonymous → nothing to attribute.
755        let mut r = rules();
756        r.max_requests_per_minute_per_person = Some(1);
757        for _ in 0..5 {
758            assert!(enforce(&r, Some("claude-x"), &GatewayTags::default()).is_ok());
759        }
760        test_reset_rate_ledger();
761    }
762
763    #[test]
764    fn rate_refusal_wire_shape_has_honest_retry_after() {
765        let refusal = Refusal::PersonRateLimited {
766            person: "mara".into(),
767            limit_rpm: 30,
768            retry_after_secs: 17,
769        };
770        let resp = refusal_response(&refusal, "OpenAI");
771        assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
772        assert_eq!(
773            resp.headers()
774                .get(axum::http::header::RETRY_AFTER)
775                .and_then(|v| v.to_str().ok()),
776            Some("17")
777        );
778
779        let resp = refusal_response(&refusal, "Anthropic");
780        assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
781    }
782}