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}