Skip to main content

converge_core/
error.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Error types for Converge.
5
6use serde::Serialize;
7use thiserror::Error;
8
9use crate::context::ContextState;
10use crate::gates::StopReason;
11use crate::invariant::InvariantClass;
12
13/// Top-level error type for Converge operations.
14///
15/// Note: Context is boxed in error variants to keep the error type small,
16/// as recommended by clippy. Access via `error.context()` method.
17#[derive(Debug, Error, Serialize)]
18pub enum ConvergeError {
19    /// Budget limit exceeded (cycles, facts, or time).
20    #[error("budget exhausted: {kind}")]
21    BudgetExhausted { kind: String },
22
23    /// An invariant was violated during execution.
24    #[error("{class:?} invariant '{name}' violated: {reason}")]
25    InvariantViolation {
26        /// Name of the violated invariant.
27        name: String,
28        /// Class of the invariant (Structural, Semantic, Acceptance).
29        class: InvariantClass,
30        /// Reason for the violation.
31        reason: String,
32        /// Final context state (including diagnostic facts). Boxed to reduce error size.
33        context: Box<ContextState>,
34    },
35
36    /// Suggestor execution failed.
37    #[error("agent failed: {agent_id}")]
38    AgentFailed { agent_id: String },
39
40    /// A fact-emitting suggestor returned a proposal with empty provenance.
41    /// Provenance is validated on the proposal because the proposal is the
42    /// kernel boundary object that carries audit metadata into promotion.
43    #[error(
44        "suggestor '{suggestor}' emitted proposals with empty provenance — \
45        stamp each ProposedFact with a non-empty ProvenanceSource string"
46    )]
47    EmptyProvenance {
48        /// Name of the offending suggestor (from `Suggestor::name()`).
49        suggestor: String,
50    },
51
52    /// Invalid HITL gate resume (e.g., gate_id mismatch between decision and pause).
53    #[error("invalid gate resume: {reason}")]
54    InvalidResume {
55        /// What went wrong.
56        reason: String,
57    },
58
59    /// Invalid external observation admission.
60    #[error("invalid admission: {reason}")]
61    InvalidAdmission {
62        /// What went wrong.
63        reason: String,
64    },
65
66    /// Invalid persisted context snapshot.
67    #[error("invalid context snapshot: {reason}")]
68    InvalidSnapshot {
69        /// What went wrong.
70        reason: String,
71    },
72
73    /// Conflicting facts detected for the same ID.
74    #[error(
75        "conflict detected for fact '{id}': existing content '{existing}' vs new content '{new}'"
76    )]
77    Conflict {
78        /// ID of the conflicting fact.
79        id: String,
80        /// Existing content.
81        existing: String,
82        /// New conflicting content.
83        new: String,
84        /// Final context state. Boxed to reduce error size.
85        context: Box<ContextState>,
86    },
87}
88
89impl ConvergeError {
90    /// Returns a reference to the context if this error variant carries one.
91    #[must_use]
92    pub fn context(&self) -> Option<&ContextState> {
93        match self {
94            Self::InvariantViolation { context, .. } | Self::Conflict { context, .. } => {
95                Some(context)
96            }
97            Self::BudgetExhausted { .. }
98            | Self::AgentFailed { .. }
99            | Self::EmptyProvenance { .. }
100            | Self::InvalidResume { .. }
101            | Self::InvalidAdmission { .. }
102            | Self::InvalidSnapshot { .. } => None,
103        }
104    }
105
106    /// Convert this error into a platform-level stop reason.
107    #[must_use]
108    pub fn stop_reason(&self) -> StopReason {
109        match self {
110            Self::BudgetExhausted { kind } => StopReason::Error {
111                message: format!("budget exhausted: {kind}"),
112                category: crate::gates::ErrorCategory::Resource,
113            },
114            Self::InvariantViolation {
115                name,
116                class,
117                reason,
118                ..
119            } => StopReason::invariant_violated(*class, name.clone(), reason.clone()),
120            Self::AgentFailed { agent_id } => StopReason::AgentRefused {
121                agent_id: agent_id.clone(),
122                reason: "agent execution failed".to_string(),
123            },
124            Self::EmptyProvenance { suggestor } => StopReason::AgentRefused {
125                agent_id: suggestor.clone(),
126                reason: "suggestor emitted proposals with empty provenance".to_string(),
127            },
128            Self::InvalidResume { reason } => StopReason::Error {
129                message: format!("invalid gate resume: {reason}"),
130                category: crate::gates::ErrorCategory::Internal,
131            },
132            Self::InvalidAdmission { reason } => StopReason::Error {
133                message: format!("invalid admission: {reason}"),
134                category: crate::gates::ErrorCategory::Configuration,
135            },
136            Self::InvalidSnapshot { reason } => StopReason::Error {
137                message: format!("invalid context snapshot: {reason}"),
138                category: crate::gates::ErrorCategory::Configuration,
139            },
140            Self::Conflict {
141                id, existing, new, ..
142            } => StopReason::Error {
143                message: format!("conflict for fact '{id}': existing '{existing}' vs new '{new}'"),
144                category: crate::gates::ErrorCategory::Internal,
145            },
146        }
147    }
148}
149
150impl From<crate::AdmissionError> for ConvergeError {
151    fn from(value: crate::AdmissionError) -> Self {
152        Self::InvalidAdmission {
153            reason: value.to_string(),
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn empty_context() -> ContextState {
163        ContextState::default()
164    }
165
166    #[test]
167    fn budget_exhausted_display() {
168        let err = ConvergeError::BudgetExhausted {
169            kind: "cycles".into(),
170        };
171        assert_eq!(err.to_string(), "budget exhausted: cycles");
172    }
173
174    #[test]
175    fn budget_exhausted_has_no_context() {
176        let err = ConvergeError::BudgetExhausted {
177            kind: "tokens".into(),
178        };
179        assert!(err.context().is_none());
180    }
181
182    #[test]
183    fn agent_failed_display() {
184        let err = ConvergeError::AgentFailed {
185            agent_id: "agent-x".into(),
186        };
187        assert_eq!(err.to_string(), "agent failed: agent-x");
188    }
189
190    #[test]
191    fn agent_failed_has_no_context() {
192        let err = ConvergeError::AgentFailed {
193            agent_id: "a".into(),
194        };
195        assert!(err.context().is_none());
196    }
197
198    #[test]
199    fn invariant_violation_has_context() {
200        let err = ConvergeError::InvariantViolation {
201            name: "no_empty".into(),
202            class: InvariantClass::Structural,
203            reason: "empty found".into(),
204            context: Box::new(empty_context()),
205        };
206        assert!(err.context().is_some());
207    }
208
209    #[test]
210    fn invariant_violation_display() {
211        let err = ConvergeError::InvariantViolation {
212            name: "no_empty".into(),
213            class: InvariantClass::Semantic,
214            reason: "bad".into(),
215            context: Box::new(empty_context()),
216        };
217        assert_eq!(
218            err.to_string(),
219            "Semantic invariant 'no_empty' violated: bad"
220        );
221    }
222
223    #[test]
224    fn conflict_has_context() {
225        let err = ConvergeError::Conflict {
226            id: "fact-1".into(),
227            existing: "old".into(),
228            new: "new".into(),
229            context: Box::new(empty_context()),
230        };
231        assert!(err.context().is_some());
232    }
233
234    #[test]
235    fn conflict_display() {
236        let err = ConvergeError::Conflict {
237            id: "f".into(),
238            existing: "a".into(),
239            new: "b".into(),
240            context: Box::new(empty_context()),
241        };
242        assert!(err.to_string().contains("conflict detected for fact 'f'"));
243    }
244
245    #[test]
246    fn stop_reason_budget_exhausted() {
247        let err = ConvergeError::BudgetExhausted {
248            kind: "time".into(),
249        };
250        let reason = err.stop_reason();
251        assert!(matches!(reason, StopReason::Error { .. }));
252    }
253
254    #[test]
255    fn stop_reason_invariant_violated() {
256        let err = ConvergeError::InvariantViolation {
257            name: "inv".into(),
258            class: InvariantClass::Acceptance,
259            reason: "fail".into(),
260            context: Box::new(empty_context()),
261        };
262        let reason = err.stop_reason();
263        assert!(matches!(reason, StopReason::InvariantViolated { .. }));
264    }
265
266    #[test]
267    fn stop_reason_agent_refused() {
268        let err = ConvergeError::AgentFailed {
269            agent_id: "bot".into(),
270        };
271        let reason = err.stop_reason();
272        assert!(matches!(reason, StopReason::AgentRefused { .. }));
273    }
274
275    #[test]
276    fn invalid_resume_display() {
277        let err = ConvergeError::InvalidResume {
278            reason: "gate_id mismatch".into(),
279        };
280        assert_eq!(err.to_string(), "invalid gate resume: gate_id mismatch");
281    }
282
283    #[test]
284    fn invalid_resume_has_no_context() {
285        let err = ConvergeError::InvalidResume {
286            reason: "test".into(),
287        };
288        assert!(err.context().is_none());
289    }
290
291    #[test]
292    fn stop_reason_invalid_resume() {
293        let err = ConvergeError::InvalidResume {
294            reason: "wrong gate".into(),
295        };
296        let reason = err.stop_reason();
297        assert!(matches!(reason, StopReason::Error { .. }));
298    }
299
300    #[test]
301    fn stop_reason_conflict() {
302        let err = ConvergeError::Conflict {
303            id: "x".into(),
304            existing: "old".into(),
305            new: "new".into(),
306            context: Box::new(empty_context()),
307        };
308        let reason = err.stop_reason();
309        assert!(matches!(reason, StopReason::Error { .. }));
310    }
311}