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}