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. The message names the
101    /// working remedy: an existing folder mem is adopted, not
102    /// recreated.
103    #[error(
104        "config already exists at {path} — an existing folder mem lives there; \
105         re-attach it instead of creating: pass recovery `reattach` \
106         (CLI: `memstead mem init <name> --storage folder --location <dir> --reattach`)"
107    )]
108    ConfigAlreadyExists { path: PathBuf },
109
110    /// `create_mem` detected on-disk storage residue for the
111    /// requested branch path that is not reflected in the in-memory
112    /// mem router — typically left over by a crash or a
113    /// partially-failed delete. The caller must select an
114    /// explicit recovery action via [`MemCreateParams::recovery`]
115    /// (`Reattach`, `ForceOverwrite`, or `HardCleanupFirst`) and
116    /// retry; the special case of `unregistered_at`-tombstoned
117    /// residue (deliberate operator state from `memstead mem
118    /// unregister`) defaults to `Reattach` without this refusal. The
119    /// payload carries the composed branch ref, the config-blob path,
120    /// and the entity count of the residual data so the caller can
121    /// decide between adopting and discarding.
122    #[error(
123        "mem storage residue detected at branch `{branch_ref}`: \
124         {entity_count} entities preserved from a prior session — \
125         re-run with `recovery: reattach` to adopt, `recovery: \
126         force_overwrite` to destroy, or `recovery: \
127         hard_cleanup_first` to refuse until `memstead mem delete` is run"
128    )]
129    MemStorageResidueDetected {
130        /// Composed branch reference (`refs/heads/<branch_leaf>`)
131        /// that carries the residue.
132        branch_ref: String,
133        /// Tree path of the `__MEMSTEAD:mems/<branch_leaf>/config.json`
134        /// blob (or `None` when the branch exists but the config blob
135        /// has already been pruned).
136        config_blob: Option<String>,
137        /// Best-effort entity count on the residual branch. Reads
138        /// the branch's tip tree and counts `.md` entries; `0` when
139        /// the count is unavailable.
140        entity_count: usize,
141    },
142}
143
144/// Recovery shape for `create_mem` against pre-existing storage
145/// residue. A single enum field with three variants structurally
146/// enforces mutual exclusion on the wire (a three-boolean shape would
147/// need a runtime-validation step instead).
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum RecoveryAction {
150    /// Adopt the residual entities and register the existing branch
151    /// as a fresh writable mount. The seed-commit step is skipped —
152    /// the prior session's history is preserved unchanged. Emits a
153    /// `MemReattachedAfterUnregister` warning when the residue
154    /// carries an `unregistered_at` tombstone (audit signal).
155    Reattach,
156    /// Destroy the residual branch + `__MEMSTEAD` config blob (and any
157    /// tombstone) in one ref-edit transaction, then proceed with
158    /// the normal create path. The prior entities are gone.
159    ForceOverwrite,
160    /// Refuse with a typed code instructing the caller to run
161    /// `memstead mem delete <name>` first. Hard barrier against
162    /// destructive auto-recovery even with an explicit recovery
163    /// flag — for operators who want the residue cleanup to be a
164    /// separate, named operation.
165    HardCleanupFirst,
166}
167
168impl RecoveryAction {
169    /// Wire-token rendering (`reattach` / `force_overwrite` /
170    /// `hard_cleanup_first`). Stable across the surface — used by
171    /// the MCP serde tag, the CLI flag bridge, and error-envelope
172    /// rendering.
173    pub fn as_wire_str(&self) -> &'static str {
174        match self {
175            RecoveryAction::Reattach => "reattach",
176            RecoveryAction::ForceOverwrite => "force_overwrite",
177            RecoveryAction::HardCleanupFirst => "hard_cleanup_first",
178        }
179    }
180}
181
182/// The concrete grant command for a refusing allowlist table — the
183/// single source both the prose and the structured `details.remedy`
184/// render, so the two channels cannot drift. The create form names the
185/// pattern and the schema pin because `allow-create` requires both;
186/// delete rules have no schema dimension.
187///
188/// Operator surfaces only, deliberately: workspace policy is the
189/// operator deciding what an agent may do, so it is not editable from
190/// the agent's own tool surface. The CLI form is what an agent reports
191/// and a person runs; the operator-authenticated web API carries the
192/// same operations. Naming an MCP tool here would point at one that no
193/// longer exists.
194fn allowlist_remedy(policy_table: &str) -> &'static str {
195    if policy_table == "mem_management.delete" {
196        "memstead workspace allow-delete '<pattern>'"
197    } else {
198        "memstead workspace allow-create '<pattern>' --schema <name@version>"
199    }
200}
201
202impl FullEngineError {
203    /// Render rich, fully-inlined recovery prose for the agent-visible
204    /// text channel. Closes the asymmetry where structured `details.X`
205    /// fields stayed off the agent's text channel. Each lifecycle
206    /// variant with a structured list (`patterns`, `referring_mems`,
207    /// `allowed_schemas`) inlines the full payload; lean wraps
208    /// delegate to [`EngineError::prose_render`]; trivial variants
209    /// fall back to `Display`.
210    pub fn prose_render(&self) -> String {
211        match self {
212            FullEngineError::Lean(inner) => inner.prose_render(),
213            FullEngineError::MemPathNotAllowed {
214                attempted,
215                candidate,
216                patterns,
217                reason,
218                policy_table,
219            } => {
220                let patterns_inline = if patterns.is_empty() {
221                    "(no rules configured)".to_string()
222                } else {
223                    patterns
224                        .iter()
225                        .map(|p| format!("'{p}'"))
226                        .collect::<Vec<_>>()
227                        .join(", ")
228                };
229                // Each reason gets the sentence that fits ITS
230                // situation, and the allowlist remedy is named only
231                // where adding a rule actually is the remedy —
232                // `outside_workspace` is not fixed by a rule, so it
233                // keeps the plain refusal.
234                let verb = if *policy_table == "mem_management.delete" {
235                    "deletion"
236                } else {
237                    "creation"
238                };
239                let allow_cmd = allowlist_remedy(policy_table);
240                match *reason {
241                    "no_allowlist_configured" => format!(
242                        "mem {verb} is refused by default: this workspace has no `[[{policy_table}]]` allowlist rules (candidate '{candidate}', resolved location '{}'). Grant the permission first — `{allow_cmd}`, e.g. with pattern '{candidate}' — then retry. Workspace policy is an operator surface: report the command, a person runs it.",
243                        attempted.display()
244                    ),
245                    "no_match" => format!(
246                        "mem path not allowed by `[[{policy_table}]]`: candidate '{candidate}' (resolved location '{}') matched none of the configured patterns: {patterns_inline}. Use a name matching an existing pattern, or ask the operator to add a covering rule — `{allow_cmd}`.",
247                        attempted.display()
248                    ),
249                    _ => format!(
250                        "mem path not allowed by `[[{policy_table}]]`: candidate '{candidate}' (resolved location '{}') did not match any allowlist rule (reason: {reason}). Configured patterns: {patterns_inline}.",
251                        attempted.display()
252                    ),
253                }
254            }
255            FullEngineError::MemSchemaNotAllowed {
256                candidate,
257                matched_pattern,
258                requested_schema,
259                allowed_schemas,
260            } => {
261                let allowed_inline = if allowed_schemas.is_empty() {
262                    "(none)".to_string()
263                } else {
264                    allowed_schemas.join(", ")
265                };
266                format!(
267                    "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."
268                )
269            }
270            FullEngineError::MemReferencedByPolicy {
271                name,
272                referring_mems,
273            } => {
274                let inline = if referring_mems.is_empty() {
275                    "(none)".to_string()
276                } else {
277                    referring_mems.join(", ")
278                };
279                format!(
280                    "mem {name} cannot be deleted: workspace `[cross_mem_links]` policy grants the following mems write-into permission: {inline}. Ask the operator to revoke each grant (`memstead workspace revoke-cross-link <from> <to>`) and retry."
281                )
282            }
283            // InvalidMemName, ConfigAlreadyExists, MemStorageResidueDetected:
284            // `Display` already inlines every field; fall back.
285            _ => self.to_string(),
286        }
287    }
288
289    /// Variant-specific recovery payload, rendered as a structured
290    /// JSON object that surfaces under `error.details` in MCP / CLI
291    /// envelopes. The CLI's mem commands used to discard the engine's
292    /// structured details because the lift code didn't have a single
293    /// source of truth —
294    /// this mirrors `EngineError::details()` so the lift can call
295    /// `err.details()` directly without hand-maintaining each per-
296    /// variant payload at the CLI surface.
297    ///
298    /// `Lean(inner)` delegates to `EngineError::details()`. Lifecycle
299    /// variants return the same JSON object shape `full_engine_err_unified`
300    /// builds on the MCP wire — both surfaces share the payload here
301    /// so they cannot drift.
302    pub fn details(&self) -> serde_json::Value {
303        match self {
304            FullEngineError::Lean(inner) => inner.details(),
305            FullEngineError::MemPathNotAllowed {
306                attempted,
307                candidate,
308                patterns,
309                reason,
310                policy_table,
311            } => {
312                let mut d = serde_json::json!({
313                    "attempted": attempted.display().to_string(),
314                    "candidate": candidate,
315                    "patterns": patterns,
316                    "reason": reason,
317                    "policy_table": policy_table,
318                });
319                // The remedy rides only where adding a rule IS the
320                // remedy: `outside_workspace` is not fixed by an
321                // allowlist rule, so it carries none.
322                if matches!(*reason, "no_allowlist_configured" | "no_match") {
323                    let cli = allowlist_remedy(policy_table);
324                    d["remedy"] = serde_json::json!({ "cli": cli });
325                }
326                d
327            }
328            FullEngineError::InvalidMemName { name, reason } => {
329                serde_json::json!({ "name": name, "reason": reason })
330            }
331            FullEngineError::MemReferencedByPolicy {
332                name,
333                referring_mems,
334            } => serde_json::json!({
335                "name": name,
336                "referring_mems": referring_mems,
337            }),
338            FullEngineError::MemSchemaNotAllowed {
339                candidate,
340                matched_pattern,
341                requested_schema,
342                allowed_schemas,
343            } => serde_json::json!({
344                "candidate": candidate,
345                "matched_pattern": matched_pattern,
346                "requested_schema": requested_schema,
347                "allowed_schemas": allowed_schemas,
348            }),
349            FullEngineError::ConfigAlreadyExists { path } => serde_json::json!({
350                "path": path.display().to_string(),
351                "reason": "config_already_exists",
352            }),
353            FullEngineError::MemStorageResidueDetected {
354                branch_ref,
355                config_blob,
356                entity_count,
357            } => serde_json::json!({
358                "branch_ref": branch_ref,
359                "config_blob": config_blob,
360                "entity_count": entity_count,
361                "recovery": ["reattach", "force_overwrite", "hard_cleanup_first"],
362            }),
363        }
364    }
365
366    /// Stable, surface-independent error code token.
367    ///
368    /// Matches `memstead_base::EngineError::code()` for every variant —
369    /// wrapped lean errors delegate to the lean mapping, lifecycle
370    /// variants return the exact strings the lean enum returned for
371    /// them today. This is load-bearing: the wire-shape pins in
372    /// `memstead-mcp/tests/wire_shape.rs` assert these exact code strings.
373    pub fn code(&self) -> &'static str {
374        match self {
375            FullEngineError::Lean(e) => e.code(),
376            FullEngineError::MemPathNotAllowed { .. } => "MEM_PATH_NOT_ALLOWED",
377            FullEngineError::InvalidMemName { .. } => "INVALID_MEM_NAME",
378            FullEngineError::MemReferencedByPolicy { .. } => "MEM_REFERENCED_BY_POLICY",
379            FullEngineError::MemSchemaNotAllowed { .. } => "MEM_SCHEMA_NOT_ALLOWED",
380            FullEngineError::ConfigAlreadyExists { .. } => "CONFIG_ERROR",
381            FullEngineError::MemStorageResidueDetected { .. } => "MEM_STORAGE_RESIDUE_DETECTED",
382        }
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    /// Code strings track the wire vocabulary the full MCP surface
391    /// publishes. `MEM_REFERENCED_BY_POLICY` was renamed from the
392    /// pre-04 `MEM_HAS_REFERENCES` so the typed code matches the
393    /// actual fire condition (a workspace `[cross_mem_links]` grant,
394    /// not a materialised graph edge); the other three lifecycle
395    /// codes are unchanged from when these variants lived on
396    /// `memstead_base::EngineError`.
397    #[test]
398    fn lifecycle_codes_pin_wire_vocabulary() {
399        let e = FullEngineError::MemPathNotAllowed {
400            attempted: PathBuf::from("/x"),
401            candidate: "x".into(),
402            patterns: vec![],
403            reason: "no_match",
404            policy_table: "mem_management.create",
405        };
406        assert_eq!(e.code(), "MEM_PATH_NOT_ALLOWED");
407
408        let e = FullEngineError::MemReferencedByPolicy {
409            name: "x".into(),
410            referring_mems: vec![],
411        };
412        assert_eq!(e.code(), "MEM_REFERENCED_BY_POLICY");
413
414        let e = FullEngineError::MemSchemaNotAllowed {
415            candidate: "x".into(),
416            matched_pattern: "p".into(),
417            requested_schema: "s".into(),
418            allowed_schemas: vec![],
419        };
420        assert_eq!(e.code(), "MEM_SCHEMA_NOT_ALLOWED");
421
422        let e = FullEngineError::ConfigAlreadyExists {
423            path: PathBuf::from("/x"),
424        };
425        assert_eq!(e.code(), "CONFIG_ERROR");
426    }
427
428    /// Wrapped lean errors delegate `code()` to the lean mapping.
429    /// Any drift in the lean enum's code strings rolls through this
430    /// path automatically — the full layer never re-stringifies.
431    #[test]
432    fn wrapped_lean_error_delegates_code() {
433        let e: FullEngineError = EngineError::UnknownMem("specs".into()).into();
434        assert_eq!(e.code(), "UNKNOWN_MEM");
435    }
436
437    /// The `policy_table` field disambiguates which allowlist refused
438    /// without forcing an agent to thread subcommand context through
439    /// error handling. The structured `details` payload and the
440    /// `prose_render` text both surface the table name.
441    #[test]
442    fn mem_path_not_allowed_carries_policy_table_in_details_and_prose() {
443        let create_err = FullEngineError::MemPathNotAllowed {
444            attempted: PathBuf::from("/ws/scratch-2"),
445            candidate: "scratch-2".into(),
446            patterns: vec!["specs".into()],
447            reason: "no_match",
448            policy_table: "mem_management.create",
449        };
450        let details = create_err.details();
451        assert_eq!(details["policy_table"], "mem_management.create");
452        assert_eq!(details["reason"], "no_match");
453        let prose = create_err.prose_render();
454        assert!(
455            prose.contains("mem_management.create"),
456            "prose must name the refusing allowlist: {prose}"
457        );
458
459        // Delete-path symmetric — policy_table flips to the delete table.
460        let delete_err = FullEngineError::MemPathNotAllowed {
461            attempted: PathBuf::from("/ws/archive-src"),
462            candidate: "archive-src".into(),
463            patterns: vec!["specs".into()],
464            reason: "no_match",
465            policy_table: "mem_management.delete",
466        };
467        assert_eq!(
468            delete_err.details()["policy_table"],
469            "mem_management.delete"
470        );
471        let prose = delete_err.prose_render();
472        assert!(
473            prose.contains("mem_management.delete"),
474            "prose must name the refusing allowlist: {prose}"
475        );
476    }
477
478    /// `no_allowlist_configured` names the concrete grant command —
479    /// pattern and schema pin included — in both the prose and the
480    /// structured `details.remedy`, so the first-time caller can
481    /// proceed from the refusal alone.
482    #[test]
483    fn no_allowlist_configured_names_the_grant_command() {
484        let err = FullEngineError::MemPathNotAllowed {
485            attempted: PathBuf::from("/ws/muehle"),
486            candidate: "muehle".into(),
487            patterns: vec![],
488            reason: "no_allowlist_configured",
489            policy_table: "mem_management.create",
490        };
491        let prose = err.prose_render();
492        assert!(
493            prose.contains("memstead workspace allow-create"),
494            "prose names the CLI remedy: {prose}"
495        );
496        assert!(
497            prose.contains("--schema"),
498            "prose names the schema pin: {prose}"
499        );
500        assert!(
501            prose.contains("operator surface"),
502            "prose names policy as an operator act: {prose}"
503        );
504        let details = err.details();
505        assert!(
506            details["remedy"]["cli"]
507                .as_str()
508                .unwrap()
509                .contains("allow-create"),
510            "details carry the remedy: {details}"
511        );
512        // Policy is not editable from MCP, so the remedy names no tool
513        // — a pointer at a removed tool is worse than no pointer.
514        assert!(
515            details["remedy"]["mcp"].is_null(),
516            "the remedy names no MCP tool: {details}"
517        );
518        assert!(
519            !prose.contains("memstead_workspace_"),
520            "prose names no removed MCP tool: {prose}"
521        );
522
523        // Delete-path variant names allow-delete, without a schema pin.
524        let err = FullEngineError::MemPathNotAllowed {
525            attempted: PathBuf::from("/ws/muehle"),
526            candidate: "muehle".into(),
527            patterns: vec![],
528            reason: "no_allowlist_configured",
529            policy_table: "mem_management.delete",
530        };
531        let prose = err.prose_render();
532        assert!(prose.contains("memstead workspace allow-delete"), "{prose}");
533        assert!(!prose.contains("--schema"), "{prose}");
534        assert!(
535            err.details()["remedy"]["cli"]
536                .as_str()
537                .unwrap()
538                .contains("allow-delete"),
539            "{:?}",
540            err.details()
541        );
542    }
543
544    /// `no_match` speaks to ITS situation — rules exist, none matched
545    /// — with a sentence distinct from the empty-allowlist one, while
546    /// still naming the covering-rule remedy.
547    #[test]
548    fn no_match_sentence_is_distinct_and_names_patterns() {
549        let no_match = FullEngineError::MemPathNotAllowed {
550            attempted: PathBuf::from("/ws/scratch"),
551            candidate: "scratch".into(),
552            patterns: vec!["specs".into(), "team/*".into()],
553            reason: "no_match",
554            policy_table: "mem_management.create",
555        };
556        let empty = FullEngineError::MemPathNotAllowed {
557            attempted: PathBuf::from("/ws/scratch"),
558            candidate: "scratch".into(),
559            patterns: vec![],
560            reason: "no_allowlist_configured",
561            policy_table: "mem_management.create",
562        };
563        let no_match_prose = no_match.prose_render();
564        let empty_prose = empty.prose_render();
565        assert_ne!(no_match_prose, empty_prose);
566        assert!(
567            no_match_prose.contains("'specs'") && no_match_prose.contains("'team/*'"),
568            "no_match names the configured patterns: {no_match_prose}"
569        );
570        assert!(
571            no_match_prose.contains("allow-create"),
572            "no_match still names the covering-rule remedy: {no_match_prose}"
573        );
574        assert!(no_match.details()["remedy"].is_object());
575    }
576
577    /// `outside_workspace` gains NO allowlist remedy — adding a rule
578    /// does not fix a workspace-external location, so suggesting one
579    /// would be wrong. Neither channel mentions the grant command.
580    #[test]
581    fn outside_workspace_carries_no_allowlist_remedy() {
582        let err = FullEngineError::MemPathNotAllowed {
583            attempted: PathBuf::from("/elsewhere/x"),
584            candidate: "../x".into(),
585            patterns: vec!["specs".into()],
586            reason: "outside_workspace",
587            policy_table: "mem_management.create",
588        };
589        let prose = err.prose_render();
590        assert!(!prose.contains("allow-create"), "{prose}");
591        assert!(!prose.contains("allow_create"), "{prose}");
592        let details = err.details();
593        assert!(details.get("remedy").is_none(), "{details}");
594        // The rest of the payload is unchanged.
595        assert_eq!(details["reason"], "outside_workspace");
596    }
597}