use crate::mcp::VectorIndex;
use crate::mcp::param_names;
use crate::mcp::registry::McpTool;
use crate::{db, validate};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use std::path::Path;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct DeleteRequest {
pub id: String,
}
#[allow(dead_code)]
pub struct DeleteTool;
impl McpTool for DeleteTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_DELETE
}
fn description() -> &'static str {
"Delete a memory by ID."
}
fn docs() -> &'static str {
"Hard-delete by id (removes row, embedding, FTS, links). Use memory_forget for bulk pattern delete (archives first)."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<DeleteRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Lifecycle.name()
}
}
#[cfg(test)]
mod d1_6_987_tests {
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn delete_parity_987() {
let derived = derived_props_for::<DeleteRequest>();
assert_property_set_parity("memory_delete", &derived);
assert_descriptions_match("memory_delete", &derived);
}
#[test]
fn delete_tool_metadata_987() {
assert_eq!(DeleteTool::name(), "memory_delete");
assert_eq!(DeleteTool::family(), "lifecycle");
}
}
pub(super) fn handle_delete(
conn: &rusqlite::Connection,
db_path: &Path,
params: &Value,
vector_index: Option<&VectorIndex>,
mcp_client: Option<&str>,
) -> Result<Value, String> {
let id = params["id"]
.as_str()
.ok_or(crate::errors::msg::ID_REQUIRED)?;
validate::validate_id(id).map_err(|e| e.to_string())?;
let caller_for_forensic =
crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
.unwrap_or_else(|_| crate::identity::sentinels::ANONYMOUS_INVALID.to_string());
crate::governance::audit::record_decision(
&caller_for_forensic,
"allow",
crate::mcp::registry::tool_names::MEMORY_DELETE,
"",
json!({ "id": id }),
);
let target = if let Some(m) = db::get(conn, id).map_err(|e| e.to_string())? {
Some(m)
} else {
db::get_by_prefix(conn, id).map_err(|e| e.to_string())?
};
let Some(target) = target else {
return Err(crate::errors::msg::MEMORY_NOT_FOUND.into());
};
let snapshot_namespace = target.namespace.clone();
let snapshot_title = target.title.clone();
let snapshot_tier = target.tier.as_str().to_string();
let snapshot_owner: Option<String> = target
.metadata
.get(param_names::AGENT_ID)
.and_then(|v| v.as_str())
.map(str::to_string);
{
use crate::permissions::{Op, PermissionContext, Permissions};
let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
.map_err(|e| e.to_string())?;
let payload = json!({"id": target.id, "title": target.title});
let ctx = PermissionContext {
op: Op::MemoryDelete,
namespace: target.namespace.clone(),
agent_id,
payload,
};
match Permissions::evaluate(&ctx, &[]) {
crate::permissions::Decision::Allow | crate::permissions::Decision::Modify(_) => {}
crate::permissions::Decision::Deny(reason) => {
return Err(crate::governance::deny_message(
"delete",
crate::governance::DenyGate::PermissionRule,
&reason,
));
}
crate::permissions::Decision::Ask(prompt) => {
return Ok(json!({
"status": "ask",
"reason": prompt,
"action": "delete",
"memory_id": target.id,
}));
}
}
}
{
use crate::models::{GovernanceDecision, GovernedAction};
let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), mcp_client)
.map_err(|e| e.to_string())?;
let mem_owner = target
.metadata
.get(param_names::AGENT_ID)
.and_then(|v| v.as_str())
.map(str::to_string);
let payload = json!({"id": target.id, "title": target.title});
match db::enforce_governance(
conn,
GovernedAction::Delete,
&target.namespace,
&agent_id,
Some(&target.id),
mem_owner.as_deref(),
&payload,
)
.map_err(|e| e.to_string())?
{
GovernanceDecision::Allow => {}
GovernanceDecision::Deny(refusal) => {
return Err(crate::governance::deny_message(
"delete",
crate::governance::DenyGate::Governance,
&refusal.reason,
));
}
GovernanceDecision::Pending(pending_id) => {
crate::subscriptions::dispatch_approval_requested(conn, &pending_id, db_path);
return Ok(json!({
"status": "pending",
"pending_id": pending_id,
"reason": crate::errors::msg::GOVERNANCE_REQUIRES_APPROVAL,
"action": "delete",
"memory_id": target.id,
}));
}
}
}
let deleted = db::delete(conn, &target.id).map_err(|e| e.to_string())?;
if deleted {
if let Some(idx) = vector_index {
idx.remove(&target.id);
}
crate::audit::emit(crate::audit::EventBuilder::new(
crate::audit::AuditAction::Delete,
crate::audit::actor(
snapshot_owner
.clone()
.unwrap_or_else(|| "unknown".to_string()),
mcp_client.map_or(crate::audit::synthesis_sources::HOST_FALLBACK, |_| {
crate::audit::synthesis_sources::MCP_CLIENT_INFO
}),
None,
),
crate::audit::target_memory(
target.id.clone(),
snapshot_namespace.clone(),
Some(snapshot_title.clone()),
Some(snapshot_tier.clone()),
None,
),
));
let details = serde_json::to_value(crate::subscriptions::DeleteEventDetails {
title: snapshot_title,
tier: snapshot_tier,
})
.ok();
crate::subscriptions::dispatch_event_with_details(
conn,
crate::mcp::registry::tool_names::MEMORY_DELETE,
&target.id,
&snapshot_namespace,
snapshot_owner.as_deref(),
db_path,
details,
);
Ok(json!({"deleted": true}))
} else {
Err(crate::errors::msg::MEMORY_NOT_FOUND.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{Memory, Tier};
use crate::storage as db;
fn fresh_conn() -> rusqlite::Connection {
db::open(std::path::Path::new(":memory:")).expect("open in-memory db")
}
fn db_path() -> std::path::PathBuf {
std::path::PathBuf::from(":memory:")
}
fn make_mem(title: &str, ns: &str) -> Memory {
let now = chrono::Utc::now().to_rfc3339();
Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: ns.to_string(),
title: title.to_string(),
content: format!("c {title}"),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: json!({"agent_id": "ai:alice"}),
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
}
}
#[test]
fn happy_path_deletes_full_id() {
let conn = fresh_conn();
let mem = make_mem("doomed", "test");
let id = db::insert(&conn, &mem).expect("insert");
let db_path = db_path();
let out =
handle_delete(&conn, &db_path, &json!({"id": id.clone()}), None, None).expect("ok");
assert_eq!(out["deleted"].as_bool(), Some(true));
assert!(db::get(&conn, &id).unwrap().is_none(), "row removed");
}
#[test]
fn happy_path_prefix_resolution() {
let conn = fresh_conn();
let mut mem = make_mem("prefixed", "test");
mem.id = "abcdef01-aaaa-bbbb-cccc-ddddeeeeffff".to_string();
let id = db::insert(&conn, &mem).expect("insert");
let db_path = db_path();
let out = handle_delete(&conn, &db_path, &json!({"id": "abcdef01"}), None, None)
.expect("prefix delete");
assert_eq!(out["deleted"].as_bool(), Some(true));
assert!(db::get(&conn, &id).unwrap().is_none());
}
#[test]
fn happy_path_with_vector_index_removes_entry() {
use crate::hnsw::VectorIndex;
let conn = fresh_conn();
let mem = make_mem("vec-target", "test");
let id = db::insert(&conn, &mem).expect("insert");
let idx = VectorIndex::empty();
idx.insert(id.clone(), vec![0.1; 384]);
let db_path = db_path();
let out = handle_delete(
&conn,
&db_path,
&json!({"id": id.clone()}),
Some(&idx),
Some("ai:claude-code"),
)
.expect("delete");
assert_eq!(out["deleted"].as_bool(), Some(true));
}
#[test]
fn missing_id_returns_error() {
let conn = fresh_conn();
let db_path = db_path();
let err = handle_delete(&conn, &db_path, &json!({}), None, None).unwrap_err();
assert!(err.contains("id is required"));
}
#[test]
fn invalid_id_format_rejected() {
let conn = fresh_conn();
let db_path = db_path();
let err = handle_delete(&conn, &db_path, &json!({"id": ""}), None, None).unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn unknown_id_returns_not_found() {
let conn = fresh_conn();
let db_path = db_path();
let err = handle_delete(
&conn,
&db_path,
&json!({"id": "deadbeef-1234-5678-9abc-def012345678"}),
None,
None,
)
.unwrap_err();
assert!(err.contains("not found"));
}
#[test]
fn double_delete_errors_second_time() {
let conn = fresh_conn();
let mem = make_mem("twice", "test");
let id = db::insert(&conn, &mem).expect("insert");
let db_path = db_path();
let _ = handle_delete(&conn, &db_path, &json!({"id": id.clone()}), None, None)
.expect("first delete");
let err = handle_delete(&conn, &db_path, &json!({"id": id}), None, None).unwrap_err();
assert!(err.contains("not found"));
}
#[test]
fn happy_path_drives_audit_emit_call_path() {
let conn = fresh_conn();
let mem = make_mem("audit", "test");
let id = db::insert(&conn, &mem).expect("insert");
let db_path = db_path();
let out = handle_delete(
&conn,
&db_path,
&json!({"id": id, "agent_id": "ai:caller"}),
None,
Some("ai:claude-code"),
)
.expect("delete");
assert_eq!(out["deleted"].as_bool(), Some(true));
}
fn lock_rules() -> std::sync::MutexGuard<'static, ()> {
crate::mcp::SHARED_PERMISSION_RULES_GUARD
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct RulesScope {
_rules: std::sync::MutexGuard<'static, ()>,
_mode: std::sync::MutexGuard<'static, ()>,
}
impl Drop for RulesScope {
fn drop(&mut self) {
crate::permissions::clear_active_permission_rules_for_test();
crate::config::clear_permissions_mode_override_for_test();
}
}
fn rules_scope() -> RulesScope {
let mode = crate::config::lock_permissions_mode_for_test();
let rules = lock_rules();
crate::permissions::clear_active_permission_rules_for_test();
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Advisory,
);
RulesScope {
_rules: rules,
_mode: mode,
}
}
#[test]
fn k9_deny_rule_short_circuits() {
use crate::permissions::{PermissionRule, RuleDecision, set_active_permission_rules};
let _g = rules_scope();
let conn = fresh_conn();
let mem = make_mem("deny-target", "k9-deny-delete");
let id = db::insert(&conn, &mem).expect("insert");
let db_path = db_path();
set_active_permission_rules(vec![PermissionRule {
namespace_pattern: "k9-deny-delete".to_string(),
op: "memory_delete".to_string(),
agent_pattern: "*".to_string(),
decision: RuleDecision::Deny,
reason: Some("denied".to_string()),
}]);
let err = handle_delete(
&conn,
&db_path,
&json!({"id": id, "agent_id": "ai:caller"}),
None,
None,
)
.unwrap_err();
assert!(err.contains("denied"), "got: {err}");
}
#[test]
fn k9_ask_rule_returns_ask_envelope() {
use crate::permissions::{PermissionRule, RuleDecision, set_active_permission_rules};
let _g = rules_scope();
let conn = fresh_conn();
let mem = make_mem("ask-target", "k9-ask-delete");
let id = db::insert(&conn, &mem).expect("insert");
let db_path = db_path();
set_active_permission_rules(vec![PermissionRule {
namespace_pattern: "k9-ask-delete".to_string(),
op: "memory_delete".to_string(),
agent_pattern: "*".to_string(),
decision: RuleDecision::Ask,
reason: Some("operator approval required".to_string()),
}]);
let out = handle_delete(
&conn,
&db_path,
&json!({"id": id, "agent_id": "ai:caller"}),
None,
None,
)
.expect("ask returns Ok");
assert_eq!(out["status"].as_str(), Some("ask"));
assert_eq!(out["action"].as_str(), Some("delete"));
}
fn install_delete_policy(
conn: &rusqlite::Connection,
ns: &str,
delete_level: crate::models::GovernanceLevel,
approver: crate::models::ApproverType,
owner: &str,
) {
use crate::models::{CorePolicy, GovernancePolicy, default_metadata};
let policy = GovernancePolicy {
core: CorePolicy {
delete: delete_level,
approver,
..CorePolicy::default()
},
..Default::default()
};
let now = chrono::Utc::now().to_rfc3339();
let mut metadata = default_metadata();
if let Some(obj) = metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String(owner.to_string()),
);
obj.insert(
"governance".to_string(),
serde_json::to_value(&policy).unwrap(),
);
}
let standard = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: format!("_standards-{ns}"),
title: format!("std-{ns}"),
content: "policy".to_string(),
tags: vec![],
priority: 9,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let sid = db::insert(conn, &standard).expect("insert standard");
db::set_namespace_standard(conn, ns, &sid, None).expect("set standard");
}
#[test]
fn governance_deny_blocks_delete() {
let _gate = crate::config::lock_permissions_mode_for_test();
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Enforce,
);
let conn = fresh_conn();
let ns = "gov-deny-del";
install_delete_policy(
&conn,
ns,
crate::models::GovernanceLevel::Owner,
crate::models::ApproverType::Human,
"ai:alice",
);
let mut mem = make_mem("target", ns);
if let Some(obj) = mem.metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String("ai:alice".to_string()),
);
}
let id = db::insert(&conn, &mem).expect("insert");
let db_path = db_path();
let err = handle_delete(
&conn,
&db_path,
&json!({"id": id, "agent_id": "ai:eve"}),
None,
None,
)
.unwrap_err();
assert!(
err.contains("governance") || err.contains("denied") || err.contains("owner"),
"got: {err}"
);
crate::config::clear_permissions_mode_override_for_test();
}
#[test]
fn governance_pending_returns_pending_envelope() {
let _gate = crate::config::lock_permissions_mode_for_test();
crate::config::override_active_permissions_mode_for_test(
crate::config::PermissionsMode::Enforce,
);
let conn = fresh_conn();
let ns = "gov-pending-del";
install_delete_policy(
&conn,
ns,
crate::models::GovernanceLevel::Approve,
crate::models::ApproverType::Human,
"ai:alice",
);
let mem = make_mem("target", ns);
let id = db::insert(&conn, &mem).expect("insert");
let db_path = db_path();
let out = handle_delete(
&conn,
&db_path,
&json!({"id": id, "agent_id": "ai:bob"}),
None,
None,
)
.expect("pending returns Ok");
assert_eq!(out["status"].as_str(), Some("pending"));
assert_eq!(out["action"].as_str(), Some("delete"));
assert!(out["pending_id"].as_str().is_some());
crate::config::clear_permissions_mode_override_for_test();
}
}