use std::path::PathBuf;
use memstead_base::EngineError;
#[derive(Debug, thiserror::Error)]
pub enum FullEngineError {
#[error(transparent)]
Lean(#[from] EngineError),
#[error("mem path not allowed by [[{policy_table}]]: {candidate} ({reason})")]
MemPathNotAllowed {
attempted: PathBuf,
candidate: String,
patterns: Vec<String>,
reason: &'static str,
policy_table: &'static str,
},
#[error("mem name `{name}` is invalid ({reason})")]
InvalidMemName { name: String, reason: &'static str },
#[error(
"mem {name} cannot be deleted: workspace `[cross_mem_links]` policy grants {referring_mems:?} write-into permission — revoke that grant and retry"
)]
MemReferencedByPolicy {
name: String,
referring_mems: Vec<String>,
},
#[error(
"schema {requested_schema} not allowed by create-rule {matched_pattern:?} for candidate {candidate:?}"
)]
MemSchemaNotAllowed {
candidate: String,
matched_pattern: String,
requested_schema: String,
allowed_schemas: Vec<String>,
},
#[error("config already exists at {path}")]
ConfigAlreadyExists { path: PathBuf },
#[error(
"mem storage residue detected at branch `{branch_ref}`: \
{entity_count} entities preserved from a prior session — \
re-run with `recovery: reattach` to adopt, `recovery: \
force_overwrite` to destroy, or `recovery: \
hard_cleanup_first` to refuse until `memstead mem delete` is run"
)]
MemStorageResidueDetected {
branch_ref: String,
config_blob: Option<String>,
entity_count: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoveryAction {
Reattach,
ForceOverwrite,
HardCleanupFirst,
}
impl RecoveryAction {
pub fn as_wire_str(&self) -> &'static str {
match self {
RecoveryAction::Reattach => "reattach",
RecoveryAction::ForceOverwrite => "force_overwrite",
RecoveryAction::HardCleanupFirst => "hard_cleanup_first",
}
}
}
fn allowlist_remedy(policy_table: &str) -> (&'static str, &'static str) {
if policy_table == "mem_management.delete" {
(
"memstead workspace allow-delete '<pattern>'",
"memstead_workspace_allow_delete",
)
} else {
(
"memstead workspace allow-create '<pattern>' --schema <name@version>",
"memstead_workspace_allow_create",
)
}
}
impl FullEngineError {
pub fn prose_render(&self) -> String {
match self {
FullEngineError::Lean(inner) => inner.prose_render(),
FullEngineError::MemPathNotAllowed {
attempted,
candidate,
patterns,
reason,
policy_table,
} => {
let patterns_inline = if patterns.is_empty() {
"(no rules configured)".to_string()
} else {
patterns
.iter()
.map(|p| format!("'{p}'"))
.collect::<Vec<_>>()
.join(", ")
};
let verb = if *policy_table == "mem_management.delete" {
"deletion"
} else {
"creation"
};
let (allow_cmd, allow_tool) = allowlist_remedy(policy_table);
match *reason {
"no_allowlist_configured" => format!(
"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.",
attempted.display()
),
"no_match" => format!(
"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}`).",
attempted.display()
),
_ => format!(
"mem path not allowed by `[[{policy_table}]]`: candidate '{candidate}' (resolved location '{}') did not match any allowlist rule (reason: {reason}). Configured patterns: {patterns_inline}.",
attempted.display()
),
}
}
FullEngineError::MemSchemaNotAllowed {
candidate,
matched_pattern,
requested_schema,
allowed_schemas,
} => {
let allowed_inline = if allowed_schemas.is_empty() {
"(none)".to_string()
} else {
allowed_schemas.join(", ")
};
format!(
"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."
)
}
FullEngineError::MemReferencedByPolicy {
name,
referring_mems,
} => {
let inline = if referring_mems.is_empty() {
"(none)".to_string()
} else {
referring_mems.join(", ")
};
format!(
"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."
)
}
_ => self.to_string(),
}
}
pub fn details(&self) -> serde_json::Value {
match self {
FullEngineError::Lean(inner) => inner.details(),
FullEngineError::MemPathNotAllowed {
attempted,
candidate,
patterns,
reason,
policy_table,
} => {
let mut d = serde_json::json!({
"attempted": attempted.display().to_string(),
"candidate": candidate,
"patterns": patterns,
"reason": reason,
"policy_table": policy_table,
});
if matches!(*reason, "no_allowlist_configured" | "no_match") {
let (cli, mcp) = allowlist_remedy(policy_table);
d["remedy"] = serde_json::json!({ "cli": cli, "mcp": mcp });
}
d
}
FullEngineError::InvalidMemName { name, reason } => {
serde_json::json!({ "name": name, "reason": reason })
}
FullEngineError::MemReferencedByPolicy {
name,
referring_mems,
} => serde_json::json!({
"name": name,
"referring_mems": referring_mems,
}),
FullEngineError::MemSchemaNotAllowed {
candidate,
matched_pattern,
requested_schema,
allowed_schemas,
} => serde_json::json!({
"candidate": candidate,
"matched_pattern": matched_pattern,
"requested_schema": requested_schema,
"allowed_schemas": allowed_schemas,
}),
FullEngineError::ConfigAlreadyExists { path } => serde_json::json!({
"path": path.display().to_string(),
"reason": "config_already_exists",
}),
FullEngineError::MemStorageResidueDetected {
branch_ref,
config_blob,
entity_count,
} => serde_json::json!({
"branch_ref": branch_ref,
"config_blob": config_blob,
"entity_count": entity_count,
"recovery": ["reattach", "force_overwrite", "hard_cleanup_first"],
}),
}
}
pub fn code(&self) -> &'static str {
match self {
FullEngineError::Lean(e) => e.code(),
FullEngineError::MemPathNotAllowed { .. } => "MEM_PATH_NOT_ALLOWED",
FullEngineError::InvalidMemName { .. } => "INVALID_MEM_NAME",
FullEngineError::MemReferencedByPolicy { .. } => "MEM_REFERENCED_BY_POLICY",
FullEngineError::MemSchemaNotAllowed { .. } => "MEM_SCHEMA_NOT_ALLOWED",
FullEngineError::ConfigAlreadyExists { .. } => "CONFIG_ERROR",
FullEngineError::MemStorageResidueDetected { .. } => "MEM_STORAGE_RESIDUE_DETECTED",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lifecycle_codes_pin_wire_vocabulary() {
let e = FullEngineError::MemPathNotAllowed {
attempted: PathBuf::from("/x"),
candidate: "x".into(),
patterns: vec![],
reason: "no_match",
policy_table: "mem_management.create",
};
assert_eq!(e.code(), "MEM_PATH_NOT_ALLOWED");
let e = FullEngineError::MemReferencedByPolicy {
name: "x".into(),
referring_mems: vec![],
};
assert_eq!(e.code(), "MEM_REFERENCED_BY_POLICY");
let e = FullEngineError::MemSchemaNotAllowed {
candidate: "x".into(),
matched_pattern: "p".into(),
requested_schema: "s".into(),
allowed_schemas: vec![],
};
assert_eq!(e.code(), "MEM_SCHEMA_NOT_ALLOWED");
let e = FullEngineError::ConfigAlreadyExists {
path: PathBuf::from("/x"),
};
assert_eq!(e.code(), "CONFIG_ERROR");
}
#[test]
fn wrapped_lean_error_delegates_code() {
let e: FullEngineError = EngineError::UnknownMem("specs".into()).into();
assert_eq!(e.code(), "UNKNOWN_MEM");
}
#[test]
fn mem_path_not_allowed_carries_policy_table_in_details_and_prose() {
let create_err = FullEngineError::MemPathNotAllowed {
attempted: PathBuf::from("/ws/scratch-2"),
candidate: "scratch-2".into(),
patterns: vec!["specs".into()],
reason: "no_match",
policy_table: "mem_management.create",
};
let details = create_err.details();
assert_eq!(details["policy_table"], "mem_management.create");
assert_eq!(details["reason"], "no_match");
let prose = create_err.prose_render();
assert!(
prose.contains("mem_management.create"),
"prose must name the refusing allowlist: {prose}"
);
let delete_err = FullEngineError::MemPathNotAllowed {
attempted: PathBuf::from("/ws/archive-src"),
candidate: "archive-src".into(),
patterns: vec!["specs".into()],
reason: "no_match",
policy_table: "mem_management.delete",
};
assert_eq!(
delete_err.details()["policy_table"],
"mem_management.delete"
);
let prose = delete_err.prose_render();
assert!(
prose.contains("mem_management.delete"),
"prose must name the refusing allowlist: {prose}"
);
}
#[test]
fn no_allowlist_configured_names_the_grant_command() {
let err = FullEngineError::MemPathNotAllowed {
attempted: PathBuf::from("/ws/muehle"),
candidate: "muehle".into(),
patterns: vec![],
reason: "no_allowlist_configured",
policy_table: "mem_management.create",
};
let prose = err.prose_render();
assert!(
prose.contains("memstead workspace allow-create"),
"prose names the CLI remedy: {prose}"
);
assert!(
prose.contains("--schema"),
"prose names the schema pin: {prose}"
);
assert!(
prose.contains("memstead_workspace_allow_create"),
"prose names the MCP remedy: {prose}"
);
let details = err.details();
assert!(
details["remedy"]["cli"]
.as_str()
.unwrap()
.contains("allow-create"),
"details carry the remedy: {details}"
);
assert_eq!(details["remedy"]["mcp"], "memstead_workspace_allow_create");
let err = FullEngineError::MemPathNotAllowed {
attempted: PathBuf::from("/ws/muehle"),
candidate: "muehle".into(),
patterns: vec![],
reason: "no_allowlist_configured",
policy_table: "mem_management.delete",
};
let prose = err.prose_render();
assert!(prose.contains("memstead workspace allow-delete"), "{prose}");
assert!(!prose.contains("--schema"), "{prose}");
assert_eq!(
err.details()["remedy"]["mcp"],
"memstead_workspace_allow_delete"
);
}
#[test]
fn no_match_sentence_is_distinct_and_names_patterns() {
let no_match = FullEngineError::MemPathNotAllowed {
attempted: PathBuf::from("/ws/scratch"),
candidate: "scratch".into(),
patterns: vec!["specs".into(), "team/*".into()],
reason: "no_match",
policy_table: "mem_management.create",
};
let empty = FullEngineError::MemPathNotAllowed {
attempted: PathBuf::from("/ws/scratch"),
candidate: "scratch".into(),
patterns: vec![],
reason: "no_allowlist_configured",
policy_table: "mem_management.create",
};
let no_match_prose = no_match.prose_render();
let empty_prose = empty.prose_render();
assert_ne!(no_match_prose, empty_prose);
assert!(
no_match_prose.contains("'specs'") && no_match_prose.contains("'team/*'"),
"no_match names the configured patterns: {no_match_prose}"
);
assert!(
no_match_prose.contains("allow-create"),
"no_match still names the covering-rule remedy: {no_match_prose}"
);
assert!(no_match.details()["remedy"].is_object());
}
#[test]
fn outside_workspace_carries_no_allowlist_remedy() {
let err = FullEngineError::MemPathNotAllowed {
attempted: PathBuf::from("/elsewhere/x"),
candidate: "../x".into(),
patterns: vec!["specs".into()],
reason: "outside_workspace",
policy_table: "mem_management.create",
};
let prose = err.prose_render();
assert!(!prose.contains("allow-create"), "{prose}");
assert!(!prose.contains("allow_create"), "{prose}");
let details = err.details();
assert!(details.get("remedy").is_none(), "{details}");
assert_eq!(details["reason"], "outside_workspace");
}
}