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
176/// The concrete grant command for a refusing allowlist table, CLI and
177/// MCP forms — the single source both the prose and the structured
178/// `details.remedy` render, so the two channels cannot drift. The
179/// create form names the pattern and the schema pin because
180/// `allow-create` requires both; delete rules have no schema
181/// dimension.
182fn allowlist_remedy(policy_table: &str) -> (&'static str, &'static str) {
183 if policy_table == "mem_management.delete" {
184 (
185 "memstead workspace allow-delete '<pattern>'",
186 "memstead_workspace_allow_delete",
187 )
188 } else {
189 (
190 "memstead workspace allow-create '<pattern>' --schema <name@version>",
191 "memstead_workspace_allow_create",
192 )
193 }
194}
195
196impl FullEngineError {
197 /// Render rich, fully-inlined recovery prose for the agent-visible
198 /// text channel. Closes the asymmetry where structured `details.X`
199 /// fields stayed off the agent's text channel. Each lifecycle
200 /// variant with a structured list (`patterns`, `referring_mems`,
201 /// `allowed_schemas`) inlines the full payload; lean wraps
202 /// delegate to [`EngineError::prose_render`]; trivial variants
203 /// fall back to `Display`.
204 pub fn prose_render(&self) -> String {
205 match self {
206 FullEngineError::Lean(inner) => inner.prose_render(),
207 FullEngineError::MemPathNotAllowed {
208 attempted,
209 candidate,
210 patterns,
211 reason,
212 policy_table,
213 } => {
214 let patterns_inline = if patterns.is_empty() {
215 "(no rules configured)".to_string()
216 } else {
217 patterns
218 .iter()
219 .map(|p| format!("'{p}'"))
220 .collect::<Vec<_>>()
221 .join(", ")
222 };
223 // Each reason gets the sentence that fits ITS
224 // situation, and the allowlist remedy is named only
225 // where adding a rule actually is the remedy —
226 // `outside_workspace` is not fixed by a rule, so it
227 // keeps the plain refusal.
228 let verb = if *policy_table == "mem_management.delete" {
229 "deletion"
230 } else {
231 "creation"
232 };
233 let (allow_cmd, allow_tool) = allowlist_remedy(policy_table);
234 match *reason {
235 "no_allowlist_configured" => format!(
236 "mem {verb} is refused by default: this workspace has no `[[{policy_table}]]` allowlist rules (candidate '{candidate}', resolved location '{}'). Grant the permission first — `{allow_cmd}` (MCP: `{allow_tool}`), e.g. with pattern '{candidate}' — then retry.",
237 attempted.display()
238 ),
239 "no_match" => format!(
240 "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 add a covering rule — `{allow_cmd}` (MCP: `{allow_tool}`).",
241 attempted.display()
242 ),
243 _ => format!(
244 "mem path not allowed by `[[{policy_table}]]`: candidate '{candidate}' (resolved location '{}') did not match any allowlist rule (reason: {reason}). Configured patterns: {patterns_inline}.",
245 attempted.display()
246 ),
247 }
248 }
249 FullEngineError::MemSchemaNotAllowed {
250 candidate,
251 matched_pattern,
252 requested_schema,
253 allowed_schemas,
254 } => {
255 let allowed_inline = if allowed_schemas.is_empty() {
256 "(none)".to_string()
257 } else {
258 allowed_schemas.join(", ")
259 };
260 format!(
261 "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."
262 )
263 }
264 FullEngineError::MemReferencedByPolicy {
265 name,
266 referring_mems,
267 } => {
268 let inline = if referring_mems.is_empty() {
269 "(none)".to_string()
270 } else {
271 referring_mems.join(", ")
272 };
273 format!(
274 "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."
275 )
276 }
277 // InvalidMemName, ConfigAlreadyExists, MemStorageResidueDetected:
278 // `Display` already inlines every field; fall back.
279 _ => self.to_string(),
280 }
281 }
282
283 /// Variant-specific recovery payload, rendered as a structured
284 /// JSON object that surfaces under `error.details` in MCP / CLI
285 /// envelopes. The CLI's mem commands used to discard the engine's
286 /// structured details because the lift code didn't have a single
287 /// source of truth —
288 /// this mirrors `EngineError::details()` so the lift can call
289 /// `err.details()` directly without hand-maintaining each per-
290 /// variant payload at the CLI surface.
291 ///
292 /// `Lean(inner)` delegates to `EngineError::details()`. Lifecycle
293 /// variants return the same JSON object shape `full_engine_err_unified`
294 /// builds on the MCP wire — both surfaces share the payload here
295 /// so they cannot drift.
296 pub fn details(&self) -> serde_json::Value {
297 match self {
298 FullEngineError::Lean(inner) => inner.details(),
299 FullEngineError::MemPathNotAllowed {
300 attempted,
301 candidate,
302 patterns,
303 reason,
304 policy_table,
305 } => {
306 let mut d = serde_json::json!({
307 "attempted": attempted.display().to_string(),
308 "candidate": candidate,
309 "patterns": patterns,
310 "reason": reason,
311 "policy_table": policy_table,
312 });
313 // The remedy rides only where adding a rule IS the
314 // remedy: `outside_workspace` is not fixed by an
315 // allowlist rule, so it carries none.
316 if matches!(*reason, "no_allowlist_configured" | "no_match") {
317 let (cli, mcp) = allowlist_remedy(policy_table);
318 d["remedy"] = serde_json::json!({ "cli": cli, "mcp": mcp });
319 }
320 d
321 }
322 FullEngineError::InvalidMemName { name, reason } => {
323 serde_json::json!({ "name": name, "reason": reason })
324 }
325 FullEngineError::MemReferencedByPolicy {
326 name,
327 referring_mems,
328 } => serde_json::json!({
329 "name": name,
330 "referring_mems": referring_mems,
331 }),
332 FullEngineError::MemSchemaNotAllowed {
333 candidate,
334 matched_pattern,
335 requested_schema,
336 allowed_schemas,
337 } => serde_json::json!({
338 "candidate": candidate,
339 "matched_pattern": matched_pattern,
340 "requested_schema": requested_schema,
341 "allowed_schemas": allowed_schemas,
342 }),
343 FullEngineError::ConfigAlreadyExists { path } => serde_json::json!({
344 "path": path.display().to_string(),
345 "reason": "config_already_exists",
346 }),
347 FullEngineError::MemStorageResidueDetected {
348 branch_ref,
349 config_blob,
350 entity_count,
351 } => serde_json::json!({
352 "branch_ref": branch_ref,
353 "config_blob": config_blob,
354 "entity_count": entity_count,
355 "recovery": ["reattach", "force_overwrite", "hard_cleanup_first"],
356 }),
357 }
358 }
359
360 /// Stable, surface-independent error code token.
361 ///
362 /// Matches `memstead_base::EngineError::code()` for every variant —
363 /// wrapped lean errors delegate to the lean mapping, lifecycle
364 /// variants return the exact strings the lean enum returned for
365 /// them today. This is load-bearing: the wire-shape pins in
366 /// `memstead-mcp/tests/wire_shape.rs` assert these exact code strings.
367 pub fn code(&self) -> &'static str {
368 match self {
369 FullEngineError::Lean(e) => e.code(),
370 FullEngineError::MemPathNotAllowed { .. } => "MEM_PATH_NOT_ALLOWED",
371 FullEngineError::InvalidMemName { .. } => "INVALID_MEM_NAME",
372 FullEngineError::MemReferencedByPolicy { .. } => "MEM_REFERENCED_BY_POLICY",
373 FullEngineError::MemSchemaNotAllowed { .. } => "MEM_SCHEMA_NOT_ALLOWED",
374 FullEngineError::ConfigAlreadyExists { .. } => "CONFIG_ERROR",
375 FullEngineError::MemStorageResidueDetected { .. } => "MEM_STORAGE_RESIDUE_DETECTED",
376 }
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 /// Code strings track the wire vocabulary the full MCP surface
385 /// publishes. `MEM_REFERENCED_BY_POLICY` was renamed from the
386 /// pre-04 `MEM_HAS_REFERENCES` so the typed code matches the
387 /// actual fire condition (a workspace `[cross_mem_links]` grant,
388 /// not a materialised graph edge); the other three lifecycle
389 /// codes are unchanged from when these variants lived on
390 /// `memstead_base::EngineError`.
391 #[test]
392 fn lifecycle_codes_pin_wire_vocabulary() {
393 let e = FullEngineError::MemPathNotAllowed {
394 attempted: PathBuf::from("/x"),
395 candidate: "x".into(),
396 patterns: vec![],
397 reason: "no_match",
398 policy_table: "mem_management.create",
399 };
400 assert_eq!(e.code(), "MEM_PATH_NOT_ALLOWED");
401
402 let e = FullEngineError::MemReferencedByPolicy {
403 name: "x".into(),
404 referring_mems: vec![],
405 };
406 assert_eq!(e.code(), "MEM_REFERENCED_BY_POLICY");
407
408 let e = FullEngineError::MemSchemaNotAllowed {
409 candidate: "x".into(),
410 matched_pattern: "p".into(),
411 requested_schema: "s".into(),
412 allowed_schemas: vec![],
413 };
414 assert_eq!(e.code(), "MEM_SCHEMA_NOT_ALLOWED");
415
416 let e = FullEngineError::ConfigAlreadyExists {
417 path: PathBuf::from("/x"),
418 };
419 assert_eq!(e.code(), "CONFIG_ERROR");
420 }
421
422 /// Wrapped lean errors delegate `code()` to the lean mapping.
423 /// Any drift in the lean enum's code strings rolls through this
424 /// path automatically — the full layer never re-stringifies.
425 #[test]
426 fn wrapped_lean_error_delegates_code() {
427 let e: FullEngineError = EngineError::UnknownMem("specs".into()).into();
428 assert_eq!(e.code(), "UNKNOWN_MEM");
429 }
430
431 /// The `policy_table` field disambiguates which allowlist refused
432 /// without forcing an agent to thread subcommand context through
433 /// error handling. The structured `details` payload and the
434 /// `prose_render` text both surface the table name.
435 #[test]
436 fn mem_path_not_allowed_carries_policy_table_in_details_and_prose() {
437 let create_err = FullEngineError::MemPathNotAllowed {
438 attempted: PathBuf::from("/ws/scratch-2"),
439 candidate: "scratch-2".into(),
440 patterns: vec!["specs".into()],
441 reason: "no_match",
442 policy_table: "mem_management.create",
443 };
444 let details = create_err.details();
445 assert_eq!(details["policy_table"], "mem_management.create");
446 assert_eq!(details["reason"], "no_match");
447 let prose = create_err.prose_render();
448 assert!(
449 prose.contains("mem_management.create"),
450 "prose must name the refusing allowlist: {prose}"
451 );
452
453 // Delete-path symmetric — policy_table flips to the delete table.
454 let delete_err = FullEngineError::MemPathNotAllowed {
455 attempted: PathBuf::from("/ws/archive-src"),
456 candidate: "archive-src".into(),
457 patterns: vec!["specs".into()],
458 reason: "no_match",
459 policy_table: "mem_management.delete",
460 };
461 assert_eq!(
462 delete_err.details()["policy_table"],
463 "mem_management.delete"
464 );
465 let prose = delete_err.prose_render();
466 assert!(
467 prose.contains("mem_management.delete"),
468 "prose must name the refusing allowlist: {prose}"
469 );
470 }
471
472 /// `no_allowlist_configured` names the concrete grant command —
473 /// pattern and schema pin included — in both the prose and the
474 /// structured `details.remedy`, so the first-time caller can
475 /// proceed from the refusal alone.
476 #[test]
477 fn no_allowlist_configured_names_the_grant_command() {
478 let err = FullEngineError::MemPathNotAllowed {
479 attempted: PathBuf::from("/ws/muehle"),
480 candidate: "muehle".into(),
481 patterns: vec![],
482 reason: "no_allowlist_configured",
483 policy_table: "mem_management.create",
484 };
485 let prose = err.prose_render();
486 assert!(
487 prose.contains("memstead workspace allow-create"),
488 "prose names the CLI remedy: {prose}"
489 );
490 assert!(
491 prose.contains("--schema"),
492 "prose names the schema pin: {prose}"
493 );
494 assert!(
495 prose.contains("memstead_workspace_allow_create"),
496 "prose names the MCP remedy: {prose}"
497 );
498 let details = err.details();
499 assert!(
500 details["remedy"]["cli"]
501 .as_str()
502 .unwrap()
503 .contains("allow-create"),
504 "details carry the remedy: {details}"
505 );
506 assert_eq!(details["remedy"]["mcp"], "memstead_workspace_allow_create");
507
508 // Delete-path variant names allow-delete, without a schema pin.
509 let err = FullEngineError::MemPathNotAllowed {
510 attempted: PathBuf::from("/ws/muehle"),
511 candidate: "muehle".into(),
512 patterns: vec![],
513 reason: "no_allowlist_configured",
514 policy_table: "mem_management.delete",
515 };
516 let prose = err.prose_render();
517 assert!(prose.contains("memstead workspace allow-delete"), "{prose}");
518 assert!(!prose.contains("--schema"), "{prose}");
519 assert_eq!(
520 err.details()["remedy"]["mcp"],
521 "memstead_workspace_allow_delete"
522 );
523 }
524
525 /// `no_match` speaks to ITS situation — rules exist, none matched
526 /// — with a sentence distinct from the empty-allowlist one, while
527 /// still naming the covering-rule remedy.
528 #[test]
529 fn no_match_sentence_is_distinct_and_names_patterns() {
530 let no_match = FullEngineError::MemPathNotAllowed {
531 attempted: PathBuf::from("/ws/scratch"),
532 candidate: "scratch".into(),
533 patterns: vec!["specs".into(), "team/*".into()],
534 reason: "no_match",
535 policy_table: "mem_management.create",
536 };
537 let empty = FullEngineError::MemPathNotAllowed {
538 attempted: PathBuf::from("/ws/scratch"),
539 candidate: "scratch".into(),
540 patterns: vec![],
541 reason: "no_allowlist_configured",
542 policy_table: "mem_management.create",
543 };
544 let no_match_prose = no_match.prose_render();
545 let empty_prose = empty.prose_render();
546 assert_ne!(no_match_prose, empty_prose);
547 assert!(
548 no_match_prose.contains("'specs'") && no_match_prose.contains("'team/*'"),
549 "no_match names the configured patterns: {no_match_prose}"
550 );
551 assert!(
552 no_match_prose.contains("allow-create"),
553 "no_match still names the covering-rule remedy: {no_match_prose}"
554 );
555 assert!(no_match.details()["remedy"].is_object());
556 }
557
558 /// `outside_workspace` gains NO allowlist remedy — adding a rule
559 /// does not fix a workspace-external location, so suggesting one
560 /// would be wrong. Neither channel mentions the grant command.
561 #[test]
562 fn outside_workspace_carries_no_allowlist_remedy() {
563 let err = FullEngineError::MemPathNotAllowed {
564 attempted: PathBuf::from("/elsewhere/x"),
565 candidate: "../x".into(),
566 patterns: vec!["specs".into()],
567 reason: "outside_workspace",
568 policy_table: "mem_management.create",
569 };
570 let prose = err.prose_render();
571 assert!(!prose.contains("allow-create"), "{prose}");
572 assert!(!prose.contains("allow_create"), "{prose}");
573 let details = err.details();
574 assert!(details.get("remedy").is_none(), "{details}");
575 // The rest of the payload is unchanged.
576 assert_eq!(details["reason"], "outside_workspace");
577 }
578}