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}