use crate::canonical::CanonicalError;
use crate::context::ContextOverBudget;
use crate::digest::Digest;
use crate::grant::{GrantAtom, GrantSet};
use crate::id::StepId;
pub type Result<T, E = BoundGraphError> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BoundGraphError {
#[error("step {step} requested {requested}, which exceeds its grant")]
GrantViolation {
step: StepId,
requested: Box<GrantAtom>,
permitted: Box<GrantSet>,
},
#[error("step {step} exceeded its {unit} budget: used {used}, reserved {reserved}")]
BudgetExceeded {
step: StepId,
unit: &'static str,
used: u64,
reserved: u64,
},
#[error("step {step} was refused: {reason}")]
EvidenceRejected {
step: StepId,
reason: String,
},
#[error("cannot prove parallel branches are disjoint: {left} overlaps {right}")]
DisjointnessUnproven {
left: Box<GrantAtom>,
right: Box<GrantAtom>,
},
#[error("sandbox failure in step {step}: {detail}")]
SandboxError {
step: StepId,
detail: String,
},
#[error("provider {provider} failed: {detail}")]
ProviderError {
provider: String,
detail: String,
retryable: bool,
},
#[error("ledger chain broken at entry {seq}: expected previous {expected}, found {found}")]
LedgerChainBroken {
seq: u64,
expected: Digest,
found: Digest,
},
#[error(transparent)]
Canonical(#[from] CanonicalError),
#[error(transparent)]
ContextOverBudget(#[from] ContextOverBudget),
}
impl BoundGraphError {
pub fn grant_violation(step: StepId, requested: GrantAtom, permitted: GrantSet) -> Self {
Self::GrantViolation {
step,
requested: Box::new(requested),
permitted: Box::new(permitted),
}
}
pub fn disjointness_unproven(left: GrantAtom, right: GrantAtom) -> Self {
Self::DisjointnessUnproven {
left: Box::new(left),
right: Box::new(right),
}
}
pub fn step(&self) -> Option<StepId> {
match self {
Self::GrantViolation { step, .. }
| Self::BudgetExceeded { step, .. }
| Self::EvidenceRejected { step, .. }
| Self::SandboxError { step, .. } => Some(*step),
_ => None,
}
}
pub fn class(&self) -> &'static str {
match self {
Self::GrantViolation { .. } => "grant_violation",
Self::BudgetExceeded { .. } => "budget_exceeded",
Self::EvidenceRejected { .. } => "evidence_rejected",
Self::DisjointnessUnproven { .. } => "disjointness_unproven",
Self::SandboxError { .. } => "sandbox_error",
Self::ProviderError { .. } => "provider_error",
Self::LedgerChainBroken { .. } => "ledger_chain_broken",
Self::Canonical(_) => "canonical_error",
Self::ContextOverBudget(_) => "context_over_budget",
}
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::ProviderError {
retryable: true,
..
}
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn step() -> StepId {
StepId::new(Digest::of(b"step"))
}
fn one_of_each() -> Vec<BoundGraphError> {
vec![
BoundGraphError::grant_violation(
step(),
GrantAtom::new("fs", "write", "/**"),
GrantSet::EMPTY,
),
BoundGraphError::BudgetExceeded {
step: step(),
unit: "tokens",
used: 10,
reserved: 5,
},
BoundGraphError::EvidenceRejected {
step: step(),
reason: "tests failed".into(),
},
BoundGraphError::disjointness_unproven(
GrantAtom::new("fs", "write", "/a/**"),
GrantAtom::new("fs", "write", "/a/b"),
),
BoundGraphError::SandboxError {
step: step(),
detail: "spawn failed".into(),
},
BoundGraphError::ProviderError {
provider: "anthropic".into(),
detail: "429".into(),
retryable: true,
},
BoundGraphError::LedgerChainBroken {
seq: 4,
expected: Digest::of(b"a"),
found: Digest::of(b"b"),
},
BoundGraphError::Canonical(CanonicalError::Encode("x".into())),
BoundGraphError::ContextOverBudget(ContextOverBudget {
admitted: 10,
budget: 5,
}),
]
}
#[test]
fn every_class_is_distinct_and_stable() {
let classes: Vec<_> = one_of_each().iter().map(BoundGraphError::class).collect();
let mut unique = classes.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(classes.len(), unique.len(), "classes must be distinct");
}
#[test]
fn every_variant_renders_a_useful_message() {
for e in one_of_each() {
let msg = e.to_string();
assert!(!msg.is_empty(), "{} has an empty message", e.class());
}
}
#[test]
fn step_scoped_errors_identify_the_offending_step() {
assert_eq!(
BoundGraphError::SandboxError {
step: step(),
detail: "x".into()
}
.step(),
Some(step())
);
assert_eq!(
BoundGraphError::disjointness_unproven(
GrantAtom::new("fs", "write", "/a"),
GrantAtom::new("fs", "write", "/a"),
)
.step(),
None
);
}
#[test]
fn only_provider_failures_are_retryable() {
assert!(
BoundGraphError::ProviderError {
provider: "anthropic".into(),
detail: "429".into(),
retryable: true,
}
.is_retryable()
);
assert!(
!BoundGraphError::ProviderError {
provider: "anthropic".into(),
detail: "400 bad request".into(),
retryable: false,
}
.is_retryable()
);
assert!(
!BoundGraphError::grant_violation(
step(),
GrantAtom::new("fs", "write", "/**"),
GrantSet::EMPTY,
)
.is_retryable(),
"retrying never widens a grant"
);
}
#[test]
fn messages_name_the_step_and_the_problem() {
let msg = BoundGraphError::BudgetExceeded {
step: step(),
unit: "tokens",
used: 900,
reserved: 500,
}
.to_string();
assert!(msg.contains("900"));
assert!(msg.contains("500"));
assert!(msg.contains("tokens"));
}
#[test]
fn sub_errors_convert_with_the_question_mark_operator() {
fn fails() -> Result<()> {
Err(CanonicalError::Encode("bad".into()))?;
Ok(())
}
assert_eq!(fails().unwrap_err().class(), "canonical_error");
}
#[test]
fn the_error_type_stays_small() {
let size = size_of::<BoundGraphError>();
assert!(
size <= 128,
"BoundGraphError grew to {size} bytes; box the large fields of any new variant"
);
}
}