Skip to main content

firstpass_proxy/
gate.rs

1//! The gate framework (SPEC §8 — the moat).
2//!
3//! A gate inspects a candidate [`ModelResponse`] and returns a [`GateResult`] verdict. Gates are
4//! **async** because a real gate is I/O: a subprocess plugin (§8.1), an LLM judge, a test run.
5//! Pure inline gates (non-empty, json-valid, schema) simply don't await. Gate execution is
6//! wrapped by an **error budget** ([`GateHealth`]): a gate that errors too often is auto-disabled
7//! with an alarm, so a broken gate can neither silently fail closed (burns money) nor silently
8//! fail open (burns trust) (§7.2).
9
10use crate::consistency::ConsistencyGate;
11use crate::judge::JudgeGate;
12use crate::provider::{Auth, ModelRequest, ModelResponse, ProviderRegistry};
13use crate::subprocess::SubprocessGate;
14use async_trait::async_trait;
15use firstpass_core::{AbstainPolicy, GateDef, GateResult, Verdict, cost::PriceTable};
16use std::collections::VecDeque;
17use std::sync::Mutex;
18use std::time::Duration;
19
20/// A verification gate. Object-safe + async so subprocess/model gates fit the same contract.
21#[async_trait]
22pub trait Gate: Send + Sync + std::fmt::Debug {
23    /// Stable gate id (matches the name used in routing config).
24    fn id(&self) -> &str;
25    /// Evaluate the candidate response, producing a verdict + evidence.
26    async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult;
27    /// Whether an **abstain** from this gate blocks serving like a `Fail` (§7.2,
28    /// `on_abstain = "fail_closed"`). Default `false` = fail-open, the historical behavior.
29    /// The abstain verdict itself is still recorded honestly on the receipt; only the
30    /// aggregation treats it as blocking.
31    fn abstain_fails_closed(&self) -> bool {
32        false
33    }
34}
35
36/// Fails an empty (whitespace-only) completion. The cheapest possible sanity gate.
37#[derive(Debug, Clone, Copy)]
38pub struct NonEmptyGate;
39
40#[async_trait]
41impl Gate for NonEmptyGate {
42    fn id(&self) -> &str {
43        "non-empty"
44    }
45    async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
46        let verdict = if resp.text.trim().is_empty() {
47            Verdict::Fail
48        } else {
49            Verdict::Pass
50        };
51        GateResult::deterministic(self.id(), verdict, 0)
52    }
53}
54
55/// Passes only if the completion parses as JSON. Useful for structured-output routes.
56#[derive(Debug, Clone, Copy)]
57pub struct JsonValidGate;
58
59#[async_trait]
60impl Gate for JsonValidGate {
61    fn id(&self) -> &str {
62        "json-valid"
63    }
64    async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
65        let ok = serde_json::from_str::<serde_json::Value>(resp.text.trim()).is_ok();
66        GateResult::deterministic(self.id(), if ok { Verdict::Pass } else { Verdict::Fail }, 0)
67    }
68}
69
70/// Validates the candidate (parsed as JSON) against a minimal JSON-Schema subset: top-level
71/// `type`, `required`, and per-property `type`. Covers tool-call args and extraction tasks.
72///
73// ponytail: not full JSON Schema draft 2020-12 — just type/required/properties, the 90% that
74// structured-output routes need. Swap for the `jsonschema` crate if nested/`$ref` schemas appear.
75#[derive(Debug, Clone)]
76pub struct SchemaGate {
77    id: String,
78    schema: serde_json::Value,
79}
80
81impl SchemaGate {
82    /// Build a schema gate from a JSON Schema value, under the id a route references it by.
83    #[must_use]
84    pub fn new(id: impl Into<String>, schema: serde_json::Value) -> Self {
85        Self {
86            id: id.into(),
87            schema,
88        }
89    }
90
91    /// Check `value` against the minimal schema subset; returns the first violation, if any.
92    fn violation(&self, value: &serde_json::Value) -> Option<String> {
93        use serde_json::Value;
94        let type_ok = |v: &Value, ty: &str| match ty {
95            "object" => v.is_object(),
96            "array" => v.is_array(),
97            "string" => v.is_string(),
98            "number" => v.is_number(),
99            "integer" => v.is_i64() || v.is_u64(),
100            "boolean" => v.is_boolean(),
101            "null" => v.is_null(),
102            _ => true, // unknown type keyword: don't fail on it
103        };
104        if let Some(ty) = self.schema.get("type").and_then(Value::as_str)
105            && !type_ok(value, ty)
106        {
107            return Some(format!("root is not of type {ty}"));
108        }
109        if let Some(req) = self.schema.get("required").and_then(Value::as_array) {
110            for field in req.iter().filter_map(Value::as_str) {
111                if value.get(field).is_none() {
112                    return Some(format!("missing required field {field:?}"));
113                }
114            }
115        }
116        if let Some(props) = self.schema.get("properties").and_then(Value::as_object) {
117            for (name, subschema) in props {
118                if let (Some(actual), Some(ty)) = (
119                    value.get(name),
120                    subschema.get("type").and_then(Value::as_str),
121                ) && !type_ok(actual, ty)
122                {
123                    return Some(format!("property {name:?} is not of type {ty}"));
124                }
125            }
126        }
127        None
128    }
129}
130
131#[async_trait]
132impl Gate for SchemaGate {
133    fn id(&self) -> &str {
134        &self.id
135    }
136    async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
137        let Ok(value) = serde_json::from_str::<serde_json::Value>(resp.text.trim()) else {
138            let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
139            r.reason = Some("candidate is not valid JSON".to_owned());
140            return r;
141        };
142        match self.violation(&value) {
143            None => GateResult::deterministic(self.id(), Verdict::Pass, 0),
144            Some(reason) => {
145                let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
146                r.reason = Some(reason);
147                r
148            }
149        }
150    }
151}
152
153/// Rolling per-gate error budget (§7.2, §8.3.4). Tracks the last `window` outcomes; once the
154/// error (abstain) fraction over a full window exceeds `max_error_rate`, the gate is
155/// auto-disabled and an alarm is logged. A disabled gate is skipped by the runner (its verdict
156/// stops counting) rather than silently failing open or closed.
157#[derive(Debug)]
158pub struct GateHealth {
159    window: usize,
160    max_error_rate: f64,
161    outcomes: Mutex<VecDeque<bool>>, // true = error (abstain), false = ok
162    disabled: std::sync::atomic::AtomicBool,
163}
164
165impl GateHealth {
166    /// Create a health tracker. `max_error_rate` is the abstain fraction (over a full `window`)
167    /// beyond which the gate auto-disables.
168    #[must_use]
169    pub fn new(window: usize, max_error_rate: f64) -> Self {
170        Self {
171            window: window.max(1),
172            max_error_rate,
173            outcomes: Mutex::new(VecDeque::new()),
174            disabled: std::sync::atomic::AtomicBool::new(false),
175        }
176    }
177
178    /// Whether the gate is currently enabled (not auto-disabled).
179    #[must_use]
180    pub fn is_enabled(&self) -> bool {
181        !self.disabled.load(std::sync::atomic::Ordering::Relaxed)
182    }
183
184    /// Record one outcome (`errored` = the gate abstained/crashed) and re-evaluate the budget.
185    pub fn record(&self, gate_id: &str, errored: bool) {
186        // ponytail: a single global lock per gate-health; fine at proxy request rates. Shard by
187        // gate if a hot gate's lock ever shows up in a profile.
188        let Ok(mut q) = self.outcomes.lock() else {
189            return; // poisoned lock: skip accounting rather than panic on the request path
190        };
191        q.push_back(errored);
192        while q.len() > self.window {
193            q.pop_front();
194        }
195        if q.len() == self.window {
196            let errors = q.iter().filter(|e| **e).count();
197            let rate = errors as f64 / self.window as f64;
198            if rate > self.max_error_rate && self.is_enabled() {
199                self.disabled
200                    .store(true, std::sync::atomic::Ordering::Relaxed);
201                tracing::error!(
202                    gate = %gate_id,
203                    error_rate = rate,
204                    "gate exceeded its error budget — auto-disabled (ALARM)"
205                );
206            }
207        }
208    }
209}
210
211/// Per-`(tenant, gate)` error budgets for a running proxy (app-level, shared across requests;
212/// ADR 0004 §D6). Budgets (window + max abstain fraction) are registered per gate id at startup;
213/// the actual rolling-window accounting is a separate [`GateHealth`] per `(tenant, gate)` pair, so
214/// one tenant tripping a gate's budget auto-disables it only for that tenant, not globally. With
215/// auth off every request carries the tenant id `"default"`, so there is exactly one bucket per
216/// gate and behavior is unchanged from the pre-D6 global registry.
217///
218/// Unregistered gates default to enabled with no accounting (a gate the operator didn't register
219/// a budget for is simply never auto-disabled).
220#[derive(Debug, Default)]
221pub struct GateHealthRegistry {
222    budgets: std::collections::HashMap<String, (usize, f64)>,
223    state: Mutex<std::collections::HashMap<(String, String), GateHealth>>,
224}
225
226impl GateHealthRegistry {
227    /// Empty registry — every gate enabled, no accounting.
228    #[must_use]
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Register an error budget for `gate_id` (window size + max abstain fraction).
234    #[must_use]
235    pub fn with_budget(
236        mut self,
237        gate_id: impl Into<String>,
238        window: usize,
239        max_error_rate: f64,
240    ) -> Self {
241        self.budgets
242            .insert(gate_id.into(), (window, max_error_rate));
243        self
244    }
245
246    /// Whether `gate_id` is currently enabled for `tenant` (unregistered gates are always
247    /// enabled; a poisoned accounting lock fails open rather than blocking every request).
248    #[must_use]
249    pub fn enabled(&self, tenant: &str, gate_id: &str) -> bool {
250        let Some(&(window, max_error_rate)) = self.budgets.get(gate_id) else {
251            return true;
252        };
253        let Ok(mut state) = self.state.lock() else {
254            return true;
255        };
256        state
257            .entry((tenant.to_owned(), gate_id.to_owned()))
258            .or_insert_with(|| GateHealth::new(window, max_error_rate))
259            .is_enabled()
260    }
261
262    /// Record one outcome for `(tenant, gate_id)` (`errored` = abstained/crashed). No-op if the
263    /// gate has no registered budget, or if the accounting lock is poisoned.
264    pub fn record(&self, tenant: &str, gate_id: &str, errored: bool) {
265        let Some(&(window, max_error_rate)) = self.budgets.get(gate_id) else {
266            return;
267        };
268        let Ok(mut state) = self.state.lock() else {
269            return;
270        };
271        state
272            .entry((tenant.to_owned(), gate_id.to_owned()))
273            .or_insert_with(|| GateHealth::new(window, max_error_rate))
274            .record(gate_id, errored);
275    }
276}
277
278/// Wraps any gate to make its abstains block serving (`on_abstain = "fail_closed"`, §7.2).
279/// Pure delegation except [`Gate::abstain_fails_closed`]; the underlying gate's verdicts —
280/// including the abstain itself — are recorded unchanged, so the receipt stays honest.
281#[derive(Debug)]
282struct FailClosed(Box<dyn Gate>);
283
284#[async_trait]
285impl Gate for FailClosed {
286    fn id(&self) -> &str {
287        self.0.id()
288    }
289    async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult {
290        self.0.evaluate(req, resp).await
291    }
292    fn abstain_fails_closed(&self) -> bool {
293        true
294    }
295}
296
297/// Resolve a route's gate ids into runnable gates. Built-in ids (`non-empty`, `json-valid`) map to
298/// inline gates; any other id is looked up among the config's `[[gate]]` definitions and built as a
299/// [`SubprocessGate`] (`cmd`, SPEC §8.1), a [`JudgeGate`] (`judge`, §8.3), a [`ConsistencyGate`]
300/// (`consistency`), or a [`SchemaGate`] (`schema`). Model-backed gates (judge/consistency) need a
301/// provider (from `registry`), the caller's credentials (`auth`, BYOK), and `prices` so their
302/// sample-call cost lands on the receipt. An id that is neither built-in nor defined — or a
303/// model gate whose provider isn't registered — is skipped with a warning rather than failing the
304/// request. A def with `on_abstain = "fail_closed"` is wrapped so its abstains block serving.
305#[must_use]
306pub fn resolve_gates(
307    names: &[String],
308    defs: &[GateDef],
309    registry: &ProviderRegistry,
310    auth: &Auth,
311    prices: &PriceTable,
312) -> Vec<Box<dyn Gate>> {
313    let mut gates: Vec<Box<dyn Gate>> = Vec::new();
314    let mut push = |gate: Box<dyn Gate>, def: Option<&GateDef>| {
315        if def.is_some_and(|d| d.on_abstain == AbstainPolicy::FailClosed) {
316            gates.push(Box::new(FailClosed(gate)));
317        } else {
318            gates.push(gate);
319        }
320    };
321    for name in names {
322        match name.as_str() {
323            "non-empty" => push(Box::new(NonEmptyGate), None),
324            "json-valid" => push(Box::new(JsonValidGate), None),
325            other => match defs.iter().find(|d| d.id == other) {
326                Some(def) if def.judge.is_some() => {
327                    // `Config::parse` guarantees exactly one kind, so this `if let` always binds.
328                    if let Some(judge) = def.judge.as_ref() {
329                        let provider_id = judge.model.split('/').next().unwrap_or_default();
330                        match registry.get(provider_id) {
331                            Some(provider) => push(
332                                Box::new(JudgeGate::new(
333                                    def.id.clone(),
334                                    provider,
335                                    judge.model.clone(),
336                                    auth.clone(),
337                                    judge.threshold,
338                                    judge.rubric.clone().unwrap_or_default(),
339                                    prices.clone(),
340                                )),
341                                Some(def),
342                            ),
343                            None => tracing::warn!(
344                                gate = %other, provider = %provider_id,
345                                "judge gate provider not registered — skipped"
346                            ),
347                        }
348                    }
349                }
350                Some(def) if def.consistency.is_some() => {
351                    // `Config::parse` guarantees exactly one kind, so this `if let` always binds.
352                    if let Some(cons) = def.consistency.as_ref() {
353                        let provider_id = cons.model.split('/').next().unwrap_or_default();
354                        match registry.get(provider_id) {
355                            Some(provider) => push(
356                                Box::new(ConsistencyGate::new(
357                                    def.id.clone(),
358                                    provider,
359                                    cons.model.clone(),
360                                    auth.clone(),
361                                    cons.k,
362                                    cons.threshold,
363                                    prices.clone(),
364                                )),
365                                Some(def),
366                            ),
367                            None => tracing::warn!(
368                                gate = %other, provider = %provider_id,
369                                "consistency gate provider not registered — skipped"
370                            ),
371                        }
372                    }
373                }
374                Some(def) if def.schema.is_some() => {
375                    // `Config::parse` guarantees exactly one kind, so this `if let` always binds.
376                    if let Some(schema) = def.schema.as_ref() {
377                        push(
378                            Box::new(SchemaGate::new(def.id.clone(), schema.clone())),
379                            Some(def),
380                        );
381                    }
382                }
383                Some(def) => {
384                    let Some((program, args)) = def.cmd.split_first() else {
385                        tracing::warn!(gate = %other, "configured gate has empty cmd — skipped");
386                        continue;
387                    };
388                    push(
389                        Box::new(SubprocessGate::new(
390                            def.id.clone(),
391                            program.clone(),
392                            args.to_vec(),
393                            Duration::from_millis(def.timeout_ms),
394                        )),
395                        Some(def),
396                    );
397                }
398                None => tracing::warn!(
399                    gate = %other,
400                    "unknown gate id — not a built-in and not defined in [[gate]]; skipped"
401                ),
402            },
403        }
404    }
405    gates
406}
407
408/// Aggregate per-gate verdicts into the attempt's overall verdict, honoring each gate's
409/// abstain policy (§7.2): `Fail` if any gate fails **or** a `fail_closed` gate abstains;
410/// otherwise `Pass`. An empty gate set passes. `fail_closed_ids` is the set of gate ids whose
411/// abstains block serving (from [`Gate::abstain_fails_closed`]); a fail-open gate's abstain
412/// never blocks (the historical behavior).
413#[must_use]
414pub fn aggregate_with_policy(
415    results: &[GateResult],
416    fail_closed_ids: &std::collections::HashSet<&str>,
417) -> Verdict {
418    let blocking = |r: &GateResult| {
419        r.verdict == Verdict::Fail
420            || (r.verdict == Verdict::Abstain && fail_closed_ids.contains(r.gate_id.as_str()))
421    };
422    if results.iter().any(blocking) {
423        Verdict::Fail
424    } else {
425        Verdict::Pass
426    }
427}
428
429/// Aggregate per-gate verdicts with every gate fail-open — `Fail` iff any gate fails.
430#[must_use]
431pub fn aggregate(results: &[GateResult]) -> Verdict {
432    aggregate_with_policy(results, &std::collections::HashSet::new())
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use serde_json::{Value, json};
439
440    fn resp(text: &str) -> ModelResponse {
441        ModelResponse {
442            model: "anthropic/claude-haiku-4-5".to_owned(),
443            text: text.to_owned(),
444            in_tokens: 1,
445            out_tokens: 1,
446            raw: Value::Null,
447        }
448    }
449
450    fn req() -> ModelRequest {
451        ModelRequest {
452            model: "anthropic/claude-haiku-4-5".to_owned(),
453            system: None,
454            messages: vec![],
455            max_tokens: 16,
456            tools: Value::Null,
457            raw: Value::Null,
458        }
459    }
460
461    #[tokio::test]
462    async fn non_empty_gate() {
463        assert_eq!(
464            NonEmptyGate.evaluate(&req(), &resp("hi")).await.verdict,
465            Verdict::Pass
466        );
467        assert_eq!(
468            NonEmptyGate.evaluate(&req(), &resp("   ")).await.verdict,
469            Verdict::Fail
470        );
471    }
472
473    #[tokio::test]
474    async fn json_valid_gate() {
475        assert_eq!(
476            JsonValidGate
477                .evaluate(&req(), &resp(r#"{"ok":true}"#))
478                .await
479                .verdict,
480            Verdict::Pass
481        );
482        assert_eq!(
483            JsonValidGate.evaluate(&req(), &resp("nope")).await.verdict,
484            Verdict::Fail
485        );
486    }
487
488    #[tokio::test]
489    async fn schema_gate_type_and_required() {
490        let g = SchemaGate::new(
491            "schema",
492            json!({
493                "type": "object",
494                "required": ["name", "age"],
495                "properties": { "name": {"type": "string"}, "age": {"type": "integer"} }
496            }),
497        );
498        assert_eq!(
499            g.evaluate(&req(), &resp(r#"{"name":"a","age":3}"#))
500                .await
501                .verdict,
502            Verdict::Pass
503        );
504        // missing required
505        assert_eq!(
506            g.evaluate(&req(), &resp(r#"{"name":"a"}"#)).await.verdict,
507            Verdict::Fail
508        );
509        // wrong property type
510        assert_eq!(
511            g.evaluate(&req(), &resp(r#"{"name":"a","age":"x"}"#))
512                .await
513                .verdict,
514            Verdict::Fail
515        );
516        // wrong root type
517        assert_eq!(g.evaluate(&req(), &resp("[]")).await.verdict, Verdict::Fail);
518        // not even JSON
519        assert_eq!(
520            g.evaluate(&req(), &resp("plain text")).await.verdict,
521            Verdict::Fail
522        );
523    }
524
525    fn empty_registry() -> ProviderRegistry {
526        ProviderRegistry::new("http://localhost", "http://localhost")
527    }
528
529    #[test]
530    fn resolve_skips_unknown_and_keeps_known() {
531        let gates = resolve_gates(
532            &[
533                "non-empty".to_owned(),
534                "judge-diff".to_owned(),
535                "json-valid".to_owned(),
536            ],
537            &[],
538            &empty_registry(),
539            &Auth::default(),
540            &PriceTable::default(),
541        );
542        let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
543        assert_eq!(ids, ["non-empty", "json-valid"]);
544    }
545
546    #[test]
547    fn resolve_builds_configured_subprocess_gate() {
548        // A gate id that isn't built-in resolves to a SubprocessGate when defined in `[[gate]]`.
549        let defs = vec![GateDef {
550            id: "my-tests".to_owned(),
551            cmd: vec!["true".to_owned()],
552            timeout_ms: 1000,
553            judge: None,
554            consistency: None,
555            schema: None,
556            on_abstain: AbstainPolicy::FailOpen,
557        }];
558        let gates = resolve_gates(
559            &["my-tests".to_owned(), "undefined".to_owned()],
560            &defs,
561            &empty_registry(),
562            &Auth::default(),
563            &PriceTable::default(),
564        );
565        let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
566        assert_eq!(
567            ids,
568            ["my-tests"],
569            "configured id resolves; unknown id skipped"
570        );
571    }
572
573    #[tokio::test]
574    async fn configured_subprocess_gate_runs_end_to_end() {
575        // A user-defined gate that fails iff the candidate text contains "BAD" — proving the config
576        // → SubprocessGate → verdict path works over stdin, no hard-coded gate name.
577        let script = r#"c=$(cat); case "$c" in *BAD*) echo '{"verdict":"fail"}';; *) echo '{"verdict":"pass"}';; esac"#;
578        let defs = vec![GateDef {
579            id: "no-bad".to_owned(),
580            cmd: vec!["bash".to_owned(), "-c".to_owned(), script.to_owned()],
581            timeout_ms: 5000,
582            judge: None,
583            consistency: None,
584            schema: None,
585            on_abstain: AbstainPolicy::FailOpen,
586        }];
587        let gates = resolve_gates(
588            &["no-bad".to_owned()],
589            &defs,
590            &empty_registry(),
591            &Auth::default(),
592            &PriceTable::default(),
593        );
594        assert_eq!(gates.len(), 1);
595        let good = gates[0].evaluate(&req(), &resp("all good")).await;
596        assert_eq!(good.verdict, Verdict::Pass);
597        let bad = gates[0].evaluate(&req(), &resp("this is BAD")).await;
598        assert_eq!(bad.verdict, Verdict::Fail);
599    }
600
601    #[test]
602    fn resolve_builds_configured_judge_gate() {
603        use crate::provider::{MockProvider, Provider};
604        use std::collections::HashMap;
605        use std::sync::Arc;
606
607        let defs = vec![GateDef {
608            id: "quality".to_owned(),
609            cmd: vec![],
610            timeout_ms: 30_000,
611            judge: Some(firstpass_core::JudgeDef {
612                model: "anthropic/judge".to_owned(),
613                threshold: 0.7,
614                rubric: None,
615            }),
616            consistency: None,
617            schema: None,
618            on_abstain: AbstainPolicy::FailOpen,
619        }];
620
621        // Registry that serves `anthropic` → the judge gate is built.
622        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
623        map.insert(
624            "anthropic".to_owned(),
625            Arc::new(MockProvider::new("anthropic", HashMap::new())),
626        );
627        let gates = resolve_gates(
628            &["quality".to_owned()],
629            &defs,
630            &ProviderRegistry::from_map(map),
631            &Auth::default(),
632            &PriceTable::default(),
633        );
634        assert_eq!(
635            gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
636            ["quality"]
637        );
638
639        // Registry without that provider → skipped, not a hard failure.
640        let skipped = resolve_gates(
641            &["quality".to_owned()],
642            &defs,
643            &ProviderRegistry::from_map(HashMap::new()),
644            &Auth::default(),
645            &PriceTable::default(),
646        );
647        assert!(
648            skipped.is_empty(),
649            "judge with no registered provider is skipped"
650        );
651    }
652
653    #[test]
654    fn resolve_builds_configured_consistency_gate() {
655        use crate::provider::{MockProvider, Provider};
656        use std::collections::HashMap;
657        use std::sync::Arc;
658
659        let defs = vec![GateDef {
660            id: "uncertainty".to_owned(),
661            cmd: vec![],
662            timeout_ms: 30_000,
663            judge: None,
664            consistency: Some(firstpass_core::ConsistencyDef {
665                model: "anthropic/claude-haiku-4-5".to_owned(),
666                k: 3,
667                threshold: 0.6,
668            }),
669            schema: None,
670            on_abstain: AbstainPolicy::FailOpen,
671        }];
672
673        // Registry that serves `anthropic` → the consistency gate is built.
674        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
675        map.insert(
676            "anthropic".to_owned(),
677            Arc::new(MockProvider::new("anthropic", HashMap::new())),
678        );
679        let gates = resolve_gates(
680            &["uncertainty".to_owned()],
681            &defs,
682            &ProviderRegistry::from_map(map),
683            &Auth::default(),
684            &PriceTable::default(),
685        );
686        assert_eq!(
687            gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
688            ["uncertainty"]
689        );
690
691        // Registry without the provider → skipped, not a hard failure.
692        let skipped = resolve_gates(
693            &["uncertainty".to_owned()],
694            &defs,
695            &ProviderRegistry::from_map(HashMap::new()),
696            &Auth::default(),
697            &PriceTable::default(),
698        );
699        assert!(
700            skipped.is_empty(),
701            "consistency gate with no registered provider is skipped"
702        );
703    }
704
705    #[test]
706    fn aggregate_semantics() {
707        let pass = GateResult::deterministic("a", Verdict::Pass, 0);
708        let fail = GateResult::deterministic("b", Verdict::Fail, 0);
709        let abstain = GateResult::abstain("c", "x", 0);
710        assert_eq!(aggregate(&[]), Verdict::Pass);
711        assert_eq!(aggregate(std::slice::from_ref(&pass)), Verdict::Pass);
712        assert_eq!(aggregate(&[pass.clone(), fail]), Verdict::Fail);
713        assert_eq!(aggregate(&[pass, abstain]), Verdict::Pass);
714    }
715
716    #[test]
717    fn error_budget_auto_disables_past_threshold() {
718        let h = GateHealth::new(10, 0.5); // disable when >50% of the last 10 error
719        // Window fills to exactly 5 errors / 10 = 50% — NOT above threshold, still enabled.
720        for _ in 0..5 {
721            h.record("g", true);
722        }
723        for _ in 0..5 {
724            h.record("g", false);
725        }
726        assert!(h.is_enabled(), "50% is at, not above, the budget");
727        // Flood errors: the window slides until errors exceed 50% -> auto-disabled.
728        for _ in 0..6 {
729            h.record("g", true);
730        }
731        assert!(
732            !h.is_enabled(),
733            "gate should auto-disable once error rate exceeds budget"
734        );
735    }
736
737    #[test]
738    fn healthy_gate_stays_enabled() {
739        let h = GateHealth::new(20, 0.5);
740        for _ in 0..100 {
741            h.record("g", false);
742        }
743        assert!(h.is_enabled());
744    }
745
746    #[test]
747    fn gate_health_registry_default_tenant_is_a_single_bucket() {
748        // With auth off (single-operator), every request uses the same "default" tenant, so this
749        // is exactly the pre-D6 global-registry behavior.
750        let registry = GateHealthRegistry::new().with_budget("g", 4, 0.5);
751        for _ in 0..4 {
752            registry.record("default", "g", true);
753        }
754        assert!(!registry.enabled("default", "g"));
755    }
756
757    #[test]
758    fn gate_health_registry_scopes_budget_per_tenant() {
759        // ADR 0004 §D6: tenant A tripping the budget must not affect tenant B on the same gate.
760        let registry = GateHealthRegistry::new().with_budget("g", 4, 0.5);
761        for _ in 0..4 {
762            registry.record("tenant-a", "g", true);
763        }
764        assert!(!registry.enabled("tenant-a", "g"), "A should be disabled");
765        assert!(registry.enabled("tenant-b", "g"), "B must be unaffected");
766    }
767
768    #[test]
769    fn gate_health_registry_unregistered_gate_always_enabled() {
770        let registry = GateHealthRegistry::new();
771        assert!(registry.enabled("tenant-a", "unknown-gate"));
772        registry.record("tenant-a", "unknown-gate", true); // no-op, must not panic
773        assert!(registry.enabled("tenant-a", "unknown-gate"));
774    }
775
776    #[tokio::test]
777    async fn resolve_builds_configured_schema_gate() {
778        let defs = vec![GateDef {
779            id: "extract-shape".to_owned(),
780            cmd: vec![],
781            timeout_ms: 30_000,
782            judge: None,
783            consistency: None,
784            schema: Some(json!({"type": "object", "required": ["name"]})),
785            on_abstain: firstpass_core::AbstainPolicy::FailOpen,
786        }];
787        let gates = resolve_gates(
788            &["extract-shape".to_owned()],
789            &defs,
790            &empty_registry(),
791            &Auth::default(),
792            &PriceTable::default(),
793        );
794        assert_eq!(
795            gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
796            ["extract-shape"],
797            "a [[gate]] def with `schema` resolves to a runnable SchemaGate"
798        );
799        let ok = gates[0].evaluate(&req(), &resp(r#"{"name":"a"}"#)).await;
800        assert_eq!(ok.verdict, Verdict::Pass);
801        let missing = gates[0].evaluate(&req(), &resp(r#"{}"#)).await;
802        assert_eq!(missing.verdict, Verdict::Fail);
803    }
804
805    #[test]
806    fn resolve_wraps_fail_closed_gate() {
807        let mk = |on_abstain| {
808            vec![GateDef {
809                id: "tests".to_owned(),
810                cmd: vec!["true".to_owned()],
811                timeout_ms: 1000,
812                judge: None,
813                consistency: None,
814                schema: None,
815                on_abstain,
816            }]
817        };
818        let open = resolve_gates(
819            &["tests".to_owned()],
820            &mk(firstpass_core::AbstainPolicy::FailOpen),
821            &empty_registry(),
822            &Auth::default(),
823            &PriceTable::default(),
824        );
825        assert!(!open[0].abstain_fails_closed(), "default stays fail-open");
826        let closed = resolve_gates(
827            &["tests".to_owned()],
828            &mk(firstpass_core::AbstainPolicy::FailClosed),
829            &empty_registry(),
830            &Auth::default(),
831            &PriceTable::default(),
832        );
833        assert!(
834            closed[0].abstain_fails_closed(),
835            "on_abstain = fail_closed wraps the gate"
836        );
837        assert_eq!(closed[0].id(), "tests", "wrapper preserves the id");
838    }
839
840    #[test]
841    fn aggregate_with_policy_fail_closed_abstain_blocks() {
842        use std::collections::HashSet;
843        let pass = GateResult::deterministic("a", Verdict::Pass, 0);
844        let abstain = GateResult::abstain("c", "timeout", 0);
845        // Fail-open (empty set): abstain never blocks — historical behavior.
846        assert_eq!(
847            aggregate_with_policy(&[pass.clone(), abstain.clone()], &HashSet::new()),
848            Verdict::Pass
849        );
850        // Fail-closed: the same abstain blocks serving like a Fail.
851        let closed: HashSet<&str> = ["c"].into_iter().collect();
852        assert_eq!(
853            aggregate_with_policy(&[pass, abstain], &closed),
854            Verdict::Fail
855        );
856    }
857}