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::{AccountDisposition, 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 = "AccountDisposition::is_keep", default)]
46    account: AccountDisposition,
47    #[serde(skip_serializing_if = "Option::is_none", default)]
48    gate: Option<GateRefusal>,
49    #[serde(skip_serializing_if = "Option::is_none", default)]
50    provider: Option<ProviderFailureKind>,
51}
52
53/// Whether the same request may go out again, said together with
54/// whether its effect already landed. On the wire `"yes"`, `"no"` or
55/// `"unknown"`; a ledger record written as `retriable: true/false`
56/// reads as `Yes`/`No`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "lowercase")]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60pub enum Retry {
61    /// The effect did not land, and the same request may succeed later.
62    Yes,
63    /// The same request would fail the same way again.
64    No,
65    /// The request went out and its answer was lost: whether its effect
66    /// landed is not known, so only a caller who knows the action is
67    /// idempotent sends it again.
68    Unknown,
69}
70
71impl<'de> Deserialize<'de> for Retry {
72    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
73        #[derive(Deserialize)]
74        #[serde(untagged)]
75        enum Spelling {
76            Word(String),
77            Flag(bool),
78        }
79        match Spelling::deserialize(deserializer)? {
80            Spelling::Flag(true) => Ok(Retry::Yes),
81            Spelling::Flag(false) => Ok(Retry::No),
82            Spelling::Word(word) => match word.as_str() {
83                "yes" => Ok(Retry::Yes),
84                "no" => Ok(Retry::No),
85                "unknown" => Ok(Retry::Unknown),
86                other => Err(serde::de::Error::unknown_variant(
87                    other,
88                    &["yes", "no", "unknown"],
89                )),
90            },
91        }
92    }
93}
94
95/// An error that still owes the reader its recovery sentence.
96///
97/// Both constructors return this rather than an [`AxError`], and
98/// [`ErrorDraft::with_recovery`] is the only way across, so a failure that
99/// never says what to do next cannot be spelled. Shaping happens here;
100/// an `AxError` is finished the moment it exists.
101#[derive(Debug, Clone, PartialEq, Eq)]
102#[must_use = "an ErrorDraft becomes an AxError only through with_recovery"]
103pub struct ErrorDraft {
104    pending: AxError,
105}
106
107impl AxError {
108    /// Non-gate failure. `retry` starts `No` (fail-closed) and
109    /// `nearby` starts empty; both grow on the draft.
110    pub fn failure(
111        code: AxCode,
112        action: impl Into<String>,
113        subject: impl Into<String>,
114    ) -> ErrorDraft {
115        ErrorDraft {
116            pending: AxError {
117                code,
118                detail: Box::new(ErrorDetail {
119                    action: action.into(),
120                    subject: subject.into(),
121                    nearby: Vec::new(),
122                    recovery: String::new(),
123                    retry: Retry::No,
124                    retry_after_ms: None,
125                    account: AccountDisposition::Keep,
126                    gate: None,
127                    provider: None,
128                }),
129            },
130        }
131    }
132
133    /// Gate refusal: the only constructor that sets the three mandatory
134    /// parts. Gate-carrier codes must come through here (kernel::gate is
135    /// their sole producer).
136    pub fn refusal(
137        code: AxCode,
138        action: impl Into<String>,
139        subject: impl Into<String>,
140        gate: GateRefusal,
141    ) -> ErrorDraft {
142        let mut draft = AxError::failure(code, action, subject);
143        draft.pending.detail.gate = Some(gate);
144        draft
145    }
146
147    /// An `E_PROVIDER` error that names the kind of failure a model
148    /// call met. `retry` starts `No` like any failure: whether the
149    /// request may go out again is decided by the gateway's
150    /// `ProviderFailure::retry`, which sees more than the kind does.
151    pub fn provider(
152        kind: ProviderFailureKind,
153        action: impl Into<String>,
154        subject: impl Into<String>,
155    ) -> ErrorDraft {
156        let mut draft = AxError::failure(AxCode::Provider, action, subject);
157        draft.pending.detail.provider = Some(kind);
158        draft
159    }
160
161    pub fn code(&self) -> &AxCode {
162        &self.code
163    }
164
165    pub fn action(&self) -> &str {
166        &self.detail.action
167    }
168
169    pub fn subject(&self) -> &str {
170        &self.detail.subject
171    }
172
173    pub fn nearby(&self) -> &[String] {
174        &self.detail.nearby
175    }
176
177    pub fn recovery(&self) -> &str {
178        &self.detail.recovery
179    }
180
181    pub fn retry(&self) -> Retry {
182        self.detail.retry
183    }
184
185    /// The least the raiser says to wait before asking again, when it
186    /// said one; present only on a `Retry::Yes` error.
187    pub fn retry_after_ms(&self) -> Option<u64> {
188        self.detail.retry_after_ms
189    }
190
191    /// Whether the account this request went out on can still take it;
192    /// `Advance` comes only with `Retry::No` (D53).
193    pub fn account(&self) -> AccountDisposition {
194        self.detail.account
195    }
196
197    pub fn gate(&self) -> Option<&GateRefusal> {
198        self.detail.gate.as_ref()
199    }
200
201    pub fn provider_failure(&self) -> Option<ProviderFailureKind> {
202        self.detail.provider
203    }
204
205    /// Replaces the recovery sentence of an error raised further down,
206    /// for a caller who knows a better next step than its raiser did.
207    /// Named apart from [`ErrorDraft::with_recovery`] because it destroys
208    /// a sentence rather than supplying the first one.
209    #[must_use]
210    pub fn rewrite_recovery(mut self, recovery: impl Into<String>) -> Self {
211        self.detail.recovery = recovery.into();
212        self
213    }
214}
215
216impl ErrorDraft {
217    /// Names the spellings the caller could have used instead. Directly
218    /// usable values only, not a description of them.
219    pub fn with_nearby(mut self, nearby: Vec<String>) -> Self {
220        self.pending.detail.nearby = nearby;
221        self
222    }
223
224    /// Declares the action safe to retry as-is. Explicit opt-in only.
225    /// Puts the account back to `Keep`: a request worth sending again
226    /// on this account is no reason to leave it.
227    pub fn retriable(mut self) -> Self {
228        self.pending.detail.retry = Retry::Yes;
229        self.pending.detail.account = AccountDisposition::Keep;
230        self
231    }
232
233    /// Declares that the request went out and its answer was lost, so
234    /// whether its effect landed is not known. Drops any wait set
235    /// before it, because a wait belongs to `Retry::Yes` alone, and keeps
236    /// the account, because a lost answer is no evidence against it.
237    pub fn effect_unknown(mut self) -> Self {
238        self.pending.detail.retry = Retry::Unknown;
239        self.pending.detail.retry_after_ms = None;
240        self.pending.detail.account = AccountDisposition::Keep;
241        self
242    }
243
244    /// Declares that the account the request went out on cannot take it,
245    /// so the next account should (D53). Sets `Retry::No` and drops any
246    /// wait in the same step: the same request on the same account would
247    /// fail the same way, however long it waited.
248    pub fn account_unusable(mut self) -> Self {
249        self.pending.detail.retry = Retry::No;
250        self.pending.detail.retry_after_ms = None;
251        self.pending.detail.account = AccountDisposition::Advance;
252        self
253    }
254
255    /// Declares the action safe to retry, and no sooner than `wait_ms`
256    /// from now: the other side's own word on when it recovers.
257    pub fn retriable_after(mut self, wait_ms: u64) -> Self {
258        self.pending.detail.retry_after_ms = Some(wait_ms);
259        self.retriable()
260    }
261
262    /// Writes the one sentence that tells the reader what to do next, and
263    /// hands back the finished error. The reader is as often a model as a
264    /// person, so the sentence names a key, a file and line, or a command.
265    pub fn with_recovery(mut self, recovery: impl Into<String>) -> AxError {
266        self.pending.detail.recovery = recovery.into();
267        self.pending
268    }
269}
270
271#[cfg(test)]
272#[allow(
273    clippy::unwrap_used,
274    clippy::expect_used,
275    clippy::panic,
276    clippy::indexing_slicing,
277    reason = "test code"
278)]
279mod tests {
280    use super::*;
281    #[test]
282    fn failure_has_no_gate_and_is_not_retriable_by_default() {
283        let err = AxError::failure(AxCode::PathNotFound, "read file", "docs/a.md")
284            .with_recovery("create docs/a.md, or read one of the paths `ls docs` lists");
285        assert!(err.gate().is_none());
286        assert_eq!(err.retry(), Retry::No);
287        assert_eq!(err.code(), &AxCode::PathNotFound);
288    }
289
290    #[test]
291    fn builders_extend_without_touching_the_rest() {
292        let err = AxError::failure(AxCode::ToolUnknown, "call tool", "grep")
293            .with_nearby(vec!["exec".into(), "edit".into(), "status".into()])
294            .retriable()
295            .with_recovery("use an L0 tool");
296        assert_eq!(err.retry(), Retry::Yes);
297        let json = serde_json::to_value(&err).unwrap();
298        assert_eq!(json["code"], "E_TOOL_UNKNOWN");
299        assert_eq!(json["nearby"][0], "exec");
300        assert_eq!(json["recovery"], "use an L0 tool");
301        assert_eq!(json.get("gate"), None);
302    }
303
304    /// A wait belongs to a retriable error and to nothing else: the
305    /// raiser's word on when to ask again reads back as given, and
306    /// declaring the effect unknown takes it away with the retry.
307    #[test]
308    fn a_stated_wait_reads_back_until_the_effect_is_declared_unknown() {
309        let draft = AxError::failure(AxCode::Provider, "call model", "429").retriable_after(1500);
310        let waiting = draft.with_recovery("wait, then send the request again");
311        assert_eq!(
312            (waiting.retry(), waiting.retry_after_ms()),
313            (Retry::Yes, Some(1500))
314        );
315        let unknown = AxError::failure(AxCode::Provider, "call model", "cut")
316            .retriable_after(1500)
317            .effect_unknown()
318            .with_recovery("read the ledger before sending it again");
319        assert_eq!(
320            (unknown.retry(), unknown.retry_after_ms()),
321            (Retry::Unknown, None)
322        );
323    }
324
325    /// A provider error names the kind of failure it met, and an error
326    /// raised any other way names none.
327    #[test]
328    fn only_a_provider_error_names_a_provider_failure() {
329        let kind = ProviderFailureKind::Refused { status: 503 };
330        let named = AxError::provider(kind, "call model", "503").with_recovery("try later");
331        assert_eq!(
332            (named.code(), named.provider_failure()),
333            (&AxCode::Provider, Some(kind))
334        );
335        let plain = AxError::failure(AxCode::Provider, "call model", "503").with_recovery("r");
336        assert_eq!(plain.provider_failure(), None);
337    }
338
339    /// A switch is never said beside a resend: whatever order the
340    /// builders run in, `Advance` comes with `Retry::No` and no wait, and
341    /// a wait comes with `Retry::Yes` alone. Derived from
342    /// `crates/kernel/spec/Error.lean`
343    /// (`no_order_of_calls_advances_a_resendable_failure`,
344    /// `no_order_of_calls_waits_without_retrying`).
345    #[derive(Debug, Clone)]
346    enum Builder {
347        Retriable,
348        RetriableAfter(u64),
349        EffectUnknown,
350        AccountUnusable,
351    }
352
353    fn builder() -> impl proptest::strategy::Strategy<Value = Builder> {
354        use proptest::prelude::*;
355        prop_oneof![
356            Just(Builder::Retriable),
357            any::<u64>().prop_map(Builder::RetriableAfter),
358            Just(Builder::EffectUnknown),
359            Just(Builder::AccountUnusable),
360        ]
361    }
362
363    proptest::proptest! {
364        #[test]
365        fn no_order_of_calls_advances_a_resendable_failure(
366            steps in proptest::collection::vec(builder(), 0..12)
367        ) {
368            let draft = steps.into_iter().fold(
369                AxError::failure(AxCode::Provider, "call model", "s"),
370                |draft, step| match step {
371                    Builder::Retriable => draft.retriable(),
372                    Builder::RetriableAfter(wait_ms) => draft.retriable_after(wait_ms),
373                    Builder::EffectUnknown => draft.effect_unknown(),
374                    Builder::AccountUnusable => draft.account_unusable(),
375                },
376            );
377            let err = draft.with_recovery("r");
378            if err.account() == AccountDisposition::Advance {
379                proptest::prop_assert_eq!((err.retry(), err.retry_after_ms()), (Retry::No, None));
380            }
381            if err.retry_after_ms().is_some() {
382                proptest::prop_assert_eq!(err.retry(), Retry::Yes);
383            }
384        }
385    }
386
387    /// `Keep` is the absence of the field, so a ledger line written
388    /// before the field existed keeps its bytes and reads back as `Keep`;
389    /// `Advance` is spelled `"advance"`.
390    #[test]
391    fn only_a_switch_is_written_and_an_old_record_reads_as_keep() {
392        let kept = AxError::failure(AxCode::Provider, "call model", "s").with_recovery("r");
393        let advanced = AxError::failure(AxCode::Provider, "call model", "s")
394            .retriable_after(500)
395            .account_unusable()
396            .with_recovery("r");
397        let old = serde_json::json!({"code": "E_PROVIDER", "action": "call model",
398            "subject": "s", "nearby": [], "recovery": "r", "retry": "no"});
399        assert_eq!(
400            (
401                serde_json::to_value(&kept).unwrap(),
402                serde_json::to_value(&advanced).unwrap()["account"].clone(),
403                serde_json::from_value::<AxError>(old.clone())
404                    .unwrap()
405                    .account(),
406            ),
407            (old, serde_json::json!("advance"), AccountDisposition::Keep)
408        );
409        assert_eq!(
410            serde_json::from_value::<AxError>(serde_json::to_value(&advanced).unwrap()).unwrap(),
411            advanced
412        );
413    }
414
415    #[test]
416    fn axerror_serde_field_order_is_declaration_order() {
417        let err = AxError::failure(AxCode::Timeout, "run exec", "build.sh").with_recovery(
418            "raise `[tool] exec_timeout_ms` in CONFIG.toml, or run a shorter script",
419        );
420        let json = serde_json::to_string(&err).unwrap();
421        let code_at = json.find("\"code\"").unwrap();
422        let action_at = json.find("\"action\"").unwrap();
423        let subject_at = json.find("\"subject\"").unwrap();
424        let retriable_at = json.find("\"retry\"").unwrap();
425        assert!(code_at < action_at && action_at < subject_at && subject_at < retriable_at);
426    }
427
428    #[test]
429    fn a_ledger_record_written_with_the_retriable_flag_still_reads() {
430        let record = |flag: bool| {
431            serde_json::json!({"code": "E_PROVIDER", "action": "call model", "subject": "s",
432                "nearby": [], "recovery": "r", "retriable": flag})
433        };
434        let retry = |flag| {
435            serde_json::from_value::<AxError>(record(flag))
436                .unwrap()
437                .retry()
438        };
439        assert_eq!([retry(true), retry(false)], [Retry::Yes, Retry::No]);
440    }
441
442    #[test]
443    fn an_unknown_effect_carries_no_wait_whatever_the_builder_order() {
444        let err = AxError::failure(AxCode::Provider, "call model", "s")
445            .retriable_after(1_500)
446            .effect_unknown()
447            .with_recovery("r");
448        assert_eq!((err.retry(), err.retry_after_ms()), (Retry::Unknown, None));
449    }
450
451    #[test]
452    fn display_names_code_action_subject() {
453        let err = AxError::failure(AxCode::BudgetExhausted, "call model", "run-1")
454            .with_recovery("raise `[budget]` in CONFIG.toml, then resume run-1");
455        let text = err.to_string();
456        assert!(text.contains("E_BUDGET_EXHAUSTED"));
457        assert!(text.contains("call model"));
458        assert!(text.contains("run-1"));
459    }
460
461    #[test]
462    fn refusal_carries_the_three_mandatory_parts() {
463        let gate = GateRefusal::new("rule", "violation", "alternative");
464        let err = AxError::refusal(AxCode::GateDenied, "wire funds", "acct-9", gate)
465            .with_recovery("alternative");
466        let got = err.gate().expect("gate refusal must carry the three parts");
467        assert_eq!(got.rule(), "rule");
468        assert_eq!(got.violation(), "violation");
469        assert_eq!(got.alternative(), "alternative");
470    }
471
472    #[test]
473    fn every_constructed_error_carries_a_recovery_sentence() {
474        let failure = AxError::failure(AxCode::InvalidArgs, "parse address", "city//")
475            .with_recovery("write `city/<building>/<room>`");
476        let refusal = AxError::refusal(
477            AxCode::GateDenied,
478            "write file",
479            "/etc/hosts",
480            GateRefusal::new("rule", "violation", "alternative"),
481        )
482        .with_recovery("write inside the run's write domain");
483        assert!(!failure.recovery().is_empty());
484        assert!(!refusal.recovery().is_empty());
485    }
486}