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 an empty `provenance()` string.
41    /// The empty-provenance contract reserves `""` for filter / observer
42    /// suggestors that never emit proposals. If a suggestor produced
43    /// proposals it MUST override `provenance()` to return its crate's
44    /// canonical `*_PROVENANCE.as_str()`.
45    #[error(
46        "suggestor '{suggestor}' emitted proposals with empty provenance — \
47        override Suggestor::provenance() to return your crate's canonical \
48        ProvenanceSource string"
49    )]
50    EmptyProvenance {
51        /// Name of the offending suggestor (from `Suggestor::name()`).
52        suggestor: String,
53    },
54
55    /// Invalid HITL gate resume (e.g., gate_id mismatch between decision and pause).
56    #[error("invalid gate resume: {reason}")]
57    InvalidResume {
58        /// What went wrong.
59        reason: String,
60    },
61
62    /// Invalid external observation admission.
63    #[error("invalid admission: {reason}")]
64    InvalidAdmission {
65        /// What went wrong.
66        reason: String,
67    },
68
69    /// Invalid persisted context snapshot.
70    #[error("invalid context snapshot: {reason}")]
71    InvalidSnapshot {
72        /// What went wrong.
73        reason: String,
74    },
75
76    /// Conflicting facts detected for the same ID.
77    #[error(
78        "conflict detected for fact '{id}': existing content '{existing}' vs new content '{new}'"
79    )]
80    Conflict {
81        /// ID of the conflicting fact.
82        id: String,
83        /// Existing content.
84        existing: String,
85        /// New conflicting content.
86        new: String,
87        /// Final context state. Boxed to reduce error size.
88        context: Box<ContextState>,
89    },
90}
91
92impl ConvergeError {
93    /// Returns a reference to the context if this error variant carries one.
94    #[must_use]
95    pub fn context(&self) -> Option<&ContextState> {
96        match self {
97            Self::InvariantViolation { context, .. } | Self::Conflict { context, .. } => {
98                Some(context)
99            }
100            Self::BudgetExhausted { .. }
101            | Self::AgentFailed { .. }
102            | Self::EmptyProvenance { .. }
103            | Self::InvalidResume { .. }
104            | Self::InvalidAdmission { .. }
105            | Self::InvalidSnapshot { .. } => None,
106        }
107    }
108
109    /// Convert this error into a platform-level stop reason.
110    #[must_use]
111    pub fn stop_reason(&self) -> StopReason {
112        match self {
113            Self::BudgetExhausted { kind } => StopReason::Error {
114                message: format!("budget exhausted: {kind}"),
115                category: crate::gates::ErrorCategory::Resource,
116            },
117            Self::InvariantViolation {
118                name,
119                class,
120                reason,
121                ..
122            } => StopReason::invariant_violated(*class, name.clone(), reason.clone()),
123            Self::AgentFailed { agent_id } => StopReason::AgentRefused {
124                agent_id: agent_id.clone(),
125                reason: "agent execution failed".to_string(),
126            },
127            Self::EmptyProvenance { suggestor } => StopReason::AgentRefused {
128                agent_id: suggestor.clone(),
129                reason: "suggestor emitted proposals with empty provenance".to_string(),
130            },
131            Self::InvalidResume { reason } => StopReason::Error {
132                message: format!("invalid gate resume: {reason}"),
133                category: crate::gates::ErrorCategory::Internal,
134            },
135            Self::InvalidAdmission { reason } => StopReason::Error {
136                message: format!("invalid admission: {reason}"),
137                category: crate::gates::ErrorCategory::Configuration,
138            },
139            Self::InvalidSnapshot { reason } => StopReason::Error {
140                message: format!("invalid context snapshot: {reason}"),
141                category: crate::gates::ErrorCategory::Configuration,
142            },
143            Self::Conflict {
144                id, existing, new, ..
145            } => StopReason::Error {
146                message: format!("conflict for fact '{id}': existing '{existing}' vs new '{new}'"),
147                category: crate::gates::ErrorCategory::Internal,
148            },
149        }
150    }
151}
152
153impl From<crate::AdmissionError> for ConvergeError {
154    fn from(value: crate::AdmissionError) -> Self {
155        Self::InvalidAdmission {
156            reason: value.to_string(),
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn empty_context() -> ContextState {
166        ContextState::default()
167    }
168
169    #[test]
170    fn budget_exhausted_display() {
171        let err = ConvergeError::BudgetExhausted {
172            kind: "cycles".into(),
173        };
174        assert_eq!(err.to_string(), "budget exhausted: cycles");
175    }
176
177    #[test]
178    fn budget_exhausted_has_no_context() {
179        let err = ConvergeError::BudgetExhausted {
180            kind: "tokens".into(),
181        };
182        assert!(err.context().is_none());
183    }
184
185    #[test]
186    fn agent_failed_display() {
187        let err = ConvergeError::AgentFailed {
188            agent_id: "agent-x".into(),
189        };
190        assert_eq!(err.to_string(), "agent failed: agent-x");
191    }
192
193    #[test]
194    fn agent_failed_has_no_context() {
195        let err = ConvergeError::AgentFailed {
196            agent_id: "a".into(),
197        };
198        assert!(err.context().is_none());
199    }
200
201    #[test]
202    fn invariant_violation_has_context() {
203        let err = ConvergeError::InvariantViolation {
204            name: "no_empty".into(),
205            class: InvariantClass::Structural,
206            reason: "empty found".into(),
207            context: Box::new(empty_context()),
208        };
209        assert!(err.context().is_some());
210    }
211
212    #[test]
213    fn invariant_violation_display() {
214        let err = ConvergeError::InvariantViolation {
215            name: "no_empty".into(),
216            class: InvariantClass::Semantic,
217            reason: "bad".into(),
218            context: Box::new(empty_context()),
219        };
220        assert_eq!(
221            err.to_string(),
222            "Semantic invariant 'no_empty' violated: bad"
223        );
224    }
225
226    #[test]
227    fn conflict_has_context() {
228        let err = ConvergeError::Conflict {
229            id: "fact-1".into(),
230            existing: "old".into(),
231            new: "new".into(),
232            context: Box::new(empty_context()),
233        };
234        assert!(err.context().is_some());
235    }
236
237    #[test]
238    fn conflict_display() {
239        let err = ConvergeError::Conflict {
240            id: "f".into(),
241            existing: "a".into(),
242            new: "b".into(),
243            context: Box::new(empty_context()),
244        };
245        assert!(err.to_string().contains("conflict detected for fact 'f'"));
246    }
247
248    #[test]
249    fn stop_reason_budget_exhausted() {
250        let err = ConvergeError::BudgetExhausted {
251            kind: "time".into(),
252        };
253        let reason = err.stop_reason();
254        assert!(matches!(reason, StopReason::Error { .. }));
255    }
256
257    #[test]
258    fn stop_reason_invariant_violated() {
259        let err = ConvergeError::InvariantViolation {
260            name: "inv".into(),
261            class: InvariantClass::Acceptance,
262            reason: "fail".into(),
263            context: Box::new(empty_context()),
264        };
265        let reason = err.stop_reason();
266        assert!(matches!(reason, StopReason::InvariantViolated { .. }));
267    }
268
269    #[test]
270    fn stop_reason_agent_refused() {
271        let err = ConvergeError::AgentFailed {
272            agent_id: "bot".into(),
273        };
274        let reason = err.stop_reason();
275        assert!(matches!(reason, StopReason::AgentRefused { .. }));
276    }
277
278    #[test]
279    fn invalid_resume_display() {
280        let err = ConvergeError::InvalidResume {
281            reason: "gate_id mismatch".into(),
282        };
283        assert_eq!(err.to_string(), "invalid gate resume: gate_id mismatch");
284    }
285
286    #[test]
287    fn invalid_resume_has_no_context() {
288        let err = ConvergeError::InvalidResume {
289            reason: "test".into(),
290        };
291        assert!(err.context().is_none());
292    }
293
294    #[test]
295    fn stop_reason_invalid_resume() {
296        let err = ConvergeError::InvalidResume {
297            reason: "wrong gate".into(),
298        };
299        let reason = err.stop_reason();
300        assert!(matches!(reason, StopReason::Error { .. }));
301    }
302
303    #[test]
304    fn stop_reason_conflict() {
305        let err = ConvergeError::Conflict {
306            id: "x".into(),
307            existing: "old".into(),
308            new: "new".into(),
309            context: Box::new(empty_context()),
310        };
311        let reason = err.stop_reason();
312        assert!(matches!(reason, StopReason::Error { .. }));
313    }
314}