Skip to main content

keel_core_api/contract/
core_api.rs

1// keel — core API contract, contracts-v1.
2//
3// These serde types are the NORMATIVE definition of every envelope that
4// crosses the FFI boundary (contracts/core-ffi.h defines the C ABI; the wire
5// encoding is MessagePack of exactly these shapes, JSON in diagnostics and
6// reports). The `KeelCore` trait is the logical surface both the real core
7// (Team A) and `keel-core-stub` implement; language stubs (Python/Node)
8// mirror it 1:1 on native dicts/objects.
9//
10// This file is included verbatim by the `keel-core-api` crate (plain `//`
11// comments here because the file body lands mid-crate via include!).
12// Do not edit without an approved contract-change request (CCR).
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use std::fmt;
17
18pub const ENVELOPE_VERSION: u32 = 1;
19
20/// Stable error taxonomy. String forms ("KEEL-E001") appear in envelopes,
21/// logs, and `keel explain`; numeric values are frozen in core-ffi.h.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub enum ErrorCode {
24    #[serde(rename = "KEEL-E001")]
25    PolicyInvalid,
26    #[serde(rename = "KEEL-E002")]
27    TargetUnknown,
28    #[serde(rename = "KEEL-E003")]
29    EnvelopeDecode,
30    #[serde(rename = "KEEL-E004")]
31    EnvelopeVersion,
32    /// The policy is valid, but it asks for a capability this build/configuration
33    /// cannot provide (v0.1: async effects inside a durable flow; durable flows
34    /// with no journal attached).
35    #[serde(rename = "KEEL-E005")]
36    UnsupportedConfiguration,
37    #[serde(rename = "KEEL-E010")]
38    AttemptsExhausted,
39    #[serde(rename = "KEEL-E011")]
40    Timeout,
41    #[serde(rename = "KEEL-E012")]
42    BreakerOpen,
43    #[serde(rename = "KEEL-E013")]
44    RateBudgetExceeded,
45    #[serde(rename = "KEEL-E014")]
46    NonIdempotentNotRetried,
47    #[serde(rename = "KEEL-E015")]
48    NonRetryableError,
49    #[serde(rename = "KEEL-E020")]
50    CacheCodec,
51    #[serde(rename = "KEEL-E030")]
52    FlowLeaseHeld,
53    #[serde(rename = "KEEL-E031")]
54    FlowNondeterminism,
55    #[serde(rename = "KEEL-E032")]
56    FlowDead,
57    #[serde(rename = "KEEL-E040")]
58    Internal,
59}
60
61impl ErrorCode {
62    pub fn as_str(self) -> &'static str {
63        match self {
64            ErrorCode::PolicyInvalid => "KEEL-E001",
65            ErrorCode::TargetUnknown => "KEEL-E002",
66            ErrorCode::EnvelopeDecode => "KEEL-E003",
67            ErrorCode::EnvelopeVersion => "KEEL-E004",
68            ErrorCode::UnsupportedConfiguration => "KEEL-E005",
69            ErrorCode::AttemptsExhausted => "KEEL-E010",
70            ErrorCode::Timeout => "KEEL-E011",
71            ErrorCode::BreakerOpen => "KEEL-E012",
72            ErrorCode::RateBudgetExceeded => "KEEL-E013",
73            ErrorCode::NonIdempotentNotRetried => "KEEL-E014",
74            ErrorCode::NonRetryableError => "KEEL-E015",
75            ErrorCode::CacheCodec => "KEEL-E020",
76            ErrorCode::FlowLeaseHeld => "KEEL-E030",
77            ErrorCode::FlowNondeterminism => "KEEL-E031",
78            ErrorCode::FlowDead => "KEEL-E032",
79            ErrorCode::Internal => "KEEL-E040",
80        }
81    }
82}
83
84impl fmt::Display for ErrorCode {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str(self.as_str())
87    }
88}
89
90/// Configuration/internal error (e.g. from `configure`).
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct KeelError {
93    pub code: ErrorCode,
94    pub message: String,
95}
96
97impl fmt::Display for KeelError {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(f, "{}: {}", self.code, self.message)
100    }
101}
102
103impl std::error::Error for KeelError {}
104
105/// Typed error classes adapters produce. The core never sees language
106/// exceptions — adapters classify into these before crossing the boundary.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum ErrorClass {
110    /// Connection-level failure (refused, reset, DNS).
111    Conn,
112    /// The attempt timed out.
113    Timeout,
114    /// HTTP response with an error status (`http_status` is set).
115    Http,
116    /// The attempt was cancelled by the caller.
117    Cancelled,
118    /// Anything else (including effect-callback crashes).
119    Other,
120}
121
122/// One intercepted call, as submitted by a front end to `execute`.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Request {
125    /// Envelope version; must equal ENVELOPE_VERSION.
126    pub v: u32,
127    /// Resolved target id, e.g. "api.stripe.com", "llm:openai",
128    /// "py:pipeline.enrich.geocode". Resolution (globs, METHOD prefixes)
129    /// happens in the front end/adapter; the core receives the exact
130    /// policy-table key. (Real-core stretch: core-side pattern matching.)
131    pub target: String,
132    /// Human-readable operation for traces, e.g. "GET api.stripe.com/v1/charges".
133    pub op: String,
134    /// Front end's safety judgment: true if the call is safe to repeat
135    /// (idempotent method, or an idempotency key was injected). When false,
136    /// Keel NEVER retries (KEEL-E014) — DX Level 0 hard rule.
137    pub idempotent: bool,
138    /// Stable hash of call arguments; cache/journal key material.
139    /// None disables caching/journaling for the call.
140    #[serde(default)]
141    pub args_hash: Option<String>,
142}
143
144/// Result of ONE attempt, produced by the effect callback.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(tag = "status", rename_all = "snake_case")]
147pub enum AttemptResult {
148    Ok {
149        payload: Value,
150    },
151    Error {
152        class: ErrorClass,
153        #[serde(default, skip_serializing_if = "Option::is_none")]
154        http_status: Option<u16>,
155        /// Server-provided backoff (Retry-After or provider equivalent).
156        /// Overrides the schedule: wait = max(schedule_wait, retry_after_ms).
157        #[serde(default, skip_serializing_if = "Option::is_none")]
158        retry_after_ms: Option<u64>,
159        #[serde(default)]
160        message: String,
161        /// Opaque original-error token the front end can use to re-raise the
162        /// original exception unchanged (DX invariant 5). Round-tripped, never
163        /// inspected by the core.
164        #[serde(default, skip_serializing_if = "Option::is_none")]
165        original: Option<Value>,
166    },
167}
168
169/// Terminal error surfaced in an Outcome.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct OutcomeError {
172    pub code: ErrorCode,
173    pub class: ErrorClass,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub http_status: Option<u16>,
176    pub message: String,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub original: Option<Value>,
179}
180
181/// Circuit breaker state as observed after the call.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum BreakerState {
185    Closed,
186    Open,
187    HalfOpen,
188}
189
190/// The result of `execute`: what happened after the full layer chain ran.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Outcome {
193    pub v: u32,
194    /// "ok" | "error"
195    pub result: String,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub payload: Option<Value>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub error: Option<OutcomeError>,
200    /// Number of effect attempts actually made (0 on cache hit / breaker open).
201    pub attempts: u32,
202    pub from_cache: bool,
203    /// Retry backoff waits, in order. Excludes rate-limit queueing.
204    pub waits_ms: Vec<u64>,
205    /// True if the call waited on the rate limiter.
206    pub throttled: bool,
207    pub throttle_wait_ms: u64,
208    pub breaker: BreakerState,
209    /// Stable per-core trace id ("t-000001"); joins outcomes to spans/journal.
210    pub trace_id: String,
211}
212
213/// The logical core surface. The real core implements this behind the C ABI
214/// in core-ffi.h; keel-core-stub implements it in-memory. Python/Node stubs
215/// mirror the same four operations on native values.
216pub trait KeelCore {
217    /// Apply a policy document (keel.toml as JSON, per policy.schema.json).
218    /// Reconfiguration replaces the previous policy atomically.
219    fn configure(&mut self, policy_json: &Value) -> Result<(), KeelError>;
220
221    /// Run one intercepted call through the target's layer chain. `effect`
222    /// performs a single attempt (1-based attempt number) in the host
223    /// language. Must always return an Outcome — policy failures are
224    /// outcomes, not panics/exceptions.
225    fn execute(
226        &mut self,
227        request: &Request,
228        effect: &mut dyn FnMut(u32) -> AttemptResult,
229    ) -> Outcome;
230
231    /// Deterministic metrics/discovery report (JSON): per-target counters
232    /// {calls, attempts, retries, successes, failures, cache_hits, throttled,
233    /// breaker_opens, breaker_state} plus {"v", "clock_ms"}.
234    fn report(&self) -> Value;
235
236    /// Advance the core's clock (milliseconds). The stub runs on a virtual
237    /// clock starting at 0 and never sleeps — waits advance the clock and are
238    /// recorded. The real core runs on real time and implements this as a
239    /// no-op outside its test harness; conformance harnesses use it to model
240    /// the passage of time (breaker cooldowns, cache TTLs).
241    fn advance_clock(&mut self, _ms: u64) {}
242}