Skip to main content

ai_memory/storage/
error.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Typed substrate-layer error envelope (issue #962).
5//!
6//! Before this module, `src/storage/` returned `anyhow::Result<T>` and
7//! emitted `anyhow::bail!("memory not found: …")` etc. Handlers downcast
8//! to error strings (`msg.contains("ambiguous ID prefix")`) and lost
9//! typed information at every layer transition. This module captures
10//! the substrate's refusal categories as discriminable variants so the
11//! HTTP / MCP / CLI surfaces can pattern-match instead of string-match.
12//!
13//! Wire shape is preserved: `StorageError` is wrapped via
14//! `anyhow::Error::new(StorageError::…)` so the existing
15//! `anyhow::Result<T>` return type stays unchanged, and the handler
16//! layer's `MemoryError::from(anyhow::Error)` impl downcasts to map each
17//! variant to the right HTTP status. This is the same pattern used by
18//! [`super::GovernanceRefusal`], [`super::ConflictError`], and
19//! [`super::VersionConflict`].
20
21/// Identifies which end of a link a missing-memory refusal refers to.
22/// `None` is reserved for memory-not-found errors that are not part of
23/// a link operation. The `Source` and `Target` variants preserve the
24/// pre-#962 user-facing error prefixes ("source memory not found: …" /
25/// "target memory not found: …") so existing string-matching consumers
26/// keep working through the typed enum's Display impl.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum LinkEnd {
29    Source,
30    Target,
31}
32
33/// Error prefix emitted when `validate_link_pre_create` rejects a
34/// `reflects_on` edge that would close a cycle in the reflection graph.
35/// HTTP / SAL response mappers look for this prefix to surface 409
36/// CONFLICT; MCP surfaces it as a plain text error. Centralised so all
37/// three entry points stay in lockstep with [`StorageError::LinkReflectionCycle`].
38pub const LINK_CYCLE_ERR_PREFIX: &str = "link refused: reflection cycle";
39
40/// Error prefix emitted when the K9 permission pipeline returns `Deny`
41/// for a link write. HTTP / SAL response mappers translate this to 403
42/// FORBIDDEN. Paired with [`StorageError::LinkPermissionDenied`].
43pub const LINK_PERMISSION_DENIED_ERR_PREFIX: &str = "link denied by permission rule";
44
45/// Typed substrate-layer error categories. Each variant maps to a
46/// canonical HTTP status via `MemoryError::from(anyhow::Error)` and
47/// preserves the original `bail!()` message verbatim via Display so
48/// downstream `.to_string().starts_with(...)` and `.contains(...)`
49/// consumers keep working through the typed layer.
50#[derive(Debug, Clone)]
51pub enum StorageError {
52    /// Memory id (or link source/target memory id) does not resolve to
53    /// a row. `role = None` is the bare lookup ("memory not found:
54    /// `<id>`"); `role = Some(Source|Target)` qualifies the message for
55    /// link-creation paths.
56    MemoryNotFound { id: String, role: Option<LinkEnd> },
57
58    /// Pending-action lookup miss in the approvals path.
59    PendingActionNotFound { pending_id: String },
60
61    /// Truncated id prefix matches multiple memories. The full
62    /// candidate list is surfaced so the caller can retry with a
63    /// longer prefix.
64    AmbiguousIdPrefix {
65        prefix: String,
66        candidates: Vec<String>,
67    },
68
69    /// Caller-supplied argument failed substrate validation. Covers
70    /// max_depth bounds, older_than_days sign, namespace shape,
71    /// action_type, reflect-payload shape, and similar simple
72    /// validations that map to HTTP 400.
73    InvalidArgument { reason: String },
74
75    /// Pending action exists but cannot be executed in its current
76    /// status (substrate refuses to execute non-approved actions).
77    PendingActionStateInvalid {
78        #[allow(dead_code)] // Carried for future typed handlers.
79        pending_id: String,
80        status: String,
81    },
82
83    /// Substrate-level link permission denied (governance Deny, or
84    /// Ask→Deny because the storage layer has no Ask channel).
85    /// Display starts with [`LINK_PERMISSION_DENIED_ERR_PREFIX`].
86    LinkPermissionDenied { reason: String },
87
88    /// Adding the proposed `reflects_on` edge would close a cycle in
89    /// the reflection DAG. Display starts with [`LINK_CYCLE_ERR_PREFIX`].
90    LinkReflectionCycle {
91        source_id: String,
92        target_id: String,
93    },
94
95    /// Approver-on-behalf laundering refused (S5-H4): the claimed
96    /// payload `agent_id` does not match the original `requested_by`.
97    ApproverLaundering {
98        pending_id: String,
99        claimed: String,
100        requester: String,
101    },
102
103    /// Title / uniqueness conflict (existing memory or entity collision
104    /// in the same namespace, or backend exhaustion of versioned-title
105    /// suffixes within the cap).
106    UniqueConflict { reason: String },
107
108    /// Restore-from-archive would overwrite an active-table row. The
109    /// caller must explicitly delete the active row first or restore
110    /// to a different id.
111    ArchiveRestoreCollision { id: String },
112
113    /// Archive supersede transaction did not affect the expected row.
114    /// Either the archive row vanished between read and write, or the
115    /// DB is corrupt.
116    ArchiveSupersedeFailed { archived_id: String },
117
118    /// SQLCipher build started without `AI_MEMORY_DB_PASSPHRASE`.
119    /// Fatal at boot; surfaces as an `apply_sqlcipher_key` refusal.
120    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    /// ARCH-9 (FX-C4-batch2, 2026-05-26) — canonical stable error
189    /// slug for each variant.
190    ///
191    /// Returns a `&'static str` that mirrors the
192    /// [`crate::errors::MemoryError::code`] discipline. The slug is
193    /// the load-bearing key for cross-surface (HTTP/MCP/CLI) parity
194    /// tests and for structured-trace fields. Adding a variant
195    /// requires extending this match — the
196    /// `#[deny(unreachable_patterns)]` attribute on the outer match
197    /// catches dead arms; the test `arch_9_storage_error_slug_*`
198    /// in [`crate::errors::error_codes::tests`] pins the slug-set against a
199    /// regression.
200    #[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// Note: `anyhow::Error::from<E: StdError>` already covers `StorageError`
230// via the blanket impl, so an explicit `From<StorageError> for
231// anyhow::Error` would conflict. Substrate code wraps via the explicit
232// `anyhow::Error::new(StorageError::…)` constructor for clarity at the
233// call sites, but `?` + `Into` also work transparently.
234
235#[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        // The legacy bail! format `"ambiguous ID prefix 'X': N matches\n<ids>"`
281        // is preserved so existing `.to_string().contains("ambiguous ID prefix")`
282        // call sites continue to match through the typed envelope.
283        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        // Wrap via the same constructor substrate code uses
387        // (anyhow's blanket `From<E: StdError>` impl is what materially
388        // moves the value into the chain; we test that downcast still
389        // recovers the typed variant on the other side).
390        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}