boundgraph-core 0.1.0

Core primitives for the BoundGraph runtime: content-addressed digests, work orders, context, condensates, evidence, and the ledger.
Documentation
//! The error taxonomy.
//!
//! This is public API, not an implementation detail. Two consumers depend on
//! its exact shape:
//!
//! - `recover` (US-1408) dispatches on the error *class*, so failure is a
//!   control-flow value rather than a panic — a budget overrun degrades to a
//!   cheaper model instead of ending the run.
//! - The Python bindings (US-1206) mirror this hierarchy so `except
//!   GrantViolation:` works.
//!
//! Both mean the taxonomy is expensive to change later, which is why it is
//! settled at M0 rather than grown organically.
//!
//! # Why some fields are boxed
//!
//! `BoundGraphError` is the error half of every `Result` in the workspace, so its
//! size is paid on the *success* path too — a fat error type makes every
//! function that can fail more expensive to return from. Grant payloads are
//! the largest members by some distance, so the two variants carrying them box
//! those fields. Use the constructors ([`BoundGraphError::grant_violation`],
//! [`BoundGraphError::disjointness_unproven`]) and the boxing stays invisible.

use crate::canonical::CanonicalError;
use crate::context::ContextOverBudget;
use crate::digest::Digest;
use crate::grant::{GrantAtom, GrantSet};
use crate::id::StepId;

/// The result type used throughout BoundGraph.
pub type Result<T, E = BoundGraphError> = std::result::Result<T, E>;

/// Anything that can go wrong inside the governed span.
///
/// Variants are grouped by what a caller would *do* about them, which is what
/// `recover` matches on.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BoundGraphError {
    /// A step requested authority it does not have. Law I.
    ///
    /// Most of these are caught at compile time (US-0403); this is the runtime
    /// case, where a template resolved to something outside its envelope.
    #[error("step {step} requested {requested}, which exceeds its grant")]
    GrantViolation {
        /// The step that overreached.
        step: StepId,
        /// The atom it asked for.
        requested: Box<GrantAtom>,
        /// What it was actually permitted.
        permitted: Box<GrantSet>,
    },

    /// A step ran out of budget. Law II, economically.
    ///
    /// Catchable by `recover`, which is the point: degrade to a cheaper model,
    /// return a partial result, or escalate to a human.
    #[error("step {step} exceeded its {unit} budget: used {used}, reserved {reserved}")]
    BudgetExceeded {
        /// The step that overran.
        step: StepId,
        /// Which budget: `tokens`, `usd`, `wall`, or `calls`.
        unit: &'static str,
        /// What it consumed.
        used: u64,
        /// What it was allowed.
        reserved: u64,
    },

    /// A commit was refused because evidence did not permit it. Law III.
    #[error("step {step} was refused: {reason}")]
    EvidenceRejected {
        /// The step that was refused.
        step: StepId,
        /// The verdict's reason, or why evidence was missing.
        reason: String,
    },

    /// Two parallel branches could not be proved not to interfere.
    ///
    /// Conservative by default: unprovable is a refusal, never an assumption.
    #[error("cannot prove parallel branches are disjoint: {left} overlaps {right}")]
    DisjointnessUnproven {
        /// One side of the offending pair.
        left: Box<GrantAtom>,
        /// The other side.
        right: Box<GrantAtom>,
    },

    /// The sandbox could not run a tool, or the tool failed inside it.
    #[error("sandbox failure in step {step}: {detail}")]
    SandboxError {
        /// The step whose tool failed.
        step: StepId,
        /// What went wrong.
        detail: String,
    },

    /// A model provider failed.
    #[error("provider {provider} failed: {detail}")]
    ProviderError {
        /// Which provider.
        provider: String,
        /// What went wrong.
        detail: String,
        /// Whether failing over or retrying could succeed.
        retryable: bool,
    },

    /// The ledger's hash chain does not verify — tampering or corruption.
    #[error("ledger chain broken at entry {seq}: expected previous {expected}, found {found}")]
    LedgerChainBroken {
        /// The entry whose link failed.
        seq: u64,
        /// The digest the chain required.
        expected: Digest,
        /// The digest actually recorded.
        found: Digest,
    },

    /// A value could not be canonically encoded or decoded.
    #[error(transparent)]
    Canonical(#[from] CanonicalError),

    /// An admission produced a context over its own ceiling.
    #[error(transparent)]
    ContextOverBudget(#[from] ContextOverBudget),
}

impl BoundGraphError {
    /// A step asked for authority beyond its grant.
    pub fn grant_violation(step: StepId, requested: GrantAtom, permitted: GrantSet) -> Self {
        Self::GrantViolation {
            step,
            requested: Box::new(requested),
            permitted: Box::new(permitted),
        }
    }

    /// Two branches could not be proved disjoint.
    pub fn disjointness_unproven(left: GrantAtom, right: GrantAtom) -> Self {
        Self::DisjointnessUnproven {
            left: Box::new(left),
            right: Box::new(right),
        }
    }

    /// Which step this failure belongs to, where that is meaningful.
    ///
    /// Whole-graph failures such as
    /// [`DisjointnessUnproven`](Self::DisjointnessUnproven) have no single step
    /// and return `None`.
    pub fn step(&self) -> Option<StepId> {
        match self {
            Self::GrantViolation { step, .. }
            | Self::BudgetExceeded { step, .. }
            | Self::EvidenceRejected { step, .. }
            | Self::SandboxError { step, .. } => Some(*step),
            _ => None,
        }
    }

    /// A stable machine-readable class, for `recover` and the Python bindings.
    ///
    /// Stable across releases: these strings are API.
    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",
        }
    }

    /// Whether retrying or failing over could plausibly succeed.
    ///
    /// A grant violation never becomes permitted by retrying; a rate-limited
    /// provider might.
    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())
        );
        // Whole-graph failures have no single 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");
    }

    /// `BoundGraphError` is the `Err` half of every `Result` in the workspace, so its
    /// size is paid even when nothing fails. Boxing the grant payloads keeps it
    /// well inside clippy's 128-byte `result_large_err` threshold; this test
    /// fails if a future variant undoes that.
    #[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"
        );
    }
}