Skip to main content

kernel/error/
shape.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! AxError: the one error shape of the whole city, and ErrorDraft, the
7//! only way to reach it.
8//! The part `crates/kernel/spec/Error.lean` specifies this module with the
9//! rest of `kernel::error`.
10
11use serde::{Deserialize, Serialize};
12
13use super::code::AxCode;
14use super::provider::ProviderFailureKind;
15use super::refusal::GateRefusal;
16
17/// The unified error shape: seven wire fields and two that are left out
18/// when absent, serialized in declaration order (determinism rule 6).
19/// The model is the recovery subject: `nearby` and `recovery` must hold
20/// directly executable information, not apologies.
21///
22/// Everything but `code` sits behind one Box so the type stays cheap in
23/// every seam's return slot (`result_large_err`); serde flatten keeps the
24/// wire shape flat and the field order unchanged.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
26#[error("{code}: cannot {} on {}", detail.action, detail.subject)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28pub struct AxError {
29    code: AxCode,
30    #[serde(flatten)]
31    detail: Box<ErrorDetail>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36struct ErrorDetail {
37    action: String,
38    subject: String,
39    nearby: Vec<String>,
40    recovery: String,
41    #[serde(alias = "retriable")]
42    retry: Retry,
43    #[serde(skip_serializing_if = "Option::is_none", default)]
44    retry_after_ms: Option<u64>,
45    #[serde(skip_serializing_if = "Option::is_none", default)]
46    gate: Option<GateRefusal>,
47    #[serde(skip_serializing_if = "Option::is_none", default)]
48    provider: Option<ProviderFailureKind>,
49}
50
51/// Whether the same request may go out again, said together with
52/// whether its effect already landed. On the wire `"yes"`, `"no"` or
53/// `"unknown"`; a ledger record written as `retriable: true/false`
54/// reads as `Yes`/`No`.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "lowercase")]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub enum Retry {
59    /// The effect did not land, and the same request may succeed later.
60    Yes,
61    /// The same request would fail the same way again.
62    No,
63    /// The request went out and its answer was lost: whether its effect
64    /// landed is not known, so only a caller who knows the action is
65    /// idempotent sends it again.
66    Unknown,
67}
68
69impl<'de> Deserialize<'de> for Retry {
70    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
71        #[derive(Deserialize)]
72        #[serde(untagged)]
73        enum Spelling {
74            Word(String),
75            Flag(bool),
76        }
77        match Spelling::deserialize(deserializer)? {
78            Spelling::Flag(true) => Ok(Retry::Yes),
79            Spelling::Flag(false) => Ok(Retry::No),
80            Spelling::Word(word) => match word.as_str() {
81                "yes" => Ok(Retry::Yes),
82                "no" => Ok(Retry::No),
83                "unknown" => Ok(Retry::Unknown),
84                other => Err(serde::de::Error::unknown_variant(
85                    other,
86                    &["yes", "no", "unknown"],
87                )),
88            },
89        }
90    }
91}
92
93/// An error that still owes the reader its recovery sentence.
94///
95/// Both constructors return this rather than an [`AxError`], and
96/// [`ErrorDraft::with_recovery`] is the only way across, so a failure that
97/// never says what to do next cannot be spelled. Shaping happens here;
98/// an `AxError` is finished the moment it exists.
99#[derive(Debug, Clone, PartialEq, Eq)]
100#[must_use = "an ErrorDraft becomes an AxError only through with_recovery"]
101pub struct ErrorDraft {
102    pending: AxError,
103}
104
105impl AxError {
106    /// Non-gate failure. `retry` starts `No` (fail-closed) and
107    /// `nearby` starts empty; both grow on the draft.
108    pub fn failure(
109        code: AxCode,
110        action: impl Into<String>,
111        subject: impl Into<String>,
112    ) -> ErrorDraft {
113        ErrorDraft {
114            pending: AxError {
115                code,
116                detail: Box::new(ErrorDetail {
117                    action: action.into(),
118                    subject: subject.into(),
119                    nearby: Vec::new(),
120                    recovery: String::new(),
121                    retry: Retry::No,
122                    retry_after_ms: None,
123                    gate: None,
124                    provider: None,
125                }),
126            },
127        }
128    }
129
130    /// Gate refusal: the only constructor that sets the three mandatory
131    /// parts. Gate-carrier codes must come through here (kernel::gate is
132    /// their sole producer).
133    pub fn refusal(
134        code: AxCode,
135        action: impl Into<String>,
136        subject: impl Into<String>,
137        gate: GateRefusal,
138    ) -> ErrorDraft {
139        let mut draft = AxError::failure(code, action, subject);
140        draft.pending.detail.gate = Some(gate);
141        draft
142    }
143
144    /// An `E_PROVIDER` error that names the kind of failure a model
145    /// call met. `retry` starts `No` like any failure: whether the
146    /// request may go out again is decided by the gateway's
147    /// `ProviderFailure::retry`, which sees more than the kind does.
148    pub fn provider(
149        kind: ProviderFailureKind,
150        action: impl Into<String>,
151        subject: impl Into<String>,
152    ) -> ErrorDraft {
153        let mut draft = AxError::failure(AxCode::Provider, action, subject);
154        draft.pending.detail.provider = Some(kind);
155        draft
156    }
157
158    pub fn code(&self) -> &AxCode {
159        &self.code
160    }
161
162    pub fn action(&self) -> &str {
163        &self.detail.action
164    }
165
166    pub fn subject(&self) -> &str {
167        &self.detail.subject
168    }
169
170    pub fn nearby(&self) -> &[String] {
171        &self.detail.nearby
172    }
173
174    pub fn recovery(&self) -> &str {
175        &self.detail.recovery
176    }
177
178    pub fn retry(&self) -> Retry {
179        self.detail.retry
180    }
181
182    /// The least the raiser says to wait before asking again, when it
183    /// said one; present only on a `Retry::Yes` error.
184    pub fn retry_after_ms(&self) -> Option<u64> {
185        self.detail.retry_after_ms
186    }
187
188    pub fn gate(&self) -> Option<&GateRefusal> {
189        self.detail.gate.as_ref()
190    }
191
192    pub fn provider_failure(&self) -> Option<ProviderFailureKind> {
193        self.detail.provider
194    }
195
196    /// Replaces the recovery sentence of an error raised further down,
197    /// for a caller who knows a better next step than its raiser did.
198    /// Named apart from [`ErrorDraft::with_recovery`] because it destroys
199    /// a sentence rather than supplying the first one.
200    #[must_use]
201    pub fn rewrite_recovery(mut self, recovery: impl Into<String>) -> Self {
202        self.detail.recovery = recovery.into();
203        self
204    }
205}
206
207impl ErrorDraft {
208    /// Names the spellings the caller could have used instead. Directly
209    /// usable values only, not a description of them.
210    pub fn with_nearby(mut self, nearby: Vec<String>) -> Self {
211        self.pending.detail.nearby = nearby;
212        self
213    }
214
215    /// Declares the action safe to retry as-is. Explicit opt-in only.
216    pub fn retriable(mut self) -> Self {
217        self.pending.detail.retry = Retry::Yes;
218        self
219    }
220
221    /// Declares that the request went out and its answer was lost, so
222    /// whether its effect landed is not known. Drops any wait set
223    /// before it, because a wait belongs to `Retry::Yes` alone.
224    pub fn effect_unknown(mut self) -> Self {
225        self.pending.detail.retry = Retry::Unknown;
226        self.pending.detail.retry_after_ms = None;
227        self
228    }
229
230    /// Declares the action safe to retry, and no sooner than `wait_ms`
231    /// from now: the other side's own word on when it recovers.
232    pub fn retriable_after(mut self, wait_ms: u64) -> Self {
233        self.pending.detail.retry_after_ms = Some(wait_ms);
234        self.retriable()
235    }
236
237    /// Writes the one sentence that tells the reader what to do next, and
238    /// hands back the finished error. The reader is as often a model as a
239    /// person, so the sentence names a key, a file and line, or a command.
240    pub fn with_recovery(mut self, recovery: impl Into<String>) -> AxError {
241        self.pending.detail.recovery = recovery.into();
242        self.pending
243    }
244}
245
246#[cfg(test)]
247#[allow(
248    clippy::unwrap_used,
249    clippy::expect_used,
250    clippy::panic,
251    clippy::indexing_slicing,
252    reason = "test code"
253)]
254mod tests {
255    use super::*;
256    #[test]
257    fn failure_has_no_gate_and_is_not_retriable_by_default() {
258        let err = AxError::failure(AxCode::PathNotFound, "read file", "docs/a.md")
259            .with_recovery("create docs/a.md, or read one of the paths `ls docs` lists");
260        assert!(err.gate().is_none());
261        assert_eq!(err.retry(), Retry::No);
262        assert_eq!(err.code(), &AxCode::PathNotFound);
263    }
264
265    #[test]
266    fn builders_extend_without_touching_the_rest() {
267        let err = AxError::failure(AxCode::ToolUnknown, "call tool", "grep")
268            .with_nearby(vec!["exec".into(), "edit".into(), "status".into()])
269            .retriable()
270            .with_recovery("use an L0 tool");
271        assert_eq!(err.retry(), Retry::Yes);
272        let json = serde_json::to_value(&err).unwrap();
273        assert_eq!(json["code"], "E_TOOL_UNKNOWN");
274        assert_eq!(json["nearby"][0], "exec");
275        assert_eq!(json["recovery"], "use an L0 tool");
276        assert_eq!(json.get("gate"), None);
277    }
278
279    /// A wait belongs to a retriable error and to nothing else: the
280    /// raiser's word on when to ask again reads back as given, and
281    /// declaring the effect unknown takes it away with the retry.
282    #[test]
283    fn a_stated_wait_reads_back_until_the_effect_is_declared_unknown() {
284        let draft = AxError::failure(AxCode::Provider, "call model", "429").retriable_after(1500);
285        let waiting = draft.with_recovery("wait, then send the request again");
286        assert_eq!(
287            (waiting.retry(), waiting.retry_after_ms()),
288            (Retry::Yes, Some(1500))
289        );
290        let unknown = AxError::failure(AxCode::Provider, "call model", "cut")
291            .retriable_after(1500)
292            .effect_unknown()
293            .with_recovery("read the ledger before sending it again");
294        assert_eq!(
295            (unknown.retry(), unknown.retry_after_ms()),
296            (Retry::Unknown, None)
297        );
298    }
299
300    /// A provider error names the kind of failure it met, and an error
301    /// raised any other way names none.
302    #[test]
303    fn only_a_provider_error_names_a_provider_failure() {
304        let kind = ProviderFailureKind::Refused { status: 503 };
305        let named = AxError::provider(kind, "call model", "503").with_recovery("try later");
306        assert_eq!(
307            (named.code(), named.provider_failure()),
308            (&AxCode::Provider, Some(kind))
309        );
310        let plain = AxError::failure(AxCode::Provider, "call model", "503").with_recovery("r");
311        assert_eq!(plain.provider_failure(), None);
312    }
313
314    #[test]
315    fn axerror_serde_field_order_is_declaration_order() {
316        let err = AxError::failure(AxCode::Timeout, "run exec", "build.sh").with_recovery(
317            "raise `[tool] exec_timeout_ms` in CONFIG.toml, or run a shorter script",
318        );
319        let json = serde_json::to_string(&err).unwrap();
320        let code_at = json.find("\"code\"").unwrap();
321        let action_at = json.find("\"action\"").unwrap();
322        let subject_at = json.find("\"subject\"").unwrap();
323        let retriable_at = json.find("\"retry\"").unwrap();
324        assert!(code_at < action_at && action_at < subject_at && subject_at < retriable_at);
325    }
326
327    #[test]
328    fn a_ledger_record_written_with_the_retriable_flag_still_reads() {
329        let record = |flag: bool| {
330            serde_json::json!({"code": "E_PROVIDER", "action": "call model", "subject": "s",
331                "nearby": [], "recovery": "r", "retriable": flag})
332        };
333        let retry = |flag| {
334            serde_json::from_value::<AxError>(record(flag))
335                .unwrap()
336                .retry()
337        };
338        assert_eq!([retry(true), retry(false)], [Retry::Yes, Retry::No]);
339    }
340
341    #[test]
342    fn an_unknown_effect_carries_no_wait_whatever_the_builder_order() {
343        let err = AxError::failure(AxCode::Provider, "call model", "s")
344            .retriable_after(1_500)
345            .effect_unknown()
346            .with_recovery("r");
347        assert_eq!((err.retry(), err.retry_after_ms()), (Retry::Unknown, None));
348    }
349
350    #[test]
351    fn display_names_code_action_subject() {
352        let err = AxError::failure(AxCode::BudgetExhausted, "call model", "run-1")
353            .with_recovery("raise `[budget]` in CONFIG.toml, then resume run-1");
354        let text = err.to_string();
355        assert!(text.contains("E_BUDGET_EXHAUSTED"));
356        assert!(text.contains("call model"));
357        assert!(text.contains("run-1"));
358    }
359
360    #[test]
361    fn refusal_carries_the_three_mandatory_parts() {
362        let gate = GateRefusal::new("rule", "violation", "alternative");
363        let err = AxError::refusal(AxCode::GateDenied, "wire funds", "acct-9", gate)
364            .with_recovery("alternative");
365        let got = err.gate().expect("gate refusal must carry the three parts");
366        assert_eq!(got.rule(), "rule");
367        assert_eq!(got.violation(), "violation");
368        assert_eq!(got.alternative(), "alternative");
369    }
370
371    #[test]
372    fn every_constructed_error_carries_a_recovery_sentence() {
373        let failure = AxError::failure(AxCode::InvalidArgs, "parse address", "city//")
374            .with_recovery("write `city/<building>/<room>`");
375        let refusal = AxError::refusal(
376            AxCode::GateDenied,
377            "write file",
378            "/etc/hosts",
379            GateRefusal::new("rule", "violation", "alternative"),
380        )
381        .with_recovery("write inside the run's write domain");
382        assert!(!failure.recovery().is_empty());
383        assert!(!refusal.recovery().is_empty());
384    }
385}