1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum LinkEnd {
29 Source,
30 Target,
31}
32
33pub const LINK_CYCLE_ERR_PREFIX: &str = "link refused: reflection cycle";
39
40pub const LINK_PERMISSION_DENIED_ERR_PREFIX: &str = "link denied by permission rule";
44
45#[derive(Debug, Clone)]
51pub enum StorageError {
52 MemoryNotFound { id: String, role: Option<LinkEnd> },
57
58 PendingActionNotFound { pending_id: String },
60
61 AmbiguousIdPrefix {
65 prefix: String,
66 candidates: Vec<String>,
67 },
68
69 InvalidArgument { reason: String },
74
75 PendingActionStateInvalid {
78 #[allow(dead_code)] pending_id: String,
80 status: String,
81 },
82
83 LinkPermissionDenied { reason: String },
87
88 LinkReflectionCycle {
91 source_id: String,
92 target_id: String,
93 },
94
95 ApproverLaundering {
98 pending_id: String,
99 claimed: String,
100 requester: String,
101 },
102
103 UniqueConflict { reason: String },
107
108 ArchiveRestoreCollision { id: String },
112
113 ArchiveSupersedeFailed { archived_id: String },
117
118 SqlcipherMissingPassphrase,
121}
122
123impl std::fmt::Display for StorageError {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self {
126 Self::MemoryNotFound { id, role: None } => write!(f, "memory not found: {id}"),
127 Self::MemoryNotFound {
128 id,
129 role: Some(LinkEnd::Source),
130 } => write!(f, "source memory not found: {id}"),
131 Self::MemoryNotFound {
132 id,
133 role: Some(LinkEnd::Target),
134 } => write!(f, "target memory not found: {id}"),
135 Self::PendingActionNotFound { pending_id } => {
136 write!(f, "pending action not found: {pending_id}")
137 }
138 Self::AmbiguousIdPrefix { prefix, candidates } => write!(
139 f,
140 "ambiguous ID prefix '{prefix}': {n} matches\n{ids}",
141 n = candidates.len(),
142 ids = candidates.join("\n"),
143 ),
144 Self::InvalidArgument { reason } => write!(f, "{reason}"),
145 Self::PendingActionStateInvalid { status, .. } => {
146 write!(f, "cannot execute non-approved action (status={status})")
147 }
148 Self::LinkPermissionDenied { reason } => {
149 write!(f, "{LINK_PERMISSION_DENIED_ERR_PREFIX}: {reason}")
150 }
151 Self::LinkReflectionCycle {
152 source_id,
153 target_id,
154 } => write!(
155 f,
156 "{LINK_CYCLE_ERR_PREFIX}: \
157 {source_id} --reflects_on--> {target_id} would close a cycle",
158 ),
159 Self::ApproverLaundering {
160 pending_id,
161 claimed,
162 requester,
163 } => write!(
164 f,
165 "approver-on-behalf laundering refused: payload agent_id '{claimed}' \
166 != requested_by '{requester}' (pending_id={pending_id})",
167 ),
168 Self::UniqueConflict { reason } => write!(f, "{reason}"),
169 Self::ArchiveRestoreCollision { id } => write!(
170 f,
171 "cannot restore: memory {id} already exists in active table (would overwrite)",
172 ),
173 Self::ArchiveSupersedeFailed { archived_id } => {
174 write!(f, "supersede archive failed for {archived_id}")
175 }
176 Self::SqlcipherMissingPassphrase => write!(
177 f,
178 "sqlcipher build requires AI_MEMORY_DB_PASSPHRASE \
179 (set via --db-passphrase-file <path>)",
180 ),
181 }
182 }
183}
184
185impl std::error::Error for StorageError {}
186
187impl StorageError {
188 #[must_use]
201 pub fn code(&self) -> &'static str {
202 match self {
203 Self::MemoryNotFound { .. } => crate::errors::error_codes::NOT_FOUND,
204 Self::PendingActionNotFound { .. } => {
205 crate::errors::error_codes::PENDING_ACTION_NOT_FOUND
206 }
207 Self::AmbiguousIdPrefix { .. } => crate::errors::error_codes::AMBIGUOUS_ID_PREFIX,
208 Self::InvalidArgument { .. } => crate::errors::error_codes::INVALID_ARGUMENT,
209 Self::PendingActionStateInvalid { .. } => {
210 crate::errors::error_codes::PENDING_ACTION_STATE_INVALID
211 }
212 Self::LinkPermissionDenied { .. } => crate::errors::error_codes::LINK_PERMISSION_DENIED,
213 Self::LinkReflectionCycle { .. } => crate::errors::error_codes::LINK_REFLECTION_CYCLE,
214 Self::ApproverLaundering { .. } => crate::errors::error_codes::APPROVER_LAUNDERING,
215 Self::UniqueConflict { .. } => crate::errors::error_codes::UNIQUE_CONFLICT,
216 Self::ArchiveRestoreCollision { .. } => {
217 crate::errors::error_codes::ARCHIVE_RESTORE_COLLISION
218 }
219 Self::ArchiveSupersedeFailed { .. } => {
220 crate::errors::error_codes::ARCHIVE_SUPERSEDE_FAILED
221 }
222 Self::SqlcipherMissingPassphrase => {
223 crate::errors::error_codes::SQLCIPHER_MISSING_PASSPHRASE
224 }
225 }
226 }
227}
228
229#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn display_memory_not_found_bare() {
241 let e = StorageError::MemoryNotFound {
242 id: "abc123".into(),
243 role: None,
244 };
245 assert_eq!(e.to_string(), "memory not found: abc123");
246 }
247
248 #[test]
249 fn display_memory_not_found_source() {
250 let e = StorageError::MemoryNotFound {
251 id: "src1".into(),
252 role: Some(LinkEnd::Source),
253 };
254 assert_eq!(e.to_string(), "source memory not found: src1");
255 }
256
257 #[test]
258 fn display_memory_not_found_target() {
259 let e = StorageError::MemoryNotFound {
260 id: "tgt1".into(),
261 role: Some(LinkEnd::Target),
262 };
263 assert_eq!(e.to_string(), "target memory not found: tgt1");
264 }
265
266 #[test]
267 fn display_pending_action_not_found() {
268 let e = StorageError::PendingActionNotFound {
269 pending_id: "pa-7".into(),
270 };
271 assert_eq!(e.to_string(), "pending action not found: pa-7");
272 }
273
274 #[test]
275 fn display_ambiguous_id_prefix_preserves_legacy_format() {
276 let e = StorageError::AmbiguousIdPrefix {
277 prefix: "ab".into(),
278 candidates: vec!["abc1".into(), "abc2".into()],
279 };
280 assert_eq!(
284 e.to_string(),
285 "ambiguous ID prefix 'ab': 2 matches\nabc1\nabc2",
286 );
287 }
288
289 #[test]
290 fn display_invalid_argument_passes_reason_through() {
291 let e = StorageError::InvalidArgument {
292 reason: "max_depth must be >= 1".into(),
293 };
294 assert_eq!(e.to_string(), "max_depth must be >= 1");
295 }
296
297 #[test]
298 fn display_pending_action_state_invalid() {
299 let e = StorageError::PendingActionStateInvalid {
300 pending_id: "pa-9".into(),
301 status: "rejected".into(),
302 };
303 assert_eq!(
304 e.to_string(),
305 "cannot execute non-approved action (status=rejected)",
306 );
307 }
308
309 #[test]
310 fn display_link_permission_denied_starts_with_canonical_prefix() {
311 let e = StorageError::LinkPermissionDenied {
312 reason: "rule R042 fired".into(),
313 };
314 let s = e.to_string();
315 assert!(
316 s.starts_with(LINK_PERMISSION_DENIED_ERR_PREFIX),
317 "expected canonical prefix, got: {s}",
318 );
319 assert_eq!(
320 s,
321 format!("{LINK_PERMISSION_DENIED_ERR_PREFIX}: rule R042 fired")
322 );
323 }
324
325 #[test]
326 fn display_link_reflection_cycle_starts_with_canonical_prefix() {
327 let e = StorageError::LinkReflectionCycle {
328 source_id: "a".into(),
329 target_id: "b".into(),
330 };
331 let s = e.to_string();
332 assert!(
333 s.starts_with(LINK_CYCLE_ERR_PREFIX),
334 "expected canonical prefix, got: {s}",
335 );
336 assert!(s.contains("a --reflects_on--> b"));
337 }
338
339 #[test]
340 fn display_approver_laundering_includes_all_fields() {
341 let e = StorageError::ApproverLaundering {
342 pending_id: "pa-1".into(),
343 claimed: "agent-x".into(),
344 requester: "agent-y".into(),
345 };
346 let s = e.to_string();
347 assert!(s.contains("'agent-x'"));
348 assert!(s.contains("'agent-y'"));
349 assert!(s.contains("pending_id=pa-1"));
350 }
351
352 #[test]
353 fn display_unique_conflict_passes_reason_through() {
354 let e = StorageError::UniqueConflict {
355 reason: "title 'X' already exists".into(),
356 };
357 assert_eq!(e.to_string(), "title 'X' already exists");
358 }
359
360 #[test]
361 fn display_archive_restore_collision_format() {
362 let e = StorageError::ArchiveRestoreCollision { id: "m1".into() };
363 assert_eq!(
364 e.to_string(),
365 "cannot restore: memory m1 already exists in active table (would overwrite)",
366 );
367 }
368
369 #[test]
370 fn display_archive_supersede_failed_format() {
371 let e = StorageError::ArchiveSupersedeFailed {
372 archived_id: "arch-7".into(),
373 };
374 assert_eq!(e.to_string(), "supersede archive failed for arch-7");
375 }
376
377 #[test]
378 fn display_sqlcipher_missing_passphrase_format() {
379 let e = StorageError::SqlcipherMissingPassphrase;
380 assert!(e.to_string().contains("AI_MEMORY_DB_PASSPHRASE"));
381 assert!(e.to_string().contains("--db-passphrase-file"));
382 }
383
384 #[test]
385 fn anyhow_from_storage_error_roundtrip_preserves_downcast() {
386 let e: anyhow::Error = anyhow::Error::new(StorageError::MemoryNotFound {
391 id: "id1".into(),
392 role: None,
393 });
394 let recovered = e
395 .downcast_ref::<StorageError>()
396 .expect("typed error must survive anyhow round-trip");
397 assert!(matches!(recovered, StorageError::MemoryNotFound { .. }));
398 }
399}