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