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    state: Mutex<State>,
649    /// Persistence for the `scope = persistent` cache and Tier 2 flows. Behind
650    /// a lock because `configure` honors `policy.journal` by (re)attaching the
651    /// selected backend; readers clone the `Arc` out, so the lock is never held
652    /// across journal I/O (let alone an await).
653    journal: RwLock<JournalSlot>,
654    /// Traffic ledger fed one observation per `execute`, for `keel init`/`status`.
655    discovery: Option<Arc<dyn DiscoveryRecorder>>,
656    /// Live NDJSON event feed for `keel tail`/`keel trace` ([`crate::events`]).
657    /// `None` (the zero-cost path) unless the environment activates it or a
658    /// sink is attached; like journal/discovery it can never change an outcome.
659    events: Option<EventSink>,
660}
661
662/// The engine's journal attachment plus, when policy selected it, the resolved
663/// backend it was opened from — so reapplying an unchanged policy (whether a
664/// `file:` path or the same `postgres://` location) is a no-op instead of a
665/// re-open/re-connect.
666#[derive(Default)]
667struct JournalSlot {
668    journal: Option<Arc<dyn Journal>>,
669    /// `Some` only for a policy-selected attachment; construction-time
670    /// attachments have no location the engine could compare against.
671    backend: Option<JournalBackend>,
672}
673
674impl fmt::Debug for Engine {
675    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
676        // The trait-object attachments aren't `Debug`; report their presence.
677        f.debug_struct("Engine")
678            .field("policy", &self.policy)
679            .field("state", &self.state)
680            .field("journal_attached", &self.current_journal().is_some())
681            .field("discovery_attached", &self.discovery.is_some())
682            .field("events_attached", &self.events.is_some())
683            .finish_non_exhaustive()
684    }
685}
686
687impl Default for Engine {
688    fn default() -> Self {
689        Self::new()
690    }
691}
692
693impl Engine {
694    #[must_use]
695    pub fn new() -> Self {
696        Self {
697            started: Instant::now(),
698            policy: RwLock::new(Policy::default()),
699            state: Mutex::new(State::default()),
700            journal: RwLock::new(JournalSlot::default()),
701            discovery: None,
702            // Live events activate from the environment (KEEL_EVENTS / an
703            // existing ./.keel dir — see `crate::events`), so every embedding
704            // — FFI, PyO3, napi, CLI — inherits the feed with no plumbing.
705            events: EventSink::from_env(),
706        }
707    }
708
709    /// Attach a journal at construction time, enabling the persistent cache
710    /// scope. Optional; set at setup, before the engine is shared for
711    /// concurrent `execute`. A later [`configure`](Self::configure) whose
712    /// policy carries a `journal` key replaces this attachment — the effective
713    /// policy is authoritative (spec §4.2).
714    pub fn attach_journal(&mut self, journal: impl Journal + 'static) -> &mut Self {
715        let slot = self.journal.get_mut().expect("journal lock poisoned");
716        slot.journal = Some(Arc::new(journal));
717        slot.backend = None;
718        self
719    }
720
721    /// The attached journal, if any — shared (`Arc`) so a [`FlowManager`] can run
722    /// its Tier 2 steps over the *same* store the engine caches through. `None`
723    /// for an in-memory engine (Tier 2 requires a durable journal). Live: a
724    /// `configure` whose policy selects a `journal` location changes what this
725    /// returns, so Tier 2 wiring should read it after the engine is configured.
726    ///
727    /// [`FlowManager`]: crate::FlowManager
728    #[must_use]
729    pub fn journal(&self) -> Option<Arc<dyn Journal>> {
730        self.current_journal()
731    }
732
733    /// Clone the current journal out of its slot (the lock never outlives the
734    /// statement, so it is never held across journal I/O or an await).
735    fn current_journal(&self) -> Option<Arc<dyn Journal>> {
736        self.journal
737            .read()
738            .expect("journal lock poisoned")
739            .journal
740            .clone()
741    }
742
743    /// Attach a discovery store; each `execute` then records one observation.
744    /// Optional and failure-isolated — recording never affects an outcome.
745    pub fn attach_discovery(&mut self, discovery: impl DiscoveryRecorder + 'static) -> &mut Self {
746        self.discovery = Some(Arc::new(discovery));
747        self
748    }
749
750    /// Attach (or replace) a live event sink. [`Engine::new`] already resolves
751    /// one from the environment (see [`crate::events`]); this override exists
752    /// for tests and embedders that place the feed elsewhere.
753    pub fn attach_events(&mut self, sink: EventSink) -> &mut Self {
754        self.events = Some(sink);
755        self
756    }
757
758    /// The active event sink, if any — its run id anchors this engine's trace
759    /// refs, and `flush()` makes the feed current for a same-process reader.
760    #[must_use]
761    pub fn events(&self) -> Option<&EventSink> {
762        self.events.as_ref()
763    }
764
765    /// Emit one live event, stamped with engine-elapsed (virtual-clock-safe)
766    /// milliseconds. The closure defers construction — and its string clones —
767    /// to the active-sink case, so the disabled path costs one branch.
768    fn emit_event(&self, kind: impl FnOnce() -> EventKind) {
769        if let Some(sink) = self.events.as_ref() {
770            sink.emit(self.elapsed_ms(), kind());
771        }
772    }
773
774    /// Feed event for a consulted cache: hit (the call is served) or miss
775    /// (the call proceeds live). Not emitted when no cache plan exists.
776    fn emit_cache(&self, out: &Outcome, target: &str, scope: CacheStore, hit: bool) {
777        self.emit_event(|| {
778            let call = out.trace_id.clone();
779            let target = target.to_owned();
780            if hit {
781                EventKind::CacheHit {
782                    call,
783                    target,
784                    scope,
785                }
786            } else {
787                EventKind::CacheMiss {
788                    call,
789                    target,
790                    scope,
791                }
792            }
793        });
794    }
795
796    /// Apply a policy document (keel.toml as JSON, per
797    /// contracts/policy.schema.json), replacing the previous one atomically.
798    /// Rejections are `KEEL-E001` with the exact offending field path;
799    /// a valid policy naming a journal backend this build cannot provide is
800    /// `KEEL-E005` (and the previous policy stays in force).
801    pub fn configure(&self, policy_json: &Value) -> Result<(), KeelError> {
802        let policy: Policy =
803            serde_path_to_error::deserialize(policy_json).map_err(|e| KeelError {
804                code: ErrorCode::PolicyInvalid,
805                message: format!("policy invalid at {}: {}", e.path(), e.inner()),
806            })?;
807        // `journal` selects the backing store (spec §4.2 — "that override is
808        // the entire laptop→enterprise migration"), so it must take effect or
809        // fail loudly, never warn-and-ignore. Applied before the policy swap so
810        // a rejected location leaves the previous configuration fully in force.
811        if let Some(location) = &policy.journal {
812            self.apply_journal_location(location)?;
813        }
814        // `telemetry.otlp_endpoint` IS honored: native front ends built with the
815        // `otel` feature read it back via `telemetry_otlp_endpoint` (below) and
816        // pass it to `otel::init_otlp` (env still wins — see `otel::export_enabled`
817        // / `otel::resolve_endpoint`). `telemetry.console` (the local
818        // pretty-console-summary switch, architecture spec §4.5) is validated and
819        // carried but has no consumer yet; warn on an explicit non-default value
820        // rather than silently ignoring the user's intent.
821        if let Some(telemetry) = &policy.telemetry
822            && !telemetry.console
823        {
824            warn!(
825                "policy `telemetry.console = false` is validated but not yet wired: v0.1 always \
826                 uses the default local summary. `telemetry.otlp_endpoint` IS honored by front \
827                 ends built with the `otel` feature."
828            );
829        }
830        warn_inert_breaker_knobs(&policy);
831        *self.policy.write().expect("policy lock poisoned") = policy;
832        Ok(())
833    }
834
835    /// The effective `telemetry.otlp_endpoint` (`None` if `[telemetry]` is
836    /// absent or the key unset), read live so a reconfigure is honored. Native
837    /// front ends built with the `otel` feature read this after `configure` and
838    /// pass it to [`crate::otel::export_enabled`] / [`crate::otel::resolve_endpoint`]
839    /// to decide whether/where to export (env vars still take precedence).
840    #[must_use]
841    pub fn telemetry_otlp_endpoint(&self) -> Option<String> {
842        self.policy
843            .read()
844            .expect("policy lock poisoned")
845            .telemetry
846            .as_ref()
847            .and_then(|t| t.otlp_endpoint.clone())
848    }
849
850    /// Honor `policy.journal`: open and attach the backend it names, replacing
851    /// any construction-time attachment — the effective policy is
852    /// authoritative. (Front ends that want an environment escape hatch such as
853    /// `KEEL_JOURNAL` compose it into the effective policy *before* calling
854    /// `configure`, per the effective-policy contract.) Reapplying an unchanged
855    /// location — the same `file:` path, or the same `postgres://` URL — is a
856    /// no-op, so reconfigure loops never re-open the store, re-open a fresh
857    /// Postgres connection pool, or drop either's connection state.
858    ///
859    /// # Errors
860    /// - `KEEL-E040` when the selected SQLite file, or the selected Postgres
861    ///   database, cannot be opened/connected to.
862    fn apply_journal_location(&self, location: &JournalLocation) -> Result<(), KeelError> {
863        let backend = JournalBackend::select(location);
864        {
865            let slot = self.journal.read().expect("journal lock poisoned");
866            if slot.backend.as_ref() == Some(&backend) {
867                return Ok(()); // unchanged location: keep the open store
868            }
869        }
870        // Open OFF the lock (filesystem/network I/O); the brief write below
871        // only swaps pointers. Two racing configures both open, last writer
872        // wins — the loser's store (and, for Postgres, its connection pool)
873        // is just dropped.
874        let journal = journal_backend::open(&backend)?;
875        if let JournalBackend::File(path) = &backend {
876            debug!(path = %path.display(), "journal selected by policy");
877        }
878        let mut slot = self.journal.write().expect("journal lock poisoned");
879        slot.journal = Some(journal);
880        slot.backend = Some(backend);
881        Ok(())
882    }
883
884    /// The target's resolved `idempotency = { header }` knob, read live so a
885    /// reconfigure is honored. This is the engine surface of the injection
886    /// contract (contracts/adapter-pack.md "Idempotency-key injection"):
887    /// adapters/bindings consult it to mint and inject an idempotency key on
888    /// unsafe-method calls — the engine itself never injects, and the flipped
889    /// judgment reaches it as `Request.idempotent = true`.
890    #[must_use]
891    pub fn idempotency_header(&self, target: &str) -> Option<String> {
892        self.policy
893            .read()
894            .expect("policy lock poisoned")
895            .resolve(target)
896            .idempotency
897            .map(|i| i.header)
898    }
899
900    /// The configured Tier 2 `flows.on_nondeterminism` response (default
901    /// [`NondeterminismResponse::Fail`]), read live so a reconfigure is honored.
902    /// The flow manager consults this when a replay `(seq, step_key)` diverges.
903    #[must_use]
904    pub fn nondeterminism_response(&self) -> NondeterminismResponse {
905        self.policy
906            .read()
907            .expect("policy lock poisoned")
908            .flows
909            .as_ref()
910            .map_or(NondeterminismResponse::default(), |f| f.on_nondeterminism)
911    }
912
913    fn state(&self) -> MutexGuard<'_, State> {
914        self.state.lock().expect("state lock poisoned")
915    }
916
917    fn elapsed_ms(&self) -> u64 {
918        u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
919    }
920
921    /// Run one intercepted call through the target's layer chain, then record it
922    /// for discovery. `effect` performs a single attempt (1-based attempt
923    /// numbers). Always returns an `Outcome` — policy failures are outcomes, not
924    /// panics, and neither journal nor discovery I/O can change what's returned.
925    pub async fn execute<F>(&self, request: &Request, mut effect: F) -> Outcome
926    where
927        F: AsyncFnMut(u32) -> AttemptResult,
928    {
929        let started = Instant::now();
930        // One span per wrapped call (architecture spec §4.5). Terminal fields
931        // are declared `Empty` and recorded from the finished outcome; the
932        // per-attempt child spans are opened inside the instrumented chain.
933        // `%request.target`/`%request.op` are only formatted when a subscriber
934        // is active — the disabled callsite evaluates nothing.
935        let span = tracing::info_span!(
936            "keel.call",
937            target = %request.target,
938            op = %request.op,
939            trace_id = tracing::field::Empty,
940            result = tracing::field::Empty,
941            error_code = tracing::field::Empty,
942            attempts = tracing::field::Empty,
943            from_cache = tracing::field::Empty,
944            throttled = tracing::field::Empty,
945            breaker = tracing::field::Empty,
946        );
947        let out = self
948            .run_chain(request, &mut effect)
949            .instrument(span.clone())
950            .await;
951        record_call_fields(&span, &out);
952        self.observe(request, &out, started);
953        // Every call's last feed event, on all paths (cache hit, breaker
954        // reject, envelope error, live attempt).
955        self.emit_event(|| EventKind::CallEnd {
956            call: out.trace_id.clone(),
957            target: request.target.clone(),
958            result: out.result.clone(),
959            code: out.error.as_ref().map(|e| e.code),
960            attempts: out.attempts,
961        });
962        out
963    }
964
965    /// The layer chain proper — cache → rate → breaker → timeout → retry —
966    /// unchanged in semantics from the journal-free engine. The persistent cache
967    /// scope simply swaps the in-memory map for the journal's `cache` table when
968    /// a journal is attached; every other layer is byte-for-byte as before.
969    async fn run_chain<F>(&self, request: &Request, effect: &mut F) -> Outcome
970    where
971        F: AsyncFnMut(u32) -> AttemptResult,
972    {
973        let target = request.target.as_str();
974        let mut out = self.begin_call(target);
975
976        // Every call's first feed event; its seq anchors the trace ref that
977        // failure messages carry (`None` without a sink — the parity path).
978        let trace = self.events.as_ref().map(|sink| {
979            let seq = sink.emit(
980                self.elapsed_ms(),
981                EventKind::CallStart {
982                    call: out.trace_id.clone(),
983                    target: target.to_owned(),
984                    op: request.op.clone(),
985                },
986            );
987            TraceRef {
988                run: sink.run_id().to_owned(),
989                seq,
990            }
991        });
992
993        if request.v != ENVELOPE_VERSION {
994            out.error = Some(OutcomeError {
995                code: ErrorCode::EnvelopeVersion,
996                class: ErrorClass::Other,
997                http_status: None,
998                message: format!("unsupported envelope version {}", request.v),
999                original: None,
1000            });
1001            self.state().metrics_for(target).failures += 1;
1002            return out;
1003        }
1004
1005        let resolved = self
1006            .policy
1007            .read()
1008            .expect("policy lock poisoned")
1009            .resolve(target);
1010
1011        // cache (outermost layer); every planned lookup is one
1012        // `keel.cache.requests` datapoint (hit ratio, spec §4.5)
1013        let cache_plan = self.plan_cache(target, &resolved, request);
1014        match &cache_plan {
1015            CachePlan::Memory { key } => {
1016                if self.serve_from_cache(key, &mut out) {
1017                    crate::metrics::record_cache_request(target, true);
1018                    self.emit_cache(&out, target, CacheStore::Memory, true);
1019                    return out;
1020                }
1021                crate::metrics::record_cache_request(target, false);
1022                self.emit_cache(&out, target, CacheStore::Memory, false);
1023            }
1024            CachePlan::Persistent { key, .. } => {
1025                if self.serve_from_persistent(target, key, &mut out) {
1026                    crate::metrics::record_cache_request(target, true);
1027                    self.emit_cache(&out, target, CacheStore::Persistent, true);
1028                    return out;
1029                }
1030                crate::metrics::record_cache_request(target, false);
1031                self.emit_cache(&out, target, CacheStore::Persistent, false);
1032            }
1033            CachePlan::None => {}
1034        }
1035
1036        // rate limiter (lock never held across the sleep)
1037        if let Some(rate) = resolved.rate {
1038            self.throttle(target, rate, &mut out).await;
1039        }
1040
1041        // breaker admission (observes post-retry call outcomes)
1042        let admission = self.admit(target, &resolved, &mut out, trace.as_ref());
1043        if admission == Admission::Rejected {
1044            return out;
1045        }
1046
1047        // timeout + retry (innermost layers)
1048        let retry = resolved.retry.clone().unwrap_or_else(|| RetryPolicy {
1049            attempts: core::num::NonZeroU32::MIN,
1050            ..RetryPolicy::default()
1051        });
1052        let result = match resolved.poll.as_ref().filter(|_| poll_applies(request)) {
1053            Some(poll) => {
1054                self.run_poll(
1055                    request,
1056                    &resolved,
1057                    &retry,
1058                    poll,
1059                    effect,
1060                    &mut out,
1061                    trace.as_ref(),
1062                )
1063                .await
1064            }
1065            None => {
1066                self.run_attempts(request, &resolved, &retry, effect, &mut out, trace.as_ref())
1067                    .await
1068            }
1069        };
1070        // Only the memory scope writes through under the state lock; the
1071        // persistent scope writes after the lock drops (journal I/O off-lock).
1072        let memory_key = match &cache_plan {
1073            CachePlan::Memory { key } => Some(key.clone()),
1074            _ => None,
1075        };
1076        self.settle(target, &resolved, admission, memory_key, result, &mut out);
1077
1078        if let CachePlan::Persistent { key, ttl } = &cache_plan
1079            && out.result == "ok"
1080            && let Some(payload) = &out.payload
1081        {
1082            self.write_persistent(target, key, payload, *ttl);
1083        }
1084        out
1085    }
1086
1087    /// Decide which cache backend (if any) serves this call. Persistent scope
1088    /// without a journal falls back to the in-memory map — the engine stays
1089    /// fully functional un-journaled rather than silently dropping caching.
1090    fn plan_cache(&self, target: &str, resolved: &ResolvedPolicy, request: &Request) -> CachePlan {
1091        let (Some(cache), Some(hash)) = (resolved.cache.as_ref(), request.args_hash.as_ref())
1092        else {
1093            return CachePlan::None;
1094        };
1095        let Some(ttl) = cache.ttl else {
1096            return CachePlan::None;
1097        };
1098        match cache.scope {
1099            CacheScope::Persistent if self.current_journal().is_some() => CachePlan::Persistent {
1100                key: JournalCacheKey::new(format!("{target}#{hash}")),
1101                ttl,
1102            },
1103            _ => CachePlan::Memory {
1104                key: CacheKey {
1105                    target: target.to_owned(),
1106                    args_hash: hash.clone(),
1107                },
1108            },
1109        }
1110    }
1111
1112    /// Registers the call and mints its outcome envelope + trace id.
1113    fn begin_call(&self, target: &str) -> Outcome {
1114        let mut state = self.state();
1115        state.metrics_for(target).calls += 1;
1116        state.trace_seq += 1;
1117        Outcome {
1118            v: ENVELOPE_VERSION,
1119            result: String::from("error"),
1120            payload: None,
1121            error: None,
1122            attempts: 0,
1123            from_cache: false,
1124            waits_ms: Vec::new(),
1125            throttled: false,
1126            throttle_wait_ms: 0,
1127            breaker: BreakerState::Closed,
1128            trace_id: format!("t-{:06}", state.trace_seq),
1129        }
1130    }
1131
1132    /// Serves a fresh cached payload, if any (attempts stays 0). An entry found
1133    /// expired is *removed* here, not just skipped — combined with the sweep on
1134    /// write ([`settle`](Self::settle)) this bounds the in-memory map to the live
1135    /// working set rather than every distinct key ever cached.
1136    fn serve_from_cache(&self, key: &CacheKey, out: &mut Outcome) -> bool {
1137        let now = Instant::now();
1138        let mut state = self.state();
1139        let payload = match state.cache.get(key) {
1140            Some(entry) if now < entry.expires_at => entry.payload.clone(),
1141            Some(_) => {
1142                // Expired: evict so a per-call-varying key set cannot grow the
1143                // map without bound for the life of the process.
1144                state.cache.remove(key);
1145                return false;
1146            }
1147            None => return false,
1148        };
1149        out.result = String::from("ok");
1150        out.payload = Some(payload);
1151        out.from_cache = true;
1152        let metrics = state.metrics_for(&key.target);
1153        metrics.cache_hits += 1;
1154        metrics.successes += 1;
1155        out.breaker = state.breaker_state(&key.target, now);
1156        debug!(target = %key.target, scope = "memory", "cache hit");
1157        true
1158    }
1159
1160    /// Serves a fresh persistent cache payload from the journal, if any (attempts
1161    /// stays 0). The journal owns TTL expiry against its own clock (identical
1162    /// semantics to the in-memory scope). Any journal or codec failure degrades
1163    /// to a miss + `warn!`, so the call proceeds to a live attempt — a broken
1164    /// journal never fails the call. The journal read runs *before* the state
1165    /// lock is taken, so no lock is held across journal I/O.
1166    fn serve_from_persistent(
1167        &self,
1168        target: &str,
1169        key: &JournalCacheKey,
1170        out: &mut Outcome,
1171    ) -> bool {
1172        let Some(journal) = self.current_journal() else {
1173            return false;
1174        };
1175        let bytes = match journal.get_cache(key) {
1176            Ok(Some(bytes)) => bytes,
1177            Ok(None) => return false,
1178            Err(error) => {
1179                warn!(target = %target, error = %error, "persistent cache read failed; serving live");
1180                return false;
1181            }
1182        };
1183        let payload = match decode_cache_payload(&bytes) {
1184            Ok(payload) => payload,
1185            Err(reason) => {
1186                warn!(target = %target, reason = %reason, "persistent cache entry undecodable; serving live");
1187                return false;
1188            }
1189        };
1190        let now = Instant::now();
1191        let mut state = self.state();
1192        out.result = String::from("ok");
1193        out.payload = Some(payload);
1194        out.from_cache = true;
1195        let metrics = state.metrics_for(target);
1196        metrics.cache_hits += 1;
1197        metrics.successes += 1;
1198        out.breaker = state.breaker_state(target, now);
1199        debug!(target = %target, scope = "persistent", "cache hit");
1200        true
1201    }
1202
1203    /// Delays the call when the target's rate is exhausted (never fails it).
1204    async fn throttle(&self, target: &str, rate: Rate, out: &mut Outcome) {
1205        let wait_ms = {
1206            let elapsed = self.elapsed_ms();
1207            let mut state = self.state();
1208            let bucket = state.rate_buckets.entry(target.to_owned()).or_default();
1209            bucket.plan_admit(elapsed, rate)
1210        };
1211        if wait_ms > 0 {
1212            out.throttled = true;
1213            out.throttle_wait_ms = wait_ms;
1214            self.state().metrics_for(target).throttled += 1;
1215            crate::metrics::record_throttled(target, wait_ms);
1216            // Emitted before the wait so a live tail shows the queueing now.
1217            self.emit_event(|| EventKind::Throttle {
1218                call: out.trace_id.clone(),
1219                target: target.to_owned(),
1220                wait_ms,
1221            });
1222            tokio::time::sleep(Duration::from_millis(wait_ms)).await;
1223        }
1224    }
1225
1226    /// Consults the target's breaker; on rejection, fills the fail-fast
1227    /// KEEL-E012 outcome (the effect is never invoked).
1228    fn admit(
1229        &self,
1230        target: &str,
1231        resolved: &ResolvedPolicy,
1232        out: &mut Outcome,
1233        trace: Option<&TraceRef>,
1234    ) -> Admission {
1235        if resolved.breaker.is_none() {
1236            return Admission::Closed;
1237        }
1238        let now = Instant::now();
1239        let admission = {
1240            let mut state = self.state();
1241            let admission = state
1242                .breakers
1243                .entry(target.to_owned())
1244                .or_default()
1245                .admit(now);
1246            if admission == Admission::Rejected {
1247                out.error = Some(OutcomeError {
1248                    code: ErrorCode::BreakerOpen,
1249                    class: ErrorClass::Other,
1250                    http_status: None,
1251                    message: with_trace_ref(
1252                        format!("breaker OPEN for {target}: failed fast, call not attempted"),
1253                        trace,
1254                    ),
1255                    original: None,
1256                });
1257                out.breaker = BreakerState::Open;
1258                state.metrics_for(target).failures += 1;
1259            }
1260            admission
1261        };
1262        // Both feed events run off the state lock, like the debug! below.
1263        if admission == Admission::Rejected {
1264            self.emit_event(|| EventKind::BreakerReject {
1265                call: out.trace_id.clone(),
1266                target: target.to_owned(),
1267            });
1268        }
1269        // Admitting a probe is an OPEN → HALF-OPEN transition (spec §4.5).
1270        if admission == Admission::HalfOpen {
1271            debug!(target = %target, transition = "half_open", "breaker transition");
1272            crate::metrics::record_breaker_transition(target, "half_open");
1273            self.emit_event(|| EventKind::BreakerHalfOpen {
1274                call: out.trace_id.clone(),
1275                target: target.to_owned(),
1276            });
1277        }
1278        admission
1279    }
1280
1281    /// Books the call's terminal result: metrics, breaker transition, cache
1282    /// write, and the outcome's payload/error/breaker fields.
1283    fn settle(
1284        &self,
1285        target: &str,
1286        resolved: &ResolvedPolicy,
1287        admission: Admission,
1288        cache_key: Option<CacheKey>,
1289        result: Result<Value, OutcomeError>,
1290        out: &mut Outcome,
1291    ) {
1292        let now = Instant::now();
1293        let transition = {
1294            let mut state = self.state();
1295            let transition = match result {
1296                Ok(payload) => {
1297                    state.metrics_for(target).successes += 1;
1298                    let mut transition = BreakerTransition::None;
1299                    if let Some(config) = &resolved.breaker
1300                        && let Some(breaker) = state.breakers.get_mut(target)
1301                    {
1302                        transition = breaker.on_success(now, config);
1303                    }
1304                    if let (Some(key), Some(cache)) = (cache_key, &resolved.cache)
1305                        && let Some(ttl) = cache.ttl
1306                    {
1307                        // Sweep expired entries before inserting so the map is
1308                        // bounded by the live working set, not the total distinct
1309                        // keys ever seen. O(n) in current entries per cacheable
1310                        // write — cheap for the small working sets caching targets
1311                        // in practice, and it keeps a long-lived process from
1312                        // leaking every payload it ever cached (no LRU/size cap in
1313                        // v0.1; a `keel fsck`-style bound is future work).
1314                        state.cache.retain(|_, entry| entry.expires_at > now);
1315                        state.cache.insert(
1316                            key,
1317                            CacheEntry {
1318                                expires_at: now + Duration::from_millis(ttl.0),
1319                                payload: payload.clone(),
1320                            },
1321                        );
1322                    }
1323                    out.result = String::from("ok");
1324                    out.payload = Some(payload);
1325                    transition
1326                }
1327                Err(error) => {
1328                    state.metrics_for(target).failures += 1;
1329                    let mut transition = BreakerTransition::None;
1330                    if let Some(config) = &resolved.breaker
1331                        && let Some(breaker) = state.breakers.get_mut(target)
1332                    {
1333                        transition = breaker.on_terminal_failure(now, config, admission);
1334                    }
1335                    out.error = Some(error);
1336                    transition
1337                }
1338            };
1339            out.breaker = state.breaker_state(target, now);
1340            transition
1341        };
1342        emit_breaker_transition(target, transition);
1343        match transition {
1344            BreakerTransition::Opened => self.emit_event(|| EventKind::BreakerOpen {
1345                call: out.trace_id.clone(),
1346                target: target.to_owned(),
1347                cooldown_ms: resolved.breaker.as_ref().map_or(0, |b| b.cooldown.0),
1348            }),
1349            BreakerTransition::Closed => self.emit_event(|| EventKind::BreakerClose {
1350                call: out.trace_id.clone(),
1351                target: target.to_owned(),
1352            }),
1353            BreakerTransition::None => {}
1354        }
1355    }
1356
1357    /// Writes a live success into the journal's persistent cache (called after
1358    /// the state lock is dropped, so journal I/O is never under the engine
1359    /// mutex). Encoding or journal failure degrades to a `warn!`; the outcome the
1360    /// caller already holds is unaffected.
1361    fn write_persistent(
1362        &self,
1363        target: &str,
1364        key: &JournalCacheKey,
1365        payload: &Value,
1366        ttl: DurationMs,
1367    ) {
1368        let Some(journal) = self.current_journal() else {
1369            return;
1370        };
1371        let bytes = match encode_cache_payload(payload) {
1372            Ok(bytes) => bytes,
1373            Err(error) => {
1374                warn!(target = %target, error = %error, "persistent cache encode failed; entry not stored");
1375                return;
1376            }
1377        };
1378        if let Err(error) = journal.put_cache(key, &bytes, Duration::from_millis(ttl.0)) {
1379            warn!(target = %target, error = %error, "persistent cache write failed; entry not stored");
1380        }
1381    }
1382
1383    /// Records one observation of a completed call into the discovery store, if
1384    /// attached. Runs off the state lock, from data already in the `Outcome`.
1385    /// Failure degrades to a `warn!` — discovery is evidence, never on the
1386    /// call's critical path.
1387    fn observe(&self, request: &Request, out: &Outcome, started: Instant) {
1388        let Some(discovery) = self.discovery.as_ref() else {
1389            return;
1390        };
1391        let latency_ms = i64::try_from(started.elapsed().as_millis()).unwrap_or(i64::MAX);
1392        let result = if out.from_cache {
1393            CallResult::CacheHit
1394        } else if out.result == "ok" {
1395            CallResult::Success
1396        } else {
1397            CallResult::Failure
1398        };
1399        let error = out.error.as_ref().map(|e| ObservedError {
1400            class: e.class,
1401            http_status: e.http_status,
1402        });
1403        let breaker_opened = out
1404            .error
1405            .as_ref()
1406            .is_some_and(|e| e.code == ErrorCode::BreakerOpen);
1407        // "Observed, not retried" (dx-spec §1 Level 0 hard rule): the call
1408        // failed and the retry layer refused to re-send it (KEEL-E014).
1409        let not_retried = out
1410            .error
1411            .as_ref()
1412            .is_some_and(|e| e.code == ErrorCode::NonIdempotentNotRetried);
1413        // Coverage: the front end resolves globs to the exact policy-table key
1414        // before `execute`, so an exact lookup answers "did an explicit
1415        // [target."…"] entry apply?" — defaults-only calls count as unwrapped.
1416        let wrapped = self
1417            .policy
1418            .read()
1419            .expect("policy lock poisoned")
1420            .target
1421            .contains_key(&request.target);
1422        let observation = CallObservation {
1423            target: request.target.clone(),
1424            result,
1425            attempts: out.attempts,
1426            latency_ms,
1427            throttled: out.throttled,
1428            breaker_opened,
1429            not_retried,
1430            wrapped,
1431            error,
1432        };
1433        if let Err(error) = discovery.record(&observation) {
1434            warn!(target = %request.target, error = %error, "discovery record failed; observation dropped");
1435        }
1436    }
1437
1438    /// Runs a single effect invocation through the timeout layer, tagging the
1439    /// origin of a failure (adapter vs. policy timeout). The effect future is
1440    /// instrumented with `attempt_span`, so any tracing it emits nests under the
1441    /// attempt; the timeout branch synthesizes the same `KEEL-E011`-class error
1442    /// the retry loop diagnoses.
1443    ///
1444    /// `timeout` is the effective per-attempt wall-clock deadline, already gated
1445    /// by the caller: it is `None` for a non-idempotent request (Level 0 hard
1446    /// rule — never inject a synthetic failure into a call that may already have
1447    /// committed server-side; the front ends make the same judgment). Note that
1448    /// on the synchronous bindings (keel-py/keel-ffi/keel-node sync `execute`) a
1449    /// blocking effect completes within a single poll, so this timer cannot
1450    /// preempt it — sync callers get their HTTP client's adapter timeout, not the
1451    /// policy layer's. The policy per-attempt timeout is effective on the async
1452    /// path (and in-core futures) where the effect actually awaits.
1453    async fn run_one_attempt<F>(
1454        &self,
1455        timeout: Option<DurationMs>,
1456        effect: &mut F,
1457        attempt: u32,
1458        attempt_span: &tracing::Span,
1459    ) -> AttemptOutcome
1460    where
1461        F: AsyncFnMut(u32) -> AttemptResult,
1462    {
1463        match timeout {
1464            Some(limit) => {
1465                match tokio::time::timeout(
1466                    Duration::from_millis(limit.0),
1467                    effect(attempt).instrument(attempt_span.clone()),
1468                )
1469                .await
1470                {
1471                    Ok(result) => AttemptOutcome {
1472                        result,
1473                        timed_out_by_layer: false,
1474                    },
1475                    Err(_elapsed) => AttemptOutcome {
1476                        result: AttemptResult::Error {
1477                            class: ErrorClass::Timeout,
1478                            http_status: None,
1479                            retry_after_ms: None,
1480                            message: format!("no response within {}ms", limit.0),
1481                            original: None,
1482                        },
1483                        timed_out_by_layer: true,
1484                    },
1485                }
1486            }
1487            None => AttemptOutcome {
1488                result: effect(attempt).instrument(attempt_span.clone()).await,
1489                timed_out_by_layer: false,
1490            },
1491        }
1492    }
1493
1494    /// The poll layer (CCR-3): wraps the retry loop, so each iteration is one
1495    /// fully-retried call and a failed iteration is a normal terminal error.
1496    /// cache/rate/breaker sit outside (one admission, one settled outcome).
1497    #[expect(clippy::too_many_arguments, reason = "mirrors run_attempts' signature")]
1498    async fn run_poll<F>(
1499        &self,
1500        request: &Request,
1501        resolved: &ResolvedPolicy,
1502        retry: &RetryPolicy,
1503        poll: &keel_core_api::policy::PollPolicy,
1504        effect: &mut F,
1505        out: &mut Outcome,
1506        trace: Option<&TraceRef>,
1507    ) -> Result<Value, OutcomeError>
1508    where
1509        F: AsyncFnMut(u32) -> AttemptResult,
1510    {
1511        let started = Instant::now();
1512        loop {
1513            let payload = self
1514                .run_attempts(request, resolved, retry, effect, out, trace)
1515                .await?;
1516            match poll_verdict(poll, &payload) {
1517                PollVerdict::Terminal => return Ok(payload),
1518                PollVerdict::FailOpen => {
1519                    debug!(
1520                        target = %request.target, field = %poll.until.field,
1521                        "poll fail-open: response not judgeable; returned as-is"
1522                    );
1523                    return Ok(payload);
1524                }
1525                PollVerdict::Pending => {
1526                    let elapsed = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
1527                    if elapsed.saturating_add(poll.interval.0) > poll.deadline.0 {
1528                        return Err(OutcomeError {
1529                            code: ErrorCode::PollDeadlineExceeded,
1530                            class: ErrorClass::Other,
1531                            http_status: None,
1532                            message: with_trace_ref(
1533                                format!(
1534                                    "{} poll deadline exceeded: '{}' not terminal after {}ms",
1535                                    request.op, poll.until.field, poll.deadline.0
1536                                ),
1537                                trace,
1538                            ),
1539                            original: None,
1540                        });
1541                    }
1542                    tokio::time::sleep(Duration::from_millis(poll.interval.0)).await;
1543                }
1544            }
1545        }
1546    }
1547
1548    /// The timeout-wrapped retry loop. `Ok(payload)` or the terminal error
1549    /// (whose message carries the call's trace ref when a sink is live).
1550    async fn run_attempts<F>(
1551        &self,
1552        request: &Request,
1553        resolved: &ResolvedPolicy,
1554        retry: &RetryPolicy,
1555        effect: &mut F,
1556        out: &mut Outcome,
1557        trace: Option<&TraceRef>,
1558    ) -> Result<Value, OutcomeError>
1559    where
1560        F: AsyncFnMut(u32) -> AttemptResult,
1561    {
1562        let target = request.target.as_str();
1563        let max_attempts = retry.attempts.get();
1564        // Level 0: never arm the per-attempt wall-clock timeout on a
1565        // non-idempotent request. Firing it would drop the in-flight effect
1566        // future while the underlying POST may still commit server-side, then
1567        // hand the caller a synthetic timeout for a call that actually
1568        // succeeded. The front ends refuse to impose a deadline here for the
1569        // same reason; the core must not defeat that guard.
1570        let attempt_timeout = resolved.timeout.filter(|_| request.idempotent);
1571        for attempt in 1..=max_attempts {
1572            out.attempts += 1;
1573            self.state().metrics_for(target).attempts += 1;
1574            crate::metrics::record_attempt(target);
1575            self.emit_event(|| EventKind::AttemptStart {
1576                call: out.trace_id.clone(),
1577                target: target.to_owned(),
1578                attempt,
1579            });
1580
1581            // Child span per attempt (spec §4.5): `class`/`http_status`/`wait_ms`
1582            // are filled in below once the attempt resolves. The effect future
1583            // runs inside this span so any adapter tracing nests correctly.
1584            let attempt_span = tracing::debug_span!(
1585                "keel.attempt",
1586                attempt,
1587                result = tracing::field::Empty,
1588                class = tracing::field::Empty,
1589                http_status = tracing::field::Empty,
1590                wait_ms = tracing::field::Empty,
1591            );
1592
1593            let attempt_outcome = self
1594                .run_one_attempt(attempt_timeout, effect, attempt, &attempt_span)
1595                .await;
1596
1597            match attempt_outcome.result {
1598                AttemptResult::Ok { payload } => {
1599                    attempt_span.record("result", "ok");
1600                    return Ok(payload);
1601                }
1602                AttemptResult::Error {
1603                    class,
1604                    http_status,
1605                    retry_after_ms,
1606                    message,
1607                    original,
1608                } => {
1609                    attempt_span.record("result", "error");
1610                    attempt_span.record("class", class_str(class));
1611                    if let Some(status) = http_status {
1612                        attempt_span.record("http_status", status);
1613                    }
1614                    self.emit_event(|| EventKind::AttemptError {
1615                        call: out.trace_id.clone(),
1616                        target: target.to_owned(),
1617                        attempt,
1618                        class,
1619                        http_status,
1620                    });
1621                    let retryable = retry.is_retryable(class, http_status);
1622                    if let Some(code) =
1623                        terminal_code(retryable, attempt, max_attempts, request.idempotent)
1624                    {
1625                        let ctx = TerminalAttemptCtx {
1626                            request,
1627                            attempt,
1628                            max_attempts,
1629                            trace,
1630                            timed_out_by_layer: attempt_outcome.timed_out_by_layer,
1631                        };
1632                        return Err(terminal_attempt_error(
1633                            &ctx,
1634                            code,
1635                            class,
1636                            http_status,
1637                            &message,
1638                            original,
1639                        ));
1640                    }
1641                    // Jitter is the emitting segment's flag (schedules can
1642                    // compose via `upTo`/`andThen`; the walk is pure in the
1643                    // attempt number, so jitter never shifts handoff points).
1644                    let (mut wait, jitter) = retry.schedule.wait_and_jitter(attempt);
1645                    if jitter && wait > 0 {
1646                        wait = fastrand::u64(wait / 2..=wait);
1647                    }
1648                    if let Some(server_says) = retry_after_ms {
1649                        wait = wait.max(server_says);
1650                    }
1651                    attempt_span.record("wait_ms", wait);
1652                    out.waits_ms.push(wait);
1653                    self.state().metrics_for(target).retries += 1;
1654                    crate::metrics::record_retry(target, wait);
1655                    // Emitted before the wait so a live tail shows it now.
1656                    self.emit_event(|| EventKind::Backoff {
1657                        call: out.trace_id.clone(),
1658                        target: target.to_owned(),
1659                        attempt,
1660                        wait_ms: wait,
1661                    });
1662                    tokio::time::sleep(Duration::from_millis(wait)).await;
1663                }
1664            }
1665        }
1666        unreachable!("loop always returns by the final attempt");
1667    }
1668
1669    /// Deterministic metrics/discovery report (sorted keys, no wall-clock
1670    /// timestamps): the same shape `keel_report` freezes in core-ffi.h.
1671    pub fn report(&self) -> Value {
1672        let now = Instant::now();
1673        let state = self.state();
1674        let targets = state
1675            .metrics
1676            .iter()
1677            .map(|(name, m)| {
1678                let breaker = state.breakers.get(name);
1679                let row = TargetReport {
1680                    attempts: m.attempts,
1681                    breaker_opens: breaker.map_or(0, |b| b.opens),
1682                    breaker_state: state.breaker_state(name, now),
1683                    cache_hits: m.cache_hits,
1684                    calls: m.calls,
1685                    failures: m.failures,
1686                    retries: m.retries,
1687                    successes: m.successes,
1688                    throttled: m.throttled,
1689                };
1690                (name.as_str(), row)
1691            })
1692            .collect();
1693        serde_json::to_value(Report {
1694            v: 1,
1695            clock_ms: self.elapsed_ms(),
1696            targets,
1697        })
1698        .expect("report serialization is infallible")
1699    }
1700}
1701
1702#[cfg(test)]
1703mod tests {
1704    use super::{
1705        Admission, AttemptResult, Breaker, BreakerTransition, ENVELOPE_VERSION, Engine, Instant,
1706        Request, TokenBucket,
1707    };
1708    use core::num::NonZeroU32;
1709    use core::time::Duration;
1710    use keel_core_api::policy::{BreakerPolicy, DurationMs, Rate};
1711    use serde_json::json;
1712
1713    /// A failure recorded far enough in the past to have aged out of the
1714    /// window must not count towards a later trip decision: without eviction,
1715    /// this exact two-failure sequence (11s apart, `window: 10s`) would trip
1716    /// at `min_calls: 2` (rate 1.0); with eviction the first failure is gone
1717    /// by the time the second arrives, so the window only ever holds one.
1718    #[test]
1719    fn breaker_rate_mode_evicts_outcomes_older_than_the_window() {
1720        let config = BreakerPolicy {
1721            failures: None,
1722            cooldown: DurationMs(15_000),
1723            window: Some(DurationMs(10_000)),
1724            failure_rate: Some(0.5),
1725            min_calls: Some(NonZeroU32::new(2).unwrap()),
1726        };
1727        let mut breaker = Breaker::default();
1728        let t0 = Instant::now();
1729
1730        assert_eq!(
1731            breaker.on_terminal_failure(t0, &config, Admission::Closed),
1732            BreakerTransition::None,
1733            "one failure is below min_calls"
1734        );
1735        let t_11s = t0 + Duration::from_secs(11);
1736        assert_eq!(
1737            breaker.on_terminal_failure(t_11s, &config, Admission::Closed),
1738            BreakerTransition::None,
1739            "the stale failure must have aged out of the 10s window"
1740        );
1741    }
1742
1743    /// Burst capacity is `limit` tokens, never more — an idle gap refills the
1744    /// bucket but is clamped at capacity, so it cannot bank unlimited tokens
1745    /// for a future burst larger than the configured `limit`.
1746    #[test]
1747    fn token_bucket_caps_refill_at_burst_capacity() {
1748        let rate = Rate {
1749            limit: core::num::NonZeroU64::new(2).unwrap(),
1750            window_ms: 1000,
1751        };
1752        let mut bucket = TokenBucket::default();
1753
1754        assert_eq!(bucket.plan_admit(0, rate), 0, "burst covers the first call");
1755        assert_eq!(
1756            bucket.plan_admit(0, rate),
1757            0,
1758            "burst covers the second call"
1759        );
1760        assert_eq!(
1761            bucket.plan_admit(0, rate),
1762            500,
1763            "burst drained: paced at window/limit = 500ms"
1764        );
1765
1766        // A long idle gap (50s, enough to "earn" 100 tokens at this rate) must
1767        // not accrue more than the 2-token burst cap.
1768        assert_eq!(
1769            bucket.plan_admit(50_000, rate),
1770            0,
1771            "capacity refilled to burst"
1772        );
1773        assert_eq!(
1774            bucket.plan_admit(50_000, rate),
1775            0,
1776            "both burst tokens available"
1777        );
1778        assert_eq!(
1779            bucket.plan_admit(50_000, rate),
1780            500,
1781            "capacity clamp: idle time cannot bank more than `limit` tokens"
1782        );
1783    }
1784
1785    fn req(target: &str, args_hash: &str) -> Request {
1786        Request {
1787            v: ENVELOPE_VERSION,
1788            target: target.to_owned(),
1789            op: format!("GET {target}"),
1790            idempotent: true,
1791            args_hash: Some(args_hash.to_owned()),
1792        }
1793    }
1794
1795    /// The in-memory cache does not grow without bound: an expired entry is
1796    /// swept when the next cacheable success writes, so a per-call-varying key
1797    /// set leaves only the live working set behind (finding: unbounded growth).
1798    #[tokio::test(start_paused = true)]
1799    async fn in_memory_cache_evicts_expired_entries() {
1800        let engine = Engine::new();
1801        engine
1802            .configure(&json!({
1803                "target": { "api.catalog.internal": { "cache": { "ttl": "60s" } } }
1804            }))
1805            .expect("valid policy");
1806
1807        engine
1808            .execute(&req("api.catalog.internal", "k1"), async |_a| {
1809                AttemptResult::Ok { payload: json!(1) }
1810            })
1811            .await;
1812        assert_eq!(engine.state().cache.len(), 1, "k1 cached");
1813
1814        // Past k1's 60s TTL: the write for a NEW key sweeps the expired k1.
1815        tokio::time::advance(Duration::from_secs(61)).await;
1816        engine
1817            .execute(&req("api.catalog.internal", "k2"), async |_a| {
1818                AttemptResult::Ok { payload: json!(2) }
1819            })
1820            .await;
1821        assert_eq!(
1822            engine.state().cache.len(),
1823            1,
1824            "expired k1 evicted on write; only the live k2 remains"
1825        );
1826
1827        // A read of the now-swept k1 is a miss (re-runs live), and reading an
1828        // expired key also evicts it on the read path.
1829        let out = engine
1830            .execute(&req("api.catalog.internal", "k1"), async |_a| {
1831                AttemptResult::Ok { payload: json!(3) }
1832            })
1833            .await;
1834        assert!(!out.from_cache, "expired/evicted key re-runs live");
1835    }
1836
1837    fn poll_policy_json(interval: &str, deadline: &str) -> serde_json::Value {
1838        json!({ "target": { "api.jobs.internal": {
1839            "retry": { "attempts": 3, "schedule": "fixed(200ms)", "on": ["conn", "timeout", "429", "5xx"] },
1840            "poll": { "interval": interval, "deadline": deadline,
1841                       "until": { "field": "status", "terminal": ["completed", "failed"] } }
1842        } } })
1843    }
1844
1845    #[tokio::test(start_paused = true)]
1846    async fn poll_reissues_until_terminal_and_accumulates_attempts() {
1847        let engine = Engine::new();
1848        engine.configure(&poll_policy_json("10s", "90s")).unwrap();
1849        let mut script = vec![
1850            json!({"status": "running"}),
1851            json!({"status": "running"}),
1852            json!({"status": "completed", "result": 42}),
1853        ]
1854        .into_iter();
1855        let out = engine
1856            .execute(&req("api.jobs.internal", "h1"), async |_a| {
1857                AttemptResult::Ok {
1858                    payload: script.next().expect("script exhausted"),
1859                }
1860            })
1861            .await;
1862        assert_eq!(out.result, "ok");
1863        assert_eq!(
1864            out.payload,
1865            Some(json!({"status": "completed", "result": 42}))
1866        );
1867        assert_eq!(
1868            out.attempts, 3,
1869            "attempts accumulate across poll iterations"
1870        );
1871        assert!(
1872            out.waits_ms.is_empty(),
1873            "poll intervals are not retry waits"
1874        );
1875        let report = engine.report();
1876        assert_eq!(report["targets"]["api.jobs.internal"]["calls"], 1);
1877        assert_eq!(report["targets"]["api.jobs.internal"]["successes"], 1);
1878    }
1879
1880    #[tokio::test(start_paused = true)]
1881    async fn poll_deadline_is_e016_and_breaker_countable() {
1882        let engine = Engine::new();
1883        let mut policy = poll_policy_json("10s", "25s");
1884        policy["target"]["api.jobs.internal"]["breaker"] = json!({ "failures": 1 });
1885        engine.configure(&policy).unwrap();
1886        let out = engine
1887            .execute(&req("api.jobs.internal", "h1"), async |_a| {
1888                AttemptResult::Ok {
1889                    payload: json!({"status": "running"}),
1890                }
1891            })
1892            .await;
1893        assert_eq!(out.result, "error");
1894        let err = out.error.expect("terminal error");
1895        assert_eq!(err.code.as_str(), "KEEL-E016");
1896        assert_eq!(
1897            err.message,
1898            "GET api.jobs.internal poll deadline exceeded: 'status' not terminal after 25000ms"
1899        );
1900        // pendings at 0ms and 10ms and 20ms; at 20ms, 20+10 > 25 → 3 attempts.
1901        assert_eq!(out.attempts, 3);
1902        assert_eq!(
1903            out.breaker,
1904            keel_core_api::BreakerState::Open,
1905            "breaker-countable"
1906        );
1907    }
1908
1909    #[tokio::test(start_paused = true)]
1910    async fn poll_fails_open_on_missing_field_and_skips_non_get() {
1911        let engine = Engine::new();
1912        engine.configure(&poll_policy_json("10s", "90s")).unwrap();
1913        // Missing field: returned as-is, one attempt.
1914        let out = engine
1915            .execute(&req("api.jobs.internal", "h2"), async |_a| {
1916                AttemptResult::Ok {
1917                    payload: json!({"progress": 0.5}),
1918                }
1919            })
1920            .await;
1921        assert_eq!(out.result, "ok");
1922        assert_eq!(out.attempts, 1);
1923        // POST op: poll layer inert even with a pending-looking payload.
1924        let mut post = req("api.jobs.internal", "h3");
1925        post.op = String::from("POST api.jobs.internal");
1926        let out = engine
1927            .execute(&post, async |_a| AttemptResult::Ok {
1928                payload: json!({"status": "running"}),
1929            })
1930            .await;
1931        assert_eq!(out.attempts, 1);
1932    }
1933
1934    #[tokio::test(start_paused = true)]
1935    async fn poll_judges_http_envelope_bodies() {
1936        use base64::Engine as _;
1937        let engine = Engine::new();
1938        engine.configure(&poll_policy_json("10s", "90s")).unwrap();
1939        let b64 =
1940            |v: &serde_json::Value| base64::engine::general_purpose::STANDARD.encode(v.to_string());
1941        let envelope = |body: &serde_json::Value| {
1942            json!({ "status": 200, "headers": [["content-type", "application/json"]],
1943                    "body_b64": b64(body) })
1944        };
1945        let mut script = vec![
1946            envelope(&json!({"status": "running"})),
1947            envelope(&json!({"status": "completed"})),
1948        ]
1949        .into_iter();
1950        let out = engine
1951            .execute(&req("api.jobs.internal", "h4"), async |_a| {
1952                AttemptResult::Ok {
1953                    payload: script.next().expect("script exhausted"),
1954                }
1955            })
1956            .await;
1957        assert_eq!(out.result, "ok");
1958        assert_eq!(out.attempts, 2);
1959        // An envelope with a NON-JSON body fails open (returned as-is, 1 attempt).
1960        let raw = json!({ "status": 200, "headers": [],
1961            "body_b64": base64::engine::general_purpose::STANDARD.encode("not json") });
1962        let out = engine
1963            .execute(&req("api.jobs.internal", "h5"), async |_a| {
1964                AttemptResult::Ok {
1965                    payload: raw.clone(),
1966                }
1967            })
1968            .await;
1969        assert_eq!(out.result, "ok");
1970        assert_eq!(out.payload, Some(raw));
1971        assert_eq!(out.attempts, 1);
1972    }
1973}