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