Skip to main content

boundgraph_core/
error.rs

1//! The error taxonomy.
2//!
3//! This is public API, not an implementation detail. Two consumers depend on
4//! its exact shape:
5//!
6//! - `recover` (US-1408) dispatches on the error *class*, so failure is a
7//!   control-flow value rather than a panic — a budget overrun degrades to a
8//!   cheaper model instead of ending the run.
9//! - The Python bindings (US-1206) mirror this hierarchy so `except
10//!   GrantViolation:` works.
11//!
12//! Both mean the taxonomy is expensive to change later, which is why it is
13//! settled at M0 rather than grown organically.
14//!
15//! # Why some fields are boxed
16//!
17//! `BoundGraphError` is the error half of every `Result` in the workspace, so its
18//! size is paid on the *success* path too — a fat error type makes every
19//! function that can fail more expensive to return from. Grant payloads are
20//! the largest members by some distance, so the two variants carrying them box
21//! those fields. Use the constructors ([`BoundGraphError::grant_violation`],
22//! [`BoundGraphError::disjointness_unproven`]) and the boxing stays invisible.
23
24use crate::canonical::CanonicalError;
25use crate::context::ContextOverBudget;
26use crate::digest::Digest;
27use crate::grant::{GrantAtom, GrantSet};
28use crate::id::StepId;
29
30/// The result type used throughout BoundGraph.
31pub type Result<T, E = BoundGraphError> = std::result::Result<T, E>;
32
33/// Anything that can go wrong inside the governed span.
34///
35/// Variants are grouped by what a caller would *do* about them, which is what
36/// `recover` matches on.
37#[derive(Debug, thiserror::Error)]
38#[non_exhaustive]
39pub enum BoundGraphError {
40    /// A step requested authority it does not have. Law I.
41    ///
42    /// Most of these are caught at compile time (US-0403); this is the runtime
43    /// case, where a template resolved to something outside its envelope.
44    #[error("step {step} requested {requested}, which exceeds its grant")]
45    GrantViolation {
46        /// The step that overreached.
47        step: StepId,
48        /// The atom it asked for.
49        requested: Box<GrantAtom>,
50        /// What it was actually permitted.
51        permitted: Box<GrantSet>,
52    },
53
54    /// A step ran out of budget. Law II, economically.
55    ///
56    /// Catchable by `recover`, which is the point: degrade to a cheaper model,
57    /// return a partial result, or escalate to a human.
58    #[error("step {step} exceeded its {unit} budget: used {used}, reserved {reserved}")]
59    BudgetExceeded {
60        /// The step that overran.
61        step: StepId,
62        /// Which budget: `tokens`, `usd`, `wall`, or `calls`.
63        unit: &'static str,
64        /// What it consumed.
65        used: u64,
66        /// What it was allowed.
67        reserved: u64,
68    },
69
70    /// A commit was refused because evidence did not permit it. Law III.
71    #[error("step {step} was refused: {reason}")]
72    EvidenceRejected {
73        /// The step that was refused.
74        step: StepId,
75        /// The verdict's reason, or why evidence was missing.
76        reason: String,
77    },
78
79    /// Two parallel branches could not be proved not to interfere.
80    ///
81    /// Conservative by default: unprovable is a refusal, never an assumption.
82    #[error("cannot prove parallel branches are disjoint: {left} overlaps {right}")]
83    DisjointnessUnproven {
84        /// One side of the offending pair.
85        left: Box<GrantAtom>,
86        /// The other side.
87        right: Box<GrantAtom>,
88    },
89
90    /// The sandbox could not run a tool, or the tool failed inside it.
91    #[error("sandbox failure in step {step}: {detail}")]
92    SandboxError {
93        /// The step whose tool failed.
94        step: StepId,
95        /// What went wrong.
96        detail: String,
97    },
98
99    /// A model provider failed.
100    #[error("provider {provider} failed: {detail}")]
101    ProviderError {
102        /// Which provider.
103        provider: String,
104        /// What went wrong.
105        detail: String,
106        /// Whether failing over or retrying could succeed.
107        retryable: bool,
108    },
109
110    /// The ledger's hash chain does not verify — tampering or corruption.
111    #[error("ledger chain broken at entry {seq}: expected previous {expected}, found {found}")]
112    LedgerChainBroken {
113        /// The entry whose link failed.
114        seq: u64,
115        /// The digest the chain required.
116        expected: Digest,
117        /// The digest actually recorded.
118        found: Digest,
119    },
120
121    /// A value could not be canonically encoded or decoded.
122    #[error(transparent)]
123    Canonical(#[from] CanonicalError),
124
125    /// An admission produced a context over its own ceiling.
126    #[error(transparent)]
127    ContextOverBudget(#[from] ContextOverBudget),
128}
129
130impl BoundGraphError {
131    /// A step asked for authority beyond its grant.
132    pub fn grant_violation(step: StepId, requested: GrantAtom, permitted: GrantSet) -> Self {
133        Self::GrantViolation {
134            step,
135            requested: Box::new(requested),
136            permitted: Box::new(permitted),
137        }
138    }
139
140    /// Two branches could not be proved disjoint.
141    pub fn disjointness_unproven(left: GrantAtom, right: GrantAtom) -> Self {
142        Self::DisjointnessUnproven {
143            left: Box::new(left),
144            right: Box::new(right),
145        }
146    }
147
148    /// Which step this failure belongs to, where that is meaningful.
149    ///
150    /// Whole-graph failures such as
151    /// [`DisjointnessUnproven`](Self::DisjointnessUnproven) have no single step
152    /// and return `None`.
153    pub fn step(&self) -> Option<StepId> {
154        match self {
155            Self::GrantViolation { step, .. }
156            | Self::BudgetExceeded { step, .. }
157            | Self::EvidenceRejected { step, .. }
158            | Self::SandboxError { step, .. } => Some(*step),
159            _ => None,
160        }
161    }
162
163    /// A stable machine-readable class, for `recover` and the Python bindings.
164    ///
165    /// Stable across releases: these strings are API.
166    pub fn class(&self) -> &'static str {
167        match self {
168            Self::GrantViolation { .. } => "grant_violation",
169            Self::BudgetExceeded { .. } => "budget_exceeded",
170            Self::EvidenceRejected { .. } => "evidence_rejected",
171            Self::DisjointnessUnproven { .. } => "disjointness_unproven",
172            Self::SandboxError { .. } => "sandbox_error",
173            Self::ProviderError { .. } => "provider_error",
174            Self::LedgerChainBroken { .. } => "ledger_chain_broken",
175            Self::Canonical(_) => "canonical_error",
176            Self::ContextOverBudget(_) => "context_over_budget",
177        }
178    }
179
180    /// Whether retrying or failing over could plausibly succeed.
181    ///
182    /// A grant violation never becomes permitted by retrying; a rate-limited
183    /// provider might.
184    pub fn is_retryable(&self) -> bool {
185        matches!(
186            self,
187            Self::ProviderError {
188                retryable: true,
189                ..
190            }
191        )
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn step() -> StepId {
200        StepId::new(Digest::of(b"step"))
201    }
202
203    fn one_of_each() -> Vec<BoundGraphError> {
204        vec![
205            BoundGraphError::grant_violation(
206                step(),
207                GrantAtom::new("fs", "write", "/**"),
208                GrantSet::EMPTY,
209            ),
210            BoundGraphError::BudgetExceeded {
211                step: step(),
212                unit: "tokens",
213                used: 10,
214                reserved: 5,
215            },
216            BoundGraphError::EvidenceRejected {
217                step: step(),
218                reason: "tests failed".into(),
219            },
220            BoundGraphError::disjointness_unproven(
221                GrantAtom::new("fs", "write", "/a/**"),
222                GrantAtom::new("fs", "write", "/a/b"),
223            ),
224            BoundGraphError::SandboxError {
225                step: step(),
226                detail: "spawn failed".into(),
227            },
228            BoundGraphError::ProviderError {
229                provider: "anthropic".into(),
230                detail: "429".into(),
231                retryable: true,
232            },
233            BoundGraphError::LedgerChainBroken {
234                seq: 4,
235                expected: Digest::of(b"a"),
236                found: Digest::of(b"b"),
237            },
238            BoundGraphError::Canonical(CanonicalError::Encode("x".into())),
239            BoundGraphError::ContextOverBudget(ContextOverBudget {
240                admitted: 10,
241                budget: 5,
242            }),
243        ]
244    }
245
246    #[test]
247    fn every_class_is_distinct_and_stable() {
248        let classes: Vec<_> = one_of_each().iter().map(BoundGraphError::class).collect();
249        let mut unique = classes.clone();
250        unique.sort_unstable();
251        unique.dedup();
252        assert_eq!(classes.len(), unique.len(), "classes must be distinct");
253    }
254
255    #[test]
256    fn every_variant_renders_a_useful_message() {
257        for e in one_of_each() {
258            let msg = e.to_string();
259            assert!(!msg.is_empty(), "{} has an empty message", e.class());
260        }
261    }
262
263    #[test]
264    fn step_scoped_errors_identify_the_offending_step() {
265        assert_eq!(
266            BoundGraphError::SandboxError {
267                step: step(),
268                detail: "x".into()
269            }
270            .step(),
271            Some(step())
272        );
273        // Whole-graph failures have no single step.
274        assert_eq!(
275            BoundGraphError::disjointness_unproven(
276                GrantAtom::new("fs", "write", "/a"),
277                GrantAtom::new("fs", "write", "/a"),
278            )
279            .step(),
280            None
281        );
282    }
283
284    #[test]
285    fn only_provider_failures_are_retryable() {
286        assert!(
287            BoundGraphError::ProviderError {
288                provider: "anthropic".into(),
289                detail: "429".into(),
290                retryable: true,
291            }
292            .is_retryable()
293        );
294        assert!(
295            !BoundGraphError::ProviderError {
296                provider: "anthropic".into(),
297                detail: "400 bad request".into(),
298                retryable: false,
299            }
300            .is_retryable()
301        );
302        assert!(
303            !BoundGraphError::grant_violation(
304                step(),
305                GrantAtom::new("fs", "write", "/**"),
306                GrantSet::EMPTY,
307            )
308            .is_retryable(),
309            "retrying never widens a grant"
310        );
311    }
312
313    #[test]
314    fn messages_name_the_step_and_the_problem() {
315        let msg = BoundGraphError::BudgetExceeded {
316            step: step(),
317            unit: "tokens",
318            used: 900,
319            reserved: 500,
320        }
321        .to_string();
322        assert!(msg.contains("900"));
323        assert!(msg.contains("500"));
324        assert!(msg.contains("tokens"));
325    }
326
327    #[test]
328    fn sub_errors_convert_with_the_question_mark_operator() {
329        fn fails() -> Result<()> {
330            Err(CanonicalError::Encode("bad".into()))?;
331            Ok(())
332        }
333        assert_eq!(fails().unwrap_err().class(), "canonical_error");
334    }
335
336    /// `BoundGraphError` is the `Err` half of every `Result` in the workspace, so its
337    /// size is paid even when nothing fails. Boxing the grant payloads keeps it
338    /// well inside clippy's 128-byte `result_large_err` threshold; this test
339    /// fails if a future variant undoes that.
340    #[test]
341    fn the_error_type_stays_small() {
342        let size = size_of::<BoundGraphError>();
343        assert!(
344            size <= 128,
345            "BoundGraphError grew to {size} bytes; box the large fields of any new variant"
346        );
347    }
348}