1use crate::canonical::CanonicalError;
25use crate::context::ContextOverBudget;
26use crate::digest::Digest;
27use crate::grant::{GrantAtom, GrantSet};
28use crate::id::StepId;
29
30pub type Result<T, E = BoundGraphError> = std::result::Result<T, E>;
32
33#[derive(Debug, thiserror::Error)]
38#[non_exhaustive]
39pub enum BoundGraphError {
40 #[error("step {step} requested {requested}, which exceeds its grant")]
45 GrantViolation {
46 step: StepId,
48 requested: Box<GrantAtom>,
50 permitted: Box<GrantSet>,
52 },
53
54 #[error("step {step} exceeded its {unit} budget: used {used}, reserved {reserved}")]
59 BudgetExceeded {
60 step: StepId,
62 unit: &'static str,
64 used: u64,
66 reserved: u64,
68 },
69
70 #[error("step {step} was refused: {reason}")]
72 EvidenceRejected {
73 step: StepId,
75 reason: String,
77 },
78
79 #[error("cannot prove parallel branches are disjoint: {left} overlaps {right}")]
83 DisjointnessUnproven {
84 left: Box<GrantAtom>,
86 right: Box<GrantAtom>,
88 },
89
90 #[error("sandbox failure in step {step}: {detail}")]
92 SandboxError {
93 step: StepId,
95 detail: String,
97 },
98
99 #[error("provider {provider} failed: {detail}")]
101 ProviderError {
102 provider: String,
104 detail: String,
106 retryable: bool,
108 },
109
110 #[error("ledger chain broken at entry {seq}: expected previous {expected}, found {found}")]
112 LedgerChainBroken {
113 seq: u64,
115 expected: Digest,
117 found: Digest,
119 },
120
121 #[error(transparent)]
123 Canonical(#[from] CanonicalError),
124
125 #[error(transparent)]
127 ContextOverBudget(#[from] ContextOverBudget),
128}
129
130impl BoundGraphError {
131 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 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 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 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 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 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 #[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}