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::judge::JudgeGate;
11use crate::provider::{Auth, ModelRequest, ModelResponse, ProviderRegistry};
12use crate::subprocess::SubprocessGate;
13use async_trait::async_trait;
14use firstpass_core::{GateDef, GateResult, Verdict};
15use std::collections::VecDeque;
16use std::sync::Mutex;
17use std::time::Duration;
18
19/// A verification gate. Object-safe + async so subprocess/model gates fit the same contract.
20#[async_trait]
21pub trait Gate: Send + Sync + std::fmt::Debug {
22    /// Stable gate id (matches the name used in routing config).
23    fn id(&self) -> &str;
24    /// Evaluate the candidate response, producing a verdict + evidence.
25    async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult;
26}
27
28/// Fails an empty (whitespace-only) completion. The cheapest possible sanity gate.
29#[derive(Debug, Clone, Copy)]
30pub struct NonEmptyGate;
31
32#[async_trait]
33impl Gate for NonEmptyGate {
34    fn id(&self) -> &str {
35        "non-empty"
36    }
37    async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
38        let verdict = if resp.text.trim().is_empty() {
39            Verdict::Fail
40        } else {
41            Verdict::Pass
42        };
43        GateResult::deterministic(self.id(), verdict, 0)
44    }
45}
46
47/// Passes only if the completion parses as JSON. Useful for structured-output routes.
48#[derive(Debug, Clone, Copy)]
49pub struct JsonValidGate;
50
51#[async_trait]
52impl Gate for JsonValidGate {
53    fn id(&self) -> &str {
54        "json-valid"
55    }
56    async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
57        let ok = serde_json::from_str::<serde_json::Value>(resp.text.trim()).is_ok();
58        GateResult::deterministic(self.id(), if ok { Verdict::Pass } else { Verdict::Fail }, 0)
59    }
60}
61
62/// Validates the candidate (parsed as JSON) against a minimal JSON-Schema subset: top-level
63/// `type`, `required`, and per-property `type`. Covers tool-call args and extraction tasks.
64///
65// ponytail: not full JSON Schema draft 2020-12 — just type/required/properties, the 90% that
66// structured-output routes need. Swap for the `jsonschema` crate if nested/`$ref` schemas appear.
67#[derive(Debug, Clone)]
68pub struct SchemaGate {
69    schema: serde_json::Value,
70}
71
72impl SchemaGate {
73    /// Build a schema gate from a JSON Schema value.
74    #[must_use]
75    pub fn new(schema: serde_json::Value) -> Self {
76        Self { schema }
77    }
78
79    /// Check `value` against the minimal schema subset; returns the first violation, if any.
80    fn violation(&self, value: &serde_json::Value) -> Option<String> {
81        use serde_json::Value;
82        let type_ok = |v: &Value, ty: &str| match ty {
83            "object" => v.is_object(),
84            "array" => v.is_array(),
85            "string" => v.is_string(),
86            "number" => v.is_number(),
87            "integer" => v.is_i64() || v.is_u64(),
88            "boolean" => v.is_boolean(),
89            "null" => v.is_null(),
90            _ => true, // unknown type keyword: don't fail on it
91        };
92        if let Some(ty) = self.schema.get("type").and_then(Value::as_str)
93            && !type_ok(value, ty)
94        {
95            return Some(format!("root is not of type {ty}"));
96        }
97        if let Some(req) = self.schema.get("required").and_then(Value::as_array) {
98            for field in req.iter().filter_map(Value::as_str) {
99                if value.get(field).is_none() {
100                    return Some(format!("missing required field {field:?}"));
101                }
102            }
103        }
104        if let Some(props) = self.schema.get("properties").and_then(Value::as_object) {
105            for (name, subschema) in props {
106                if let (Some(actual), Some(ty)) = (
107                    value.get(name),
108                    subschema.get("type").and_then(Value::as_str),
109                ) && !type_ok(actual, ty)
110                {
111                    return Some(format!("property {name:?} is not of type {ty}"));
112                }
113            }
114        }
115        None
116    }
117}
118
119#[async_trait]
120impl Gate for SchemaGate {
121    fn id(&self) -> &str {
122        "schema"
123    }
124    async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
125        let Ok(value) = serde_json::from_str::<serde_json::Value>(resp.text.trim()) else {
126            let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
127            r.reason = Some("candidate is not valid JSON".to_owned());
128            return r;
129        };
130        match self.violation(&value) {
131            None => GateResult::deterministic(self.id(), Verdict::Pass, 0),
132            Some(reason) => {
133                let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
134                r.reason = Some(reason);
135                r
136            }
137        }
138    }
139}
140
141/// Rolling per-gate error budget (§7.2, §8.3.4). Tracks the last `window` outcomes; once the
142/// error (abstain) fraction over a full window exceeds `max_error_rate`, the gate is
143/// auto-disabled and an alarm is logged. A disabled gate is skipped by the runner (its verdict
144/// stops counting) rather than silently failing open or closed.
145#[derive(Debug)]
146pub struct GateHealth {
147    window: usize,
148    max_error_rate: f64,
149    outcomes: Mutex<VecDeque<bool>>, // true = error (abstain), false = ok
150    disabled: std::sync::atomic::AtomicBool,
151}
152
153impl GateHealth {
154    /// Create a health tracker. `max_error_rate` is the abstain fraction (over a full `window`)
155    /// beyond which the gate auto-disables.
156    #[must_use]
157    pub fn new(window: usize, max_error_rate: f64) -> Self {
158        Self {
159            window: window.max(1),
160            max_error_rate,
161            outcomes: Mutex::new(VecDeque::new()),
162            disabled: std::sync::atomic::AtomicBool::new(false),
163        }
164    }
165
166    /// Whether the gate is currently enabled (not auto-disabled).
167    #[must_use]
168    pub fn is_enabled(&self) -> bool {
169        !self.disabled.load(std::sync::atomic::Ordering::Relaxed)
170    }
171
172    /// Record one outcome (`errored` = the gate abstained/crashed) and re-evaluate the budget.
173    pub fn record(&self, gate_id: &str, errored: bool) {
174        // ponytail: a single global lock per gate-health; fine at proxy request rates. Shard by
175        // gate if a hot gate's lock ever shows up in a profile.
176        let Ok(mut q) = self.outcomes.lock() else {
177            return; // poisoned lock: skip accounting rather than panic on the request path
178        };
179        q.push_back(errored);
180        while q.len() > self.window {
181            q.pop_front();
182        }
183        if q.len() == self.window {
184            let errors = q.iter().filter(|e| **e).count();
185            let rate = errors as f64 / self.window as f64;
186            if rate > self.max_error_rate && self.is_enabled() {
187                self.disabled
188                    .store(true, std::sync::atomic::Ordering::Relaxed);
189                tracing::error!(
190                    gate = %gate_id,
191                    error_rate = rate,
192                    "gate exceeded its error budget — auto-disabled (ALARM)"
193                );
194            }
195        }
196    }
197}
198
199/// Per-`(tenant, gate)` error budgets for a running proxy (app-level, shared across requests;
200/// ADR 0004 §D6). Budgets (window + max abstain fraction) are registered per gate id at startup;
201/// the actual rolling-window accounting is a separate [`GateHealth`] per `(tenant, gate)` pair, so
202/// one tenant tripping a gate's budget auto-disables it only for that tenant, not globally. With
203/// auth off every request carries the tenant id `"default"`, so there is exactly one bucket per
204/// gate and behavior is unchanged from the pre-D6 global registry.
205///
206/// Unregistered gates default to enabled with no accounting (a gate the operator didn't register
207/// a budget for is simply never auto-disabled).
208#[derive(Debug, Default)]
209pub struct GateHealthRegistry {
210    budgets: std::collections::HashMap<String, (usize, f64)>,
211    state: Mutex<std::collections::HashMap<(String, String), GateHealth>>,
212}
213
214impl GateHealthRegistry {
215    /// Empty registry — every gate enabled, no accounting.
216    #[must_use]
217    pub fn new() -> Self {
218        Self::default()
219    }
220
221    /// Register an error budget for `gate_id` (window size + max abstain fraction).
222    #[must_use]
223    pub fn with_budget(
224        mut self,
225        gate_id: impl Into<String>,
226        window: usize,
227        max_error_rate: f64,
228    ) -> Self {
229        self.budgets
230            .insert(gate_id.into(), (window, max_error_rate));
231        self
232    }
233
234    /// Whether `gate_id` is currently enabled for `tenant` (unregistered gates are always
235    /// enabled; a poisoned accounting lock fails open rather than blocking every request).
236    #[must_use]
237    pub fn enabled(&self, tenant: &str, gate_id: &str) -> bool {
238        let Some(&(window, max_error_rate)) = self.budgets.get(gate_id) else {
239            return true;
240        };
241        let Ok(mut state) = self.state.lock() else {
242            return true;
243        };
244        state
245            .entry((tenant.to_owned(), gate_id.to_owned()))
246            .or_insert_with(|| GateHealth::new(window, max_error_rate))
247            .is_enabled()
248    }
249
250    /// Record one outcome for `(tenant, gate_id)` (`errored` = abstained/crashed). No-op if the
251    /// gate has no registered budget, or if the accounting lock is poisoned.
252    pub fn record(&self, tenant: &str, gate_id: &str, errored: bool) {
253        let Some(&(window, max_error_rate)) = self.budgets.get(gate_id) else {
254            return;
255        };
256        let Ok(mut state) = self.state.lock() else {
257            return;
258        };
259        state
260            .entry((tenant.to_owned(), gate_id.to_owned()))
261            .or_insert_with(|| GateHealth::new(window, max_error_rate))
262            .record(gate_id, errored);
263    }
264}
265
266/// Resolve a route's gate ids into runnable gates. Built-in ids (`non-empty`, `json-valid`) map to
267/// inline gates; any other id is looked up among the config's `[[gate]]` definitions and built as
268/// either a [`SubprocessGate`] (a `cmd` gate, SPEC §8.1) or a [`JudgeGate`] (a `judge` gate, §8.3).
269/// The judge needs a provider (from `registry`) and the caller's credentials (`auth`, BYOK). An id
270/// that is neither built-in nor defined — or a judge whose provider isn't registered — is skipped
271/// with a warning rather than failing the request.
272#[must_use]
273pub fn resolve_gates(
274    names: &[String],
275    defs: &[GateDef],
276    registry: &ProviderRegistry,
277    auth: &Auth,
278) -> Vec<Box<dyn Gate>> {
279    let mut gates: Vec<Box<dyn Gate>> = Vec::new();
280    for name in names {
281        match name.as_str() {
282            "non-empty" => gates.push(Box::new(NonEmptyGate)),
283            "json-valid" => gates.push(Box::new(JsonValidGate)),
284            other => match defs.iter().find(|d| d.id == other) {
285                Some(def) if def.judge.is_some() => {
286                    // `Config::parse` guarantees exactly one kind, so this `if let` always binds.
287                    if let Some(judge) = def.judge.as_ref() {
288                        let provider_id = judge.model.split('/').next().unwrap_or_default();
289                        match registry.get(provider_id) {
290                            Some(provider) => gates.push(Box::new(JudgeGate::new(
291                                def.id.clone(),
292                                provider,
293                                judge.model.clone(),
294                                auth.clone(),
295                                judge.threshold,
296                                judge.rubric.clone().unwrap_or_default(),
297                            ))),
298                            None => tracing::warn!(
299                                gate = %other, provider = %provider_id,
300                                "judge gate provider not registered — skipped"
301                            ),
302                        }
303                    }
304                }
305                Some(def) => {
306                    let Some((program, args)) = def.cmd.split_first() else {
307                        tracing::warn!(gate = %other, "configured gate has empty cmd — skipped");
308                        continue;
309                    };
310                    gates.push(Box::new(SubprocessGate::new(
311                        def.id.clone(),
312                        program.clone(),
313                        args.to_vec(),
314                        Duration::from_millis(def.timeout_ms),
315                    )));
316                }
317                None => tracing::warn!(
318                    gate = %other,
319                    "unknown gate id — not a built-in and not defined in [[gate]]; skipped"
320                ),
321            },
322        }
323    }
324    gates
325}
326
327/// Aggregate per-gate verdicts into the attempt's overall verdict.
328///
329/// `Fail` if any gate fails; otherwise `Pass`. An empty gate set passes.
330///
331// ponytail: `Abstain` is treated as pass (fail-open). Per-gate fail-open/closed policy is a
332// follow-up; until then an abstaining/disabled gate never blocks serving.
333#[must_use]
334pub fn aggregate(results: &[GateResult]) -> Verdict {
335    if results.iter().any(|r| r.verdict == Verdict::Fail) {
336        Verdict::Fail
337    } else {
338        Verdict::Pass
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use serde_json::{Value, json};
346
347    fn resp(text: &str) -> ModelResponse {
348        ModelResponse {
349            model: "anthropic/claude-haiku-4-5".to_owned(),
350            text: text.to_owned(),
351            in_tokens: 1,
352            out_tokens: 1,
353            raw: Value::Null,
354        }
355    }
356
357    fn req() -> ModelRequest {
358        ModelRequest {
359            model: "anthropic/claude-haiku-4-5".to_owned(),
360            system: None,
361            messages: vec![],
362            max_tokens: 16,
363            tools: Value::Null,
364        }
365    }
366
367    #[tokio::test]
368    async fn non_empty_gate() {
369        assert_eq!(
370            NonEmptyGate.evaluate(&req(), &resp("hi")).await.verdict,
371            Verdict::Pass
372        );
373        assert_eq!(
374            NonEmptyGate.evaluate(&req(), &resp("   ")).await.verdict,
375            Verdict::Fail
376        );
377    }
378
379    #[tokio::test]
380    async fn json_valid_gate() {
381        assert_eq!(
382            JsonValidGate
383                .evaluate(&req(), &resp(r#"{"ok":true}"#))
384                .await
385                .verdict,
386            Verdict::Pass
387        );
388        assert_eq!(
389            JsonValidGate.evaluate(&req(), &resp("nope")).await.verdict,
390            Verdict::Fail
391        );
392    }
393
394    #[tokio::test]
395    async fn schema_gate_type_and_required() {
396        let g = SchemaGate::new(json!({
397            "type": "object",
398            "required": ["name", "age"],
399            "properties": { "name": {"type": "string"}, "age": {"type": "integer"} }
400        }));
401        assert_eq!(
402            g.evaluate(&req(), &resp(r#"{"name":"a","age":3}"#))
403                .await
404                .verdict,
405            Verdict::Pass
406        );
407        // missing required
408        assert_eq!(
409            g.evaluate(&req(), &resp(r#"{"name":"a"}"#)).await.verdict,
410            Verdict::Fail
411        );
412        // wrong property type
413        assert_eq!(
414            g.evaluate(&req(), &resp(r#"{"name":"a","age":"x"}"#))
415                .await
416                .verdict,
417            Verdict::Fail
418        );
419        // wrong root type
420        assert_eq!(g.evaluate(&req(), &resp("[]")).await.verdict, Verdict::Fail);
421        // not even JSON
422        assert_eq!(
423            g.evaluate(&req(), &resp("plain text")).await.verdict,
424            Verdict::Fail
425        );
426    }
427
428    fn empty_registry() -> ProviderRegistry {
429        ProviderRegistry::new("http://localhost", "http://localhost")
430    }
431
432    #[test]
433    fn resolve_skips_unknown_and_keeps_known() {
434        let gates = resolve_gates(
435            &[
436                "non-empty".to_owned(),
437                "judge-diff".to_owned(),
438                "json-valid".to_owned(),
439            ],
440            &[],
441            &empty_registry(),
442            &Auth::default(),
443        );
444        let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
445        assert_eq!(ids, ["non-empty", "json-valid"]);
446    }
447
448    #[test]
449    fn resolve_builds_configured_subprocess_gate() {
450        // A gate id that isn't built-in resolves to a SubprocessGate when defined in `[[gate]]`.
451        let defs = vec![GateDef {
452            id: "my-tests".to_owned(),
453            cmd: vec!["true".to_owned()],
454            timeout_ms: 1000,
455            judge: None,
456        }];
457        let gates = resolve_gates(
458            &["my-tests".to_owned(), "undefined".to_owned()],
459            &defs,
460            &empty_registry(),
461            &Auth::default(),
462        );
463        let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
464        assert_eq!(
465            ids,
466            ["my-tests"],
467            "configured id resolves; unknown id skipped"
468        );
469    }
470
471    #[tokio::test]
472    async fn configured_subprocess_gate_runs_end_to_end() {
473        // A user-defined gate that fails iff the candidate text contains "BAD" — proving the config
474        // → SubprocessGate → verdict path works over stdin, no hard-coded gate name.
475        let script = r#"c=$(cat); case "$c" in *BAD*) echo '{"verdict":"fail"}';; *) echo '{"verdict":"pass"}';; esac"#;
476        let defs = vec![GateDef {
477            id: "no-bad".to_owned(),
478            cmd: vec!["bash".to_owned(), "-c".to_owned(), script.to_owned()],
479            timeout_ms: 5000,
480            judge: None,
481        }];
482        let gates = resolve_gates(
483            &["no-bad".to_owned()],
484            &defs,
485            &empty_registry(),
486            &Auth::default(),
487        );
488        assert_eq!(gates.len(), 1);
489        let good = gates[0].evaluate(&req(), &resp("all good")).await;
490        assert_eq!(good.verdict, Verdict::Pass);
491        let bad = gates[0].evaluate(&req(), &resp("this is BAD")).await;
492        assert_eq!(bad.verdict, Verdict::Fail);
493    }
494
495    #[test]
496    fn resolve_builds_configured_judge_gate() {
497        use crate::provider::{MockProvider, Provider};
498        use std::collections::HashMap;
499        use std::sync::Arc;
500
501        let defs = vec![GateDef {
502            id: "quality".to_owned(),
503            cmd: vec![],
504            timeout_ms: 30_000,
505            judge: Some(firstpass_core::JudgeDef {
506                model: "anthropic/judge".to_owned(),
507                threshold: 0.7,
508                rubric: None,
509            }),
510        }];
511
512        // Registry that serves `anthropic` → the judge gate is built.
513        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
514        map.insert(
515            "anthropic".to_owned(),
516            Arc::new(MockProvider::new("anthropic", HashMap::new())),
517        );
518        let gates = resolve_gates(
519            &["quality".to_owned()],
520            &defs,
521            &ProviderRegistry::from_map(map),
522            &Auth::default(),
523        );
524        assert_eq!(
525            gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
526            ["quality"]
527        );
528
529        // Registry without that provider → skipped, not a hard failure.
530        let skipped = resolve_gates(
531            &["quality".to_owned()],
532            &defs,
533            &ProviderRegistry::from_map(HashMap::new()),
534            &Auth::default(),
535        );
536        assert!(
537            skipped.is_empty(),
538            "judge with no registered provider is skipped"
539        );
540    }
541
542    #[test]
543    fn aggregate_semantics() {
544        let pass = GateResult::deterministic("a", Verdict::Pass, 0);
545        let fail = GateResult::deterministic("b", Verdict::Fail, 0);
546        let abstain = GateResult::abstain("c", "x", 0);
547        assert_eq!(aggregate(&[]), Verdict::Pass);
548        assert_eq!(aggregate(std::slice::from_ref(&pass)), Verdict::Pass);
549        assert_eq!(aggregate(&[pass.clone(), fail]), Verdict::Fail);
550        assert_eq!(aggregate(&[pass, abstain]), Verdict::Pass);
551    }
552
553    #[test]
554    fn error_budget_auto_disables_past_threshold() {
555        let h = GateHealth::new(10, 0.5); // disable when >50% of the last 10 error
556        // Window fills to exactly 5 errors / 10 = 50% — NOT above threshold, still enabled.
557        for _ in 0..5 {
558            h.record("g", true);
559        }
560        for _ in 0..5 {
561            h.record("g", false);
562        }
563        assert!(h.is_enabled(), "50% is at, not above, the budget");
564        // Flood errors: the window slides until errors exceed 50% -> auto-disabled.
565        for _ in 0..6 {
566            h.record("g", true);
567        }
568        assert!(
569            !h.is_enabled(),
570            "gate should auto-disable once error rate exceeds budget"
571        );
572    }
573
574    #[test]
575    fn healthy_gate_stays_enabled() {
576        let h = GateHealth::new(20, 0.5);
577        for _ in 0..100 {
578            h.record("g", false);
579        }
580        assert!(h.is_enabled());
581    }
582
583    #[test]
584    fn gate_health_registry_default_tenant_is_a_single_bucket() {
585        // With auth off (single-operator), every request uses the same "default" tenant, so this
586        // is exactly the pre-D6 global-registry behavior.
587        let registry = GateHealthRegistry::new().with_budget("g", 4, 0.5);
588        for _ in 0..4 {
589            registry.record("default", "g", true);
590        }
591        assert!(!registry.enabled("default", "g"));
592    }
593
594    #[test]
595    fn gate_health_registry_scopes_budget_per_tenant() {
596        // ADR 0004 §D6: tenant A tripping the budget must not affect tenant B on the same gate.
597        let registry = GateHealthRegistry::new().with_budget("g", 4, 0.5);
598        for _ in 0..4 {
599            registry.record("tenant-a", "g", true);
600        }
601        assert!(!registry.enabled("tenant-a", "g"), "A should be disabled");
602        assert!(registry.enabled("tenant-b", "g"), "B must be unaffected");
603    }
604
605    #[test]
606    fn gate_health_registry_unregistered_gate_always_enabled() {
607        let registry = GateHealthRegistry::new();
608        assert!(registry.enabled("tenant-a", "unknown-gate"));
609        registry.record("tenant-a", "unknown-gate", true); // no-op, must not panic
610        assert!(registry.enabled("tenant-a", "unknown-gate"));
611    }
612}