Skip to main content

keel_core_api/
policy.rs

1//! Typed policy model: `keel.toml` (in its JSON form) deserialized straight
2//! into structs. Validation *is* the type system — `NonZero*` integers make
3//! zero counts unrepresentable, newtypes parse duration/rate/schedule
4//! literals in their `Deserialize` impls, and retry conditions are a closed
5//! enum. A document that deserializes is a valid policy; anything else is
6//! `KEEL-E001` with a precise field path (via `serde_path_to_error`).
7
8use core::fmt;
9use core::num::{NonZeroU32, NonZeroU64};
10use core::str::FromStr;
11use std::collections::BTreeMap;
12
13use crate::ErrorClass;
14use serde::Deserialize;
15
16/// A literal that failed to parse; surfaces through serde as the
17/// deserialization error message for the offending field.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ParseError {
20    what: &'static str,
21    input: String,
22    /// For literals that are grammatical but invalid (e.g. a schedule
23    /// composition whose segments can never all be reached): the reason,
24    /// appended so the KEEL-E001 first line says what to fix.
25    note: Option<&'static str>,
26}
27
28impl ParseError {
29    fn new(what: &'static str, input: &str) -> Self {
30        Self {
31            what,
32            input: input.to_owned(),
33            note: None,
34        }
35    }
36
37    fn with_note(what: &'static str, input: &str, note: &'static str) -> Self {
38        Self {
39            what,
40            input: input.to_owned(),
41            note: Some(note),
42        }
43    }
44}
45
46impl fmt::Display for ParseError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self.note {
49            None => write!(f, "unparseable {} literal: {:?}", self.what, self.input),
50            Some(note) => write!(
51                f,
52                "invalid {} literal: {:?} — {note}",
53                self.what, self.input
54            ),
55        }
56    }
57}
58
59impl core::error::Error for ParseError {}
60
61/// A duration literal: `200ms`, `30s`, `5m`, `2h`.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
63#[serde(try_from = "String")]
64pub struct DurationMs(pub u64);
65
66impl FromStr for DurationMs {
67    type Err = ParseError;
68
69    fn from_str(s: &str) -> Result<Self, Self::Err> {
70        let err = || ParseError::new("duration", s);
71        let s = s.trim();
72        let unit_at = s.find(|c: char| !c.is_ascii_digit()).ok_or_else(err)?;
73        let (number, unit) = s.split_at(unit_at);
74        let n: u64 = number.parse().map_err(|_| err())?;
75        let mult = match unit {
76            "ms" => 1,
77            "s" => 1_000,
78            "m" => 60_000,
79            "h" => 3_600_000,
80            _ => return Err(err()),
81        };
82        Ok(Self(n * mult))
83    }
84}
85
86impl TryFrom<String> for DurationMs {
87    type Error = ParseError;
88
89    fn try_from(s: String) -> Result<Self, Self::Error> {
90        s.parse()
91    }
92}
93
94/// A rate literal: `90/s`, `60/min`, `10/h`. A zero limit is unrepresentable.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
96#[serde(try_from = "String")]
97pub struct Rate {
98    pub limit: NonZeroU64,
99    pub window_ms: u64,
100}
101
102impl FromStr for Rate {
103    type Err = ParseError;
104
105    fn from_str(s: &str) -> Result<Self, Self::Err> {
106        let err = || ParseError::new("rate", s);
107        let (limit, window) = s.trim().split_once('/').ok_or_else(err)?;
108        let limit: NonZeroU64 = limit.trim().parse().map_err(|_| err())?;
109        let window_ms = match window.trim() {
110            "s" | "sec" => 1_000,
111            "min" => 60_000,
112            "h" | "hour" => 3_600_000,
113            _ => return Err(err()),
114        };
115        Ok(Self { limit, window_ms })
116    }
117}
118
119impl TryFrom<String> for Rate {
120    type Error = ParseError;
121
122    fn try_from(s: String) -> Result<Self, Self::Error> {
123        s.parse()
124    }
125}
126
127/// A retry schedule per contracts/schedule-grammar.ebnf — the full algebra:
128/// one or more `andThen`-separated segments, each an `exp`/`fixed` primary
129/// with an optional cumulative-wait bound (`upTo`). Semantics are pinned
130/// normatively in conformance/README.md ("Schedule algebra"): `upTo` bounds
131/// the segment's cumulative *natural* wait and hands off to the next segment;
132/// every segment except the last must be bounded and the last never is (both
133/// degenerate shapes are configure-time `KEEL-E001`), so a schedule is always
134/// a total mapping attempt → wait.
135#[derive(Debug, Clone, PartialEq, Deserialize)]
136#[serde(try_from = "String")]
137pub struct Schedule {
138    /// Non-empty; the parser enforces `up_to_ms.is_some()` on every segment
139    /// except the last and `None` on the last.
140    pub segments: Vec<ScheduleSegment>,
141}
142
143/// One `andThen` segment: a primary plus its optional `upTo` bound.
144#[derive(Debug, Clone, Copy, PartialEq)]
145pub struct ScheduleSegment {
146    pub primary: SchedulePrimary,
147    /// `upTo` bound on this segment's cumulative natural wait, in ms.
148    pub up_to_ms: Option<u64>,
149}
150
151/// A schedule primary (`exp` / `fixed`) from the frozen grammar.
152#[derive(Debug, Clone, Copy, PartialEq)]
153pub enum SchedulePrimary {
154    Exp {
155        base_ms: u64,
156        factor: f64,
157        cap_ms: u64,
158        jitter: bool,
159    },
160    Fixed {
161        period_ms: u64,
162    },
163}
164
165impl SchedulePrimary {
166    /// Deterministic natural wait at local attempt `a` (1-based):
167    /// `min(base * factor^(a-1), cap)`, before any jitter.
168    #[expect(
169        clippy::cast_precision_loss,
170        clippy::cast_possible_truncation,
171        clippy::cast_sign_loss,
172        clippy::cast_possible_wrap,
173        reason = "backoff arithmetic: values are small and non-negative by construction"
174    )]
175    fn wait_ms(self, attempt: u32) -> u64 {
176        match self {
177            Self::Exp {
178                base_ms,
179                factor,
180                cap_ms,
181                ..
182            } => {
183                let wait = base_ms as f64 * factor.powi(attempt as i32 - 1);
184                wait.min(cap_ms as f64).round() as u64
185            }
186            Self::Fixed { period_ms } => period_ms,
187        }
188    }
189
190    fn jitter(self) -> bool {
191        matches!(self, Self::Exp { jitter: true, .. })
192    }
193}
194
195impl Default for Schedule {
196    /// The contract default: `exp(200ms, x2, max 30s, jitter)`.
197    fn default() -> Self {
198        Self {
199            segments: vec![ScheduleSegment {
200                primary: SchedulePrimary::Exp {
201                    base_ms: 200,
202                    factor: 2.0,
203                    cap_ms: 30_000,
204                    jitter: true,
205                },
206                up_to_ms: None,
207            }],
208        }
209    }
210}
211
212impl Schedule {
213    /// Deterministic wait after failed attempt `n` (1-based), before any
214    /// jitter or `Retry-After` override.
215    #[must_use]
216    pub fn wait_ms(&self, attempt: u32) -> u64 {
217        self.wait_and_jitter(attempt).0
218    }
219
220    /// `(wait, jitter?)` for retry attempt `n` — a pure function of `n`, per
221    /// the normative walk in conformance/README.md ("Schedule algebra"):
222    /// segments hand off when the next natural wait would push the segment's
223    /// cumulative emitted total past its `upTo` bound (an exact fit stays;
224    /// handoffs cascade past segments whose bound is below their first wait),
225    /// and each segment restarts at local attempt 1 on entry. The jitter flag
226    /// is the emitting segment's — the stubs ignore it (virtual clocks); the
227    /// real core samples equal jitter, uniform in `[w/2, w]`.
228    ///
229    /// # Panics
230    /// If `segments` is empty (unrepresentable via the parser).
231    #[must_use]
232    pub fn wait_and_jitter(&self, attempt: u32) -> (u64, bool) {
233        let attempt = attempt.max(1);
234        let last = self.segments.len() - 1;
235        let (mut i, mut a, mut e) = (0_usize, 1_u32, 0_u64);
236        let mut emitted = 0_u32;
237        loop {
238            let segment = self.segments[i];
239            let wait = segment.primary.wait_ms(a);
240            // A bound on the final segment is unrepresentable via the parser;
241            // `i < last` keeps hand-constructed values total anyway.
242            if i < last
243                && let Some(bound) = segment.up_to_ms
244                && e.saturating_add(wait) > bound
245            {
246                (i, a, e) = (i + 1, 1, 0);
247                continue;
248            }
249            emitted += 1;
250            if emitted == attempt {
251                return (wait, segment.primary.jitter());
252            }
253            a += 1;
254            e = e.saturating_add(wait);
255        }
256    }
257}
258
259impl FromStr for SchedulePrimary {
260    type Err = ParseError;
261
262    fn from_str(s: &str) -> Result<Self, Self::Err> {
263        let err = || ParseError::new("schedule", s);
264        let s = s.trim();
265        if let Some(inner) = s.strip_prefix("exp(").and_then(|r| r.strip_suffix(')')) {
266            let parts: Vec<&str> = inner.split(',').map(str::trim).collect();
267            let [base, factor, rest @ ..] = parts.as_slice() else {
268                return Err(err());
269            };
270            let base_ms = base.parse::<DurationMs>().map_err(|_| err())?.0;
271            let factor: f64 = factor
272                .strip_prefix('x')
273                .ok_or_else(err)?
274                .parse()
275                .map_err(|_| err())?;
276            let mut cap_ms = u64::MAX;
277            let mut jitter = false;
278            for part in rest {
279                if let Some(d) = part.strip_prefix("max ") {
280                    cap_ms = d.parse::<DurationMs>().map_err(|_| err())?.0;
281                } else if *part == "jitter" {
282                    jitter = true;
283                } else {
284                    return Err(err());
285                }
286            }
287            Ok(Self::Exp {
288                base_ms,
289                factor,
290                cap_ms,
291                jitter,
292            })
293        } else if let Some(inner) = s.strip_prefix("fixed(").and_then(|r| r.strip_suffix(')')) {
294            let period_ms = inner.parse::<DurationMs>().map_err(|_| err())?.0;
295            Ok(Self::Fixed { period_ms })
296        } else {
297            Err(err())
298        }
299    }
300}
301
302impl FromStr for Schedule {
303    type Err = ParseError;
304
305    fn from_str(s: &str) -> Result<Self, Self::Err> {
306        let err = || ParseError::new("schedule", s);
307        // The grammar's `ws` makes `upTo` / `andThen` space-separated tokens;
308        // tokenizing on whitespace runs and rejoining a primary's tokens with
309        // single spaces is lossless for the primary parsers (they trim around
310        // commas). Keywords never occur inside `exp(…)`/`fixed(…)` in a valid
311        // literal, so no paren tracking is needed — misplaced keywords make
312        // the primary unparseable, which is the same KEEL-E001.
313        let tokens: Vec<&str> = s.split_whitespace().collect();
314        if tokens.is_empty() {
315            return Err(err());
316        }
317        let mut segments = Vec::new();
318        for segment_tokens in tokens.split(|t| *t == "andThen") {
319            let (primary_tokens, up_to_ms) = match segment_tokens.iter().position(|t| *t == "upTo")
320            {
321                None => (segment_tokens, None),
322                Some(pos) => {
323                    // exactly `upTo <duration>`, at the segment's tail
324                    let [duration] = &segment_tokens[pos + 1..] else {
325                        return Err(err());
326                    };
327                    let bound = duration.parse::<DurationMs>().map_err(|_| err())?.0;
328                    (&segment_tokens[..pos], Some(bound))
329                }
330            };
331            if primary_tokens.is_empty() {
332                return Err(err());
333            }
334            let primary: SchedulePrimary = primary_tokens.join(" ").parse().map_err(|_| err())?;
335            segments.push(ScheduleSegment { primary, up_to_ms });
336        }
337        // Shape rule (normative, conformance/README.md "Schedule algebra"):
338        // bounded exactly on the non-final segments, so every segment is
339        // reachable and every attempt has a wait.
340        let last = segments.len() - 1;
341        if segments
342            .iter()
343            .enumerate()
344            .any(|(i, segment)| (i < last) != segment.up_to_ms.is_some())
345        {
346            return Err(ParseError::with_note(
347                "schedule",
348                s,
349                "`upTo` must bound every segment except the last, and never the last \
350                 (an unbounded segment never hands off; a bounded tail would leave \
351                 attempts without a wait — cap total retrying with `attempts`)",
352            ));
353        }
354        Ok(Self { segments })
355    }
356}
357
358impl TryFrom<String> for Schedule {
359    type Error = ParseError;
360
361    fn try_from(s: String) -> Result<Self, Self::Error> {
362        s.parse()
363    }
364}
365
366/// One retryable-error condition from `retry.on` (closed set; unknown
367/// conditions fail configuration instead of silently never matching).
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
369#[serde(try_from = "String")]
370pub enum Condition {
371    Conn,
372    Timeout,
373    Cancelled,
374    Other,
375    Class4xx,
376    Class5xx,
377    Status(u16),
378}
379
380impl Condition {
381    pub fn matches(self, class: ErrorClass, http_status: Option<u16>) -> bool {
382        match self {
383            Self::Conn => class == ErrorClass::Conn,
384            Self::Timeout => class == ErrorClass::Timeout,
385            Self::Cancelled => class == ErrorClass::Cancelled,
386            Self::Other => class == ErrorClass::Other,
387            Self::Class4xx => {
388                class == ErrorClass::Http && http_status.is_some_and(|s| (400..=499).contains(&s))
389            }
390            Self::Class5xx => {
391                class == ErrorClass::Http && http_status.is_some_and(|s| (500..=599).contains(&s))
392            }
393            Self::Status(want) => class == ErrorClass::Http && http_status == Some(want),
394        }
395    }
396}
397
398impl FromStr for Condition {
399    type Err = ParseError;
400
401    fn from_str(s: &str) -> Result<Self, Self::Err> {
402        match s {
403            "conn" => Ok(Self::Conn),
404            "timeout" => Ok(Self::Timeout),
405            "cancelled" => Ok(Self::Cancelled),
406            "other" => Ok(Self::Other),
407            "4xx" => Ok(Self::Class4xx),
408            "5xx" => Ok(Self::Class5xx),
409            // Frozen schema errorCondition grammar is `[1-5][0-9][0-9]` (100–599):
410            // require three ASCII digits in range, not any 3-char u16 (which
411            // accepted `099`→99 and `999`, outside the contract).
412            exact if exact.len() == 3 && exact.bytes().all(|b| b.is_ascii_digit()) => {
413                let code: u16 = exact
414                    .parse()
415                    .map_err(|_| ParseError::new("retry condition", s))?;
416                if (100..=599).contains(&code) {
417                    Ok(Self::Status(code))
418                } else {
419                    Err(ParseError::new("retry condition", s))
420                }
421            }
422            _ => Err(ParseError::new("retry condition", s)),
423        }
424    }
425}
426
427impl TryFrom<String> for Condition {
428    type Error = ParseError;
429
430    fn try_from(s: String) -> Result<Self, Self::Error> {
431        s.parse()
432    }
433}
434
435/// `retry = { attempts, schedule, on }`. `attempts` is the TOTAL attempt
436/// budget (first call included) — zero is unrepresentable by type.
437#[derive(Debug, Clone, PartialEq, Deserialize)]
438#[serde(default, deny_unknown_fields)]
439pub struct RetryPolicy {
440    pub attempts: NonZeroU32,
441    pub schedule: Schedule,
442    pub on: Vec<Condition>,
443}
444
445impl RetryPolicy {
446    pub const DEFAULT_ATTEMPTS: NonZeroU32 = NonZeroU32::new(3).unwrap();
447
448    /// The contract default retryable set: `["conn", "timeout", "429", "5xx"]`.
449    pub fn default_on() -> Vec<Condition> {
450        vec![
451            Condition::Conn,
452            Condition::Timeout,
453            Condition::Status(429),
454            Condition::Class5xx,
455        ]
456    }
457
458    pub fn is_retryable(&self, class: ErrorClass, http_status: Option<u16>) -> bool {
459        self.on.iter().any(|c| c.matches(class, http_status))
460    }
461}
462
463impl Default for RetryPolicy {
464    fn default() -> Self {
465        Self {
466            attempts: Self::DEFAULT_ATTEMPTS,
467            schedule: Schedule::default(),
468            on: Self::default_on(),
469        }
470    }
471}
472
473/// `breaker = { failures, cooldown, window, failure_rate, min_calls }`.
474///
475/// Two modes per the frozen schema (`$defs/breaker`), enforced identically by
476/// the real core and every stub — normative rules in `conformance/README.md`:
477/// - **count mode**: selected when `failures` is set (or no rate knob is set;
478///   `failures` then defaults to 5) — `failures` consecutive terminal failures
479///   open the breaker.
480/// - **rate mode**: selected when `failures` is absent and both `window` and
481///   `failure_rate` are set — trips when the trailing `window` holds at least
482///   `min_calls` outcomes (default 10) with `failed/total >= failure_rate`.
483///
484/// A rate-mode knob without both `window` and `failure_rate` (and without
485/// `failures`) is rejected at deserialize time (KEEL-E001): a half-configured
486/// mode must fail loudly, never silently degrade to count-mode defaults.
487#[derive(Debug, Clone, PartialEq, Deserialize)]
488#[serde(try_from = "BreakerPolicyDe")]
489pub struct BreakerPolicy {
490    /// Count-mode threshold. `None` means "not set by the user" — see
491    /// [`BreakerPolicy::mode`] for how that selects the mode.
492    pub failures: Option<NonZeroU64>,
493    pub cooldown: DurationMs,
494    pub window: Option<DurationMs>,
495    pub failure_rate: Option<f64>,
496    pub min_calls: Option<NonZeroU32>,
497}
498
499/// The breaker mode a [`BreakerPolicy`] resolves to, with every default
500/// applied — the engine/stubs consume this, never the raw knobs.
501#[derive(Debug, Clone, Copy, PartialEq)]
502pub enum BreakerMode {
503    /// `failures` consecutive terminal failures open the breaker.
504    Count { failures: NonZeroU64 },
505    /// Failure-rate tripping over a sliding window of post-retry outcomes.
506    Rate {
507        window: DurationMs,
508        failure_rate: f64,
509        min_calls: NonZeroU32,
510    },
511}
512
513impl BreakerPolicy {
514    /// Schema default for count mode's `failures`.
515    pub const DEFAULT_FAILURES: NonZeroU64 = NonZeroU64::new(5).unwrap();
516    /// Schema default for rate mode's `min_calls`.
517    pub const DEFAULT_MIN_CALLS: NonZeroU32 = NonZeroU32::new(10).unwrap();
518
519    /// The mode this policy selects (schema: "Setting `failures` selects count
520    /// mode"), with defaults applied. Deserialization already rejected
521    /// half-configured rate mode, so `window`+`failure_rate` are either both
522    /// present or irrelevant here.
523    #[must_use]
524    pub fn mode(&self) -> BreakerMode {
525        match (self.failures, self.window, self.failure_rate) {
526            (None, Some(window), Some(failure_rate)) => BreakerMode::Rate {
527                window,
528                failure_rate,
529                min_calls: self.min_calls.unwrap_or(Self::DEFAULT_MIN_CALLS),
530            },
531            (failures, _, _) => BreakerMode::Count {
532                failures: failures.unwrap_or(Self::DEFAULT_FAILURES),
533            },
534        }
535    }
536
537    /// Whether count mode was selected *while* rate-mode knobs are present —
538    /// those knobs are inert (the schema's precedence), which callers may want
539    /// to surface loudly rather than leave silent.
540    #[must_use]
541    pub fn has_inert_rate_knobs(&self) -> bool {
542        self.failures.is_some()
543            && (self.window.is_some() || self.failure_rate.is_some() || self.min_calls.is_some())
544    }
545}
546
547/// The raw deserialized shape of `breaker`, before mode-completeness
548/// validation promotes it to [`BreakerPolicy`].
549#[derive(Deserialize)]
550#[serde(default, deny_unknown_fields)]
551struct BreakerPolicyDe {
552    failures: Option<NonZeroU64>,
553    cooldown: DurationMs,
554    window: Option<DurationMs>,
555    #[serde(deserialize_with = "de_failure_rate")]
556    failure_rate: Option<f64>,
557    min_calls: Option<NonZeroU32>,
558}
559
560impl Default for BreakerPolicyDe {
561    fn default() -> Self {
562        Self {
563            failures: None,
564            cooldown: DurationMs(15_000),
565            window: None,
566            failure_rate: None,
567            min_calls: None,
568        }
569    }
570}
571
572impl TryFrom<BreakerPolicyDe> for BreakerPolicy {
573    type Error = String;
574
575    fn try_from(de: BreakerPolicyDe) -> Result<Self, Self::Error> {
576        let rate_pair = de.window.is_some() && de.failure_rate.is_some();
577        let any_rate_knob =
578            de.window.is_some() || de.failure_rate.is_some() || de.min_calls.is_some();
579        if de.failures.is_none() && any_rate_knob && !rate_pair {
580            return Err(String::from(
581                "breaker rate mode requires both `window` and `failure_rate` \
582                 (count mode sets `failures` instead)",
583            ));
584        }
585        Ok(Self {
586            failures: de.failures,
587            cooldown: de.cooldown,
588            window: de.window,
589            failure_rate: de.failure_rate,
590            min_calls: de.min_calls,
591        })
592    }
593}
594
595/// Validate `breaker.failure_rate` against the frozen schema range
596/// (`exclusiveMinimum: 0, maximum: 1`) at deserialize time, so an out-of-range or
597/// NaN value fails configuration with a precise field path (`KEEL-E001`) instead
598/// of being silently accepted — the bare `f64` used to take any value.
599fn de_failure_rate<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
600where
601    D: serde::Deserializer<'de>,
602{
603    let value = Option::<f64>::deserialize(deserializer)?;
604    if let Some(rate) = value
605        && !(rate > 0.0 && rate <= 1.0)
606    {
607        return Err(serde::de::Error::custom(format!(
608            "breaker.failure_rate must be greater than 0 and at most 1 (got {rate})"
609        )));
610    }
611    Ok(value)
612}
613
614impl Default for BreakerPolicy {
615    fn default() -> Self {
616        Self {
617            failures: None,
618            cooldown: DurationMs(15_000),
619            window: None,
620            failure_rate: None,
621            min_calls: None,
622        }
623    }
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
627#[serde(rename_all = "snake_case")]
628pub enum CacheScope {
629    #[default]
630    Memory,
631    Persistent,
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
635#[serde(rename_all = "snake_case")]
636pub enum CacheMode {
637    #[default]
638    Always,
639    /// Caches only when `KEEL_ENV != prod` — the LLM dev-loop cache.
640    Dev,
641}
642
643#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
644#[serde(rename_all = "snake_case")]
645pub enum CacheKeySource {
646    #[default]
647    Args,
648    Url,
649}
650
651/// `cache = { ttl, scope, mode, key }`. Caching activates only with a `ttl`.
652#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
653#[serde(default, deny_unknown_fields)]
654pub struct CachePolicy {
655    pub ttl: Option<DurationMs>,
656    pub scope: CacheScope,
657    pub mode: CacheMode,
658    pub key: CacheKeySource,
659}
660
661/// `idempotency = { header }` — the header is required by the schema.
662#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
663#[serde(deny_unknown_fields)]
664pub struct IdempotencyPolicy {
665    pub header: String,
666}
667
668/// `until = { field, terminal }` — the poll's terminal predicate (CCR-3).
669/// Non-emptiness is enforced at deserialize so an unpollable predicate is
670/// KEEL-E001 at configure, never a silent never-terminal loop.
671#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
672#[serde(try_from = "PollUntilRaw")]
673pub struct PollUntil {
674    pub field: String,
675    pub terminal: Vec<String>,
676}
677
678#[derive(Deserialize)]
679#[serde(deny_unknown_fields)]
680struct PollUntilRaw {
681    field: String,
682    terminal: Vec<String>,
683}
684
685impl TryFrom<PollUntilRaw> for PollUntil {
686    type Error = ParseError;
687
688    fn try_from(raw: PollUntilRaw) -> Result<Self, Self::Error> {
689        if raw.field.is_empty() {
690            return Err(ParseError::new("poll until.field", "(empty)"));
691        }
692        if raw.terminal.is_empty() {
693            return Err(ParseError::new("poll until.terminal", "(empty array)"));
694        }
695        Ok(Self {
696            field: raw.field,
697            terminal: raw.terminal,
698        })
699    }
700}
701
702/// `poll = { interval, deadline, until }` — poll-until-terminal (CCR-3).
703/// GET/HEAD at Level 0 only; semantics in conformance/README.md ("Poll").
704/// `interval` must be nonzero: on a virtual clock a zero interval never
705/// approaches `deadline`, looping forever, so it is rejected at deserialize
706/// (KEEL-E001) rather than left to hang at runtime.
707#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
708#[serde(try_from = "PollPolicyRaw")]
709pub struct PollPolicy {
710    pub interval: DurationMs,
711    pub deadline: DurationMs,
712    pub until: PollUntil,
713}
714
715#[derive(Deserialize)]
716#[serde(deny_unknown_fields)]
717struct PollPolicyRaw {
718    interval: DurationMs,
719    deadline: DurationMs,
720    until: PollUntil,
721}
722
723impl TryFrom<PollPolicyRaw> for PollPolicy {
724    type Error = ParseError;
725
726    fn try_from(raw: PollPolicyRaw) -> Result<Self, Self::Error> {
727        if raw.interval.0 == 0 {
728            return Err(ParseError::with_note(
729                "poll.interval",
730                "0",
731                "must be a nonzero duration",
732            ));
733        }
734        Ok(Self {
735            interval: raw.interval,
736            deadline: raw.deadline,
737            until: raw.until,
738        })
739    }
740}
741
742/// One target's policy table. Every layer is optional; a layer set at a more
743/// specific level replaces the whole layer table (no deep merge).
744#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
745#[serde(default, deny_unknown_fields)]
746pub struct TargetPolicy {
747    pub timeout: Option<DurationMs>,
748    pub retry: Option<RetryPolicy>,
749    pub breaker: Option<BreakerPolicy>,
750    pub rate: Option<Rate>,
751    pub cache: Option<CachePolicy>,
752    pub idempotency: Option<IdempotencyPolicy>,
753    pub poll: Option<PollPolicy>,
754    pub fallback: Option<Vec<String>>,
755    pub budget: Option<String>,
756}
757
758#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
759#[serde(default, deny_unknown_fields)]
760pub struct Defaults {
761    pub outbound: Option<TargetPolicy>,
762    pub llm: Option<TargetPolicy>,
763}
764
765#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
766#[serde(rename_all = "snake_case")]
767pub enum NondeterminismResponse {
768    #[default]
769    Fail,
770    Warn,
771    Branch,
772}
773
774/// What a concurrent same-identity `keel exec` does while the lease is held
775/// by a live process (CCR-4). Default `skip` — the mkdir-mutex pattern.
776#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
777#[serde(rename_all = "snake_case")]
778pub enum OnBusy {
779    #[default]
780    Skip,
781    Wait,
782    Fail,
783}
784
785/// One `[flows.match."cmd:<name>"]` rule (CCR-5): the argv patterns an
786/// in-process subprocess call site must match to be dispatched as this `cmd:`
787/// flow. `argv` is a list of per-position patterns (single-`*` wildcard
788/// dialect, docs/targeting.md); parsed and carried here, the matching itself
789/// lives in the front-end interceptors. Only in-process interception consults
790/// these — `keel exec` matches the argv typed after `--`.
791#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
792#[serde(deny_unknown_fields)]
793pub struct FlowMatchRule {
794    pub argv: Vec<String>,
795}
796
797/// Tier 2 flow designation — parsed and carried, enforced by the real core.
798#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
799#[serde(default, deny_unknown_fields)]
800pub struct FlowsPolicy {
801    pub entrypoints: Vec<String>,
802    pub on_nondeterminism: NondeterminismResponse,
803    pub on_busy: OnBusy,
804    /// `[flows.match."cmd:<name>"]` argv match rules (CCR-5), keyed by the
805    /// `cmd:<name>` entrypoint string. Parsed and carried structurally so a
806    /// keel.toml with a `[flows.match]` table configures cleanly; the
807    /// front-end subprocess interceptors are the only consumers, not the
808    /// core. `None` when the table is absent. A `BTreeMap` (not `HashMap`)
809    /// so iteration is deterministic, matching `Policy::target`.
810    #[serde(rename = "match")]
811    pub match_: Option<BTreeMap<String, FlowMatchRule>>,
812}
813
814/// A journal location literal (`policy.journal`), validated against the frozen
815/// schema pattern `^(file:.+|postgres://.+)$` at parse time so a malformed value
816/// fails configuration (KEEL-E001) rather than being silently ignored. The real
817/// core honors it at configure time: `file:` attaches a SQLite journal at that
818/// path (replacing the construction-time default), and `postgres://` fails
819/// loudly with KEEL-E005 until a Postgres backend ships.
820#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
821#[serde(try_from = "String")]
822pub struct JournalLocation(pub String);
823
824impl FromStr for JournalLocation {
825    type Err = ParseError;
826
827    fn from_str(s: &str) -> Result<Self, Self::Err> {
828        let valid = s.strip_prefix("file:").is_some_and(|rest| !rest.is_empty())
829            || s.strip_prefix("postgres://")
830                .is_some_and(|rest| !rest.is_empty());
831        if valid {
832            Ok(Self(s.to_owned()))
833        } else {
834            Err(ParseError::new("journal location", s))
835        }
836    }
837}
838
839impl TryFrom<String> for JournalLocation {
840    type Error = ParseError;
841
842    fn try_from(s: String) -> Result<Self, Self::Error> {
843        s.parse()
844    }
845}
846
847/// `[telemetry]` (`otlp_endpoint`, `console`). Parsed and carried; `Engine`
848/// exposes `otlp_endpoint` back to native front ends (`telemetry_otlp_endpoint`)
849/// which feed it to `keel-core`'s `otel::init_otlp` when built with the `otel`
850/// feature — the standard `OTEL_*` environment variables take precedence over
851/// this table (see `keel-core`'s otel module for the exact precedence rules).
852/// `console` (the local pretty-console-summary switch) is validated and
853/// carried but has no consumer yet; `Engine::configure` warns on an explicit
854/// `false` so the user is not silently surprised.
855#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
856#[serde(default, deny_unknown_fields)]
857pub struct TelemetryPolicy {
858    pub otlp_endpoint: Option<String>,
859    pub console: bool,
860}
861
862impl Default for TelemetryPolicy {
863    fn default() -> Self {
864        // Schema default: console = true.
865        Self {
866            otlp_endpoint: None,
867            console: true,
868        }
869    }
870}
871
872/// The whole `keel.toml` document (contracts/policy.schema.json), typed.
873///
874/// `deny_unknown_fields` at every object level (here and on the layer structs)
875/// makes a typo'd or unknown key a configuration error (KEEL-E001 with the exact
876/// path via `serde_path_to_error`), honoring the frozen schema's
877/// `additionalProperties: false` and E001's "an unknown key was used" — instead
878/// of the previous silent drop that ran the target on defaults the user never
879/// asked for.
880#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
881#[serde(default, deny_unknown_fields)]
882pub struct Policy {
883    pub defaults: Defaults,
884    pub target: BTreeMap<String, TargetPolicy>,
885    pub flows: Option<FlowsPolicy>,
886    /// Journal location (schema-validated), honored by the real core at
887    /// configure time (see [`JournalLocation`]).
888    pub journal: Option<JournalLocation>,
889    /// Telemetry config (schema-validated); `otlp_endpoint` is honored by
890    /// native front ends (env still wins), `console` is not yet wired — see
891    /// [`TelemetryPolicy`].
892    pub telemetry: Option<TelemetryPolicy>,
893}
894
895/// The per-layer config resolved for one target: target entry, else
896/// `defaults.llm` for `llm:*` targets, else `defaults.outbound`.
897#[derive(Debug, Clone, Default)]
898pub struct ResolvedPolicy {
899    pub timeout: Option<DurationMs>,
900    pub retry: Option<RetryPolicy>,
901    pub breaker: Option<BreakerPolicy>,
902    pub rate: Option<Rate>,
903    pub cache: Option<CachePolicy>,
904    /// `idempotency = { header }` — the knob adapters consult to *inject* a
905    /// minted idempotency key on unsafe-method calls (and to recognize a
906    /// caller-supplied one). The core itself never injects; injection lives in
907    /// the adapter per contracts/adapter-pack.md ("Idempotency-key injection").
908    pub idempotency: Option<IdempotencyPolicy>,
909    /// `poll = { interval, deadline, until }` — poll-until-terminal (CCR-3).
910    pub poll: Option<PollPolicy>,
911}
912
913impl Policy {
914    pub fn resolve(&self, target: &str) -> ResolvedPolicy {
915        ResolvedPolicy {
916            timeout: self.layer(target, |t| t.timeout.as_ref()).copied(),
917            retry: self.layer(target, |t| t.retry.as_ref()).cloned(),
918            breaker: self.layer(target, |t| t.breaker.as_ref()).cloned(),
919            rate: self.layer(target, |t| t.rate.as_ref()).copied(),
920            cache: self.layer(target, |t| t.cache.as_ref()).cloned(),
921            idempotency: self.layer(target, |t| t.idempotency.as_ref()).cloned(),
922            poll: self.layer(target, |t| t.poll.as_ref()).cloned(),
923        }
924    }
925
926    fn layer<'a, T>(
927        &'a self,
928        target: &str,
929        pick: impl Fn(&'a TargetPolicy) -> Option<&'a T>,
930    ) -> Option<&'a T> {
931        if let Some(t) = self.target.get(target)
932            && let Some(v) = pick(t)
933        {
934            return Some(v);
935        }
936        if target.starts_with("llm:")
937            && let Some(llm) = self.defaults.llm.as_ref()
938            && let Some(v) = pick(llm)
939        {
940            return Some(v);
941        }
942        self.defaults.outbound.as_ref().and_then(pick)
943    }
944}
945
946// --- outbound target resolution (SP-1) ---------------------------------
947//
948// Ported from the front-end duplicates `python/keel/src/keel/_targets.py`
949// (the `[target]` host/URL-pattern matcher) and
950// `python/keel/src/keel/adapters/_http.py` (`LLM_HOST_PROVIDERS`,
951// `VERTEX_REGIONAL_SUFFIX`, `resolve_policy_target`), unified into one core
952// function. The two front-end functions this replaces each had a gap the
953// other didn't: the old host-only `resolve_target` never consulted the
954// `[target]` pattern tier, and the old `resolve_policy_target` checked the
955// LLM host map by exact host only, missing the Vertex regional-endpoint
956// suffix rule. `Policy::resolve_target` below applies both on every call.
957//
958// Precedence, per `docs/targeting.md` (the cross-language parity contract
959// with the Node twin's `judge.mjs`):
960//   1. LLM host map — exact provider host, or a Vertex regional endpoint via
961//      the `-aiplatform.googleapis.com` suffix rule.
962//   2. Exact bare-host `[target]` key (no method/port/path/`*`).
963//   3. The most specific matching host/URL pattern key: fewest `*`, then most
964//      literal characters, then method-prefixed over unprefixed, then
965//      lexicographically smallest key (a total, deterministic tie-break).
966//   4. The bare host (falls through to `[defaults.outbound]` as before).
967
968/// Methods the frozen targetKey grammar admits as a key prefix.
969const OUTBOUND_METHODS: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
970
971/// Non-outbound target classes (function + semantic targets) — never host keys.
972const CLASS_PREFIXES: [&str; 6] = ["py:", "ts:", "rs:", "llm:", "tool:", "mcp:"];
973
974/// Suffix matching any Vertex AI REGIONAL endpoint host, e.g.
975/// `us-central1-aiplatform.googleapis.com`. Parity contract with the Python
976/// (`adapters/_http.py`) and Node (`judge.mjs`) twins' identical suffix check.
977const VERTEX_REGIONAL_SUFFIX: &str = "-aiplatform.googleapis.com";
978
979/// Host → LLM provider. Ported verbatim from `adapters/_http.py:61-68`
980/// (`LLM_HOST_PROVIDERS`) — a cross-language parity contract with the Node
981/// front end (`LLM_HOST_PROVIDERS` in `judge.mjs`); extend in lockstep across
982/// languages, since adding a host here changes which default pack applies.
983const LLM_HOST_PROVIDERS: &[(&str, &str)] = &[
984    ("api.openai.com", "openai"),
985    ("api.anthropic.com", "anthropic"),
986    ("generativelanguage.googleapis.com", "google-genai"),
987    ("aiplatform.googleapis.com", "google-genai"),
988];
989
990/// Default port for a `:port`-less key, by scheme (parity with the Python
991/// `_SCHEME_PORTS` / Node twin). `None` for anything else (or no scheme).
992fn scheme_port(scheme: Option<&str>) -> Option<u16> {
993    match scheme {
994        Some("http") => Some(80),
995        Some("https") => Some(443),
996        _ => None,
997    }
998}
999
1000/// `*`-only wildcard match, anchored end-to-end: `*` matches any byte
1001/// sequence (including `.` in hosts and `/` in paths), every other byte is
1002/// literal. Byte-for-byte equivalent to the Python `_glob_regex` (`^` +
1003/// `re.escape`-parts joined by `.*` + `$`) without a regex dependency —
1004/// classic two-pointer wildcard matching with backtracking (correct for a
1005/// `*`-only alphabet: on a literal mismatch, retry the most recent `*` one
1006/// character further into the text).
1007fn glob_match(pattern: &[u8], text: &[u8]) -> bool {
1008    let (mut p, mut t) = (0usize, 0usize);
1009    let (mut star, mut mark) = (None::<usize>, 0usize);
1010    while t < text.len() {
1011        if p < pattern.len() && pattern[p] == b'*' {
1012            star = Some(p);
1013            mark = t;
1014            p += 1;
1015        } else if p < pattern.len() && pattern[p] == text[t] {
1016            p += 1;
1017            t += 1;
1018        } else if let Some(sp) = star {
1019            p = sp + 1;
1020            mark += 1;
1021            t = mark;
1022        } else {
1023            return false;
1024        }
1025    }
1026    while p < pattern.len() && pattern[p] == b'*' {
1027        p += 1;
1028    }
1029    p == pattern.len()
1030}
1031
1032/// (method, host, port, path) parsed out of one outbound-shaped `[target]`
1033/// key, per the frozen grammar — mirrors `_parse_outbound_key`. `None` when
1034/// the key is not outbound-shaped (an empty host after stripping method/
1035/// port/path; defensive, since the schema validates keys before this ever
1036/// runs).
1037#[allow(clippy::type_complexity)]
1038fn parse_outbound_key(key: &str) -> Option<(Option<String>, String, Option<u16>, Option<String>)> {
1039    let mut method: Option<String> = None;
1040    let mut rest = key;
1041    for m in OUTBOUND_METHODS {
1042        if let Some(stripped) = rest.strip_prefix(m).and_then(|s| s.strip_prefix(' ')) {
1043            method = Some(m.to_owned());
1044            rest = stripped;
1045            break;
1046        }
1047    }
1048    let mut path: Option<String> = None;
1049    if let Some(slash) = rest.find('/') {
1050        path = Some(rest[slash..].to_owned());
1051        rest = &rest[..slash];
1052    }
1053    let (mut host, mut port) = (rest.to_owned(), None);
1054    if let Some((head, tail)) = rest.rsplit_once(':')
1055        && !tail.is_empty()
1056        && tail.bytes().all(|b| b.is_ascii_digit())
1057        && let Ok(n) = tail.parse::<u16>()
1058    {
1059        head.clone_into(&mut host);
1060        port = Some(n);
1061    }
1062    if host.is_empty() {
1063        return None;
1064    }
1065    Some((method, host, port, path))
1066}
1067
1068/// True iff `key` is a bare host — an outbound-shaped key with no method
1069/// prefix, port, path, or `*` — the tier-1 "exact" classification `_targets.
1070/// compile_outbound_targets` applies once per key, used identically here for
1071/// both the exact-match short-circuit and excluding these keys from the
1072/// pattern tier.
1073fn is_bare_host_key(key: &str) -> bool {
1074    !key.contains('*')
1075        && parse_outbound_key(key)
1076            .is_some_and(|(m, _, port, path)| m.is_none() && port.is_none() && path.is_none())
1077}
1078
1079/// One compiled pattern-tier `[target]` key. Mirrors the Python
1080/// `OutboundPattern` NamedTuple.
1081struct OutboundPattern {
1082    key: String,
1083    method: Option<String>,
1084    host_glob: String, // lowercased; matched with glob_match
1085    port: Option<u16>,
1086    path_glob: Option<String>,
1087    wildcards: usize,
1088    literal: usize,
1089}
1090
1091impl Policy {
1092    /// The policy target key for one outbound request. See the module-level
1093    /// precedence comment above `OUTBOUND_METHODS` for the four tiers.
1094    #[must_use]
1095    pub fn resolve_target(
1096        &self,
1097        method: &str,
1098        host: &str,
1099        scheme: Option<&str>,
1100        port: Option<u16>,
1101        path: Option<&str>,
1102    ) -> String {
1103        // 1. LLM host map (exact host, then the Vertex regional suffix rule).
1104        let provider = LLM_HOST_PROVIDERS
1105            .iter()
1106            .find(|(h, _)| *h == host)
1107            .map(|(_, p)| *p)
1108            .or_else(|| {
1109                host.ends_with(VERTEX_REGIONAL_SUFFIX)
1110                    .then_some("google-genai")
1111            });
1112        if let Some(p) = provider {
1113            return format!("llm:{p}");
1114        }
1115        // 2. Exact bare-host [target] key.
1116        if !CLASS_PREFIXES.iter().any(|c| host.starts_with(c))
1117            && self.target.contains_key(host)
1118            && is_bare_host_key(host)
1119        {
1120            return host.to_owned();
1121        }
1122        // 3. Compile the pattern tier and pick the most specific match.
1123        let mut patterns: Vec<OutboundPattern> = Vec::new();
1124        for key in self.target.keys() {
1125            if CLASS_PREFIXES.iter().any(|c| key.starts_with(c)) || is_bare_host_key(key) {
1126                continue;
1127            }
1128            let Some((m, h, pt, pa)) = parse_outbound_key(key) else {
1129                continue;
1130            };
1131            let wildcards = key.matches('*').count();
1132            patterns.push(OutboundPattern {
1133                key: key.clone(),
1134                method: m,
1135                host_glob: h.to_lowercase(),
1136                port: pt,
1137                path_glob: pa,
1138                wildcards,
1139                literal: key.chars().count() - wildcards,
1140            });
1141        }
1142        // Most specific first, then a total lexicographic tail:
1143        // (wildcards asc, literal desc, method-prefixed first, key asc).
1144        patterns.sort_by(|a, b| {
1145            a.wildcards
1146                .cmp(&b.wildcards)
1147                .then(b.literal.cmp(&a.literal))
1148                .then(a.method.is_none().cmp(&b.method.is_none()))
1149                .then(a.key.cmp(&b.key))
1150        });
1151        let effective_port = port.or_else(|| scheme_port(scheme));
1152        let host_l = host.to_lowercase();
1153        let method_u = if method.is_empty() {
1154            "GET".to_owned()
1155        } else {
1156            method.to_uppercase()
1157        };
1158        let path_n = match path {
1159            Some(p) if !p.is_empty() => p,
1160            _ => "/",
1161        };
1162        for p in &patterns {
1163            if let Some(m) = &p.method
1164                && *m != method_u
1165            {
1166                continue;
1167            }
1168            if !glob_match(p.host_glob.as_bytes(), host_l.as_bytes()) {
1169                continue;
1170            }
1171            if let Some(pt) = p.port
1172                && Some(pt) != effective_port
1173            {
1174                continue;
1175            }
1176            if let Some(pg) = &p.path_glob
1177                && !glob_match(pg.as_bytes(), path_n.as_bytes())
1178            {
1179                continue;
1180            }
1181            return p.key.clone();
1182        }
1183        // 4. No pattern matched: fall through to the bare host.
1184        host.to_owned()
1185    }
1186
1187    /// Every host the LLM host map (tier 1 of `resolve_target`'s precedence)
1188    /// knows about, as `(host, provider)` pairs — the enumeration twin of
1189    /// `resolve_target`'s single-lookup form. Not tied to any policy
1190    /// instance: the map is a hardcoded constant, identical for every
1191    /// `Policy`. Lets front-end packs' `targets()` (consumed by `keel
1192    /// doctor`/`keel init` documentation output) enumerate every known LLM
1193    /// provider host without holding their own copy (issue #49). Vertex's
1194    /// REGIONAL endpoints are matched by suffix (`VERTEX_REGIONAL_SUFFIX`),
1195    /// not enumerated here — there is no fixed list of regions to list.
1196    #[must_use]
1197    pub fn known_llm_hosts() -> Vec<(&'static str, &'static str)> {
1198        LLM_HOST_PROVIDERS.to_vec()
1199    }
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204    use super::*;
1205    use serde_json::json;
1206
1207    #[test]
1208    fn duration_literals() {
1209        assert_eq!("200ms".parse(), Ok(DurationMs(200)));
1210        assert_eq!("30s".parse(), Ok(DurationMs(30_000)));
1211        assert_eq!("5m".parse(), Ok(DurationMs(300_000)));
1212        assert_eq!("2h".parse(), Ok(DurationMs(7_200_000)));
1213        assert!("30".parse::<DurationMs>().is_err());
1214        assert!("30sec".parse::<DurationMs>().is_err());
1215        assert!("-1s".parse::<DurationMs>().is_err());
1216    }
1217
1218    #[test]
1219    fn rate_literals() {
1220        let rate: Rate = "90/s".parse().unwrap();
1221        assert_eq!((rate.limit.get(), rate.window_ms), (90, 1_000));
1222        let rate: Rate = "60/min".parse().unwrap();
1223        assert_eq!((rate.limit.get(), rate.window_ms), (60, 60_000));
1224        assert!("0/s".parse::<Rate>().is_err(), "zero limit unrepresentable");
1225        assert!("10/day".parse::<Rate>().is_err());
1226    }
1227
1228    #[test]
1229    fn schedule_exp_waits_and_cap() {
1230        let schedule: Schedule = "exp(1s, x2, max 4s)".parse().unwrap();
1231        let waits: Vec<u64> = (1..=4).map(|n| schedule.wait_ms(n)).collect();
1232        assert_eq!(waits, [1_000, 2_000, 4_000, 4_000]);
1233    }
1234
1235    #[test]
1236    fn schedule_fixed_and_rejections() {
1237        assert_eq!(
1238            "fixed(1s)".parse::<Schedule>(),
1239            Ok(Schedule {
1240                segments: vec![ScheduleSegment {
1241                    primary: SchedulePrimary::Fixed { period_ms: 1_000 },
1242                    up_to_ms: None,
1243                }],
1244            })
1245        );
1246        assert!("linear(1s)".parse::<Schedule>().is_err());
1247    }
1248
1249    #[test]
1250    fn schedule_composition_parses_the_spec_example() {
1251        // architecture-spec §4.1 / the frozen grammar's own example
1252        let schedule: Schedule = "exp(1s, x2, max 5m) upTo 10m andThen fixed(1m)"
1253            .parse()
1254            .unwrap();
1255        assert_eq!(
1256            schedule.segments,
1257            vec![
1258                ScheduleSegment {
1259                    primary: SchedulePrimary::Exp {
1260                        base_ms: 1_000,
1261                        factor: 2.0,
1262                        cap_ms: 300_000,
1263                        jitter: false,
1264                    },
1265                    up_to_ms: Some(600_000),
1266                },
1267                ScheduleSegment {
1268                    primary: SchedulePrimary::Fixed { period_ms: 60_000 },
1269                    up_to_ms: None,
1270                },
1271            ]
1272        );
1273        // The grammar's ws is "one or more spaces": extra spacing still parses.
1274        assert_eq!(
1275            "exp(1s, x2, max 5m)  upTo  10m  andThen  fixed(1m)".parse::<Schedule>(),
1276            Ok(schedule)
1277        );
1278    }
1279
1280    #[test]
1281    fn schedule_composition_hands_off_when_the_bound_would_be_overshot() {
1282        let schedule: Schedule = "exp(1s, x2) upTo 4s andThen fixed(500ms)".parse().unwrap();
1283        let waits: Vec<u64> = (1..=5).map(|n| schedule.wait_ms(n)).collect();
1284        // 1s + 2s = 3s fits; the natural 4s would overshoot the 4s bound.
1285        assert_eq!(waits, [1_000, 2_000, 500, 500, 500]);
1286    }
1287
1288    #[test]
1289    fn schedule_composition_exact_fit_stays_and_cascade_skips() {
1290        let schedule: Schedule =
1291            "fixed(1s) upTo 3s andThen fixed(10s) upTo 5s andThen fixed(250ms)"
1292                .parse()
1293                .unwrap();
1294        let waits: Vec<u64> = (1..=6).map(|n| schedule.wait_ms(n)).collect();
1295        // Three 1s waits fill upTo 3s exactly (e + w == bound stays); the 10s
1296        // segment's first wait exceeds its own 5s bound, so it contributes
1297        // zero waits and the handoff cascades to the 250ms tail.
1298        assert_eq!(waits, [1_000, 1_000, 1_000, 250, 250, 250]);
1299    }
1300
1301    #[test]
1302    fn schedule_composition_restarts_exp_and_tracks_jitter_per_segment() {
1303        let schedule: Schedule = "fixed(1s) upTo 2s andThen exp(100ms, x3, jitter)"
1304            .parse()
1305            .unwrap();
1306        // exp restarts at local attempt 1 after the handoff.
1307        let waits: Vec<u64> = (1..=5).map(|n| schedule.wait_ms(n)).collect();
1308        assert_eq!(waits, [1_000, 1_000, 100, 300, 900]);
1309        // jitter is the emitting segment's flag, not schedule-global.
1310        assert_eq!(schedule.wait_and_jitter(1), (1_000, false));
1311        assert_eq!(schedule.wait_and_jitter(3), (100, true));
1312    }
1313
1314    #[test]
1315    fn schedule_composition_shape_rule_rejections() {
1316        // Grammatical but invalid shapes fail configure-time (KEEL-E001), per
1317        // conformance/README.md "Schedule algebra": a non-final segment
1318        // without upTo never hands off; a bounded final segment would leave
1319        // attempts without a wait.
1320        for degenerate in [
1321            "fixed(1s) andThen fixed(2s)",
1322            "exp(1s, x2, max 5m) upTo 10m",
1323            "fixed(1s) upTo 3s andThen fixed(2s) andThen fixed(4s)",
1324            "fixed(1s) upTo 3s andThen fixed(2s) upTo 5s",
1325        ] {
1326            let error = degenerate.parse::<Schedule>().unwrap_err();
1327            assert!(
1328                error.to_string().contains("upTo"),
1329                "{degenerate}: expected the shape-rule note, got {error}"
1330            );
1331        }
1332        // Broken composition syntax stays a plain parse rejection.
1333        for broken in [
1334            "fixed(1s) upTo",
1335            "upTo 3s andThen fixed(1s)",
1336            "fixed(1s) upTo 1s upTo 2s andThen fixed(1s)",
1337            "fixed(1s) andThen",
1338            "andThen fixed(1s)",
1339            "fixed(1s) upTo 3s fixed(2s)",
1340        ] {
1341            assert!(
1342                broken.parse::<Schedule>().is_err(),
1343                "{broken} must be rejected"
1344            );
1345        }
1346    }
1347
1348    #[test]
1349    fn condition_matching() {
1350        let on = RetryPolicy::default_on();
1351        let matches = |class, status| on.iter().any(|c| c.matches(class, status));
1352        assert!(matches(ErrorClass::Conn, None));
1353        assert!(matches(ErrorClass::Http, Some(429)));
1354        assert!(matches(ErrorClass::Http, Some(503)));
1355        assert!(!matches(ErrorClass::Http, Some(400)));
1356        assert!(!matches(ErrorClass::Cancelled, None));
1357        assert!("teapot".parse::<Condition>().is_err());
1358        // Exact-status literals follow the frozen schema grammar [1-5][0-9][0-9].
1359        assert_eq!("429".parse::<Condition>(), Ok(Condition::Status(429)));
1360        assert_eq!("100".parse::<Condition>(), Ok(Condition::Status(100)));
1361        assert_eq!("599".parse::<Condition>(), Ok(Condition::Status(599)));
1362        for bad in ["999", "099", "600", "000", "12", "1234", "1x9"] {
1363            assert!(bad.parse::<Condition>().is_err(), "{bad} must be rejected");
1364        }
1365    }
1366
1367    #[test]
1368    fn zero_attempts_is_unrepresentable() {
1369        let doc = json!({ "target": { "x": { "retry": { "attempts": 0 } } } });
1370        let err = serde_path_to_error::deserialize::<_, Policy>(&doc).unwrap_err();
1371        assert_eq!(err.path().to_string(), "target.x.retry.attempts");
1372    }
1373
1374    #[test]
1375    fn breaker_failure_rate_range_is_enforced() {
1376        // Frozen schema: breaker.failure_rate is exclusiveMinimum 0, maximum 1.
1377        let bad = |rate: serde_json::Value| {
1378            let doc = json!({ "target": { "x": { "breaker": { "failure_rate": rate } } } });
1379            serde_path_to_error::deserialize::<_, Policy>(&doc)
1380        };
1381        for rate in [json!(0.0), json!(-0.1), json!(1.5), json!(2.0)] {
1382            let err = bad(rate.clone()).unwrap_err();
1383            assert_eq!(
1384                err.path().to_string(),
1385                "target.x.breaker.failure_rate",
1386                "out-of-range failure_rate {rate} must fail at its path"
1387            );
1388        }
1389        // In-range values (0, 1] deserialize fine (paired with `window`:
1390        // rate mode requires both knobs).
1391        for rate in [0.01_f64, 0.5, 1.0] {
1392            let doc = json!({
1393                "target": { "x": { "breaker": { "window": "30s", "failure_rate": rate } } }
1394            });
1395            let policy = serde_path_to_error::deserialize::<_, Policy>(&doc).unwrap();
1396            let breaker = policy.target["x"].breaker.as_ref().unwrap();
1397            assert_eq!(breaker.failure_rate, Some(rate));
1398        }
1399    }
1400
1401    #[test]
1402    fn breaker_mode_selection_follows_the_schema() {
1403        let breaker = |doc: serde_json::Value| -> BreakerPolicy {
1404            let doc = json!({ "target": { "x": { "breaker": doc } } });
1405            let policy: Policy = serde_path_to_error::deserialize(&doc).unwrap();
1406            policy.target["x"].breaker.clone().unwrap()
1407        };
1408
1409        // Empty table: count mode on the schema default (failures = 5).
1410        assert_eq!(
1411            breaker(json!({})).mode(),
1412            BreakerMode::Count {
1413                failures: BreakerPolicy::DEFAULT_FAILURES
1414            }
1415        );
1416
1417        // Both rate knobs, no `failures`: rate mode, min_calls defaults to 10.
1418        assert_eq!(
1419            breaker(json!({ "window": "30s", "failure_rate": 0.5 })).mode(),
1420            BreakerMode::Rate {
1421                window: DurationMs(30_000),
1422                failure_rate: 0.5,
1423                min_calls: BreakerPolicy::DEFAULT_MIN_CALLS,
1424            }
1425        );
1426        assert_eq!(
1427            breaker(json!({ "window": "10s", "failure_rate": 1.0, "min_calls": 4 })).mode(),
1428            BreakerMode::Rate {
1429                window: DurationMs(10_000),
1430                failure_rate: 1.0,
1431                min_calls: NonZeroU32::new(4).unwrap(),
1432            }
1433        );
1434
1435        // "Setting `failures` selects count mode" (frozen schema): rate knobs
1436        // present alongside it are inert, and the policy says so.
1437        let mixed = breaker(json!({ "failures": 3, "window": "30s", "failure_rate": 0.5 }));
1438        assert_eq!(
1439            mixed.mode(),
1440            BreakerMode::Count {
1441                failures: NonZeroU64::new(3).unwrap()
1442            }
1443        );
1444        assert!(mixed.has_inert_rate_knobs());
1445        assert!(!breaker(json!({ "failures": 3 })).has_inert_rate_knobs());
1446    }
1447
1448    #[test]
1449    fn half_configured_breaker_rate_mode_is_rejected() {
1450        // A rate-mode knob without both `window` and `failure_rate` (and
1451        // without `failures`) must fail at configure, not silently run count
1452        // mode the user never asked for.
1453        for doc in [
1454            json!({ "window": "30s" }),
1455            json!({ "failure_rate": 0.5 }),
1456            json!({ "min_calls": 10 }),
1457            json!({ "window": "30s", "min_calls": 10 }),
1458            json!({ "failure_rate": 0.5, "min_calls": 10 }),
1459        ] {
1460            let policy = json!({ "target": { "x": { "breaker": doc } } });
1461            let err = serde_path_to_error::deserialize::<_, Policy>(&policy).unwrap_err();
1462            assert_eq!(err.path().to_string(), "target.x.breaker", "doc: {doc}");
1463            assert!(
1464                err.inner().to_string().contains("rate mode requires both"),
1465                "doc {doc}: got {}",
1466                err.inner()
1467            );
1468        }
1469        // `failures` present makes any knob combination count mode (schema
1470        // precedence), so those documents stay valid.
1471        let policy =
1472            json!({ "target": { "x": { "breaker": { "failures": 3, "window": "30s" } } } });
1473        assert!(serde_path_to_error::deserialize::<_, Policy>(&policy).is_ok());
1474    }
1475
1476    #[test]
1477    fn unknown_key_is_rejected_with_its_path() {
1478        // A typo'd nested key: the frozen schema's additionalProperties:false and
1479        // E001's "an unknown key was used" mean this must fail, not silently run
1480        // on the defaults.
1481        let doc = json!({ "target": { "api.stripe.com": { "retry": { "atempts": 10 } } } });
1482        let err = serde_path_to_error::deserialize::<_, Policy>(&doc).unwrap_err();
1483        assert!(
1484            err.inner().to_string().contains("atempts")
1485                || err.inner().to_string().contains("unknown field"),
1486            "expected an unknown-field error, got {}",
1487            err.inner()
1488        );
1489    }
1490
1491    #[test]
1492    fn unknown_top_level_and_layer_keys_are_rejected() {
1493        assert!(
1494            serde_path_to_error::deserialize::<_, Policy>(&json!({ "bogus_top": true })).is_err()
1495        );
1496        assert!(
1497            serde_path_to_error::deserialize::<_, Policy>(
1498                &json!({ "target": { "api.x": { "retrys": {} } } })
1499            )
1500            .is_err(),
1501            "a mistyped layer table must be rejected, not dropped"
1502        );
1503    }
1504
1505    #[test]
1506    fn journal_and_telemetry_parse_and_validate() {
1507        let doc = json!({
1508            "journal": "file:/srv/keel/journal.db",
1509            "telemetry": { "otlp_endpoint": "http://collector:4317" }
1510        });
1511        let policy: Policy = serde_path_to_error::deserialize(&doc).unwrap();
1512        assert_eq!(
1513            policy.journal.unwrap(),
1514            JournalLocation("file:/srv/keel/journal.db".to_owned())
1515        );
1516        let telemetry = policy.telemetry.unwrap();
1517        assert_eq!(
1518            telemetry.otlp_endpoint.as_deref(),
1519            Some("http://collector:4317")
1520        );
1521        assert!(telemetry.console, "schema default console = true");
1522
1523        // A journal string that matches neither `file:` nor `postgres://` fails.
1524        let bad = json!({ "journal": "sqlite:/tmp/x.db" });
1525        assert!(serde_path_to_error::deserialize::<_, Policy>(&bad).is_err());
1526    }
1527
1528    #[test]
1529    fn idempotency_resolves_like_any_other_layer() {
1530        // The `idempotency` layer must surface through `resolve()` so adapters
1531        // (via the front ends) and the engine can honor the injection contract
1532        // (contracts/adapter-pack.md "Idempotency-key injection").
1533        let doc = json!({
1534            "defaults": {
1535                "outbound": { "idempotency": { "header": "X-Idem" } },
1536                "llm": { "idempotency": { "header": "X-Llm-Idem" } }
1537            },
1538            "target": {
1539                "api.stripe.com": { "idempotency": { "header": "Idempotency-Key" } },
1540                "api.plain.example": { "timeout": "1s" }
1541            }
1542        });
1543        let policy: Policy = serde_path_to_error::deserialize(&doc).unwrap();
1544
1545        // Exact target entry wins.
1546        let stripe = policy.resolve("api.stripe.com");
1547        assert_eq!(
1548            stripe.idempotency.as_ref().map(|i| i.header.as_str()),
1549            Some("Idempotency-Key")
1550        );
1551        // llm:* falls to defaults.llm, then anything else to defaults.outbound.
1552        let llm = policy.resolve("llm:openai");
1553        assert_eq!(
1554            llm.idempotency.as_ref().map(|i| i.header.as_str()),
1555            Some("X-Llm-Idem")
1556        );
1557        let plain = policy.resolve("api.plain.example");
1558        assert_eq!(
1559            plain.idempotency.as_ref().map(|i| i.header.as_str()),
1560            Some("X-Idem")
1561        );
1562        // No idempotency anywhere: resolves to None.
1563        let empty: Policy = serde_path_to_error::deserialize(&json!({})).unwrap();
1564        assert!(empty.resolve("api.stripe.com").idempotency.is_none());
1565    }
1566
1567    #[test]
1568    fn poll_policy_parses_and_resolves() {
1569        let policy: Policy = serde_json::from_value(serde_json::json!({
1570            "target": { "api.jobs.example": { "poll": {
1571                "interval": "10s", "deadline": "90s",
1572                "until": { "field": "status", "terminal": ["completed", "failed"] }
1573            } } }
1574        }))
1575        .expect("valid poll policy");
1576        let resolved = policy.resolve("api.jobs.example");
1577        let poll = resolved.poll.expect("poll resolved");
1578        assert_eq!(poll.interval.0, 10_000);
1579        assert_eq!(poll.deadline.0, 90_000);
1580        assert_eq!(poll.until.field, "status");
1581        assert_eq!(poll.until.terminal, vec!["completed", "failed"]);
1582    }
1583
1584    #[test]
1585    fn poll_rejects_empty_terminal_and_empty_field() {
1586        for bad in [
1587            serde_json::json!({ "interval": "10s", "deadline": "90s",
1588                "until": { "field": "status", "terminal": [] } }),
1589            serde_json::json!({ "interval": "10s", "deadline": "90s",
1590                "until": { "field": "", "terminal": ["done"] } }),
1591            serde_json::json!({ "interval": "10s",
1592                "until": { "field": "status", "terminal": ["done"] } }),
1593        ] {
1594            let doc = serde_json::json!({ "target": { "x": { "poll": bad } } });
1595            assert!(serde_json::from_value::<Policy>(doc).is_err());
1596        }
1597    }
1598
1599    #[test]
1600    fn poll_rejects_zero_interval() {
1601        let doc = serde_json::json!({ "target": { "x": { "poll": {
1602            "interval": "0ms", "deadline": "90s",
1603            "until": { "field": "status", "terminal": ["done"] }
1604        } } } });
1605        assert!(serde_json::from_value::<Policy>(doc).is_err());
1606
1607        let doc = serde_json::json!({ "target": { "x": { "poll": {
1608            "interval": "1ms", "deadline": "90s",
1609            "until": { "field": "status", "terminal": ["done"] }
1610        } } } });
1611        assert!(serde_json::from_value::<Policy>(doc).is_ok());
1612    }
1613
1614    #[test]
1615    fn layer_resolution_precedence() {
1616        let doc = json!({
1617            "defaults": {
1618                "outbound": { "retry": { "attempts": 3 }, "rate": "9/s" },
1619                "llm": { "retry": { "attempts": 6 } }
1620            },
1621            "target": { "llm:openai": { "cache": { "ttl": "10m" } } }
1622        });
1623        let policy: Policy = serde_path_to_error::deserialize(&doc).unwrap();
1624
1625        // llm:* target: cache from its own entry, retry from defaults.llm,
1626        // rate falls through to defaults.outbound
1627        let llm = policy.resolve("llm:openai");
1628        assert_eq!(llm.cache.unwrap().ttl, Some(DurationMs(600_000)));
1629        assert_eq!(llm.retry.unwrap().attempts.get(), 6);
1630        assert_eq!(llm.rate.unwrap().limit.get(), 9);
1631
1632        // plain target: everything from defaults.outbound
1633        let plain = policy.resolve("api.example.com");
1634        assert_eq!(plain.retry.unwrap().attempts.get(), 3);
1635        assert!(plain.cache.is_none());
1636    }
1637
1638    #[test]
1639    fn flows_on_busy_parses_with_skip_default() {
1640        let p: Policy = serde_json::from_value(serde_json::json!({
1641            "flows": { "entrypoints": ["cmd:autonomous-run"], "on_busy": "wait" }
1642        }))
1643        .unwrap();
1644        assert_eq!(p.flows.as_ref().unwrap().on_busy, OnBusy::Wait);
1645        let p: Policy = serde_json::from_value(serde_json::json!({ "flows": {} })).unwrap();
1646        assert_eq!(p.flows.unwrap().on_busy, OnBusy::Skip);
1647    }
1648
1649    #[test]
1650    fn flows_match_cmd_rules_parse() {
1651        // CCR-5: `[flows.match."cmd:<name>"] argv = [...]` parses structurally
1652        // into the carried `match_` map (renamed from the reserved `match`).
1653        let p: Policy = serde_json::from_value(serde_json::json!({
1654            "flows": {
1655                "entrypoints": ["cmd:nightly-etl"],
1656                "match": { "cmd:nightly-etl": { "argv": ["*/run_etl.sh", "--env=prod"] } }
1657            }
1658        }))
1659        .unwrap();
1660        let flows = p.flows.unwrap();
1661        let rules = flows.match_.expect("match table carried");
1662        assert_eq!(
1663            rules["cmd:nightly-etl"].argv,
1664            vec!["*/run_etl.sh".to_owned(), "--env=prod".to_owned()]
1665        );
1666        // Absent `[flows.match]` leaves `match_` None (struct-level default).
1667        let p: Policy = serde_json::from_value(serde_json::json!({ "flows": {} })).unwrap();
1668        assert!(p.flows.unwrap().match_.is_none());
1669        // An unknown key inside a rule is rejected (deny_unknown_fields).
1670        assert!(
1671            serde_json::from_value::<Policy>(serde_json::json!({
1672                "flows": { "match": { "cmd:x": { "argv": ["a"], "bogus": 1 } } }
1673            }))
1674            .is_err()
1675        );
1676    }
1677}
1678
1679#[cfg(test)]
1680mod resolve_target_tests {
1681    use super::*;
1682
1683    fn policy(keys: &[&str]) -> Policy {
1684        let mut p = Policy::default();
1685        for k in keys {
1686            p.target.insert((*k).to_owned(), TargetPolicy::default());
1687        }
1688        p
1689    }
1690
1691    #[test]
1692    fn no_table_returns_bare_host() {
1693        assert_eq!(
1694            Policy::default().resolve_target("GET", "api.example.com", None, None, None),
1695            "api.example.com"
1696        );
1697    }
1698    #[test]
1699    fn exact_beats_pattern() {
1700        let p = policy(&["api.example.com", "*.example.com"]);
1701        assert_eq!(
1702            p.resolve_target("GET", "api.example.com", None, None, None),
1703            "api.example.com"
1704        );
1705    }
1706    #[test]
1707    fn host_wildcard_crosses_dots() {
1708        let p = policy(&["*.internal.corp"]);
1709        assert_eq!(
1710            p.resolve_target("GET", "a.b.internal.corp", None, None, None),
1711            "*.internal.corp"
1712        );
1713        assert_eq!(
1714            p.resolve_target("GET", "internal.corp", None, None, None),
1715            "internal.corp"
1716        );
1717    }
1718    #[test]
1719    fn host_is_case_insensitive() {
1720        let p = policy(&["*.Internal.Corp"]);
1721        assert_eq!(
1722            p.resolve_target("GET", "DB.INTERNAL.CORP", None, None, None),
1723            "*.Internal.Corp"
1724        );
1725    }
1726    #[test]
1727    fn path_glob_crosses_slashes_case_sensitive() {
1728        let p = policy(&["api.catalog.internal/*"]);
1729        assert_eq!(
1730            p.resolve_target("GET", "api.catalog.internal", None, None, Some("/a/b/c")),
1731            "api.catalog.internal/*"
1732        );
1733        let p2 = policy(&["api.x/A/*"]);
1734        assert_eq!(
1735            p2.resolve_target("GET", "api.x", None, None, Some("/a/y")),
1736            "api.x"
1737        );
1738    }
1739    #[test]
1740    fn missing_path_normalizes_to_slash() {
1741        let p = policy(&["api.x/*"]);
1742        assert_eq!(
1743            p.resolve_target("GET", "api.x", None, None, None),
1744            "api.x/*"
1745        );
1746        assert_eq!(
1747            p.resolve_target("GET", "api.x", None, None, Some("")),
1748            "api.x/*"
1749        );
1750    }
1751    #[test]
1752    fn method_prefix_must_match() {
1753        let p = policy(&["POST api.example.com"]);
1754        assert_eq!(
1755            p.resolve_target("GET", "api.example.com", None, None, None),
1756            "api.example.com"
1757        );
1758        assert_eq!(
1759            p.resolve_target("POST", "api.example.com", None, None, None),
1760            "POST api.example.com"
1761        );
1762        assert_eq!(
1763            p.resolve_target("post", "api.example.com", None, None, None),
1764            "POST api.example.com"
1765        );
1766    }
1767    #[test]
1768    fn port_uses_scheme_default() {
1769        let p = policy(&["api.example.com:443"]);
1770        assert_eq!(
1771            p.resolve_target("GET", "api.example.com", Some("https"), None, None),
1772            "api.example.com:443"
1773        );
1774        assert_eq!(
1775            p.resolve_target("GET", "api.example.com", Some("http"), None, None),
1776            "api.example.com"
1777        );
1778    }
1779    #[test]
1780    fn explicit_port_overrides_scheme() {
1781        let p = policy(&["api.example.com:8443"]);
1782        assert_eq!(
1783            p.resolve_target("GET", "api.example.com", Some("https"), Some(8443), None),
1784            "api.example.com:8443"
1785        );
1786        assert_eq!(
1787            p.resolve_target("GET", "api.example.com", Some("https"), Some(443), None),
1788            "api.example.com"
1789        );
1790    }
1791    #[test]
1792    fn most_specific_by_literal_length() {
1793        let p = policy(&["*.example.com", "GET api.example.com/*"]);
1794        assert_eq!(
1795            p.resolve_target("GET", "api.example.com", None, None, Some("/v1/x")),
1796            "GET api.example.com/*"
1797        );
1798    }
1799    #[test]
1800    fn lexicographic_tie_break_is_total() {
1801        let p = policy(&["api.example.com/x/*", "api.example.com/*/y"]);
1802        assert_eq!(
1803            p.resolve_target("GET", "api.example.com", None, None, Some("/x/y")),
1804            "api.example.com/*/y"
1805        );
1806    }
1807    #[test]
1808    fn class_prefixed_keys_are_not_hosts() {
1809        let p = policy(&["py:pkg.mod.fn", "llm:openai"]);
1810        assert_eq!(
1811            p.resolve_target("GET", "py:pkg.mod.fn", None, None, None),
1812            "py:pkg.mod.fn"
1813        );
1814    }
1815    #[test]
1816    fn llm_host_map_wins_over_patterns() {
1817        let p = policy(&["*.openai.com"]);
1818        assert_eq!(
1819            p.resolve_target("POST", "api.openai.com", None, None, None),
1820            "llm:openai"
1821        );
1822    }
1823    #[test]
1824    fn vertex_regional_suffix_maps_to_google_genai() {
1825        assert_eq!(
1826            Policy::default().resolve_target(
1827                "POST",
1828                "us-central1-aiplatform.googleapis.com",
1829                None,
1830                None,
1831                None
1832            ),
1833            "llm:google-genai"
1834        );
1835    }
1836
1837    #[test]
1838    fn known_llm_hosts_matches_resolve_target_for_every_pair() {
1839        let hosts = Policy::known_llm_hosts();
1840        assert!(hosts.contains(&("api.openai.com", "openai")));
1841        assert!(hosts.contains(&("api.anthropic.com", "anthropic")));
1842        assert!(hosts.contains(&("generativelanguage.googleapis.com", "google-genai")));
1843        for (host, provider) in hosts {
1844            assert_eq!(
1845                Policy::default().resolve_target("GET", host, None, None, None),
1846                format!("llm:{provider}")
1847            );
1848        }
1849    }
1850}