Skip to main content

keelrun_core/
engine.rs

1//! The engine: per-target state machines on real (tokio) time, orchestrating
2//! the fixed layer chain cache → rate → breaker → timeout → retry.
3//! Normative semantics: `conformance/README.md`; envelope types:
4//! `contracts/core_api.rs`.
5
6use core::fmt;
7use std::collections::{BTreeMap, HashMap, VecDeque};
8use std::sync::{Arc, Mutex, MutexGuard, RwLock};
9use std::time::Duration;
10
11use keel_core_api::policy::{
12    BreakerMode, BreakerPolicy, CacheScope, DurationMs, JournalLocation, NondeterminismResponse,
13    Policy, Rate, ResolvedPolicy, RetryPolicy,
14};
15use keel_core_api::{
16    AttemptResult, BreakerState, ENVELOPE_VERSION, ErrorClass, ErrorCode, KeelError, Outcome,
17    OutcomeError, Request,
18};
19use keel_journal::{
20    CacheKey as JournalCacheKey, CallObservation, CallResult, Clock, DiscoveryStore, Journal,
21    ObservedError,
22};
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use tokio::time::Instant;
26use tracing::{Instrument, debug, warn};
27
28use crate::events::{CacheStore, EventKind, EventSink, TraceRef};
29use crate::journal_backend::{self, JournalBackend};
30
31/// Circuit breaker in count mode (consecutive terminal failures) or rate mode
32/// (failure rate over a sliding window; `BreakerPolicy::mode` selects).
33/// Observes post-retry call outcomes — layer order puts it outside the retry
34/// loop. Normative semantics: conformance/README.md §4.
35#[derive(Debug, Default)]
36struct Breaker {
37    /// Count mode: consecutive terminal failures.
38    consecutive: u64,
39    /// Rate mode: post-retry outcomes `(completed_at, failed)` inside the
40    /// trailing window, oldest first. Pruned on every observation; cleared
41    /// when the breaker opens or a probe closes it.
42    outcomes: VecDeque<(Instant, bool)>,
43    open_until: Option<Instant>,
44    opens: u64,
45}
46
47/// What the breaker decided before a call was attempted.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum Admission {
50    Closed,
51    /// Cooldown elapsed: exactly one probe is admitted.
52    HalfOpen,
53    /// Still open: fail fast, do not invoke the effect.
54    Rejected,
55}
56
57/// A breaker state change worth surfacing as a telemetry event. Pure
58/// observability — the value never influences an outcome or the report; it
59/// exists only so the caller can emit a `tracing` event off the state lock.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum BreakerTransition {
62    /// No edge was crossed by this call.
63    None,
64    /// The breaker tripped OPEN (threshold reached, or a probe failed).
65    Opened,
66    /// A successful probe closed a previously-open breaker.
67    Closed,
68}
69
70impl Breaker {
71    fn admit(&self, now: Instant) -> Admission {
72        match self.open_until {
73            Some(until) if now < until => Admission::Rejected,
74            Some(_) => Admission::HalfOpen,
75            None => Admission::Closed,
76        }
77    }
78
79    fn state_at(&self, now: Instant) -> BreakerState {
80        match self.admit(now) {
81            Admission::Rejected => BreakerState::Open,
82            Admission::Closed | Admission::HalfOpen => BreakerState::Closed,
83        }
84    }
85
86    fn on_success(&mut self, now: Instant, config: &BreakerPolicy) -> BreakerTransition {
87        // A live success is only reached while closed or half-open; an open
88        // breaker fails fast (never runs the effect). So `open_until.is_some()`
89        // here means a probe just closed the breaker.
90        let closed_a_probe = self.open_until.is_some();
91        self.consecutive = 0;
92        self.open_until = None;
93        if closed_a_probe {
94            // A closing probe resets the window: the pre-open failure history
95            // must not instantly re-trip a freshly-recovered target.
96            self.outcomes.clear();
97            return BreakerTransition::Closed;
98        }
99        if let BreakerMode::Rate { window, .. } = config.mode() {
100            self.observe(now, window, false);
101        }
102        BreakerTransition::None
103    }
104
105    fn on_terminal_failure(
106        &mut self,
107        now: Instant,
108        config: &BreakerPolicy,
109        admission: Admission,
110    ) -> BreakerTransition {
111        let should_trip = if admission == Admission::HalfOpen {
112            true // failed probe: re-open for another full cooldown
113        } else {
114            match config.mode() {
115                BreakerMode::Count { failures } => {
116                    self.consecutive += 1;
117                    self.consecutive >= failures.get()
118                }
119                BreakerMode::Rate {
120                    window,
121                    failure_rate,
122                    min_calls,
123                } => {
124                    self.observe(now, window, true);
125                    self.window_rate_reached(failure_rate, min_calls)
126                }
127            }
128        };
129        if should_trip {
130            self.open_until = Some(now + Duration::from_millis(config.cooldown.0));
131            self.opens += 1;
132            self.consecutive = 0;
133            self.outcomes.clear();
134            BreakerTransition::Opened
135        } else {
136            BreakerTransition::None
137        }
138    }
139
140    /// Rate mode: prune outcomes that aged out of the window (strictly: an
141    /// outcome exactly `window` old is evicted, per conformance/README.md §4),
142    /// then record this one.
143    fn observe(&mut self, now: Instant, window: DurationMs, failed: bool) {
144        let window = Duration::from_millis(window.0);
145        while let Some(&(at, _)) = self.outcomes.front() {
146            if now.duration_since(at) >= window {
147                self.outcomes.pop_front();
148            } else {
149                break;
150            }
151        }
152        self.outcomes.push_back((now, failed));
153    }
154
155    /// Rate mode's trip condition over the (already-pruned) window.
156    fn window_rate_reached(&self, failure_rate: f64, min_calls: core::num::NonZeroU32) -> bool {
157        let total = self.outcomes.len();
158        if (total as u64) < u64::from(min_calls.get()) {
159            return false;
160        }
161        let failed = self.outcomes.iter().filter(|&&(_, f)| f).count();
162        #[expect(
163            clippy::cast_precision_loss,
164            reason = "window counts are bounded by the calls observed within one \
165                      breaker window — far below f64's 2^53 exact-integer range"
166        )]
167        let rate = failed as f64 / total as f64;
168        rate >= failure_rate
169    }
170}
171
172/// Token-bucket rate limiter over engine-elapsed milliseconds (dx-spec §4.1
173/// promises token-bucket rate limiting), bit-identical to every stub (parity
174/// rule — `crates/keel-core-stub`, `python/keel-core-stub`,
175/// `node/keel-core-stub`). Burst capacity is the rate's `limit`; refill is
176/// continuous at `limit` per `window`. Exceeding the rate delays the call
177/// (`throttled`), never fails it.
178///
179/// All arithmetic is integer fixed-point — token amounts are scaled by
180/// `window_ms`, so one token is `window_ms` scaled units and refill is exactly
181/// `limit` scaled units per elapsed millisecond. No float drift: identical
182/// call timings plan identical waits, so conformance scenarios may assert the
183/// exact `throttle_wait_ms` (conformance/README.md §3).
184#[derive(Debug, Default)]
185struct TokenBucket {
186    /// Tokens in scaled units (1 token = `window_ms` units). Negative means
187    /// admissions were already booked ahead of refill — queued waiters, each
188    /// spaced `window/limit` apart.
189    scaled_tokens: i128,
190    /// Engine-elapsed ms of the last refill.
191    last_refill_ms: u64,
192    /// Whether the bucket has been filled to burst on first use (a `Default`
193    /// bucket cannot know the rate yet).
194    primed: bool,
195}
196
197impl TokenBucket {
198    /// Plans one admission at `elapsed_ms`, pre-booking the token the call
199    /// will consume after sleeping. Returns the wait (0 = immediate): the time
200    /// until continuous refill covers this booking's deficit.
201    fn plan_admit(&mut self, elapsed_ms: u64, rate: Rate) -> u64 {
202        let limit = i128::from(rate.limit.get());
203        let window = i128::from(rate.window_ms);
204        let capacity = limit * window; // burst = `limit` whole tokens
205        if !self.primed {
206            self.primed = true;
207            self.scaled_tokens = capacity;
208            self.last_refill_ms = elapsed_ms;
209        }
210        // Concurrent planners may observe `elapsed_ms` before taking the state
211        // lock, so a reading older than the last refill credits nothing.
212        let elapsed = i128::from(elapsed_ms.saturating_sub(self.last_refill_ms));
213        self.last_refill_ms = self.last_refill_ms.max(elapsed_ms);
214        self.scaled_tokens = capacity.min(
215            self.scaled_tokens
216                .saturating_add(elapsed.saturating_mul(limit)),
217        );
218        self.scaled_tokens -= window;
219        if self.scaled_tokens >= 0 {
220            0
221        } else {
222            // ceil(deficit / refill-per-ms)
223            let deficit = -self.scaled_tokens;
224            u64::try_from((deficit + limit - 1) / limit).unwrap_or(u64::MAX)
225        }
226    }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Hash)]
230struct CacheKey {
231    target: String,
232    args_hash: String,
233}
234
235#[derive(Debug, Clone)]
236struct CacheEntry {
237    expires_at: Instant,
238    payload: Value,
239}
240
241/// Self-describing tag stamped into every persistent cache payload, so a future
242/// reader (`keel trace`, a schema migration, a foreign tool) can tell the
243/// encoding and its version apart from a bare value — journal.sql specifies the
244/// `cache.value` blob as "MessagePack, schema-tagged".
245const CACHE_PAYLOAD_SCHEMA: &str = "keel.cache/v1";
246
247/// The schema-tagged envelope the persistent cache stores. Written by reference
248/// so the payload is never cloned on the hot write path.
249#[derive(Serialize)]
250struct CachePayloadRef<'a> {
251    schema: &'a str,
252    payload: &'a Value,
253}
254
255/// The owned form read back from the journal, before its tag is verified.
256#[derive(Deserialize)]
257struct CachePayloadOwned {
258    schema: String,
259    payload: Value,
260}
261
262/// MessagePack-encode a cache payload with its schema tag.
263fn encode_cache_payload(payload: &Value) -> Result<Vec<u8>, rmp_serde::encode::Error> {
264    rmp_serde::to_vec_named(&CachePayloadRef {
265        schema: CACHE_PAYLOAD_SCHEMA,
266        payload,
267    })
268}
269
270/// Decode a schema-tagged cache payload. A codec failure or an unrecognized tag
271/// returns a reason string, so the read path can degrade to a miss rather than
272/// surfacing a poisoned entry to the caller.
273fn decode_cache_payload(bytes: &[u8]) -> Result<Value, String> {
274    let envelope: CachePayloadOwned =
275        rmp_serde::from_slice(bytes).map_err(|e| format!("messagepack decode failed: {e}"))?;
276    if envelope.schema != CACHE_PAYLOAD_SCHEMA {
277        return Err(format!(
278            "unrecognized cache payload schema {:?}",
279            envelope.schema
280        ));
281    }
282    Ok(envelope.payload)
283}
284
285/// Which cache backend serves a call, decided once per `execute`. `Persistent`
286/// is chosen only when the policy asks for it *and* a journal is attached;
287/// otherwise the in-memory `Memory` path keeps the engine fully functional
288/// un-journaled.
289#[derive(Debug)]
290enum CachePlan {
291    None,
292    Memory {
293        key: CacheKey,
294    },
295    Persistent {
296        key: JournalCacheKey,
297        ttl: DurationMs,
298    },
299}
300
301/// The discovery-recording surface the engine depends on: a single method, so
302/// the engine can hold a [`DiscoveryStore`] type-erased regardless of the
303/// [`Clock`] it was opened with. Implemented for every `DiscoveryStore<C>`.
304pub trait DiscoveryRecorder: Send + Sync {
305    /// Fold one observed call into the store; the error is the journal's own.
306    fn record(&self, observation: &CallObservation) -> keel_journal::Result<()>;
307}
308
309impl<C: Clock> DiscoveryRecorder for DiscoveryStore<C> {
310    fn record(&self, observation: &CallObservation) -> keel_journal::Result<()> {
311        DiscoveryStore::record(self, observation)
312    }
313}
314
315#[derive(Debug, Default)]
316struct TargetMetrics {
317    calls: u64,
318    attempts: u64,
319    retries: u64,
320    successes: u64,
321    failures: u64,
322    cache_hits: u64,
323    throttled: u64,
324}
325
326/// One target's row in the `keel_report` document (frozen report contract;
327/// `successes` includes cache hits, `failures` includes breaker rejections).
328#[derive(Debug, Serialize)]
329struct TargetReport {
330    attempts: u64,
331    breaker_opens: u64,
332    breaker_state: BreakerState,
333    cache_hits: u64,
334    calls: u64,
335    failures: u64,
336    retries: u64,
337    successes: u64,
338    throttled: u64,
339}
340
341#[derive(Debug, Serialize)]
342struct Report<'a> {
343    v: u32,
344    clock_ms: u64,
345    targets: BTreeMap<&'a str, TargetReport>,
346}
347
348#[derive(Debug, Default)]
349struct State {
350    trace_seq: u64,
351    breakers: HashMap<String, Breaker>,
352    rate_buckets: HashMap<String, TokenBucket>,
353    cache: HashMap<CacheKey, CacheEntry>,
354    metrics: BTreeMap<String, TargetMetrics>,
355}
356
357impl State {
358    fn metrics_for(&mut self, target: &str) -> &mut TargetMetrics {
359        self.metrics.entry(target.to_owned()).or_default()
360    }
361
362    fn breaker_state(&self, target: &str, now: Instant) -> BreakerState {
363        self.breakers
364            .get(target)
365            .map_or(BreakerState::Closed, |b| b.state_at(now))
366    }
367}
368
369/// The result of one attempt, tagged with whether the policy timeout layer
370/// (not the adapter) produced the failure — that origin is what turns a
371/// terminal outcome into `KEEL-E011`.
372#[derive(Debug)]
373struct AttemptOutcome {
374    result: AttemptResult,
375    timed_out_by_layer: bool,
376}
377
378/// Why a failed attempt ended the call, per the normative decision order
379/// (conformance/README.md §5).
380fn terminal_code(
381    retryable: bool,
382    attempt: u32,
383    max_attempts: u32,
384    idempotent: bool,
385) -> Option<ErrorCode> {
386    if !retryable {
387        Some(ErrorCode::NonRetryableError)
388    } else if attempt == max_attempts {
389        Some(ErrorCode::AttemptsExhausted)
390    } else if !idempotent {
391        Some(ErrorCode::NonIdempotentNotRetried)
392    } else {
393        None
394    }
395}
396
397/// What judging one successful poll iteration's payload decided
398/// (conformance/README.md "Poll", verdict rules — parity-critical).
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400enum PollVerdict {
401    Terminal,
402    Pending,
403    FailOpen,
404}
405
406/// Judge `payload` against the poll predicate. An adapter HTTP envelope
407/// (`body_b64`, or `status`+`headers`) is judged by its decoded JSON body;
408/// a plain object is judged directly; everything else fails open.
409fn poll_verdict(poll: &keel_core_api::policy::PollPolicy, payload: &Value) -> PollVerdict {
410    use base64::Engine as _;
411    let Some(obj) = payload.as_object() else {
412        return PollVerdict::FailOpen;
413    };
414    let doc_owned;
415    let doc = if obj.contains_key("body_b64")
416        || (obj.contains_key("status") && obj.contains_key("headers"))
417    {
418        let Some(b64) = obj.get("body_b64").and_then(Value::as_str) else {
419            return PollVerdict::FailOpen;
420        };
421        let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64) else {
422            return PollVerdict::FailOpen;
423        };
424        let Ok(parsed) = serde_json::from_slice::<Value>(&bytes) else {
425            return PollVerdict::FailOpen;
426        };
427        if !parsed.is_object() {
428            return PollVerdict::FailOpen;
429        }
430        doc_owned = parsed;
431        doc_owned.as_object().expect("checked object")
432    } else {
433        obj
434    };
435    match doc.get(&poll.until.field) {
436        None => PollVerdict::FailOpen,
437        Some(Value::String(s)) if poll.until.terminal.iter().any(|t| t == s) => {
438            PollVerdict::Terminal
439        }
440        Some(_) => PollVerdict::Pending,
441    }
442}
443
444/// The poll gate: a resolved poll table applies only to idempotent GET/HEAD
445/// ops (re-issuing a GET is as safe as retrying it — CCR-3).
446fn poll_applies(request: &Request) -> bool {
447    request.idempotent && (request.op.starts_with("GET ") || request.op.starts_with("HEAD "))
448}
449
450fn class_str(class: ErrorClass) -> &'static str {
451    match class {
452        ErrorClass::Conn => "conn",
453        ErrorClass::Timeout => "timeout",
454        ErrorClass::Http => "http",
455        ErrorClass::Cancelled => "cancelled",
456        ErrorClass::Other => "other",
457    }
458}
459
460/// Static label for a breaker state, matching its `snake_case` serialized form,
461/// so a span field reads the same as the report/journal.
462fn breaker_str(state: BreakerState) -> &'static str {
463    match state {
464        BreakerState::Closed => "closed",
465        BreakerState::Open => "open",
466        BreakerState::HalfOpen => "half_open",
467    }
468}
469
470/// Stamps the terminal outcome onto the `keel.call` span. Every field was
471/// declared `Empty` at span open, so on a disabled span each `record` is a
472/// no-op and the trivial accessors below cost effectively nothing — the
473/// disabled-callsite fast path telemetry must not perturb.
474fn record_call_fields(span: &tracing::Span, out: &Outcome) {
475    span.record("trace_id", out.trace_id.as_str());
476    span.record("result", out.result.as_str());
477    if let Some(error) = out.error.as_ref() {
478        span.record("error_code", error.code.as_str());
479    }
480    span.record("attempts", out.attempts);
481    span.record("from_cache", out.from_cache);
482    span.record("throttled", out.throttled);
483    span.record("breaker", breaker_str(out.breaker));
484}
485
486/// Emits a breaker state change at debug level and as a
487/// `keel.breaker.transitions` metric (architecture spec §4.5). Called off the
488/// state lock; a no-op when nothing changed.
489fn emit_breaker_transition(target: &str, transition: BreakerTransition) {
490    match transition {
491        BreakerTransition::Opened => {
492            debug!(target = %target, transition = "opened", "breaker transition");
493            crate::metrics::record_breaker_transition(target, "opened");
494        }
495        BreakerTransition::Closed => {
496            debug!(target = %target, transition = "closed", "breaker transition");
497            crate::metrics::record_breaker_transition(target, "closed");
498        }
499        BreakerTransition::None => {}
500    }
501}
502
503/// Warn (once per offending table) when a breaker sets `failures` alongside
504/// rate-mode knobs: the frozen schema's "setting `failures` selects count
505/// mode" makes window/failure_rate/min_calls inert, and inert config must be
506/// loud (the same rule `configure` applies to `journal`/`telemetry`).
507fn warn_inert_breaker_knobs(policy: &Policy) {
508    let defaults = &policy.defaults;
509    let tables = defaults
510        .outbound
511        .iter()
512        .map(|t| (String::from("defaults.outbound"), t))
513        .chain(
514            defaults
515                .llm
516                .iter()
517                .map(|t| (String::from("defaults.llm"), t)),
518        )
519        .chain(
520            policy
521                .target
522                .iter()
523                .map(|(name, t)| (format!("target.\"{name}\""), t)),
524        );
525    for (path, table) in tables {
526        if table
527            .breaker
528            .as_ref()
529            .is_some_and(BreakerPolicy::has_inert_rate_knobs)
530        {
531            warn!(
532                "policy {path}.breaker sets `failures` (count mode) alongside rate-mode knobs \
533                 (window/failure_rate/min_calls), which are inert in count mode. Remove \
534                 `failures` to select rate mode."
535            );
536        }
537    }
538}
539
540/// Append the dx-invariant-4 trace reference (`… trace: keel trace <ref>`) to
541/// a terminal failure message. `trace` is `Some` only while a live event sink
542/// minted a ref for this call, so without a sink — the conformance condition —
543/// every implementation stays message-identical (parity rule).
544fn with_trace_ref(message: String, trace: Option<&TraceRef>) -> String {
545    match trace {
546        Some(t) => {
547            let sep = if message.ends_with('.') { "" } else { "." };
548            format!("{message}{sep} trace: keel trace {t}")
549        }
550        None => message,
551    }
552}
553
554fn terminal_message(
555    code: ErrorCode,
556    request: &Request,
557    attempt: u32,
558    max_attempts: u32,
559    class: ErrorClass,
560    http_status: Option<u16>,
561    message: &str,
562) -> String {
563    let detail = match http_status {
564        Some(status) => format!("{} {status}", class_str(class)),
565        None => class_str(class).to_owned(),
566    };
567    let text = match code {
568        ErrorCode::Timeout => format!(
569            "{} exceeded its policy timeout on attempt {attempt}/{max_attempts}. {message}",
570            request.op
571        ),
572        ErrorCode::AttemptsExhausted => format!(
573            "{} failed {attempt}/{max_attempts} attempts (last: {detail}). {message}",
574            request.op
575        ),
576        ErrorCode::NonIdempotentNotRetried => format!(
577            "{} failed ({detail}). Not retried: call is not idempotent — observed, not retried. {message}",
578            request.op
579        ),
580        _ => format!(
581            "{} failed ({detail}); error class is not retryable per policy. {message}",
582            request.op
583        ),
584    };
585    text.trim_end().to_owned()
586}
587
588/// Per-attempt context [`terminal_attempt_error`] needs but that never
589/// changes across the retry loop's calls to it — bundled so the helper stays
590/// under clippy's argument-count ceiling.
591struct TerminalAttemptCtx<'a> {
592    request: &'a Request,
593    attempt: u32,
594    max_attempts: u32,
595    trace: Option<&'a TraceRef>,
596    /// Whether THIS attempt was cut off by the policy timeout layer (distinct
597    /// from the retryable-class check): a policy-layer timeout is the more
598    /// precise diagnosis than "exhausted"/"non-retryable" — except the Level 0
599    /// non-idempotent rule, which callers must always see verbatim.
600    timed_out_by_layer: bool,
601}
602
603/// Builds the terminal `OutcomeError` once `run_attempts` has decided this
604/// failed attempt is not retried — folds the timeout-diagnosis override and
605/// the dx-invariant-4 trace ref into one place so the retry loop itself stays
606/// under clippy's line-count ceiling.
607fn terminal_attempt_error(
608    ctx: &TerminalAttemptCtx<'_>,
609    code: ErrorCode,
610    class: ErrorClass,
611    http_status: Option<u16>,
612    message: &str,
613    original: Option<Value>,
614) -> OutcomeError {
615    let code = if ctx.timed_out_by_layer && code != ErrorCode::NonIdempotentNotRetried {
616        ErrorCode::Timeout
617    } else {
618        code
619    };
620    OutcomeError {
621        code,
622        class,
623        http_status,
624        message: with_trace_ref(
625            terminal_message(
626                code,
627                ctx.request,
628                ctx.attempt,
629                ctx.max_attempts,
630                class,
631                http_status,
632                message,
633            ),
634            ctx.trace,
635        ),
636        original,
637    }
638}
639
640/// The Keel kernel, Tier 1 scope. One per process; `&self`-concurrent.
641///
642/// A journal and/or discovery store are optional attachments: the engine is
643/// fully functional without either, and neither can change a call's outcome —
644/// their I/O failures degrade to a `warn!` (resilience first, honest reporting).
645pub struct Engine {
646    started: Instant,
647    policy: RwLock<Policy>,
648    /// The exact JSON document last passed to [`configure`](Self::configure),
649    /// verbatim — kept alongside the typed `policy` so [`Engine::layer`] can
650    /// resolve a value over the RAW configured shape (e.g. `"120s"`, not a
651    /// re-serialized `DurationMs`), matching what the front ends' `layer()`
652    /// readers have always returned. Written atomically with `policy` inside
653    /// `configure`, so the two can never drift apart.
654    raw_policy: RwLock<Value>,
655    state: Mutex<State>,
656    /// Persistence for the `scope = persistent` cache and Tier 2 flows. Behind
657    /// a lock because `configure` honors `policy.journal` by (re)attaching the
658    /// selected backend; readers clone the `Arc` out, so the lock is never held
659    /// across journal I/O (let alone an await).
660    journal: RwLock<JournalSlot>,
661    /// Traffic ledger fed one observation per `execute`, for `keel init`/`status`.
662    discovery: Option<Arc<dyn DiscoveryRecorder>>,
663    /// Live NDJSON event feed for `keel tail`/`keel trace` ([`crate::events`]).
664    /// `None` (the zero-cost path) unless the environment activates it or a
665    /// sink is attached; like journal/discovery it can never change an outcome.
666    events: Option<EventSink>,
667}
668
669/// The engine's journal attachment plus, when policy selected it, the resolved
670/// backend it was opened from — so reapplying an unchanged policy (whether a
671/// `file:` path or the same `postgres://` location) is a no-op instead of a
672/// re-open/re-connect.
673#[derive(Default)]
674struct JournalSlot {
675    journal: Option<Arc<dyn Journal>>,
676    /// `Some` only for a policy-selected attachment; construction-time
677    /// attachments have no location the engine could compare against.
678    backend: Option<JournalBackend>,
679}
680
681impl fmt::Debug for Engine {
682    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
683        // The trait-object attachments aren't `Debug`; report their presence.
684        f.debug_struct("Engine")
685            .field("policy", &self.policy)
686            .field("raw_policy", &self.raw_policy)
687            .field("state", &self.state)
688            .field("journal_attached", &self.current_journal().is_some())
689            .field("discovery_attached", &self.discovery.is_some())
690            .field("events_attached", &self.events.is_some())
691            .finish_non_exhaustive()
692    }
693}
694
695impl Default for Engine {
696    fn default() -> Self {
697        Self::new()
698    }
699}
700
701impl Engine {
702    #[must_use]
703    pub fn new() -> Self {
704        Self {
705            started: Instant::now(),
706            policy: RwLock::new(Policy::default()),
707            raw_policy: RwLock::new(Value::Null),
708            state: Mutex::new(State::default()),
709            journal: RwLock::new(JournalSlot::default()),
710            discovery: None,
711            // Live events activate from the environment (KEEL_EVENTS / an
712            // existing ./.keel dir — see `crate::events`), so every embedding
713            // — FFI, PyO3, napi, CLI — inherits the feed with no plumbing.
714            events: EventSink::from_env(),
715        }
716    }
717
718    /// Attach a journal at construction time, enabling the persistent cache
719    /// scope. Optional; set at setup, before the engine is shared for
720    /// concurrent `execute`. A later [`configure`](Self::configure) whose
721    /// policy carries a `journal` key replaces this attachment — the effective
722    /// policy is authoritative (spec §4.2).
723    pub fn attach_journal(&mut self, journal: impl Journal + 'static) -> &mut Self {
724        let slot = self.journal.get_mut().expect("journal lock poisoned");
725        slot.journal = Some(Arc::new(journal));
726        slot.backend = None;
727        self
728    }
729
730    /// The attached journal, if any — shared (`Arc`) so a [`FlowManager`] can run
731    /// its Tier 2 steps over the *same* store the engine caches through. `None`
732    /// for an in-memory engine (Tier 2 requires a durable journal). Live: a
733    /// `configure` whose policy selects a `journal` location changes what this
734    /// returns, so Tier 2 wiring should read it after the engine is configured.
735    ///
736    /// [`FlowManager`]: crate::FlowManager
737    #[must_use]
738    pub fn journal(&self) -> Option<Arc<dyn Journal>> {
739        self.current_journal()
740    }
741
742    /// Clone the current journal out of its slot (the lock never outlives the
743    /// statement, so it is never held across journal I/O or an await).
744    fn current_journal(&self) -> Option<Arc<dyn Journal>> {
745        self.journal
746            .read()
747            .expect("journal lock poisoned")
748            .journal
749            .clone()
750    }
751
752    /// Attach a discovery store; each `execute` then records one observation.
753    /// Optional and failure-isolated — recording never affects an outcome.
754    pub fn attach_discovery(&mut self, discovery: impl DiscoveryRecorder + 'static) -> &mut Self {
755        self.discovery = Some(Arc::new(discovery));
756        self
757    }
758
759    /// Attach (or replace) a live event sink. [`Engine::new`] already resolves
760    /// one from the environment (see [`crate::events`]); this override exists
761    /// for tests and embedders that place the feed elsewhere.
762    pub fn attach_events(&mut self, sink: EventSink) -> &mut Self {
763        self.events = Some(sink);
764        self
765    }
766
767    /// The active event sink, if any — its run id anchors this engine's trace
768    /// refs, and `flush()` makes the feed current for a same-process reader.
769    #[must_use]
770    pub fn events(&self) -> Option<&EventSink> {
771        self.events.as_ref()
772    }
773
774    /// Emit one live event, stamped with engine-elapsed (virtual-clock-safe)
775    /// milliseconds. The closure defers construction — and its string clones —
776    /// to the active-sink case, so the disabled path costs one branch.
777    fn emit_event(&self, kind: impl FnOnce() -> EventKind) {
778        if let Some(sink) = self.events.as_ref() {
779            sink.emit(self.elapsed_ms(), kind());
780        }
781    }
782
783    /// Feed event for a consulted cache: hit (the call is served) or miss
784    /// (the call proceeds live). Not emitted when no cache plan exists.
785    fn emit_cache(&self, out: &Outcome, target: &str, scope: CacheStore, hit: bool) {
786        self.emit_event(|| {
787            let call = out.trace_id.clone();
788            let target = target.to_owned();
789            if hit {
790                EventKind::CacheHit {
791                    call,
792                    target,
793                    scope,
794                }
795            } else {
796                EventKind::CacheMiss {
797                    call,
798                    target,
799                    scope,
800                }
801            }
802        });
803    }
804
805    /// Apply a policy document (keel.toml as JSON, per
806    /// contracts/policy.schema.json), replacing the previous one atomically.
807    /// Rejections are `KEEL-E001` with the exact offending field path;
808    /// a valid policy naming a journal backend this build cannot provide is
809    /// `KEEL-E005` (and the previous policy stays in force).
810    pub fn configure(&self, policy_json: &Value) -> Result<(), KeelError> {
811        let policy: Policy =
812            serde_path_to_error::deserialize(policy_json).map_err(|e| KeelError {
813                code: ErrorCode::PolicyInvalid,
814                message: format!("policy invalid at {}: {}", e.path(), e.inner()),
815            })?;
816        // `journal` selects the backing store (spec §4.2 — "that override is
817        // the entire laptop→enterprise migration"), so it must take effect or
818        // fail loudly, never warn-and-ignore. Applied before the policy swap so
819        // a rejected location leaves the previous configuration fully in force.
820        if let Some(location) = &policy.journal {
821            self.apply_journal_location(location)?;
822        }
823        // `telemetry.otlp_endpoint` IS honored: native front ends built with the
824        // `otel` feature read it back via `telemetry_otlp_endpoint` (below) and
825        // pass it to `otel::init_otlp` (env still wins — see `otel::export_enabled`
826        // / `otel::resolve_endpoint`). `telemetry.console` (the local
827        // pretty-console-summary switch, architecture spec §4.5) is validated and
828        // carried but has no consumer yet; warn on an explicit non-default value
829        // rather than silently ignoring the user's intent.
830        if let Some(telemetry) = &policy.telemetry
831            && !telemetry.console
832        {
833            warn!(
834                "policy `telemetry.console = false` is validated but not yet wired: v0.1 always \
835                 uses the default local summary. `telemetry.otlp_endpoint` IS honored by front \
836                 ends built with the `otel` feature."
837            );
838        }
839        warn_inert_breaker_knobs(&policy);
840        *self.policy.write().expect("policy lock poisoned") = policy;
841        *self.raw_policy.write().expect("raw policy lock poisoned") = policy_json.clone();
842        Ok(())
843    }
844
845    /// The effective `telemetry.otlp_endpoint` (`None` if `[telemetry]` is
846    /// absent or the key unset), read live so a reconfigure is honored. Native
847    /// front ends built with the `otel` feature read this after `configure` and
848    /// pass it to [`crate::otel::export_enabled`] / [`crate::otel::resolve_endpoint`]
849    /// to decide whether/where to export (env vars still take precedence).
850    #[must_use]
851    pub fn telemetry_otlp_endpoint(&self) -> Option<String> {
852        self.policy
853            .read()
854            .expect("policy lock poisoned")
855            .telemetry
856            .as_ref()
857            .and_then(|t| t.otlp_endpoint.clone())
858    }
859
860    /// Honor `policy.journal`: open and attach the backend it names, replacing
861    /// any construction-time attachment — the effective policy is
862    /// authoritative. (Front ends that want an environment escape hatch such as
863    /// `KEEL_JOURNAL` compose it into the effective policy *before* calling
864    /// `configure`, per the effective-policy contract.) Reapplying an unchanged
865    /// location — the same `file:` path, or the same `postgres://` URL — is a
866    /// no-op, so reconfigure loops never re-open the store, re-open a fresh
867    /// Postgres connection pool, or drop either's connection state.
868    ///
869    /// # Errors
870    /// - `KEEL-E040` when the selected SQLite file, or the selected Postgres
871    ///   database, cannot be opened/connected to.
872    fn apply_journal_location(&self, location: &JournalLocation) -> Result<(), KeelError> {
873        let backend = JournalBackend::select(location);
874        {
875            let slot = self.journal.read().expect("journal lock poisoned");
876            if slot.backend.as_ref() == Some(&backend) {
877                return Ok(()); // unchanged location: keep the open store
878            }
879        }
880        // Open OFF the lock (filesystem/network I/O); the brief write below
881        // only swaps pointers. Two racing configures both open, last writer
882        // wins — the loser's store (and, for Postgres, its connection pool)
883        // is just dropped.
884        let journal = journal_backend::open(&backend)?;
885        if let JournalBackend::File(path) = &backend {
886            debug!(path = %path.display(), "journal selected by policy");
887        }
888        let mut slot = self.journal.write().expect("journal lock poisoned");
889        slot.journal = Some(journal);
890        slot.backend = Some(backend);
891        Ok(())
892    }
893
894    /// The target's resolved `idempotency = { header }` knob, read live so a
895    /// reconfigure is honored. This is the engine surface of the injection
896    /// contract (contracts/adapter-pack.md "Idempotency-key injection"):
897    /// adapters/bindings consult it to mint and inject an idempotency key on
898    /// unsafe-method calls — the engine itself never injects, and the flipped
899    /// judgment reaches it as `Request.idempotent = true`.
900    #[must_use]
901    pub fn idempotency_header(&self, target: &str) -> Option<String> {
902        self.policy
903            .read()
904            .expect("policy lock poisoned")
905            .resolve(target)
906            .idempotency
907            .map(|i| i.header)
908    }
909
910    /// Resolve the policy target key for one outbound request — the engine
911    /// surface of [`Policy::resolve_target`] (SP-1: LLM host map, exact
912    /// bare-host `[target]` key, most-specific pattern match, then the bare
913    /// host). Read live so a reconfigure is honored.
914    #[must_use]
915    pub fn resolve_target(
916        &self,
917        method: &str,
918        host: &str,
919        scheme: Option<&str>,
920        port: Option<u16>,
921        path: Option<&str>,
922    ) -> String {
923        self.policy
924            .read()
925            .expect("policy lock poisoned")
926            .resolve_target(method, host, scheme, port, path)
927    }
928
929    /// One resolved layer value as JSON (`null` when unset) — the front ends'
930    /// `backend.layer(target, key)` reader. Walks the RAW configured JSON
931    /// (never a typed re-serialization) with the same precedence as
932    /// [`Policy::resolve`]/the stubs' `_layer`: an exact `[target]` entry for
933    /// `target`, else (for `llm:*` targets) `defaults.llm`, else
934    /// `defaults.outbound`, else `null`. Resolving over the raw document
935    /// (rather than serializing the typed `ResolvedPolicy`) preserves the
936    /// original config literal verbatim — e.g. `"120s"`, not a re-serialized
937    /// `DurationMs` number — which is what callers actually need; see
938    /// `DurationMs`'s `Deserialize`-only impl (`policy.rs`).
939    #[must_use]
940    pub fn layer(&self, target: &str, key: &str) -> Value {
941        let raw = self.raw_policy.read().expect("raw policy lock poisoned");
942        if let Some(value) = raw
943            .get("target")
944            .and_then(|t| t.get(target))
945            .and_then(|t| t.get(key))
946        {
947            return value.clone();
948        }
949        if target.starts_with("llm:")
950            && let Some(value) = raw
951                .get("defaults")
952                .and_then(|d| d.get("llm"))
953                .and_then(|l| l.get(key))
954        {
955            return value.clone();
956        }
957        raw.get("defaults")
958            .and_then(|d| d.get("outbound"))
959            .and_then(|o| o.get(key))
960            .cloned()
961            .unwrap_or(Value::Null)
962    }
963
964    /// Every host the LLM host map knows about, as `(host, provider)` pairs —
965    /// the engine surface of [`Policy::known_llm_hosts`]. Not read from
966    /// `self`: the map is a hardcoded constant, identical regardless of
967    /// which policy is configured, so this needs no live lock (issue #49).
968    #[must_use]
969    pub fn known_llm_hosts() -> Vec<(&'static str, &'static str)> {
970        Policy::known_llm_hosts()
971    }
972
973    /// The configured Tier 2 `flows.on_nondeterminism` response (default
974    /// [`NondeterminismResponse::Fail`]), read live so a reconfigure is honored.
975    /// The flow manager consults this when a replay `(seq, step_key)` diverges.
976    #[must_use]
977    pub fn nondeterminism_response(&self) -> NondeterminismResponse {
978        self.policy
979            .read()
980            .expect("policy lock poisoned")
981            .flows
982            .as_ref()
983            .map_or(NondeterminismResponse::default(), |f| f.on_nondeterminism)
984    }
985
986    fn state(&self) -> MutexGuard<'_, State> {
987        self.state.lock().expect("state lock poisoned")
988    }
989
990    fn elapsed_ms(&self) -> u64 {
991        u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
992    }
993
994    /// Run one intercepted call through the target's layer chain, then record it
995    /// for discovery. `effect` performs a single attempt (1-based attempt
996    /// numbers). Always returns an `Outcome` — policy failures are outcomes, not
997    /// panics, and neither journal nor discovery I/O can change what's returned.
998    pub async fn execute<F>(&self, request: &Request, mut effect: F) -> Outcome
999    where
1000        F: AsyncFnMut(u32) -> AttemptResult,
1001    {
1002        let started = Instant::now();
1003        // One span per wrapped call (architecture spec §4.5). Terminal fields
1004        // are declared `Empty` and recorded from the finished outcome; the
1005        // per-attempt child spans are opened inside the instrumented chain.
1006        // `%request.target`/`%request.op` are only formatted when a subscriber
1007        // is active — the disabled callsite evaluates nothing.
1008        let span = tracing::info_span!(
1009            "keel.call",
1010            target = %request.target,
1011            op = %request.op,
1012            trace_id = tracing::field::Empty,
1013            result = tracing::field::Empty,
1014            error_code = tracing::field::Empty,
1015            attempts = tracing::field::Empty,
1016            from_cache = tracing::field::Empty,
1017            throttled = tracing::field::Empty,
1018            breaker = tracing::field::Empty,
1019        );
1020        let out = self
1021            .run_chain(request, &mut effect)
1022            .instrument(span.clone())
1023            .await;
1024        record_call_fields(&span, &out);
1025        self.observe(request, &out, started);
1026        // Every call's last feed event, on all paths (cache hit, breaker
1027        // reject, envelope error, live attempt).
1028        self.emit_event(|| EventKind::CallEnd {
1029            call: out.trace_id.clone(),
1030            target: request.target.clone(),
1031            result: out.result.clone(),
1032            code: out.error.as_ref().map(|e| e.code),
1033            attempts: out.attempts,
1034        });
1035        out
1036    }
1037
1038    /// The layer chain proper — cache → rate → breaker → timeout → retry —
1039    /// unchanged in semantics from the journal-free engine. The persistent cache
1040    /// scope simply swaps the in-memory map for the journal's `cache` table when
1041    /// a journal is attached; every other layer is byte-for-byte as before.
1042    async fn run_chain<F>(&self, request: &Request, effect: &mut F) -> Outcome
1043    where
1044        F: AsyncFnMut(u32) -> AttemptResult,
1045    {
1046        let target = request.target.as_str();
1047        let mut out = self.begin_call(target);
1048
1049        // Every call's first feed event; its seq anchors the trace ref that
1050        // failure messages carry (`None` without a sink — the parity path).
1051        let trace = self.events.as_ref().map(|sink| {
1052            let seq = sink.emit(
1053                self.elapsed_ms(),
1054                EventKind::CallStart {
1055                    call: out.trace_id.clone(),
1056                    target: target.to_owned(),
1057                    op: request.op.clone(),
1058                },
1059            );
1060            TraceRef {
1061                run: sink.run_id().to_owned(),
1062                seq,
1063            }
1064        });
1065
1066        if request.v != ENVELOPE_VERSION {
1067            out.error = Some(OutcomeError {
1068                code: ErrorCode::EnvelopeVersion,
1069                class: ErrorClass::Other,
1070                http_status: None,
1071                message: format!("unsupported envelope version {}", request.v),
1072                original: None,
1073            });
1074            self.state().metrics_for(target).failures += 1;
1075            return out;
1076        }
1077
1078        let resolved = self
1079            .policy
1080            .read()
1081            .expect("policy lock poisoned")
1082            .resolve(target);
1083
1084        // cache (outermost layer); every planned lookup is one
1085        // `keel.cache.requests` datapoint (hit ratio, spec §4.5)
1086        let cache_plan = self.plan_cache(target, &resolved, request);
1087        match &cache_plan {
1088            CachePlan::Memory { key } => {
1089                if self.serve_from_cache(key, &mut out) {
1090                    crate::metrics::record_cache_request(target, true);
1091                    self.emit_cache(&out, target, CacheStore::Memory, true);
1092                    return out;
1093                }
1094                crate::metrics::record_cache_request(target, false);
1095                self.emit_cache(&out, target, CacheStore::Memory, false);
1096            }
1097            CachePlan::Persistent { key, .. } => {
1098                if self.serve_from_persistent(target, key, &mut out) {
1099                    crate::metrics::record_cache_request(target, true);
1100                    self.emit_cache(&out, target, CacheStore::Persistent, true);
1101                    return out;
1102                }
1103                crate::metrics::record_cache_request(target, false);
1104                self.emit_cache(&out, target, CacheStore::Persistent, false);
1105            }
1106            CachePlan::None => {}
1107        }
1108
1109        // rate limiter (lock never held across the sleep)
1110        if let Some(rate) = resolved.rate {
1111            self.throttle(target, rate, &mut out).await;
1112        }
1113
1114        // breaker admission (observes post-retry call outcomes)
1115        let admission = self.admit(target, &resolved, &mut out, trace.as_ref());
1116        if admission == Admission::Rejected {
1117            return out;
1118        }
1119
1120        // timeout + retry (innermost layers)
1121        let retry = resolved.retry.clone().unwrap_or_else(|| RetryPolicy {
1122            attempts: core::num::NonZeroU32::MIN,
1123            ..RetryPolicy::default()
1124        });
1125        let result = match resolved.poll.as_ref().filter(|_| poll_applies(request)) {
1126            Some(poll) => {
1127                self.run_poll(
1128                    request,
1129                    &resolved,
1130                    &retry,
1131                    poll,
1132                    effect,
1133                    &mut out,
1134                    trace.as_ref(),
1135                )
1136                .await
1137            }
1138            None => {
1139                self.run_attempts(request, &resolved, &retry, effect, &mut out, trace.as_ref())
1140                    .await
1141            }
1142        };
1143        // Only the memory scope writes through under the state lock; the
1144        // persistent scope writes after the lock drops (journal I/O off-lock).
1145        let memory_key = match &cache_plan {
1146            CachePlan::Memory { key } => Some(key.clone()),
1147            _ => None,
1148        };
1149        self.settle(target, &resolved, admission, memory_key, result, &mut out);
1150
1151        if let CachePlan::Persistent { key, ttl } = &cache_plan
1152            && out.result == "ok"
1153            && let Some(payload) = &out.payload
1154        {
1155            self.write_persistent(target, key, payload, *ttl);
1156        }
1157        out
1158    }
1159
1160    /// Decide which cache backend (if any) serves this call. Persistent scope
1161    /// without a journal falls back to the in-memory map — the engine stays
1162    /// fully functional un-journaled rather than silently dropping caching.
1163    fn plan_cache(&self, target: &str, resolved: &ResolvedPolicy, request: &Request) -> CachePlan {
1164        let (Some(cache), Some(hash)) = (resolved.cache.as_ref(), request.args_hash.as_ref())
1165        else {
1166            return CachePlan::None;
1167        };
1168        let Some(ttl) = cache.ttl else {
1169            return CachePlan::None;
1170        };
1171        match cache.scope {
1172            CacheScope::Persistent if self.current_journal().is_some() => CachePlan::Persistent {
1173                key: JournalCacheKey::new(format!("{target}#{hash}")),
1174                ttl,
1175            },
1176            _ => CachePlan::Memory {
1177                key: CacheKey {
1178                    target: target.to_owned(),
1179                    args_hash: hash.clone(),
1180                },
1181            },
1182        }
1183    }
1184
1185    /// Registers the call and mints its outcome envelope + trace id.
1186    fn begin_call(&self, target: &str) -> Outcome {
1187        let mut state = self.state();
1188        state.metrics_for(target).calls += 1;
1189        state.trace_seq += 1;
1190        Outcome {
1191            v: ENVELOPE_VERSION,
1192            result: String::from("error"),
1193            payload: None,
1194            error: None,
1195            attempts: 0,
1196            from_cache: false,
1197            waits_ms: Vec::new(),
1198            throttled: false,
1199            throttle_wait_ms: 0,
1200            breaker: BreakerState::Closed,
1201            trace_id: format!("t-{:06}", state.trace_seq),
1202        }
1203    }
1204
1205    /// Serves a fresh cached payload, if any (attempts stays 0). An entry found
1206    /// expired is *removed* here, not just skipped — combined with the sweep on
1207    /// write ([`settle`](Self::settle)) this bounds the in-memory map to the live
1208    /// working set rather than every distinct key ever cached.
1209    fn serve_from_cache(&self, key: &CacheKey, out: &mut Outcome) -> bool {
1210        let now = Instant::now();
1211        let mut state = self.state();
1212        let payload = match state.cache.get(key) {
1213            Some(entry) if now < entry.expires_at => entry.payload.clone(),
1214            Some(_) => {
1215                // Expired: evict so a per-call-varying key set cannot grow the
1216                // map without bound for the life of the process.
1217                state.cache.remove(key);
1218                return false;
1219            }
1220            None => return false,
1221        };
1222        out.result = String::from("ok");
1223        out.payload = Some(payload);
1224        out.from_cache = true;
1225        let metrics = state.metrics_for(&key.target);
1226        metrics.cache_hits += 1;
1227        metrics.successes += 1;
1228        out.breaker = state.breaker_state(&key.target, now);
1229        debug!(target = %key.target, scope = "memory", "cache hit");
1230        true
1231    }
1232
1233    /// Serves a fresh persistent cache payload from the journal, if any (attempts
1234    /// stays 0). The journal owns TTL expiry against its own clock (identical
1235    /// semantics to the in-memory scope). Any journal or codec failure degrades
1236    /// to a miss + `warn!`, so the call proceeds to a live attempt — a broken
1237    /// journal never fails the call. The journal read runs *before* the state
1238    /// lock is taken, so no lock is held across journal I/O.
1239    fn serve_from_persistent(
1240        &self,
1241        target: &str,
1242        key: &JournalCacheKey,
1243        out: &mut Outcome,
1244    ) -> bool {
1245        let Some(journal) = self.current_journal() else {
1246            return false;
1247        };
1248        let bytes = match journal.get_cache(key) {
1249            Ok(Some(bytes)) => bytes,
1250            Ok(None) => return false,
1251            Err(error) => {
1252                warn!(target = %target, error = %error, "persistent cache read failed; serving live");
1253                return false;
1254            }
1255        };
1256        let payload = match decode_cache_payload(&bytes) {
1257            Ok(payload) => payload,
1258            Err(reason) => {
1259                warn!(target = %target, reason = %reason, "persistent cache entry undecodable; serving live");
1260                return false;
1261            }
1262        };
1263        let now = Instant::now();
1264        let mut state = self.state();
1265        out.result = String::from("ok");
1266        out.payload = Some(payload);
1267        out.from_cache = true;
1268        let metrics = state.metrics_for(target);
1269        metrics.cache_hits += 1;
1270        metrics.successes += 1;
1271        out.breaker = state.breaker_state(target, now);
1272        debug!(target = %target, scope = "persistent", "cache hit");
1273        true
1274    }
1275
1276    /// Delays the call when the target's rate is exhausted (never fails it).
1277    async fn throttle(&self, target: &str, rate: Rate, out: &mut Outcome) {
1278        let wait_ms = {
1279            let elapsed = self.elapsed_ms();
1280            let mut state = self.state();
1281            let bucket = state.rate_buckets.entry(target.to_owned()).or_default();
1282            bucket.plan_admit(elapsed, rate)
1283        };
1284        if wait_ms > 0 {
1285            out.throttled = true;
1286            out.throttle_wait_ms = wait_ms;
1287            self.state().metrics_for(target).throttled += 1;
1288            crate::metrics::record_throttled(target, wait_ms);
1289            // Emitted before the wait so a live tail shows the queueing now.
1290            self.emit_event(|| EventKind::Throttle {
1291                call: out.trace_id.clone(),
1292                target: target.to_owned(),
1293                wait_ms,
1294            });
1295            tokio::time::sleep(Duration::from_millis(wait_ms)).await;
1296        }
1297    }
1298
1299    /// Consults the target's breaker; on rejection, fills the fail-fast
1300    /// KEEL-E012 outcome (the effect is never invoked).
1301    fn admit(
1302        &self,
1303        target: &str,
1304        resolved: &ResolvedPolicy,
1305        out: &mut Outcome,
1306        trace: Option<&TraceRef>,
1307    ) -> Admission {
1308        if resolved.breaker.is_none() {
1309            return Admission::Closed;
1310        }
1311        let now = Instant::now();
1312        let admission = {
1313            let mut state = self.state();
1314            let admission = state
1315                .breakers
1316                .entry(target.to_owned())
1317                .or_default()
1318                .admit(now);
1319            if admission == Admission::Rejected {
1320                out.error = Some(OutcomeError {
1321                    code: ErrorCode::BreakerOpen,
1322                    class: ErrorClass::Other,
1323                    http_status: None,
1324                    message: with_trace_ref(
1325                        format!("breaker OPEN for {target}: failed fast, call not attempted"),
1326                        trace,
1327                    ),
1328                    original: None,
1329                });
1330                out.breaker = BreakerState::Open;
1331                state.metrics_for(target).failures += 1;
1332            }
1333            admission
1334        };
1335        // Both feed events run off the state lock, like the debug! below.
1336        if admission == Admission::Rejected {
1337            self.emit_event(|| EventKind::BreakerReject {
1338                call: out.trace_id.clone(),
1339                target: target.to_owned(),
1340            });
1341        }
1342        // Admitting a probe is an OPEN → HALF-OPEN transition (spec §4.5).
1343        if admission == Admission::HalfOpen {
1344            debug!(target = %target, transition = "half_open", "breaker transition");
1345            crate::metrics::record_breaker_transition(target, "half_open");
1346            self.emit_event(|| EventKind::BreakerHalfOpen {
1347                call: out.trace_id.clone(),
1348                target: target.to_owned(),
1349            });
1350        }
1351        admission
1352    }
1353
1354    /// Books the call's terminal result: metrics, breaker transition, cache
1355    /// write, and the outcome's payload/error/breaker fields.
1356    fn settle(
1357        &self,
1358        target: &str,
1359        resolved: &ResolvedPolicy,
1360        admission: Admission,
1361        cache_key: Option<CacheKey>,
1362        result: Result<Value, OutcomeError>,
1363        out: &mut Outcome,
1364    ) {
1365        let now = Instant::now();
1366        let transition = {
1367            let mut state = self.state();
1368            let transition = match result {
1369                Ok(payload) => {
1370                    state.metrics_for(target).successes += 1;
1371                    let mut transition = BreakerTransition::None;
1372                    if let Some(config) = &resolved.breaker
1373                        && let Some(breaker) = state.breakers.get_mut(target)
1374                    {
1375                        transition = breaker.on_success(now, config);
1376                    }
1377                    if let (Some(key), Some(cache)) = (cache_key, &resolved.cache)
1378                        && let Some(ttl) = cache.ttl
1379                    {
1380                        // Sweep expired entries before inserting so the map is
1381                        // bounded by the live working set, not the total distinct
1382                        // keys ever seen. O(n) in current entries per cacheable
1383                        // write — cheap for the small working sets caching targets
1384                        // in practice, and it keeps a long-lived process from
1385                        // leaking every payload it ever cached (no LRU/size cap in
1386                        // v0.1; a `keel fsck`-style bound is future work).
1387                        state.cache.retain(|_, entry| entry.expires_at > now);
1388                        state.cache.insert(
1389                            key,
1390                            CacheEntry {
1391                                expires_at: now + Duration::from_millis(ttl.0),
1392                                payload: payload.clone(),
1393                            },
1394                        );
1395                    }
1396                    out.result = String::from("ok");
1397                    out.payload = Some(payload);
1398                    transition
1399                }
1400                Err(error) => {
1401                    state.metrics_for(target).failures += 1;
1402                    let mut transition = BreakerTransition::None;
1403                    if let Some(config) = &resolved.breaker
1404                        && let Some(breaker) = state.breakers.get_mut(target)
1405                    {
1406                        transition = breaker.on_terminal_failure(now, config, admission);
1407                    }
1408                    out.error = Some(error);
1409                    transition
1410                }
1411            };
1412            out.breaker = state.breaker_state(target, now);
1413            transition
1414        };
1415        emit_breaker_transition(target, transition);
1416        match transition {
1417            BreakerTransition::Opened => self.emit_event(|| EventKind::BreakerOpen {
1418                call: out.trace_id.clone(),
1419                target: target.to_owned(),
1420                cooldown_ms: resolved.breaker.as_ref().map_or(0, |b| b.cooldown.0),
1421            }),
1422            BreakerTransition::Closed => self.emit_event(|| EventKind::BreakerClose {
1423                call: out.trace_id.clone(),
1424                target: target.to_owned(),
1425            }),
1426            BreakerTransition::None => {}
1427        }
1428    }
1429
1430    /// Writes a live success into the journal's persistent cache (called after
1431    /// the state lock is dropped, so journal I/O is never under the engine
1432    /// mutex). Encoding or journal failure degrades to a `warn!`; the outcome the
1433    /// caller already holds is unaffected.
1434    fn write_persistent(
1435        &self,
1436        target: &str,
1437        key: &JournalCacheKey,
1438        payload: &Value,
1439        ttl: DurationMs,
1440    ) {
1441        let Some(journal) = self.current_journal() else {
1442            return;
1443        };
1444        let bytes = match encode_cache_payload(payload) {
1445            Ok(bytes) => bytes,
1446            Err(error) => {
1447                warn!(target = %target, error = %error, "persistent cache encode failed; entry not stored");
1448                return;
1449            }
1450        };
1451        if let Err(error) = journal.put_cache(key, &bytes, Duration::from_millis(ttl.0)) {
1452            warn!(target = %target, error = %error, "persistent cache write failed; entry not stored");
1453        }
1454    }
1455
1456    /// Records one observation of a completed call into the discovery store, if
1457    /// attached. Runs off the state lock, from data already in the `Outcome`.
1458    /// Failure degrades to a `warn!` — discovery is evidence, never on the
1459    /// call's critical path.
1460    fn observe(&self, request: &Request, out: &Outcome, started: Instant) {
1461        let Some(discovery) = self.discovery.as_ref() else {
1462            return;
1463        };
1464        let latency_ms = i64::try_from(started.elapsed().as_millis()).unwrap_or(i64::MAX);
1465        let result = if out.from_cache {
1466            CallResult::CacheHit
1467        } else if out.result == "ok" {
1468            CallResult::Success
1469        } else {
1470            CallResult::Failure
1471        };
1472        let error = out.error.as_ref().map(|e| ObservedError {
1473            class: e.class,
1474            http_status: e.http_status,
1475        });
1476        let breaker_opened = out
1477            .error
1478            .as_ref()
1479            .is_some_and(|e| e.code == ErrorCode::BreakerOpen);
1480        // "Observed, not retried" (dx-spec §1 Level 0 hard rule): the call
1481        // failed and the retry layer refused to re-send it (KEEL-E014).
1482        let not_retried = out
1483            .error
1484            .as_ref()
1485            .is_some_and(|e| e.code == ErrorCode::NonIdempotentNotRetried);
1486        // Coverage: the front end resolves globs to the exact policy-table key
1487        // before `execute`, so an exact lookup answers "did an explicit
1488        // [target."…"] entry apply?" — defaults-only calls count as unwrapped.
1489        let wrapped = self
1490            .policy
1491            .read()
1492            .expect("policy lock poisoned")
1493            .target
1494            .contains_key(&request.target);
1495        let observation = CallObservation {
1496            target: request.target.clone(),
1497            result,
1498            attempts: out.attempts,
1499            latency_ms,
1500            throttled: out.throttled,
1501            breaker_opened,
1502            not_retried,
1503            wrapped,
1504            error,
1505        };
1506        if let Err(error) = discovery.record(&observation) {
1507            warn!(target = %request.target, error = %error, "discovery record failed; observation dropped");
1508        }
1509    }
1510
1511    /// Runs a single effect invocation through the timeout layer, tagging the
1512    /// origin of a failure (adapter vs. policy timeout). The effect future is
1513    /// instrumented with `attempt_span`, so any tracing it emits nests under the
1514    /// attempt; the timeout branch synthesizes the same `KEEL-E011`-class error
1515    /// the retry loop diagnoses.
1516    ///
1517    /// `timeout` is the effective per-attempt wall-clock deadline, already gated
1518    /// by the caller: it is `None` for a non-idempotent request (Level 0 hard
1519    /// rule — never inject a synthetic failure into a call that may already have
1520    /// committed server-side; the front ends make the same judgment). Note that
1521    /// on the synchronous bindings (keel-py/keel-ffi/keel-node sync `execute`) a
1522    /// blocking effect completes within a single poll, so this timer cannot
1523    /// preempt it — sync callers get their HTTP client's adapter timeout, not the
1524    /// policy layer's. The policy per-attempt timeout is effective on the async
1525    /// path (and in-core futures) where the effect actually awaits.
1526    async fn run_one_attempt<F>(
1527        &self,
1528        timeout: Option<DurationMs>,
1529        effect: &mut F,
1530        attempt: u32,
1531        attempt_span: &tracing::Span,
1532    ) -> AttemptOutcome
1533    where
1534        F: AsyncFnMut(u32) -> AttemptResult,
1535    {
1536        match timeout {
1537            Some(limit) => {
1538                match tokio::time::timeout(
1539                    Duration::from_millis(limit.0),
1540                    effect(attempt).instrument(attempt_span.clone()),
1541                )
1542                .await
1543                {
1544                    Ok(result) => AttemptOutcome {
1545                        result,
1546                        timed_out_by_layer: false,
1547                    },
1548                    Err(_elapsed) => AttemptOutcome {
1549                        result: AttemptResult::Error {
1550                            class: ErrorClass::Timeout,
1551                            http_status: None,
1552                            retry_after_ms: None,
1553                            message: format!("no response within {}ms", limit.0),
1554                            original: None,
1555                        },
1556                        timed_out_by_layer: true,
1557                    },
1558                }
1559            }
1560            None => AttemptOutcome {
1561                result: effect(attempt).instrument(attempt_span.clone()).await,
1562                timed_out_by_layer: false,
1563            },
1564        }
1565    }
1566
1567    /// The poll layer (CCR-3): wraps the retry loop, so each iteration is one
1568    /// fully-retried call and a failed iteration is a normal terminal error.
1569    /// cache/rate/breaker sit outside (one admission, one settled outcome).
1570    #[expect(clippy::too_many_arguments, reason = "mirrors run_attempts' signature")]
1571    async fn run_poll<F>(
1572        &self,
1573        request: &Request,
1574        resolved: &ResolvedPolicy,
1575        retry: &RetryPolicy,
1576        poll: &keel_core_api::policy::PollPolicy,
1577        effect: &mut F,
1578        out: &mut Outcome,
1579        trace: Option<&TraceRef>,
1580    ) -> Result<Value, OutcomeError>
1581    where
1582        F: AsyncFnMut(u32) -> AttemptResult,
1583    {
1584        let started = Instant::now();
1585        loop {
1586            let payload = self
1587                .run_attempts(request, resolved, retry, effect, out, trace)
1588                .await?;
1589            match poll_verdict(poll, &payload) {
1590                PollVerdict::Terminal => return Ok(payload),
1591                PollVerdict::FailOpen => {
1592                    debug!(
1593                        target = %request.target, field = %poll.until.field,
1594                        "poll fail-open: response not judgeable; returned as-is"
1595                    );
1596                    return Ok(payload);
1597                }
1598                PollVerdict::Pending => {
1599                    let elapsed = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
1600                    if elapsed.saturating_add(poll.interval.0) > poll.deadline.0 {
1601                        return Err(OutcomeError {
1602                            code: ErrorCode::PollDeadlineExceeded,
1603                            class: ErrorClass::Other,
1604                            http_status: None,
1605                            message: with_trace_ref(
1606                                format!(
1607                                    "{} poll deadline exceeded: '{}' not terminal after {}ms",
1608                                    request.op, poll.until.field, poll.deadline.0
1609                                ),
1610                                trace,
1611                            ),
1612                            original: None,
1613                        });
1614                    }
1615                    tokio::time::sleep(Duration::from_millis(poll.interval.0)).await;
1616                }
1617            }
1618        }
1619    }
1620
1621    /// The timeout-wrapped retry loop. `Ok(payload)` or the terminal error
1622    /// (whose message carries the call's trace ref when a sink is live).
1623    async fn run_attempts<F>(
1624        &self,
1625        request: &Request,
1626        resolved: &ResolvedPolicy,
1627        retry: &RetryPolicy,
1628        effect: &mut F,
1629        out: &mut Outcome,
1630        trace: Option<&TraceRef>,
1631    ) -> Result<Value, OutcomeError>
1632    where
1633        F: AsyncFnMut(u32) -> AttemptResult,
1634    {
1635        let target = request.target.as_str();
1636        let max_attempts = retry.attempts.get();
1637        // Level 0: never arm the per-attempt wall-clock timeout on a
1638        // non-idempotent request. Firing it would drop the in-flight effect
1639        // future while the underlying POST may still commit server-side, then
1640        // hand the caller a synthetic timeout for a call that actually
1641        // succeeded. The front ends refuse to impose a deadline here for the
1642        // same reason; the core must not defeat that guard.
1643        let attempt_timeout = resolved.timeout.filter(|_| request.idempotent);
1644        for attempt in 1..=max_attempts {
1645            out.attempts += 1;
1646            self.state().metrics_for(target).attempts += 1;
1647            crate::metrics::record_attempt(target);
1648            self.emit_event(|| EventKind::AttemptStart {
1649                call: out.trace_id.clone(),
1650                target: target.to_owned(),
1651                attempt,
1652            });
1653
1654            // Child span per attempt (spec §4.5): `class`/`http_status`/`wait_ms`
1655            // are filled in below once the attempt resolves. The effect future
1656            // runs inside this span so any adapter tracing nests correctly.
1657            let attempt_span = tracing::debug_span!(
1658                "keel.attempt",
1659                attempt,
1660                result = tracing::field::Empty,
1661                class = tracing::field::Empty,
1662                http_status = tracing::field::Empty,
1663                wait_ms = tracing::field::Empty,
1664            );
1665
1666            let attempt_outcome = self
1667                .run_one_attempt(attempt_timeout, effect, attempt, &attempt_span)
1668                .await;
1669
1670            match attempt_outcome.result {
1671                AttemptResult::Ok { payload } => {
1672                    attempt_span.record("result", "ok");
1673                    return Ok(payload);
1674                }
1675                AttemptResult::Error {
1676                    class,
1677                    http_status,
1678                    retry_after_ms,
1679                    message,
1680                    original,
1681                } => {
1682                    attempt_span.record("result", "error");
1683                    attempt_span.record("class", class_str(class));
1684                    if let Some(status) = http_status {
1685                        attempt_span.record("http_status", status);
1686                    }
1687                    self.emit_event(|| EventKind::AttemptError {
1688                        call: out.trace_id.clone(),
1689                        target: target.to_owned(),
1690                        attempt,
1691                        class,
1692                        http_status,
1693                    });
1694                    let retryable = retry.is_retryable(class, http_status);
1695                    if let Some(code) =
1696                        terminal_code(retryable, attempt, max_attempts, request.idempotent)
1697                    {
1698                        let ctx = TerminalAttemptCtx {
1699                            request,
1700                            attempt,
1701                            max_attempts,
1702                            trace,
1703                            timed_out_by_layer: attempt_outcome.timed_out_by_layer,
1704                        };
1705                        return Err(terminal_attempt_error(
1706                            &ctx,
1707                            code,
1708                            class,
1709                            http_status,
1710                            &message,
1711                            original,
1712                        ));
1713                    }
1714                    // Jitter is the emitting segment's flag (schedules can
1715                    // compose via `upTo`/`andThen`; the walk is pure in the
1716                    // attempt number, so jitter never shifts handoff points).
1717                    let (mut wait, jitter) = retry.schedule.wait_and_jitter(attempt);
1718                    if jitter && wait > 0 {
1719                        wait = fastrand::u64(wait / 2..=wait);
1720                    }
1721                    if let Some(server_says) = retry_after_ms {
1722                        wait = wait.max(server_says);
1723                    }
1724                    attempt_span.record("wait_ms", wait);
1725                    out.waits_ms.push(wait);
1726                    self.state().metrics_for(target).retries += 1;
1727                    crate::metrics::record_retry(target, wait);
1728                    // Emitted before the wait so a live tail shows it now.
1729                    self.emit_event(|| EventKind::Backoff {
1730                        call: out.trace_id.clone(),
1731                        target: target.to_owned(),
1732                        attempt,
1733                        wait_ms: wait,
1734                    });
1735                    tokio::time::sleep(Duration::from_millis(wait)).await;
1736                }
1737            }
1738        }
1739        unreachable!("loop always returns by the final attempt");
1740    }
1741
1742    /// Deterministic metrics/discovery report (sorted keys, no wall-clock
1743    /// timestamps): the same shape `keel_report` freezes in core-ffi.h.
1744    pub fn report(&self) -> Value {
1745        let now = Instant::now();
1746        let state = self.state();
1747        let targets = state
1748            .metrics
1749            .iter()
1750            .map(|(name, m)| {
1751                let breaker = state.breakers.get(name);
1752                let row = TargetReport {
1753                    attempts: m.attempts,
1754                    breaker_opens: breaker.map_or(0, |b| b.opens),
1755                    breaker_state: state.breaker_state(name, now),
1756                    cache_hits: m.cache_hits,
1757                    calls: m.calls,
1758                    failures: m.failures,
1759                    retries: m.retries,
1760                    successes: m.successes,
1761                    throttled: m.throttled,
1762                };
1763                (name.as_str(), row)
1764            })
1765            .collect();
1766        serde_json::to_value(Report {
1767            v: 1,
1768            clock_ms: self.elapsed_ms(),
1769            targets,
1770        })
1771        .expect("report serialization is infallible")
1772    }
1773}
1774
1775#[cfg(test)]
1776mod tests {
1777    use super::{
1778        Admission, AttemptResult, Breaker, BreakerTransition, ENVELOPE_VERSION, Engine, Instant,
1779        Request, TokenBucket,
1780    };
1781    use core::num::NonZeroU32;
1782    use core::time::Duration;
1783    use keel_core_api::policy::{BreakerPolicy, DurationMs, Rate};
1784    use serde_json::json;
1785
1786    /// A failure recorded far enough in the past to have aged out of the
1787    /// window must not count towards a later trip decision: without eviction,
1788    /// this exact two-failure sequence (11s apart, `window: 10s`) would trip
1789    /// at `min_calls: 2` (rate 1.0); with eviction the first failure is gone
1790    /// by the time the second arrives, so the window only ever holds one.
1791    #[test]
1792    fn breaker_rate_mode_evicts_outcomes_older_than_the_window() {
1793        let config = BreakerPolicy {
1794            failures: None,
1795            cooldown: DurationMs(15_000),
1796            window: Some(DurationMs(10_000)),
1797            failure_rate: Some(0.5),
1798            min_calls: Some(NonZeroU32::new(2).unwrap()),
1799        };
1800        let mut breaker = Breaker::default();
1801        let t0 = Instant::now();
1802
1803        assert_eq!(
1804            breaker.on_terminal_failure(t0, &config, Admission::Closed),
1805            BreakerTransition::None,
1806            "one failure is below min_calls"
1807        );
1808        let t_11s = t0 + Duration::from_secs(11);
1809        assert_eq!(
1810            breaker.on_terminal_failure(t_11s, &config, Admission::Closed),
1811            BreakerTransition::None,
1812            "the stale failure must have aged out of the 10s window"
1813        );
1814    }
1815
1816    /// Burst capacity is `limit` tokens, never more — an idle gap refills the
1817    /// bucket but is clamped at capacity, so it cannot bank unlimited tokens
1818    /// for a future burst larger than the configured `limit`.
1819    #[test]
1820    fn token_bucket_caps_refill_at_burst_capacity() {
1821        let rate = Rate {
1822            limit: core::num::NonZeroU64::new(2).unwrap(),
1823            window_ms: 1000,
1824        };
1825        let mut bucket = TokenBucket::default();
1826
1827        assert_eq!(bucket.plan_admit(0, rate), 0, "burst covers the first call");
1828        assert_eq!(
1829            bucket.plan_admit(0, rate),
1830            0,
1831            "burst covers the second call"
1832        );
1833        assert_eq!(
1834            bucket.plan_admit(0, rate),
1835            500,
1836            "burst drained: paced at window/limit = 500ms"
1837        );
1838
1839        // A long idle gap (50s, enough to "earn" 100 tokens at this rate) must
1840        // not accrue more than the 2-token burst cap.
1841        assert_eq!(
1842            bucket.plan_admit(50_000, rate),
1843            0,
1844            "capacity refilled to burst"
1845        );
1846        assert_eq!(
1847            bucket.plan_admit(50_000, rate),
1848            0,
1849            "both burst tokens available"
1850        );
1851        assert_eq!(
1852            bucket.plan_admit(50_000, rate),
1853            500,
1854            "capacity clamp: idle time cannot bank more than `limit` tokens"
1855        );
1856    }
1857
1858    fn req(target: &str, args_hash: &str) -> Request {
1859        Request {
1860            v: ENVELOPE_VERSION,
1861            target: target.to_owned(),
1862            op: format!("GET {target}"),
1863            idempotent: true,
1864            args_hash: Some(args_hash.to_owned()),
1865        }
1866    }
1867
1868    /// The in-memory cache does not grow without bound: an expired entry is
1869    /// swept when the next cacheable success writes, so a per-call-varying key
1870    /// set leaves only the live working set behind (finding: unbounded growth).
1871    #[tokio::test(start_paused = true)]
1872    async fn in_memory_cache_evicts_expired_entries() {
1873        let engine = Engine::new();
1874        engine
1875            .configure(&json!({
1876                "target": { "api.catalog.internal": { "cache": { "ttl": "60s" } } }
1877            }))
1878            .expect("valid policy");
1879
1880        engine
1881            .execute(&req("api.catalog.internal", "k1"), async |_a| {
1882                AttemptResult::Ok { payload: json!(1) }
1883            })
1884            .await;
1885        assert_eq!(engine.state().cache.len(), 1, "k1 cached");
1886
1887        // Past k1's 60s TTL: the write for a NEW key sweeps the expired k1.
1888        tokio::time::advance(Duration::from_secs(61)).await;
1889        engine
1890            .execute(&req("api.catalog.internal", "k2"), async |_a| {
1891                AttemptResult::Ok { payload: json!(2) }
1892            })
1893            .await;
1894        assert_eq!(
1895            engine.state().cache.len(),
1896            1,
1897            "expired k1 evicted on write; only the live k2 remains"
1898        );
1899
1900        // A read of the now-swept k1 is a miss (re-runs live), and reading an
1901        // expired key also evicts it on the read path.
1902        let out = engine
1903            .execute(&req("api.catalog.internal", "k1"), async |_a| {
1904                AttemptResult::Ok { payload: json!(3) }
1905            })
1906            .await;
1907        assert!(!out.from_cache, "expired/evicted key re-runs live");
1908    }
1909
1910    fn poll_policy_json(interval: &str, deadline: &str) -> serde_json::Value {
1911        json!({ "target": { "api.jobs.internal": {
1912            "retry": { "attempts": 3, "schedule": "fixed(200ms)", "on": ["conn", "timeout", "429", "5xx"] },
1913            "poll": { "interval": interval, "deadline": deadline,
1914                       "until": { "field": "status", "terminal": ["completed", "failed"] } }
1915        } } })
1916    }
1917
1918    #[tokio::test(start_paused = true)]
1919    async fn poll_reissues_until_terminal_and_accumulates_attempts() {
1920        let engine = Engine::new();
1921        engine.configure(&poll_policy_json("10s", "90s")).unwrap();
1922        let mut script = vec![
1923            json!({"status": "running"}),
1924            json!({"status": "running"}),
1925            json!({"status": "completed", "result": 42}),
1926        ]
1927        .into_iter();
1928        let out = engine
1929            .execute(&req("api.jobs.internal", "h1"), async |_a| {
1930                AttemptResult::Ok {
1931                    payload: script.next().expect("script exhausted"),
1932                }
1933            })
1934            .await;
1935        assert_eq!(out.result, "ok");
1936        assert_eq!(
1937            out.payload,
1938            Some(json!({"status": "completed", "result": 42}))
1939        );
1940        assert_eq!(
1941            out.attempts, 3,
1942            "attempts accumulate across poll iterations"
1943        );
1944        assert!(
1945            out.waits_ms.is_empty(),
1946            "poll intervals are not retry waits"
1947        );
1948        let report = engine.report();
1949        assert_eq!(report["targets"]["api.jobs.internal"]["calls"], 1);
1950        assert_eq!(report["targets"]["api.jobs.internal"]["successes"], 1);
1951    }
1952
1953    #[tokio::test(start_paused = true)]
1954    async fn poll_deadline_is_e016_and_breaker_countable() {
1955        let engine = Engine::new();
1956        let mut policy = poll_policy_json("10s", "25s");
1957        policy["target"]["api.jobs.internal"]["breaker"] = json!({ "failures": 1 });
1958        engine.configure(&policy).unwrap();
1959        let out = engine
1960            .execute(&req("api.jobs.internal", "h1"), async |_a| {
1961                AttemptResult::Ok {
1962                    payload: json!({"status": "running"}),
1963                }
1964            })
1965            .await;
1966        assert_eq!(out.result, "error");
1967        let err = out.error.expect("terminal error");
1968        assert_eq!(err.code.as_str(), "KEEL-E016");
1969        assert_eq!(
1970            err.message,
1971            "GET api.jobs.internal poll deadline exceeded: 'status' not terminal after 25000ms"
1972        );
1973        // pendings at 0ms and 10ms and 20ms; at 20ms, 20+10 > 25 → 3 attempts.
1974        assert_eq!(out.attempts, 3);
1975        assert_eq!(
1976            out.breaker,
1977            keel_core_api::BreakerState::Open,
1978            "breaker-countable"
1979        );
1980    }
1981
1982    #[tokio::test(start_paused = true)]
1983    async fn poll_fails_open_on_missing_field_and_skips_non_get() {
1984        let engine = Engine::new();
1985        engine.configure(&poll_policy_json("10s", "90s")).unwrap();
1986        // Missing field: returned as-is, one attempt.
1987        let out = engine
1988            .execute(&req("api.jobs.internal", "h2"), async |_a| {
1989                AttemptResult::Ok {
1990                    payload: json!({"progress": 0.5}),
1991                }
1992            })
1993            .await;
1994        assert_eq!(out.result, "ok");
1995        assert_eq!(out.attempts, 1);
1996        // POST op: poll layer inert even with a pending-looking payload.
1997        let mut post = req("api.jobs.internal", "h3");
1998        post.op = String::from("POST api.jobs.internal");
1999        let out = engine
2000            .execute(&post, async |_a| AttemptResult::Ok {
2001                payload: json!({"status": "running"}),
2002            })
2003            .await;
2004        assert_eq!(out.attempts, 1);
2005    }
2006
2007    #[tokio::test(start_paused = true)]
2008    async fn poll_judges_http_envelope_bodies() {
2009        use base64::Engine as _;
2010        let engine = Engine::new();
2011        engine.configure(&poll_policy_json("10s", "90s")).unwrap();
2012        let b64 =
2013            |v: &serde_json::Value| base64::engine::general_purpose::STANDARD.encode(v.to_string());
2014        let envelope = |body: &serde_json::Value| {
2015            json!({ "status": 200, "headers": [["content-type", "application/json"]],
2016                    "body_b64": b64(body) })
2017        };
2018        let mut script = vec![
2019            envelope(&json!({"status": "running"})),
2020            envelope(&json!({"status": "completed"})),
2021        ]
2022        .into_iter();
2023        let out = engine
2024            .execute(&req("api.jobs.internal", "h4"), async |_a| {
2025                AttemptResult::Ok {
2026                    payload: script.next().expect("script exhausted"),
2027                }
2028            })
2029            .await;
2030        assert_eq!(out.result, "ok");
2031        assert_eq!(out.attempts, 2);
2032        // An envelope with a NON-JSON body fails open (returned as-is, 1 attempt).
2033        let raw = json!({ "status": 200, "headers": [],
2034            "body_b64": base64::engine::general_purpose::STANDARD.encode("not json") });
2035        let out = engine
2036            .execute(&req("api.jobs.internal", "h5"), async |_a| {
2037                AttemptResult::Ok {
2038                    payload: raw.clone(),
2039                }
2040            })
2041            .await;
2042        assert_eq!(out.result, "ok");
2043        assert_eq!(out.payload, Some(raw));
2044        assert_eq!(out.attempts, 1);
2045    }
2046
2047    #[test]
2048    fn engine_resolve_target_delegates_to_policy() {
2049        let e = Engine::new();
2050        e.configure(&json!({ "target": { "*.internal.corp": {} } }))
2051            .unwrap();
2052        assert_eq!(
2053            e.resolve_target("GET", "db.internal.corp", None, None, None),
2054            "*.internal.corp"
2055        );
2056    }
2057
2058    #[test]
2059    fn engine_layer_reads_resolved_value() {
2060        let e = Engine::new();
2061        e.configure(
2062            &json!({ "target": { "api.x": { "idempotency": { "header": "Idempotency-Key" } } } }),
2063        )
2064        .unwrap();
2065        assert_eq!(
2066            e.layer("api.x", "idempotency"),
2067            json!({ "header": "Idempotency-Key" })
2068        );
2069        assert_eq!(e.layer("api.x", "retry"), serde_json::Value::Null);
2070    }
2071
2072    /// The real value of resolving over the RAW configured JSON instead of a
2073    /// typed re-serialization: `DurationMs` derives only `Deserialize` (see
2074    /// `policy.rs`), so `serde_json::to_value(resolved.timeout)` cannot even
2075    /// compile — and even a hypothetical round-trip would naturally produce a
2076    /// number (120000), not the original `"120s"` string literal callers
2077    /// actually wrote. `layer` must hand that literal back verbatim.
2078    #[test]
2079    fn engine_layer_round_trips_the_original_duration_literal_verbatim() {
2080        let e = Engine::new();
2081        e.configure(&json!({ "defaults": { "llm": { "timeout": "120s" } } }))
2082            .unwrap();
2083        assert_eq!(e.layer("llm:openai", "timeout"), json!("120s"));
2084    }
2085}