Skip to main content

ai_memory/
errors.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4use axum::Json;
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde::Serialize;
8
9// ---------------------------------------------------------------------------
10// ARCH-9 (FX-C4-batch2, 2026-05-26) — canonical stable error slugs.
11//
12// Single source of truth for the `&'static str` error slugs returned
13// by `MemoryError::code()`, `StorageError::code()`, and
14// `StoreError::code()`. Hoisting the literal slugs into shared
15// `const`s lets cross-surface parity tests (HTTP / MCP / CLI) assert
16// byte-equal slug strings without re-spelling them at each call
17// site. Any future slug rename touches one constant rather than
18// scattered string literals across three enum bodies and N handler
19// branches.
20//
21// Slug convention: SCREAMING_SNAKE_CASE matching the legacy
22// `MemoryError::code()` output so downstream consumers' string-match
23// expectations carry through.
24// ---------------------------------------------------------------------------
25
26#[allow(dead_code)]
27pub mod error_codes {
28    // ---- MemoryError-side (handler-facing) ----------------------------------
29    pub const NOT_FOUND: &str = "NOT_FOUND";
30    pub const VALIDATION_FAILED: &str = "VALIDATION_FAILED";
31    pub const DATABASE_ERROR: &str = "DATABASE_ERROR";
32    pub const CONFLICT: &str = "CONFLICT";
33    pub const REFLECTION_DEPTH_EXCEEDED: &str = "REFLECTION_DEPTH_EXCEEDED";
34    pub const SYNTHESIS_DEPTH_EXCEEDED: &str = "SYNTHESIS_DEPTH_EXCEEDED";
35    pub const REFLECTION_CYCLE_DETECTED: &str = "REFLECTION_CYCLE_DETECTED";
36    pub const GOVERNANCE_REFUSED: &str = "GOVERNANCE_REFUSED";
37    /// v0.7.0 multi-agent literal-sweep (scanner B finding F-B5.x) —
38    /// per-agent / per-namespace K8 quota exceeded. Emitted by the
39    /// HTTP create-handler (`src/handlers/create.rs`) and the
40    /// equivalent MCP store path when `agent_quotas` enforcement
41    /// trips before the canonical `db::insert` write. Was previously
42    /// a scattered string literal at 4 production sites; centralised
43    /// here as the canonical shared slug.
44    pub const QUOTA_EXCEEDED: &str = "QUOTA_EXCEEDED";
45    /// #626 Layer-3 (C7) — agent-attestation gate rejection on the write
46    /// path. Emitted (403 FORBIDDEN) by the HTTP create-handler
47    /// (`src/handlers/create.rs`) when a presented Ed25519 signature fails
48    /// to verify against the agent's bound public key, or when
49    /// `AI_MEMORY_REQUIRE_AGENT_ATTESTATION` is set and the write is
50    /// unsigned / the agent has no bound key. The MCP store path surfaces
51    /// the same condition as a plain error string.
52    pub const ATTESTATION_FAILED: &str = "ATTESTATION_FAILED";
53
54    // ---- StorageError-side (substrate-facing) -------------------------------
55    pub const PENDING_ACTION_NOT_FOUND: &str = "PENDING_ACTION_NOT_FOUND";
56    pub const AMBIGUOUS_ID_PREFIX: &str = "AMBIGUOUS_ID_PREFIX";
57    pub const INVALID_ARGUMENT: &str = "INVALID_ARGUMENT";
58    pub const PENDING_ACTION_STATE_INVALID: &str = "PENDING_ACTION_STATE_INVALID";
59    pub const LINK_PERMISSION_DENIED: &str = "LINK_PERMISSION_DENIED";
60    pub const LINK_REFLECTION_CYCLE: &str = "LINK_REFLECTION_CYCLE";
61    pub const APPROVER_LAUNDERING: &str = "APPROVER_LAUNDERING";
62    pub const UNIQUE_CONFLICT: &str = "UNIQUE_CONFLICT";
63    pub const ARCHIVE_RESTORE_COLLISION: &str = "ARCHIVE_RESTORE_COLLISION";
64    pub const ARCHIVE_SUPERSEDE_FAILED: &str = "ARCHIVE_SUPERSEDE_FAILED";
65    pub const SQLCIPHER_MISSING_PASSPHRASE: &str = "SQLCIPHER_MISSING_PASSPHRASE";
66
67    // ---- StoreError-side (SAL trait-facing) ---------------------------------
68    pub const STORE_BACKEND_UNAVAILABLE: &str = "BACKEND_UNAVAILABLE";
69    pub const STORE_UNSUPPORTED_CAPABILITY: &str = "UNSUPPORTED_CAPABILITY";
70    pub const STORE_OPERATION_FAILED: &str = "STORE_OPERATION_FAILED";
71    pub const STORE_DATABASE_ERROR: &str = "DATABASE_ERROR";
72    pub const STORE_NOT_FOUND: &str = "NOT_FOUND";
73    pub const STORE_VALIDATION_FAILED: &str = "VALIDATION_FAILED";
74    pub const STORE_GOVERNANCE_REFUSED: &str = "GOVERNANCE_REFUSED";
75    pub const STORE_VERSION_CONFLICT: &str = "VERSION_CONFLICT";
76}
77
78// ---------------------------------------------------------------------------
79// #1558 batch 5 wave 2 — canonical wire-visible error prose.
80//
81// Single source of truth for duplicated human-readable error messages
82// emitted across the HTTP / MCP / CLI surfaces. Hoisting the spellings
83// into shared `const`s (and tiny `format!` helpers for the templated
84// shapes) means a future wording change touches one definition rather
85// than dozens of scattered string literals — and the no-hardcoded-
86// literals ratchet (`scripts/check-hardcoded-literals.sh`) can burn
87// the baseline down. Every value below is BYTE-IDENTICAL to the
88// literal it replaced; this module never rewords.
89//
90// `Display`-impl `write!` bodies and `#[error(...)]` attributes keep
91// their literal spellings (the format string must be a literal there);
92// those irreducible sites stay below the ratchet's 3-site threshold.
93// ---------------------------------------------------------------------------
94
95#[allow(dead_code)]
96pub mod msg {
97    // ---- sanitized 500 body (issue #851 canonical envelope) -----------------
98    pub const INTERNAL_SERVER_ERROR: &str = "internal server error";
99
100    // ---- not-found family ----------------------------------------------------
101    pub const MEMORY_NOT_FOUND: &str = "memory not found";
102    pub const NOT_FOUND_IN_ARCHIVE: &str = "not found in archive";
103    pub const SKILL_NOT_FOUND: &str = "skill not found";
104    pub const SOURCE_MEMORY_NOT_FOUND: &str = "source memory not found";
105    pub const PENDING_ACTION_NOT_FOUND_OR_DECIDED: &str =
106        "pending action not found or already decided";
107
108    // ---- governance ------------------------------------------------------------
109    pub const GOVERNANCE_REQUIRES_APPROVAL: &str = "governance requires approval";
110    pub const GOVERNANCE_CHECK_FAILED: &str = "governance check failed";
111    pub const CONSENSUS_NOT_REACHED: &str = "consensus threshold not yet reached";
112    pub const DECISION_WRITE_FAILED: &str = "decision write failed";
113
114    // ---- ownership / identity ---------------------------------------------------
115    pub const CALLER_NOT_SOURCE_MEMORY_OWNER: &str = "caller does not own this source memory";
116    pub const CALLER_NOT_NAMESPACE_STANDARD_OWNER: &str =
117        "caller does not own this namespace standard";
118    pub const AGENT_ID_BODY_MISMATCH: &str =
119        "agent_id body parameter does not match authenticated caller";
120    pub const AGENT_ID_QUERY_MISMATCH: &str =
121        "agent_id query parameter does not match authenticated caller";
122    pub const INVALID_OR_MISSING_SIGNATURE: &str = "invalid or missing X-AI-Memory-Signature";
123
124    // ---- validation -------------------------------------------------------------
125    pub const FORGET_FILTER_REQUIRED: &str =
126        "at least one of namespace, pattern, or tier is required";
127    pub const MAX_DEPTH_MIN: &str = "max_depth must be >= 1";
128    pub const VERIFY_LINK_ARGS_REQUIRED: &str = "verify_link requires either source_id or link_id";
129    pub const ENTITY_ID_EMPTY: &str = "entity_id cannot be empty";
130    pub const MEMORY_ID_EMPTY: &str = "memory_id cannot be empty";
131
132    // ---- "<field> is required" family --------------------------------------------
133    pub const ID_REQUIRED: &str = "id is required";
134    pub const CONTENT_REQUIRED: &str = "content is required";
135    pub const TITLE_REQUIRED: &str = "title is required";
136    pub const MEMORY_ID_REQUIRED: &str = "memory_id is required";
137    pub const NAMESPACE_REQUIRED: &str = "namespace is required";
138    pub const SOURCE_ID_REQUIRED: &str = "source_id is required";
139    pub const TARGET_ID_REQUIRED: &str = "target_id is required";
140    pub const QUERY_REQUIRED: &str = "query is required";
141    pub const CONTEXT_REQUIRED: &str = "context is required";
142
143    // ---- shared sqlx/rusqlite context label (postgres adapter + schema-init) -----
144    pub const READ_SCHEMA_VERSION: &str = "read schema_version";
145
146    // ---- templated shapes ----------------------------------------------------
147    /// `"invalid {field}: {e}"` — the canonical invalid-parameter prose.
148    #[must_use]
149    pub fn invalid(field: &str, e: impl std::fmt::Display) -> String {
150        format!("invalid {field}: {e}")
151    }
152
153    /// `"error: {e}"` — the canonical CLI stderr error line.
154    #[must_use]
155    pub fn error_line(e: impl std::fmt::Display) -> String {
156        format!("error: {e}")
157    }
158
159    /// `"memory not found: {id}"`.
160    #[must_use]
161    pub fn memory_not_found(id: impl std::fmt::Display) -> String {
162        format!("{MEMORY_NOT_FOUND}: {id}")
163    }
164
165    /// `"skill not found: {skill_id}"`.
166    #[must_use]
167    pub fn skill_not_found(skill_id: impl std::fmt::Display) -> String {
168        format!("{SKILL_NOT_FOUND}: {skill_id}")
169    }
170
171    /// `"pending action not found: {pending_id}"`.
172    #[must_use]
173    pub fn pending_action_not_found(pending_id: impl std::fmt::Display) -> String {
174        format!("pending action not found: {pending_id}")
175    }
176
177    /// `"not found: {id}"` — the CLI stderr not-found line.
178    #[must_use]
179    pub fn not_found(id: impl std::fmt::Display) -> String {
180        format!("not found: {id}")
181    }
182
183    /// `"approve rejected: {reason}"`.
184    #[must_use]
185    pub fn approve_rejected(reason: impl std::fmt::Display) -> String {
186        format!("approve rejected: {reason}")
187    }
188
189    /// `"older_than_days must be non-negative (got {days})"`.
190    #[must_use]
191    pub fn older_than_days_negative(days: impl std::fmt::Display) -> String {
192        format!("older_than_days must be non-negative (got {days})")
193    }
194
195    /// `"signature verify failed: {e}"`.
196    #[must_use]
197    pub fn signature_verify_failed(e: impl std::fmt::Display) -> String {
198        format!("signature verify failed: {e}")
199    }
200
201    /// `"zstd decompress body: {e}"`.
202    #[must_use]
203    pub fn zstd_decompress_body(e: impl std::fmt::Display) -> String {
204        format!("zstd decompress body: {e}")
205    }
206
207    /// `"network: {e}"`.
208    #[must_use]
209    pub fn network(e: impl std::fmt::Display) -> String {
210        format!("network: {e}")
211    }
212
213    /// `"unsubscribe: {e}"`.
214    #[must_use]
215    pub fn unsubscribe(e: impl std::fmt::Display) -> String {
216        format!("unsubscribe: {e}")
217    }
218
219    /// `"opening {path}"` — fs-open `.with_context` label (#1558 batch 6).
220    #[must_use]
221    pub fn opening(path: impl std::fmt::Display) -> String {
222        format!("opening {path}")
223    }
224
225    /// `"reading {path}"` — fs-read `.with_context` label (#1558 batch 6).
226    #[must_use]
227    pub fn reading(path: impl std::fmt::Display) -> String {
228        format!("reading {path}")
229    }
230
231    /// `"writing {path}"` — fs-write `.with_context` label (#1558 batch 6).
232    #[must_use]
233    pub fn writing(path: impl std::fmt::Display) -> String {
234        format!("writing {path}")
235    }
236}
237
238#[cfg(test)]
239mod arch_9_slug_tests {
240    use super::error_codes::*;
241
242    #[test]
243    fn arch_9_canonical_slugs_have_stable_string_values() {
244        // ARCH-9 — pin the wire string values for the canonical
245        // shared slugs (NOT_FOUND, VALIDATION_FAILED, etc.) so a
246        // future rename in `error_codes` fails this test loudly.
247        // The `STORE_*` prefixed constants have wire values
248        // distinct from their constant names (e.g.
249        // `STORE_BACKEND_UNAVAILABLE` resolves to the wire slug
250        // `"BACKEND_UNAVAILABLE"`); their stability is pinned by
251        // the round-trip tests below.
252        assert_eq!(NOT_FOUND, "NOT_FOUND");
253        assert_eq!(VALIDATION_FAILED, "VALIDATION_FAILED");
254        assert_eq!(DATABASE_ERROR, "DATABASE_ERROR");
255        assert_eq!(CONFLICT, "CONFLICT");
256        assert_eq!(GOVERNANCE_REFUSED, "GOVERNANCE_REFUSED");
257        assert_eq!(REFLECTION_DEPTH_EXCEEDED, "REFLECTION_DEPTH_EXCEEDED");
258        assert_eq!(SYNTHESIS_DEPTH_EXCEEDED, "SYNTHESIS_DEPTH_EXCEEDED");
259        assert_eq!(REFLECTION_CYCLE_DETECTED, "REFLECTION_CYCLE_DETECTED");
260        assert_eq!(AMBIGUOUS_ID_PREFIX, "AMBIGUOUS_ID_PREFIX");
261        assert_eq!(INVALID_ARGUMENT, "INVALID_ARGUMENT");
262        assert_eq!(LINK_PERMISSION_DENIED, "LINK_PERMISSION_DENIED");
263        assert_eq!(LINK_REFLECTION_CYCLE, "LINK_REFLECTION_CYCLE");
264        assert_eq!(UNIQUE_CONFLICT, "UNIQUE_CONFLICT");
265        // STORE_-prefixed constants — wire values intentionally
266        // strip the `STORE_` prefix because the wire is
267        // backend-agnostic.
268        assert_eq!(STORE_BACKEND_UNAVAILABLE, "BACKEND_UNAVAILABLE");
269        assert_eq!(STORE_UNSUPPORTED_CAPABILITY, "UNSUPPORTED_CAPABILITY");
270        assert_eq!(STORE_OPERATION_FAILED, "STORE_OPERATION_FAILED");
271        assert_eq!(STORE_VERSION_CONFLICT, "VERSION_CONFLICT");
272    }
273
274    // FX-E1 (2026-05-27) — `crate::store` is gated behind
275    // `#[cfg(feature = "sal")]` in `src/lib.rs:336`. Without this
276    // matching gate the test fails to compile under the default
277    // feature set (the `cargo build --tests` invocation used by
278    // `token-budget.yml`, `mobile-cross-compile`, etc.), which
279    // cascaded into the Per-Module Coverage / CI Check x3 / Postgres
280    // gate failures observed on `release/v0.7.0`. The SAL-feature
281    // gating is intentional — the round-trip test exercises
282    // `StoreError::code()` which only exists when the SAL trait
283    // surface is compiled in.
284    #[cfg(feature = "sal")]
285    #[test]
286    fn arch_9_store_error_slug_round_trip() {
287        use crate::store::{BoxBackendError, StoreError};
288        let variants = [
289            StoreError::NotFound { id: "x".into() },
290            StoreError::Conflict { id: "x".into() },
291            StoreError::PermissionDenied {
292                action: "a".into(),
293                target: "t".into(),
294                reason: "r".into(),
295            },
296            StoreError::BackendUnavailable {
297                backend: "b".into(),
298                detail: "d".into(),
299            },
300            StoreError::InvalidInput { detail: "d".into() },
301            StoreError::LinkRefused { detail: "d".into() },
302            StoreError::UnsupportedCapability {
303                capability: "c".into(),
304            },
305            StoreError::IntegrityFailed { detail: "d".into() },
306            StoreError::Backend(BoxBackendError::new("boom")),
307        ];
308        let expected = [
309            NOT_FOUND,
310            CONFLICT,
311            GOVERNANCE_REFUSED,
312            STORE_BACKEND_UNAVAILABLE,
313            VALIDATION_FAILED,
314            CONFLICT,
315            STORE_UNSUPPORTED_CAPABILITY,
316            STORE_OPERATION_FAILED,
317            DATABASE_ERROR,
318        ];
319        for (got, want) in variants.iter().zip(expected.iter()) {
320            assert_eq!(got.code(), *want, "ARCH-9 StoreError code drift");
321        }
322    }
323
324    #[test]
325    fn arch_9_storage_error_slug_round_trip() {
326        // Walk every StorageError variant and confirm `code()` returns
327        // one of the canonical slugs from the const set.
328        use crate::storage::{LinkEnd, StorageError};
329        let variants = [
330            StorageError::MemoryNotFound {
331                id: "x".into(),
332                role: None,
333            },
334            StorageError::MemoryNotFound {
335                id: "x".into(),
336                role: Some(LinkEnd::Source),
337            },
338            StorageError::PendingActionNotFound {
339                pending_id: "p".into(),
340            },
341            StorageError::AmbiguousIdPrefix {
342                prefix: "x".into(),
343                candidates: vec!["a".into(), "b".into()],
344            },
345            StorageError::InvalidArgument { reason: "r".into() },
346            StorageError::PendingActionStateInvalid {
347                pending_id: "p".into(),
348                status: "s".into(),
349            },
350            StorageError::LinkPermissionDenied { reason: "r".into() },
351            StorageError::LinkReflectionCycle {
352                source_id: "s".into(),
353                target_id: "t".into(),
354            },
355            StorageError::ApproverLaundering {
356                pending_id: "p".into(),
357                claimed: "a".into(),
358                requester: "b".into(),
359            },
360            StorageError::UniqueConflict { reason: "r".into() },
361            StorageError::ArchiveRestoreCollision { id: "x".into() },
362            StorageError::ArchiveSupersedeFailed {
363                archived_id: "x".into(),
364            },
365            StorageError::SqlcipherMissingPassphrase,
366        ];
367        let expected = [
368            NOT_FOUND,
369            NOT_FOUND,
370            PENDING_ACTION_NOT_FOUND,
371            AMBIGUOUS_ID_PREFIX,
372            INVALID_ARGUMENT,
373            PENDING_ACTION_STATE_INVALID,
374            LINK_PERMISSION_DENIED,
375            LINK_REFLECTION_CYCLE,
376            APPROVER_LAUNDERING,
377            UNIQUE_CONFLICT,
378            ARCHIVE_RESTORE_COLLISION,
379            ARCHIVE_SUPERSEDE_FAILED,
380            SQLCIPHER_MISSING_PASSPHRASE,
381        ];
382        for (got, want) in variants.iter().zip(expected.iter()) {
383            assert_eq!(got.code(), *want, "ARCH-9 StorageError code drift");
384        }
385    }
386}
387
388#[allow(dead_code)]
389#[derive(Debug, Serialize)]
390pub struct ApiError {
391    pub code: &'static str,
392    pub message: String,
393}
394
395#[allow(dead_code)]
396#[derive(Debug)]
397pub enum MemoryError {
398    NotFound(String),
399    ValidationFailed(String),
400    DatabaseError(String),
401    Conflict(String),
402    /// v0.7.0 recursive-learning Task 4/8 (issue #655) — emitted by the
403    /// `memory_reflect` write path when the proposed reflection's depth
404    /// exceeds the resolved namespace
405    /// [`crate::models::GovernancePolicy::effective_max_reflection_depth`]
406    /// cap. The variant carries the structured triple so Task 5/8 can
407    /// match on it without parsing a string, then emit a `signed_events`
408    /// audit row for the refusal decision.
409    ///
410    /// Wire shape (HTTP): `409 CONFLICT` with code `REFLECTION_DEPTH_EXCEEDED`.
411    ReflectionDepthExceeded {
412        attempted: u32,
413        cap: u32,
414        namespace: String,
415    },
416    /// Issue #1240 (HIGH) — emitted by the `memory_store` write path when
417    /// the synthesis-pass recursion depth (post-store hooks that fire
418    /// further `memory_store` calls, e.g. via curator chain-fire) exceeds
419    /// the compiled-in cap of 3. Mirrors the
420    /// [`Self::ReflectionDepthExceeded`] variant so audit + wire shape
421    /// stay symmetric across the two recursive-write primitives.
422    ///
423    /// Wire shape (HTTP): `409 CONFLICT` with code `SYNTHESIS_DEPTH_EXCEEDED`.
424    SynthesisDepthExceeded {
425        attempted: u32,
426        cap: u32,
427        namespace: String,
428    },
429    /// v0.7.0 L1-2 (issue #659) — emitted by the `memory_link` write path
430    /// when adding a `reflects_on` edge would close a cycle in the
431    /// reflection graph. Carries `source`, `target`, and the reconstructed
432    /// `cycle_path` (ordered `source → … → source`) for the audit row and
433    /// the operator-readable error message.
434    ///
435    /// Wire shape (HTTP / MCP): surfaced as a `String` error at the MCP
436    /// layer with code `REFLECTION_CYCLE_DETECTED`.
437    ReflectionCycleDetected {
438        source: String,
439        target: String,
440        cycle_path: Vec<String>,
441    },
442    /// v0.7.0 L1-6 Deliverable E (issue #691) — emitted by
443    /// [`crate::storage::insert`], [`crate::storage::insert_with_conflict`],
444    /// and [`crate::storage::insert_if_newer`] when the optional
445    /// [`crate::storage::GOVERNANCE_PRE_WRITE`] hook returns `Err(reason)`.
446    /// The hook is installed once at daemon `serve` boot and consults the
447    /// substrate's signed `governance_rules` table via
448    /// `governance::agent_action::check_agent_action` against a synthetic
449    /// `Custom { custom_kind = "memory_write" }` action; a `Refuse`
450    /// decision short-circuits the SQL `INSERT` cleanly (no row written,
451    /// no partial state).
452    ///
453    /// The hook is NOT installed in CLI one-shot mode — operator-direct
454    /// CLI invocations stay unimpeded by design (operator standing
455    /// directive: rules gate AGENT writes, not the operator's own
456    /// hands-on substrate ops).
457    ///
458    /// Wire shape (HTTP): `403 FORBIDDEN` with code `GOVERNANCE_REFUSED`.
459    /// Carries the operator-authored `reason` from the matching
460    /// `governance_rules.reason` column verbatim.
461    RefusedByGovernance(String),
462    /// #963 Phase 2 — substrate gate-evaluator refusal
463    /// ([`crate::storage::enforce_governance`] /
464    /// `Store::enforce_governance_action`). Distinguished from
465    /// [`Self::RefusedByGovernance`] (the substrate pre-write hook) by
466    /// carrying the typed [`crate::governance::GovernanceRefusal`]
467    /// envelope so handlers can surface
468    /// `denied_level` / `namespace` / `owner` in HTTP / MCP / CLI
469    /// responses without re-parsing the wire message.
470    ///
471    /// Wire shape (HTTP): `403 FORBIDDEN` with code `GOVERNANCE_REFUSED`.
472    /// The `message()` is the canonical envelope `Display`
473    /// (`"<action> denied by governance: <reason>"`), byte-identical to
474    /// the pre-#963 free-form `Deny(String)` wire shape.
475    RefusedByGovernanceGate(crate::governance::GovernanceRefusal),
476}
477
478impl MemoryError {
479    pub fn code(&self) -> &'static str {
480        // ARCH-9 (FX-C4-batch2, 2026-05-26): each arm returns a slug
481        // from the shared `error_codes` const set so cross-surface
482        // (HTTP / MCP / CLI) parity tests can assert byte-equal slug
483        // strings against the same source of truth.
484        match self {
485            Self::NotFound(_) => error_codes::NOT_FOUND,
486            Self::ValidationFailed(_) => error_codes::VALIDATION_FAILED,
487            Self::DatabaseError(_) => error_codes::DATABASE_ERROR,
488            Self::Conflict(_) => error_codes::CONFLICT,
489            Self::ReflectionDepthExceeded { .. } => error_codes::REFLECTION_DEPTH_EXCEEDED,
490            Self::SynthesisDepthExceeded { .. } => error_codes::SYNTHESIS_DEPTH_EXCEEDED,
491            Self::ReflectionCycleDetected { .. } => error_codes::REFLECTION_CYCLE_DETECTED,
492            Self::RefusedByGovernance(_) | Self::RefusedByGovernanceGate(_) => {
493                error_codes::GOVERNANCE_REFUSED
494            }
495        }
496    }
497
498    pub fn status(&self) -> StatusCode {
499        match self {
500            Self::NotFound(_) => StatusCode::NOT_FOUND,
501            Self::ValidationFailed(_) => StatusCode::BAD_REQUEST,
502            Self::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
503            // The substrate refusal is a policy-conflict (caller asked
504            // for an action the configured cap forbids); CONFLICT matches
505            // the rest of governance-style refusals.
506            Self::Conflict(_)
507            | Self::ReflectionDepthExceeded { .. }
508            | Self::SynthesisDepthExceeded { .. }
509            | Self::ReflectionCycleDetected { .. } => StatusCode::CONFLICT,
510            // L1-6 Deliverable E — a pre-write hook refusal is a typed
511            // authorization-style denial: the caller's request was
512            // well-formed but the operator-signed governance ruleset
513            // explicitly refuses it. 403 FORBIDDEN matches the HTTP
514            // semantic the rest of the substrate exposes for "the
515            // server understood but refuses to authorize".
516            Self::RefusedByGovernance(_) | Self::RefusedByGovernanceGate(_) => {
517                StatusCode::FORBIDDEN
518            }
519        }
520    }
521
522    pub fn message(&self) -> String {
523        match self {
524            Self::NotFound(m)
525            | Self::ValidationFailed(m)
526            | Self::DatabaseError(m)
527            | Self::Conflict(m) => m.clone(),
528            Self::ReflectionDepthExceeded {
529                attempted,
530                cap,
531                namespace,
532            } => format!(
533                "reflection depth {attempted} would exceed namespace \
534                 max_reflection_depth {cap} (namespace='{namespace}')"
535            ),
536            Self::SynthesisDepthExceeded {
537                attempted,
538                cap,
539                namespace,
540            } => format!(
541                "synthesis depth {attempted} would exceed compiled \
542                 max_synthesis_depth {cap} (namespace='{namespace}')"
543            ),
544            Self::ReflectionCycleDetected {
545                source,
546                target,
547                cycle_path,
548            } => format!(
549                "adding reflects_on edge {source} → {target} would create a cycle: {}",
550                cycle_path.join(" → ")
551            ),
552            Self::RefusedByGovernance(reason) => {
553                format!("write refused by substrate governance: {reason}")
554            }
555            // #963 Phase 2 — the gate-evaluator refusal carries the
556            // canonical Display via `GovernanceRefusal::Display`, which
557            // is byte-identical to the pre-#963 `Deny(String)` shape:
558            // `"<action> denied by governance: <reason>"`. Surface that
559            // directly so the HTTP / MCP / CLI surfaces emit the same
560            // wire string they did before the typed envelope landed.
561            Self::RefusedByGovernanceGate(refusal) => refusal.to_string(),
562        }
563    }
564}
565
566impl IntoResponse for MemoryError {
567    fn into_response(self) -> Response {
568        let body = ApiError {
569            code: self.code(),
570            message: self.message(),
571        };
572        (self.status(), Json(body)).into_response()
573    }
574}
575
576impl std::fmt::Display for MemoryError {
577    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
578        write!(f, "[{}] {}", self.code(), self.message())
579    }
580}
581
582impl From<anyhow::Error> for MemoryError {
583    fn from(e: anyhow::Error) -> Self {
584        // v0.7.0 L1-6 Deliverable E — promote a substrate-layer
585        // `GovernanceRefusal` wrapped in `anyhow::Error` (the shape
586        // emitted by `storage::insert*` when the pre-write hook fires)
587        // into the typed `RefusedByGovernance` variant so HTTP handlers
588        // get the right 403 status + `GOVERNANCE_REFUSED` code without
589        // every callsite having to downcast manually. Kept as a
590        // generic fall-through to `DatabaseError` for all other
591        // anyhow chains so this conversion stays additive.
592        if let Some(refusal) = e.downcast_ref::<crate::storage::GovernanceRefusal>() {
593            return Self::RefusedByGovernance(refusal.reason.clone());
594        }
595        // #963 Phase 2 — the gate-evaluator refusal envelope (typed
596        // `crate::governance::GovernanceRefusal`). Distinct from the
597        // pre-write hook refusal above (`crate::storage::GovernanceRefusal`),
598        // even though they share a struct name in different modules. The
599        // canonical Display is preserved through `RefusedByGovernanceGate`
600        // (see `message()` arm), and the typed fields survive the round
601        // trip via the variant's payload so downstream handlers can
602        // surface `denied_level`/`namespace`/`owner` in JSON.
603        if let Some(refusal) = e.downcast_ref::<crate::governance::GovernanceRefusal>() {
604            return Self::RefusedByGovernanceGate(refusal.clone());
605        }
606        // #962 — typed substrate-layer error envelope. Each
607        // `StorageError` variant maps to its canonical HTTP status by
608        // selecting the right `MemoryError` discriminant; the
609        // user-facing message is the variant's `Display` impl so the
610        // wire shape stays byte-identical to the pre-#962 `bail!()`
611        // strings (preserves `.to_string().starts_with(...)` consumers).
612        if let Some(se) = e.downcast_ref::<crate::storage::StorageError>() {
613            use crate::storage::StorageError as SE;
614            return match se {
615                SE::MemoryNotFound { .. } | SE::PendingActionNotFound { .. } => {
616                    Self::NotFound(se.to_string())
617                }
618                SE::AmbiguousIdPrefix { .. } | SE::InvalidArgument { .. } => {
619                    Self::ValidationFailed(se.to_string())
620                }
621                SE::PendingActionStateInvalid { .. }
622                | SE::UniqueConflict { .. }
623                | SE::ArchiveRestoreCollision { .. }
624                | SE::LinkReflectionCycle { .. } => Self::Conflict(se.to_string()),
625                SE::LinkPermissionDenied { .. } | SE::ApproverLaundering { .. } => {
626                    Self::RefusedByGovernance(se.to_string())
627                }
628                SE::ArchiveSupersedeFailed { .. } | SE::SqlcipherMissingPassphrase => {
629                    Self::DatabaseError(se.to_string())
630                }
631            };
632        }
633        Self::DatabaseError(e.to_string())
634    }
635}
636
637impl From<rusqlite::Error> for MemoryError {
638    fn from(e: rusqlite::Error) -> Self {
639        Self::DatabaseError(e.to_string())
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn error_codes() {
649        assert_eq!(MemoryError::NotFound("x".into()).code(), "NOT_FOUND");
650        assert_eq!(
651            MemoryError::ValidationFailed("x".into()).code(),
652            "VALIDATION_FAILED"
653        );
654        assert_eq!(
655            MemoryError::DatabaseError("x".into()).code(),
656            "DATABASE_ERROR"
657        );
658        assert_eq!(MemoryError::Conflict("x".into()).code(), "CONFLICT");
659    }
660
661    #[test]
662    fn error_status_codes() {
663        assert_eq!(
664            MemoryError::NotFound("x".into()).status(),
665            StatusCode::NOT_FOUND
666        );
667        assert_eq!(
668            MemoryError::ValidationFailed("x".into()).status(),
669            StatusCode::BAD_REQUEST
670        );
671        assert_eq!(
672            MemoryError::DatabaseError("x".into()).status(),
673            StatusCode::INTERNAL_SERVER_ERROR
674        );
675        assert_eq!(
676            MemoryError::Conflict("x".into()).status(),
677            StatusCode::CONFLICT
678        );
679    }
680
681    #[test]
682    fn error_messages() {
683        assert_eq!(
684            MemoryError::NotFound("not here".into()).message(),
685            "not here"
686        );
687        assert_eq!(
688            MemoryError::ValidationFailed("bad input".into()).message(),
689            "bad input"
690        );
691    }
692
693    #[test]
694    fn error_display() {
695        let err = MemoryError::NotFound("memory xyz".into());
696        let display = format!("{err}");
697        assert!(display.contains("NOT_FOUND"));
698        assert!(display.contains("memory xyz"));
699    }
700
701    #[test]
702    fn from_anyhow() {
703        let err: MemoryError = anyhow::anyhow!("db broke").into();
704        assert_eq!(err.code(), "DATABASE_ERROR");
705        assert!(err.message().contains("db broke"));
706    }
707
708    /// #963 Phase 2 — a typed `crate::governance::GovernanceRefusal`
709    /// wrapped in `anyhow::Error` MUST downcast through
710    /// `From<anyhow::Error>` into the new `RefusedByGovernanceGate`
711    /// variant (NOT the pre-write-hook `RefusedByGovernance`), preserve
712    /// the canonical Display in `message()`, surface
713    /// `GOVERNANCE_REFUSED` + 403, and keep the typed fields readable
714    /// for downstream JSON projection.
715    #[test]
716    fn from_anyhow_downcasts_governance_gate_refusal() {
717        use crate::governance::GovernanceRefusal;
718        use crate::models::{GovernanceLevel, GovernedAction};
719
720        let refusal = GovernanceRefusal::new(
721            GovernedAction::Store,
722            GovernanceLevel::Owner,
723            "ai:bob",
724            "caller 'ai:bob' is not the owner ('ai:alice')",
725        )
726        .with_namespace("team/prod")
727        .with_owner("ai:alice");
728        let anyhow_err = anyhow::Error::new(refusal.clone());
729
730        let mem_err: MemoryError = anyhow_err.into();
731        assert_eq!(mem_err.code(), "GOVERNANCE_REFUSED");
732        assert_eq!(mem_err.status(), StatusCode::FORBIDDEN);
733        assert_eq!(
734            mem_err.message(),
735            "store denied by governance: caller 'ai:bob' is not the owner ('ai:alice')",
736        );
737
738        match &mem_err {
739            MemoryError::RefusedByGovernanceGate(r) => {
740                assert_eq!(r.action, GovernedAction::Store);
741                assert_eq!(r.denied_level, GovernanceLevel::Owner);
742                assert_eq!(r.namespace.as_deref(), Some("team/prod"));
743                assert_eq!(r.owner.as_deref(), Some("ai:alice"));
744                assert_eq!(r.agent_id, "ai:bob");
745                assert_eq!(r, &refusal);
746            }
747            other => {
748                panic!("typed envelope must downcast to RefusedByGovernanceGate; got {other:?}")
749            }
750        }
751    }
752
753    /// The substrate pre-write hook refusal
754    /// (`crate::storage::GovernanceRefusal`, a *different type* with the
755    /// same name in a different module) still downcasts into the legacy
756    /// `RefusedByGovernance(String)` variant. Pins the disambiguation —
757    /// the new `From<anyhow::Error>` arm for the gate-evaluator refusal
758    /// MUST NOT cannibalise the pre-write-hook path.
759    #[test]
760    fn from_anyhow_preserves_pre_write_hook_refusal_variant() {
761        let hook_refusal = crate::storage::GovernanceRefusal {
762            reason: "rule R1 denies write".to_string(),
763        };
764        let anyhow_err = anyhow::Error::new(hook_refusal);
765
766        let mem_err: MemoryError = anyhow_err.into();
767        assert_eq!(mem_err.code(), "GOVERNANCE_REFUSED");
768        assert_eq!(mem_err.status(), StatusCode::FORBIDDEN);
769        // The pre-write-hook path wraps with "write refused by substrate
770        // governance: " — distinct prefix from the gate-evaluator path
771        // ("<action> denied by governance: ").
772        assert_eq!(
773            mem_err.message(),
774            "write refused by substrate governance: rule R1 denies write",
775        );
776        assert!(
777            matches!(mem_err, MemoryError::RefusedByGovernance(_)),
778            "pre-write-hook refusal must map to RefusedByGovernance, not the new gate variant",
779        );
780    }
781
782    #[test]
783    fn api_error_serializes() {
784        let api_err = ApiError {
785            code: "TEST",
786            message: "test msg".into(),
787        };
788        let json = serde_json::to_value(&api_err).unwrap();
789        assert_eq!(json["code"], "TEST");
790        assert_eq!(json["message"], "test msg");
791    }
792
793    // -----------------------------------------------------------------
794    // W12-H — variant-by-variant display + into_response coverage
795    // -----------------------------------------------------------------
796
797    #[test]
798    fn error_display_validation() {
799        let err = MemoryError::ValidationFailed("bad input".into());
800        let s = format!("{err}");
801        assert!(s.contains("VALIDATION_FAILED"));
802        assert!(s.contains("bad input"));
803    }
804
805    #[test]
806    fn error_display_database() {
807        let err = MemoryError::DatabaseError("conn refused".into());
808        let s = format!("{err}");
809        assert!(s.contains("DATABASE_ERROR"));
810        assert!(s.contains("conn refused"));
811    }
812
813    #[test]
814    fn error_display_conflict() {
815        let err = MemoryError::Conflict("dup".into());
816        let s = format!("{err}");
817        assert!(s.contains("CONFLICT"));
818        assert!(s.contains("dup"));
819    }
820
821    #[test]
822    fn error_message_database_and_conflict() {
823        assert_eq!(MemoryError::DatabaseError("oops".into()).message(), "oops");
824        assert_eq!(MemoryError::Conflict("c".into()).message(), "c");
825    }
826
827    #[test]
828    fn from_rusqlite_error_maps_to_database() {
829        let rusqlite_err = rusqlite::Error::InvalidQuery;
830        let err: MemoryError = rusqlite_err.into();
831        assert_eq!(err.code(), "DATABASE_ERROR");
832    }
833
834    #[test]
835    fn into_response_carries_status_and_body() {
836        use axum::response::IntoResponse;
837        let err = MemoryError::NotFound("missing".into());
838        let resp = err.into_response();
839        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
840    }
841
842    #[test]
843    fn into_response_validation_status() {
844        use axum::response::IntoResponse;
845        let err = MemoryError::ValidationFailed("v".into());
846        let resp = err.into_response();
847        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
848    }
849
850    #[test]
851    fn into_response_database_status() {
852        use axum::response::IntoResponse;
853        let err = MemoryError::DatabaseError("d".into());
854        let resp = err.into_response();
855        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
856    }
857
858    #[test]
859    fn into_response_conflict_status() {
860        use axum::response::IntoResponse;
861        let err = MemoryError::Conflict("c".into());
862        let resp = err.into_response();
863        assert_eq!(resp.status(), StatusCode::CONFLICT);
864    }
865
866    // -----------------------------------------------------------------
867    // L0.7-2 Tier A — ReflectionDepthExceeded variant coverage
868    // (Layer 0 Task 4/8 added the variant; no tests followed)
869    // -----------------------------------------------------------------
870
871    #[test]
872    fn reflection_depth_exceeded_code() {
873        let err = MemoryError::ReflectionDepthExceeded {
874            attempted: 4,
875            cap: 3,
876            namespace: "ns/x".into(),
877        };
878        assert_eq!(err.code(), "REFLECTION_DEPTH_EXCEEDED");
879    }
880
881    #[test]
882    fn reflection_depth_exceeded_status_is_conflict() {
883        let err = MemoryError::ReflectionDepthExceeded {
884            attempted: 5,
885            cap: 3,
886            namespace: "ns/y".into(),
887        };
888        assert_eq!(err.status(), StatusCode::CONFLICT);
889    }
890
891    #[test]
892    fn reflection_depth_exceeded_message_contains_triple() {
893        let err = MemoryError::ReflectionDepthExceeded {
894            attempted: 7,
895            cap: 3,
896            namespace: "ai-memory/research".into(),
897        };
898        let msg = err.message();
899        assert!(msg.contains("7"));
900        assert!(msg.contains("3"));
901        assert!(msg.contains("ai-memory/research"));
902        assert!(msg.contains("max_reflection_depth"));
903    }
904
905    #[test]
906    fn reflection_depth_exceeded_display() {
907        let err = MemoryError::ReflectionDepthExceeded {
908            attempted: 4,
909            cap: 3,
910            namespace: "ns".into(),
911        };
912        let s = format!("{err}");
913        assert!(s.contains("REFLECTION_DEPTH_EXCEEDED"));
914        assert!(s.contains("max_reflection_depth"));
915    }
916
917    #[test]
918    fn reflection_depth_exceeded_into_response_is_conflict() {
919        use axum::response::IntoResponse;
920        let err = MemoryError::ReflectionDepthExceeded {
921            attempted: 4,
922            cap: 3,
923            namespace: "ns".into(),
924        };
925        let resp = err.into_response();
926        assert_eq!(resp.status(), StatusCode::CONFLICT);
927    }
928
929    // -----------------------------------------------------------------
930    // Issue #1240 — SynthesisDepthExceeded variant coverage. Mirrors
931    // the ReflectionDepthExceeded contract above so the two recursive-
932    // write primitives stay symmetric across audit + wire surfaces.
933    // -----------------------------------------------------------------
934
935    #[test]
936    fn synthesis_depth_exceeded_code() {
937        let err = MemoryError::SynthesisDepthExceeded {
938            attempted: 4,
939            cap: 3,
940            namespace: "ns/x".into(),
941        };
942        assert_eq!(err.code(), "SYNTHESIS_DEPTH_EXCEEDED");
943    }
944
945    #[test]
946    fn synthesis_depth_exceeded_status_is_conflict() {
947        let err = MemoryError::SynthesisDepthExceeded {
948            attempted: 5,
949            cap: 3,
950            namespace: "ns/y".into(),
951        };
952        assert_eq!(err.status(), StatusCode::CONFLICT);
953    }
954
955    #[test]
956    fn synthesis_depth_exceeded_message_contains_triple() {
957        let err = MemoryError::SynthesisDepthExceeded {
958            attempted: 7,
959            cap: 3,
960            namespace: "ai-memory/research".into(),
961        };
962        let msg = err.message();
963        assert!(msg.contains("7"));
964        assert!(msg.contains("3"));
965        assert!(msg.contains("ai-memory/research"));
966        assert!(msg.contains("max_synthesis_depth"));
967    }
968
969    #[test]
970    fn synthesis_depth_exceeded_display() {
971        let err = MemoryError::SynthesisDepthExceeded {
972            attempted: 4,
973            cap: 3,
974            namespace: "ns".into(),
975        };
976        let s = format!("{err}");
977        assert!(s.contains("SYNTHESIS_DEPTH_EXCEEDED"));
978        assert!(s.contains("max_synthesis_depth"));
979    }
980
981    // -----------------------------------------------------------------
982    // L1-2 — ReflectionCycleDetected variant coverage
983    // (anti-self-reflection cycle check on memory_link)
984    // -----------------------------------------------------------------
985
986    #[test]
987    fn reflection_cycle_detected_code() {
988        let err = MemoryError::ReflectionCycleDetected {
989            source: "uuid-A".into(),
990            target: "uuid-B".into(),
991            cycle_path: vec!["uuid-B".into(), "uuid-C".into(), "uuid-A".into()],
992        };
993        assert_eq!(err.code(), "REFLECTION_CYCLE_DETECTED");
994    }
995
996    #[test]
997    fn reflection_cycle_detected_status_is_conflict() {
998        let err = MemoryError::ReflectionCycleDetected {
999            source: "src".into(),
1000            target: "dst".into(),
1001            cycle_path: vec!["dst".into(), "src".into()],
1002        };
1003        assert_eq!(err.status(), StatusCode::CONFLICT);
1004    }
1005
1006    #[test]
1007    fn reflection_cycle_detected_message_contains_path() {
1008        let err = MemoryError::ReflectionCycleDetected {
1009            source: "uuid-A".into(),
1010            target: "uuid-B".into(),
1011            cycle_path: vec!["uuid-B".into(), "uuid-C".into(), "uuid-A".into()],
1012        };
1013        let msg = err.message();
1014        assert!(
1015            msg.contains("uuid-A"),
1016            "expected source UUID in message, got: {msg}"
1017        );
1018        assert!(
1019            msg.contains("uuid-B"),
1020            "expected target UUID in message, got: {msg}"
1021        );
1022        assert!(
1023            msg.contains("uuid-C"),
1024            "expected cycle path intermediate in message, got: {msg}"
1025        );
1026        assert!(
1027            msg.contains("cycle"),
1028            "expected cycle context in message, got: {msg}"
1029        );
1030    }
1031
1032    #[test]
1033    fn reflection_cycle_detected_display_includes_code() {
1034        let err = MemoryError::ReflectionCycleDetected {
1035            source: "s".into(),
1036            target: "t".into(),
1037            cycle_path: vec!["t".into(), "s".into()],
1038        };
1039        let s = format!("{err}");
1040        assert!(
1041            s.contains("REFLECTION_CYCLE_DETECTED"),
1042            "Display should include code prefix; got: {s}"
1043        );
1044        assert!(
1045            s.contains("cycle"),
1046            "Display should describe the cycle; got: {s}"
1047        );
1048    }
1049
1050    #[test]
1051    fn reflection_cycle_detected_into_response_is_conflict() {
1052        use axum::response::IntoResponse;
1053        let err = MemoryError::ReflectionCycleDetected {
1054            source: "s".into(),
1055            target: "t".into(),
1056            cycle_path: vec!["t".into(), "s".into()],
1057        };
1058        let resp = err.into_response();
1059        assert_eq!(resp.status(), StatusCode::CONFLICT);
1060    }
1061
1062    // -----------------------------------------------------------------
1063    // L1-6 Deliverable E — RefusedByGovernance variant coverage
1064    // (storage::insert pre-write hook refusal path)
1065    // -----------------------------------------------------------------
1066
1067    #[test]
1068    fn refused_by_governance_code() {
1069        let err = MemoryError::RefusedByGovernance("blocked".into());
1070        assert_eq!(err.code(), "GOVERNANCE_REFUSED");
1071    }
1072
1073    #[test]
1074    fn refused_by_governance_status_is_forbidden() {
1075        let err = MemoryError::RefusedByGovernance("blocked".into());
1076        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1077    }
1078
1079    #[test]
1080    fn refused_by_governance_message_contains_reason() {
1081        let err = MemoryError::RefusedByGovernance("secrets namespace is read-only".into());
1082        let msg = err.message();
1083        assert!(
1084            msg.contains("secrets namespace is read-only"),
1085            "expected reason in message, got: {msg}"
1086        );
1087        assert!(
1088            msg.contains("substrate governance"),
1089            "expected refusal context in message, got: {msg}"
1090        );
1091    }
1092
1093    #[test]
1094    fn refused_by_governance_display_includes_code_and_reason() {
1095        let err = MemoryError::RefusedByGovernance("rule R042 fired".into());
1096        let s = format!("{err}");
1097        assert!(s.contains("GOVERNANCE_REFUSED"));
1098        assert!(s.contains("rule R042 fired"));
1099    }
1100
1101    #[test]
1102    fn refused_by_governance_into_response_is_forbidden() {
1103        use axum::response::IntoResponse;
1104        let err = MemoryError::RefusedByGovernance("nope".into());
1105        let resp = err.into_response();
1106        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1107    }
1108
1109    #[test]
1110    fn from_anyhow_promotes_governance_refusal() {
1111        // A `GovernanceRefusal` wrapped in `anyhow::Error` round-trips
1112        // back to the typed `RefusedByGovernance` variant — that's the
1113        // contract the pre-write hook callers rely on for the 403
1114        // status mapping.
1115        let refusal = crate::storage::GovernanceRefusal {
1116            reason: "test reason".to_string(),
1117        };
1118        let any_err: anyhow::Error = anyhow::Error::new(refusal);
1119        let mapped: MemoryError = any_err.into();
1120        match mapped {
1121            MemoryError::RefusedByGovernance(r) => assert_eq!(r, "test reason"),
1122            other => panic!("expected RefusedByGovernance, got {other:?}"),
1123        }
1124    }
1125
1126    #[test]
1127    fn from_anyhow_unrelated_falls_through_to_database_error() {
1128        // Defence-in-depth: a non-governance anyhow chain must still
1129        // collapse to DatabaseError (we are NOT widening this conversion).
1130        let any_err = anyhow::anyhow!("plain old db failure");
1131        let mapped: MemoryError = any_err.into();
1132        assert_eq!(mapped.code(), "DATABASE_ERROR");
1133    }
1134
1135    // ---------------------------------------------------------------
1136    // #962 — typed StorageError downcast coverage. Pins that every
1137    // variant lands on the right HTTP status discriminant; the wire
1138    // body is the variant's Display impl (preserves byte-identical
1139    // pre-#962 bail!() strings).
1140    // ---------------------------------------------------------------
1141
1142    fn map_storage(se: crate::storage::StorageError) -> MemoryError {
1143        let any_err: anyhow::Error = anyhow::Error::new(se);
1144        MemoryError::from(any_err)
1145    }
1146
1147    #[test]
1148    fn from_anyhow_storage_memory_not_found_maps_to_notfound() {
1149        let mapped = map_storage(crate::storage::StorageError::MemoryNotFound {
1150            id: "m1".into(),
1151            role: None,
1152        });
1153        assert_eq!(mapped.code(), "NOT_FOUND");
1154        assert_eq!(mapped.status(), StatusCode::NOT_FOUND);
1155        assert!(mapped.message().contains("memory not found"));
1156    }
1157
1158    #[test]
1159    fn from_anyhow_storage_pending_action_not_found_maps_to_notfound() {
1160        let mapped = map_storage(crate::storage::StorageError::PendingActionNotFound {
1161            pending_id: "pa-1".into(),
1162        });
1163        assert_eq!(mapped.status(), StatusCode::NOT_FOUND);
1164        assert!(mapped.message().contains("pending action not found"));
1165    }
1166
1167    #[test]
1168    fn from_anyhow_storage_ambiguous_id_prefix_maps_to_validation() {
1169        let mapped = map_storage(crate::storage::StorageError::AmbiguousIdPrefix {
1170            prefix: "ab".into(),
1171            candidates: vec!["abc1".into(), "abc2".into()],
1172        });
1173        assert_eq!(mapped.code(), "VALIDATION_FAILED");
1174        assert_eq!(mapped.status(), StatusCode::BAD_REQUEST);
1175        // Wire body must contain the legacy prefix so consumers that
1176        // string-match `.contains("ambiguous ID prefix")` keep working.
1177        assert!(mapped.message().contains("ambiguous ID prefix"));
1178    }
1179
1180    #[test]
1181    fn from_anyhow_storage_invalid_argument_maps_to_validation() {
1182        let mapped = map_storage(crate::storage::StorageError::InvalidArgument {
1183            reason: "max_depth must be >= 1".into(),
1184        });
1185        assert_eq!(mapped.status(), StatusCode::BAD_REQUEST);
1186        assert_eq!(mapped.message(), "max_depth must be >= 1");
1187    }
1188
1189    #[test]
1190    fn from_anyhow_storage_pending_action_state_invalid_maps_to_conflict() {
1191        let mapped = map_storage(crate::storage::StorageError::PendingActionStateInvalid {
1192            pending_id: "pa-9".into(),
1193            status: "rejected".into(),
1194        });
1195        assert_eq!(mapped.code(), "CONFLICT");
1196        assert_eq!(mapped.status(), StatusCode::CONFLICT);
1197    }
1198
1199    #[test]
1200    fn from_anyhow_storage_unique_conflict_maps_to_conflict() {
1201        let mapped = map_storage(crate::storage::StorageError::UniqueConflict {
1202            reason: "title 'X' already exists".into(),
1203        });
1204        assert_eq!(mapped.status(), StatusCode::CONFLICT);
1205    }
1206
1207    #[test]
1208    fn from_anyhow_storage_archive_restore_collision_maps_to_conflict() {
1209        let mapped =
1210            map_storage(crate::storage::StorageError::ArchiveRestoreCollision { id: "m1".into() });
1211        assert_eq!(mapped.status(), StatusCode::CONFLICT);
1212        assert!(mapped.message().contains("already exists in active table"));
1213    }
1214
1215    #[test]
1216    fn from_anyhow_storage_link_reflection_cycle_maps_to_conflict() {
1217        let mapped = map_storage(crate::storage::StorageError::LinkReflectionCycle {
1218            source_id: "a".into(),
1219            target_id: "b".into(),
1220        });
1221        // The link handler maps reflects_on cycles to 409 CONFLICT —
1222        // the graph state conflicts with the new edge.
1223        assert_eq!(mapped.status(), StatusCode::CONFLICT);
1224        assert!(
1225            mapped
1226                .message()
1227                .starts_with(crate::storage::LINK_CYCLE_ERR_PREFIX),
1228            "wire body must preserve the canonical cycle prefix"
1229        );
1230    }
1231
1232    #[test]
1233    fn from_anyhow_storage_link_permission_denied_maps_to_governance() {
1234        let mapped = map_storage(crate::storage::StorageError::LinkPermissionDenied {
1235            reason: "rule R7".into(),
1236        });
1237        assert_eq!(mapped.code(), "GOVERNANCE_REFUSED");
1238        assert_eq!(mapped.status(), StatusCode::FORBIDDEN);
1239        // `MemoryError::message()` for `RefusedByGovernance` wraps the
1240        // reason with `"write refused by substrate governance: "`, so we
1241        // assert containment (not `starts_with`) of the canonical prefix
1242        // — the typed prefix survives the layered wrap, and the
1243        // `handlers/links.rs` 403 path that bypasses `MemoryError`
1244        // serialises `StorageError::Display` directly (see the dedicated
1245        // unit test in `src/storage/error.rs`).
1246        assert!(
1247            mapped
1248                .message()
1249                .contains(crate::storage::LINK_PERMISSION_DENIED_ERR_PREFIX),
1250            "wire body must preserve the canonical denial prefix as a substring"
1251        );
1252    }
1253
1254    #[test]
1255    fn from_anyhow_storage_approver_laundering_maps_to_governance() {
1256        let mapped = map_storage(crate::storage::StorageError::ApproverLaundering {
1257            pending_id: "pa-1".into(),
1258            claimed: "x".into(),
1259            requester: "y".into(),
1260        });
1261        assert_eq!(mapped.status(), StatusCode::FORBIDDEN);
1262        assert!(mapped.message().contains("approver-on-behalf laundering"));
1263    }
1264
1265    #[test]
1266    fn from_anyhow_storage_archive_supersede_failed_maps_to_database_error() {
1267        let mapped = map_storage(crate::storage::StorageError::ArchiveSupersedeFailed {
1268            archived_id: "arch-1".into(),
1269        });
1270        assert_eq!(mapped.code(), "DATABASE_ERROR");
1271        assert_eq!(mapped.status(), StatusCode::INTERNAL_SERVER_ERROR);
1272    }
1273
1274    #[test]
1275    fn from_anyhow_storage_sqlcipher_missing_passphrase_maps_to_database_error() {
1276        let mapped = map_storage(crate::storage::StorageError::SqlcipherMissingPassphrase);
1277        assert_eq!(mapped.code(), "DATABASE_ERROR");
1278        assert!(mapped.message().contains("AI_MEMORY_DB_PASSPHRASE"));
1279    }
1280
1281    // ---------------------------------------------------------------
1282    // #1558 batch 5 wave 2 — `msg::` canonical wire-prose helpers.
1283    // Every templated `format!` helper is pinned byte-exact (against
1284    // the sibling const where one exists) so a future wording change
1285    // fails loudly here instead of drifting the HTTP / MCP / CLI wire
1286    // surfaces apart. Also restores the errors.rs per-module coverage
1287    // floor (99%) after the helpers landed under-covered (GA-drive
1288    // 2026-06-09, HEAD 90929dfd: 98.13% < 99%).
1289    // ---------------------------------------------------------------
1290
1291    #[test]
1292    fn msg_invalid_and_error_line_shapes() {
1293        assert_eq!(msg::invalid("limit", "boom"), "invalid limit: boom");
1294        assert_eq!(msg::error_line("boom"), "error: boom");
1295    }
1296
1297    #[test]
1298    fn msg_not_found_family_shapes() {
1299        assert_eq!(
1300            msg::memory_not_found("m-1"),
1301            format!("{}: m-1", msg::MEMORY_NOT_FOUND)
1302        );
1303        assert_eq!(
1304            msg::skill_not_found("sk-1"),
1305            format!("{}: sk-1", msg::SKILL_NOT_FOUND)
1306        );
1307        assert_eq!(
1308            msg::pending_action_not_found("pa-1"),
1309            "pending action not found: pa-1"
1310        );
1311        assert_eq!(msg::not_found("x-9"), "not found: x-9");
1312    }
1313
1314    #[test]
1315    fn msg_governance_and_quota_shapes() {
1316        assert_eq!(
1317            msg::approve_rejected("consensus pending"),
1318            "approve rejected: consensus pending"
1319        );
1320        assert_eq!(
1321            msg::older_than_days_negative(-3),
1322            "older_than_days must be non-negative (got -3)"
1323        );
1324    }
1325
1326    #[test]
1327    fn msg_transport_error_shapes() {
1328        assert_eq!(
1329            msg::signature_verify_failed("bad sig"),
1330            "signature verify failed: bad sig"
1331        );
1332        assert_eq!(
1333            msg::zstd_decompress_body("truncated"),
1334            "zstd decompress body: truncated"
1335        );
1336        assert_eq!(msg::network("timeout"), "network: timeout");
1337        assert_eq!(msg::unsubscribe("missing id"), "unsubscribe: missing id");
1338    }
1339
1340    #[test]
1341    fn msg_fs_context_label_shapes() {
1342        assert_eq!(msg::opening("/a/b.toml"), "opening /a/b.toml");
1343        assert_eq!(msg::reading("/a/b.toml"), "reading /a/b.toml");
1344        assert_eq!(msg::writing("/a/b.toml"), "writing /a/b.toml");
1345    }
1346
1347    #[test]
1348    fn from_anyhow_storage_governance_refusal_still_wins_when_chained() {
1349        // The original substrate `GovernanceRefusal` mapping is checked
1350        // first; pinning here so a future refactor can't silently move
1351        // it past the new StorageError branch and demote a refusal to
1352        // an internal DB error.
1353        let refusal = crate::storage::GovernanceRefusal {
1354            reason: "policy".into(),
1355        };
1356        let mapped: MemoryError = anyhow::Error::new(refusal).into();
1357        assert_eq!(mapped.code(), "GOVERNANCE_REFUSED");
1358    }
1359}