Skip to main content

memstead_engine/
error.rs

1//! Full-flavor engine error envelope.
2//!
3//! Mirrors the wrap-not-embed pattern: full errors **wrap** lean
4//! errors via `From<memstead_base::EngineError>`, so full code paths can
5//! transparently propagate a lean failure without re-wrapping at each
6//! call site. The full MCP render layer reads the wrapped chain to
7//! produce the typed `code`; the lean render layer only ever sees
8//! lean errors.
9//!
10//! The four lifecycle-only variants live here rather than on
11//! `memstead_base::EngineError`: they are produced by this crate's
12//! mem-management orchestrator (`create_mem` / `delete_mem`),
13//! which returns `Result<_, FullEngineError>`, so the lean crate
14//! carries no full-specific lifecycle types.
15
16use std::path::PathBuf;
17
18use memstead_base::EngineError;
19
20/// Errors surfaced by the full engine extension.
21///
22/// `Lean(EngineError)` wraps any failure that originates in the
23/// underlying lean engine — full orchestrators that delegate to
24/// `memstead_base::Engine` propagate lean errors verbatim through this
25/// variant (`#[from]`), so the wire-rendering layer at the full MCP
26/// surface can recover the lean `code()` for any wrapped variant.
27///
28/// The remaining variants are **lifecycle-only**: they fire from the
29/// full mem management orchestrator (`create_mem` / `delete_mem`)
30/// and have no lean-side fire conditions. They live in this crate
31/// alongside their orchestrator.
32#[derive(Debug, thiserror::Error)]
33pub enum FullEngineError {
34    /// Wrapped lean-engine error. Use this variant whenever a full
35    /// code path delegates to `memstead_base::Engine` and a lean-side
36    /// failure should surface unchanged.
37    #[error(transparent)]
38    Lean(#[from] EngineError),
39
40    /// `create_mem` / `delete_mem` rejected because the mem
41    /// path is not covered by an allowlist rule. `reason` is one of
42    /// `no_allowlist_configured` / `no_match` / `outside_workspace`.
43    /// `policy_table` names the refusing allowlist —
44    /// `"mem_management.create"` or `"mem_management.delete"` —
45    /// so an agent recovering from the envelope knows which TOML
46    /// table to edit without threading subcommand context through
47    /// error handling. The two discriminators are orthogonal: `reason`
48    /// names *why* the gate refused; `policy_table` names *which*
49    /// gate refused.
50    #[error("mem path not allowed by [[{policy_table}]]: {candidate} ({reason})")]
51    MemPathNotAllowed {
52        attempted: PathBuf,
53        candidate: String,
54        patterns: Vec<String>,
55        reason: &'static str,
56        policy_table: &'static str,
57    },
58
59    /// `create_mem` rejected before the allowlist check because the
60    /// supplied `name` is structurally malformed — empty, whitespace,
61    /// invalid characters, or carries the reserved `__` prefix.
62    /// `reason` discriminates the four shapes so an agent who typed
63    /// the wrong thing gets a recoverable signal instead of an
64    /// allowlist refusal. Split out of the `MemPathNotAllowed
65    /// (no_match)` catch-all so the structural failure modes are
66    /// visible.
67    #[error("mem name `{name}` is invalid ({reason})")]
68    InvalidMemName { name: String, reason: &'static str },
69
70    /// `delete_mem` rejected because the workspace
71    /// `[cross_mem_links]` policy grants one or more other mems
72    /// permission to write into this one. `referring_mems` lists the
73    /// granting mems sorted alphabetically so the agent can walk
74    /// the policy table. The condition is a *policy grant*, not a
75    /// materialised graph edge — revoking the grant in
76    /// `.memstead/workspace.toml` is the recovery path.
77    #[error(
78        "mem {name} cannot be deleted: workspace `[cross_mem_links]` policy grants {referring_mems:?} write-into permission — revoke that grant and retry"
79    )]
80    MemReferencedByPolicy {
81        name: String,
82        referring_mems: Vec<String>,
83    },
84
85    /// `create_mem` rejected because the matched create-rule does
86    /// not allow the requested schema. `allowed_schemas` is the
87    /// canonicalised allow-list (each entry `name@version`).
88    #[error(
89        "schema {requested_schema} not allowed by create-rule {matched_pattern:?} for candidate {candidate:?}"
90    )]
91    MemSchemaNotAllowed {
92        candidate: String,
93        matched_pattern: String,
94        requested_schema: String,
95        allowed_schemas: Vec<String>,
96    },
97
98    /// `create_mem` rejected because the target `.memstead/config.json`
99    /// already exists at the requested location — the engine never
100    /// silently overwrites a prior attempt.
101    #[error("config already exists at {path}")]
102    ConfigAlreadyExists { path: PathBuf },
103
104    /// `create_mem` detected on-disk storage residue for the
105    /// requested branch path that is not reflected in the in-memory
106    /// mem router — typically left over by a crash or a
107    /// partially-failed delete. The caller must select an
108    /// explicit recovery action via [`MemCreateParams::recovery`]
109    /// (`Reattach`, `ForceOverwrite`, or `HardCleanupFirst`) and
110    /// retry; the special case of `unregistered_at`-tombstoned
111    /// residue (deliberate operator state from `memstead mem
112    /// unregister`) defaults to `Reattach` without this refusal. The
113    /// payload carries the composed branch ref, the config-blob path,
114    /// and the entity count of the residual data so the caller can
115    /// decide between adopting and discarding.
116    #[error(
117        "mem storage residue detected at branch `{branch_ref}`: \
118         {entity_count} entities preserved from a prior session — \
119         re-run with `recovery: reattach` to adopt, `recovery: \
120         force_overwrite` to destroy, or `recovery: \
121         hard_cleanup_first` to refuse until `memstead mem delete` is run"
122    )]
123    MemStorageResidueDetected {
124        /// Composed branch reference (`refs/heads/<branch_leaf>`)
125        /// that carries the residue.
126        branch_ref: String,
127        /// Tree path of the `__MEMSTEAD:mems/<branch_leaf>/config.json`
128        /// blob (or `None` when the branch exists but the config blob
129        /// has already been pruned).
130        config_blob: Option<String>,
131        /// Best-effort entity count on the residual branch. Reads
132        /// the branch's tip tree and counts `.md` entries; `0` when
133        /// the count is unavailable.
134        entity_count: usize,
135    },
136}
137
138/// Recovery shape for `create_mem` against pre-existing storage
139/// residue. A single enum field with three variants structurally
140/// enforces mutual exclusion on the wire (a three-boolean shape would
141/// need a runtime-validation step instead).
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum RecoveryAction {
144    /// Adopt the residual entities and register the existing branch
145    /// as a fresh writable mount. The seed-commit step is skipped —
146    /// the prior session's history is preserved unchanged. Emits a
147    /// `MemReattachedAfterUnregister` warning when the residue
148    /// carries an `unregistered_at` tombstone (audit signal).
149    Reattach,
150    /// Destroy the residual branch + `__MEMSTEAD` config blob (and any
151    /// tombstone) in one ref-edit transaction, then proceed with
152    /// the normal create path. The prior entities are gone.
153    ForceOverwrite,
154    /// Refuse with a typed code instructing the caller to run
155    /// `memstead mem delete <name>` first. Hard barrier against
156    /// destructive auto-recovery even with an explicit recovery
157    /// flag — for operators who want the residue cleanup to be a
158    /// separate, named operation.
159    HardCleanupFirst,
160}
161
162impl RecoveryAction {
163    /// Wire-token rendering (`reattach` / `force_overwrite` /
164    /// `hard_cleanup_first`). Stable across the surface — used by
165    /// the MCP serde tag, the CLI flag bridge, and error-envelope
166    /// rendering.
167    pub fn as_wire_str(&self) -> &'static str {
168        match self {
169            RecoveryAction::Reattach => "reattach",
170            RecoveryAction::ForceOverwrite => "force_overwrite",
171            RecoveryAction::HardCleanupFirst => "hard_cleanup_first",
172        }
173    }
174}
175
176impl FullEngineError {
177    /// Render rich, fully-inlined recovery prose for the agent-visible
178    /// text channel. Closes the asymmetry where structured `details.X`
179    /// fields stayed off the agent's text channel. Each lifecycle
180    /// variant with a structured list (`patterns`, `referring_mems`,
181    /// `allowed_schemas`) inlines the full payload; lean wraps
182    /// delegate to [`EngineError::prose_render`]; trivial variants
183    /// fall back to `Display`.
184    pub fn prose_render(&self) -> String {
185        match self {
186            FullEngineError::Lean(inner) => inner.prose_render(),
187            FullEngineError::MemPathNotAllowed {
188                attempted,
189                candidate,
190                patterns,
191                reason,
192                policy_table,
193            } => {
194                let patterns_inline = if patterns.is_empty() {
195                    "(no rules configured)".to_string()
196                } else {
197                    patterns
198                        .iter()
199                        .map(|p| format!("'{p}'"))
200                        .collect::<Vec<_>>()
201                        .join(", ")
202                };
203                format!(
204                    "mem path not allowed by `[[{policy_table}]]`: candidate '{candidate}' (resolved location '{}') did not match any allowlist rule (reason: {reason}). Configured patterns: {patterns_inline}.",
205                    attempted.display()
206                )
207            }
208            FullEngineError::MemSchemaNotAllowed {
209                candidate,
210                matched_pattern,
211                requested_schema,
212                allowed_schemas,
213            } => {
214                let allowed_inline = if allowed_schemas.is_empty() {
215                    "(none)".to_string()
216                } else {
217                    allowed_schemas.join(", ")
218                };
219                format!(
220                    "schema '{requested_schema}' not allowed by create-rule '{matched_pattern}' for candidate '{candidate}' — allowed schemas: {allowed_inline}. Pick a schema from this list or add a new `[[mem_management.create]]` rule covering this candidate."
221                )
222            }
223            FullEngineError::MemReferencedByPolicy {
224                name,
225                referring_mems,
226            } => {
227                let inline = if referring_mems.is_empty() {
228                    "(none)".to_string()
229                } else {
230                    referring_mems.join(", ")
231                };
232                format!(
233                    "mem {name} cannot be deleted: workspace `[cross_mem_links]` policy grants the following mems write-into permission: {inline}. Revoke each grant (`memstead_workspace_revoke_cross_link`) and retry."
234                )
235            }
236            // InvalidMemName, ConfigAlreadyExists, MemStorageResidueDetected:
237            // `Display` already inlines every field; fall back.
238            _ => self.to_string(),
239        }
240    }
241
242    /// Variant-specific recovery payload, rendered as a structured
243    /// JSON object that surfaces under `error.details` in MCP / CLI
244    /// envelopes. The CLI's mem commands used to discard the engine's
245    /// structured details because the lift code didn't have a single
246    /// source of truth —
247    /// this mirrors `EngineError::details()` so the lift can call
248    /// `err.details()` directly without hand-maintaining each per-
249    /// variant payload at the CLI surface.
250    ///
251    /// `Lean(inner)` delegates to `EngineError::details()`. Lifecycle
252    /// variants return the same JSON object shape `pro_engine_err_unified`
253    /// builds on the MCP wire — both surfaces share the payload here
254    /// so they cannot drift.
255    pub fn details(&self) -> serde_json::Value {
256        match self {
257            FullEngineError::Lean(inner) => inner.details(),
258            FullEngineError::MemPathNotAllowed {
259                attempted,
260                candidate,
261                patterns,
262                reason,
263                policy_table,
264            } => serde_json::json!({
265                "attempted": attempted.display().to_string(),
266                "candidate": candidate,
267                "patterns": patterns,
268                "reason": reason,
269                "policy_table": policy_table,
270            }),
271            FullEngineError::InvalidMemName { name, reason } => {
272                serde_json::json!({ "name": name, "reason": reason })
273            }
274            FullEngineError::MemReferencedByPolicy {
275                name,
276                referring_mems,
277            } => serde_json::json!({
278                "name": name,
279                "referring_mems": referring_mems,
280            }),
281            FullEngineError::MemSchemaNotAllowed {
282                candidate,
283                matched_pattern,
284                requested_schema,
285                allowed_schemas,
286            } => serde_json::json!({
287                "candidate": candidate,
288                "matched_pattern": matched_pattern,
289                "requested_schema": requested_schema,
290                "allowed_schemas": allowed_schemas,
291            }),
292            FullEngineError::ConfigAlreadyExists { path } => serde_json::json!({
293                "path": path.display().to_string(),
294                "reason": "config_already_exists",
295            }),
296            FullEngineError::MemStorageResidueDetected {
297                branch_ref,
298                config_blob,
299                entity_count,
300            } => serde_json::json!({
301                "branch_ref": branch_ref,
302                "config_blob": config_blob,
303                "entity_count": entity_count,
304                "recovery": ["reattach", "force_overwrite", "hard_cleanup_first"],
305            }),
306        }
307    }
308
309    /// Stable, surface-independent error code token.
310    ///
311    /// Matches `memstead_base::EngineError::code()` for every variant —
312    /// wrapped lean errors delegate to the lean mapping, lifecycle
313    /// variants return the exact strings the lean enum returned for
314    /// them today. This is load-bearing: the wire-shape pins in
315    /// `memstead-mcp/tests/wire_shape.rs` assert these exact code strings.
316    pub fn code(&self) -> &'static str {
317        match self {
318            FullEngineError::Lean(e) => e.code(),
319            FullEngineError::MemPathNotAllowed { .. } => "MEM_PATH_NOT_ALLOWED",
320            FullEngineError::InvalidMemName { .. } => "INVALID_MEM_NAME",
321            FullEngineError::MemReferencedByPolicy { .. } => "MEM_REFERENCED_BY_POLICY",
322            FullEngineError::MemSchemaNotAllowed { .. } => "MEM_SCHEMA_NOT_ALLOWED",
323            FullEngineError::ConfigAlreadyExists { .. } => "CONFIG_ERROR",
324            FullEngineError::MemStorageResidueDetected { .. } => "MEM_STORAGE_RESIDUE_DETECTED",
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    /// Code strings track the wire vocabulary the full MCP surface
334    /// publishes. `MEM_REFERENCED_BY_POLICY` was renamed from the
335    /// pre-04 `MEM_HAS_REFERENCES` so the typed code matches the
336    /// actual fire condition (a workspace `[cross_mem_links]` grant,
337    /// not a materialised graph edge); the other three lifecycle
338    /// codes are unchanged from when these variants lived on
339    /// `memstead_base::EngineError`.
340    #[test]
341    fn lifecycle_codes_pin_wire_vocabulary() {
342        let e = FullEngineError::MemPathNotAllowed {
343            attempted: PathBuf::from("/x"),
344            candidate: "x".into(),
345            patterns: vec![],
346            reason: "no_match",
347            policy_table: "mem_management.create",
348        };
349        assert_eq!(e.code(), "MEM_PATH_NOT_ALLOWED");
350
351        let e = FullEngineError::MemReferencedByPolicy {
352            name: "x".into(),
353            referring_mems: vec![],
354        };
355        assert_eq!(e.code(), "MEM_REFERENCED_BY_POLICY");
356
357        let e = FullEngineError::MemSchemaNotAllowed {
358            candidate: "x".into(),
359            matched_pattern: "p".into(),
360            requested_schema: "s".into(),
361            allowed_schemas: vec![],
362        };
363        assert_eq!(e.code(), "MEM_SCHEMA_NOT_ALLOWED");
364
365        let e = FullEngineError::ConfigAlreadyExists {
366            path: PathBuf::from("/x"),
367        };
368        assert_eq!(e.code(), "CONFIG_ERROR");
369    }
370
371    /// Wrapped lean errors delegate `code()` to the lean mapping.
372    /// Any drift in the lean enum's code strings rolls through this
373    /// path automatically — the full layer never re-stringifies.
374    #[test]
375    fn wrapped_lean_error_delegates_code() {
376        let e: FullEngineError = EngineError::UnknownMem("specs".into()).into();
377        assert_eq!(e.code(), "UNKNOWN_MEM");
378    }
379
380    /// The `policy_table` field disambiguates which allowlist refused
381    /// without forcing an agent to thread subcommand context through
382    /// error handling. The structured `details` payload and the
383    /// `prose_render` text both surface the table name.
384    #[test]
385    fn mem_path_not_allowed_carries_policy_table_in_details_and_prose() {
386        let create_err = FullEngineError::MemPathNotAllowed {
387            attempted: PathBuf::from("/ws/scratch-2"),
388            candidate: "scratch-2".into(),
389            patterns: vec!["specs".into()],
390            reason: "no_match",
391            policy_table: "mem_management.create",
392        };
393        let details = create_err.details();
394        assert_eq!(details["policy_table"], "mem_management.create");
395        assert_eq!(details["reason"], "no_match");
396        let prose = create_err.prose_render();
397        assert!(
398            prose.contains("mem_management.create"),
399            "prose must name the refusing allowlist: {prose}"
400        );
401
402        // Delete-path symmetric — policy_table flips to the delete table.
403        let delete_err = FullEngineError::MemPathNotAllowed {
404            attempted: PathBuf::from("/ws/archive-src"),
405            candidate: "archive-src".into(),
406            patterns: vec!["specs".into()],
407            reason: "no_match",
408            policy_table: "mem_management.delete",
409        };
410        assert_eq!(
411            delete_err.details()["policy_table"],
412            "mem_management.delete"
413        );
414        let prose = delete_err.prose_render();
415        assert!(
416            prose.contains("mem_management.delete"),
417            "prose must name the refusing allowlist: {prose}"
418        );
419    }
420}