Skip to main content

codetether_agent/session/
faults.rs

1//! Observable faults with typed reason codes (ClawVM §3).
2//!
3//! Core invariant: no subsystem that participates in context
4//! construction or recall ever returns an *empty* result silently. A
5//! genuine "no match" is a [`Fault::NoMatch`], a denied backend is a
6//! [`Fault::Denied`], and so on. This makes every recall / derivation
7//! failure diagnosable without adding instrumentation after the fact.
8//!
9//! ClawVM shows that silent returns (their "silent-recall" failure
10//! family) are one of the three root causes of agent memory bugs in
11//! the field. Surfacing them as typed reason codes is the Phase A
12//! structural remedy.
13//!
14//! ## Examples
15//!
16//! ```rust
17//! use codetether_agent::session::faults::Fault;
18//!
19//! // Distinct from `Option::None` — a caller can tell an empty
20//! // archive apart from an un-authorised backend from a transport
21//! // error.
22//! let f = Fault::Denied {
23//!     reason: "policy rejected session_recall".into(),
24//! };
25//! assert!(f.is_denied());
26//! ```
27
28use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31/// Typed fault reason codes surfaced from context construction,
32/// recall, and writeback.
33///
34/// Each variant is a terminal outcome that should be logged or
35/// rendered in the audit view; none of them is an internal retry
36/// signal. Callers that want to distinguish recoverable errors from
37/// terminal faults should match on the variant.
38#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
39#[serde(tag = "kind", rename_all = "snake_case")]
40pub enum Fault {
41    /// The query ran to completion but produced no matching entries.
42    /// Distinct from an error — this is the empty-result reason code.
43    #[error("no match")]
44    NoMatch,
45    /// The backend rejected the request (policy, auth, rate-limit).
46    #[error("denied: {reason}")]
47    Denied { reason: String },
48    /// The backend returned a transport / IO error.
49    #[error("backend error: {reason}")]
50    BackendError { reason: String },
51    /// A dirty page was dropped because the writeback protocol was
52    /// skipped or bypassed.
53    #[error("flush miss")]
54    FlushMiss,
55    /// A `Bootstrap` page that should have been pinned was missing
56    /// after compaction / reset.
57    #[error("post-compaction bootstrap fault")]
58    PostCompactionBootstrap,
59    /// A hard-pinned page was not present at prompt-assembly time.
60    #[error("pinned invariant miss")]
61    PinnedInvariantMiss,
62    /// An equivalent tool call ran again because its previous result
63    /// was evicted.
64    #[error("duplicate tool")]
65    DuplicateTool,
66    /// A tool result had to be re-fetched after eviction.
67    #[error("refetch")]
68    Refetch,
69}
70
71impl Fault {
72    /// `true` iff the fault is the empty-result reason code.
73    pub fn is_no_match(&self) -> bool {
74        matches!(self, Fault::NoMatch)
75    }
76
77    /// `true` iff the fault is a policy / auth rejection.
78    pub fn is_denied(&self) -> bool {
79        matches!(self, Fault::Denied { .. })
80    }
81
82    /// Short machine-readable reason-code string suitable for logs
83    /// and TUI audit view filtering.
84    pub fn code(&self) -> &'static str {
85        match self {
86            Fault::NoMatch => "no_match",
87            Fault::Denied { .. } => "denied",
88            Fault::BackendError { .. } => "backend_error",
89            Fault::FlushMiss => "flush_miss",
90            Fault::PostCompactionBootstrap => "post_compaction_bootstrap",
91            Fault::PinnedInvariantMiss => "pinned_invariant_miss",
92            Fault::DuplicateTool => "duplicate_tool",
93            Fault::Refetch => "refetch",
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn no_match_is_its_own_thing() {
104        let f = Fault::NoMatch;
105        assert!(f.is_no_match());
106        assert!(!f.is_denied());
107        assert_eq!(f.code(), "no_match");
108        assert_eq!(f.to_string(), "no match");
109    }
110
111    #[test]
112    fn denied_carries_reason_string() {
113        let f = Fault::Denied {
114            reason: "policy rejected".into(),
115        };
116        assert!(f.is_denied());
117        assert!(f.to_string().contains("policy rejected"));
118        assert_eq!(f.code(), "denied");
119    }
120
121    #[test]
122    fn all_codes_are_unique_snake_case() {
123        let faults = [
124            Fault::NoMatch,
125            Fault::Denied { reason: "x".into() },
126            Fault::BackendError { reason: "y".into() },
127            Fault::FlushMiss,
128            Fault::PostCompactionBootstrap,
129            Fault::PinnedInvariantMiss,
130            Fault::DuplicateTool,
131            Fault::Refetch,
132        ];
133        let codes: Vec<&'static str> = faults.iter().map(Fault::code).collect();
134        let mut seen = codes.clone();
135        seen.sort_unstable();
136        seen.dedup();
137        assert_eq!(seen.len(), codes.len(), "reason codes must be unique");
138        for c in &codes {
139            assert!(!c.is_empty());
140            assert_eq!(c.to_ascii_lowercase(), *c, "codes must be snake_case");
141        }
142    }
143
144    #[test]
145    fn fault_round_trips_through_serde() {
146        let f = Fault::Denied {
147            reason: "policy".into(),
148        };
149        let json = serde_json::to_string(&f).unwrap();
150        assert!(json.contains("\"kind\":\"denied\""));
151        let back: Fault = serde_json::from_str(&json).unwrap();
152        assert_eq!(back, f);
153    }
154}