ai-memory 0.7.1

AI-agnostic persistent memory system — MCP server, HTTP API, and CLI for any AI platform
Documentation
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! MCP `memory_kg_invalidate` handler.

use crate::mcp::param_names;
use crate::mcp::registry::McpTool;
use crate::models::field_names;
use crate::{db, validate};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use std::path::Path;

// --- D1.4 (#985): per-tool McpTool impl for `memory_kg_invalidate` (graph family) ---

/// v0.7.0 #972 D1.4 (#985) — request body for `memory_kg_invalidate`.
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct KgInvalidateRequest {
    /// Source memory ID.
    pub source_id: String,

    /// Target memory ID.
    pub target_id: String,

    /// Relation label.
    pub relation: String,

    /// RFC3339 supersession instant. Default now.
    #[serde(default)]
    pub valid_until: Option<String>,
}

/// v0.7.0 #972 D1.4 (#985) — `McpTool` impl for `memory_kg_invalidate`.
#[allow(dead_code)]
pub struct KgInvalidateTool;

impl McpTool for KgInvalidateTool {
    fn name() -> &'static str {
        crate::mcp::registry::tool_names::MEMORY_KG_INVALIDATE
    }
    fn description() -> &'static str {
        "Mark a KG link as superseded by setting its valid_until column."
    }
    fn docs() -> &'static str {
        "Pillar 2 / Stream C: set valid_until on (source_id, target_id, relation). valid_until defaults to now. Idempotent; response carries previous_valid_until. found:false when no match."
    }
    fn input_schema() -> Value {
        crate::mcp::registry::input_schema_for::<KgInvalidateRequest>()
    }
    fn family() -> &'static str {
        crate::profile::Family::Graph.name()
    }
}

/// SEC-2 / COV-8 (Cluster D, issue #767) — `pub` so the integration
/// test fleet can drive the handler directly. The function is still
/// only registered as the MCP `memory_kg_invalidate` tool; visibility
/// is the only thing that changed.
pub fn handle_kg_invalidate(
    conn: &rusqlite::Connection,
    db_path: &Path,
    params: &Value,
) -> Result<Value, String> {
    let source_id = params["source_id"]
        .as_str()
        .ok_or(crate::errors::msg::SOURCE_ID_REQUIRED)?;
    let target_id = params["target_id"]
        .as_str()
        .ok_or(crate::errors::msg::TARGET_ID_REQUIRED)?;
    let relation = params["relation"].as_str().ok_or("relation is required")?;
    validate::RequestValidator::validate_link_triple(source_id, target_id, relation)
        .map_err(|e| e.to_string())?;
    let valid_until = params[param_names::VALID_UNTIL]
        .as_str()
        .map(str::trim)
        .filter(|s| !s.is_empty());
    if let Some(ts) = valid_until {
        validate::validate_expires_at_format(ts).map_err(|e| e.to_string())?;
    }

    // v0.7.0 K9 (#628 H5/H6/I1 follow-up): the unified permission
    // pipeline must gate `kg_invalidate` symmetrically with
    // `handle_link`. Without this gate, a cross-tenant call could
    // NULL another tenant's signed-link signature (H5 supersession
    // semantic clears the signature row). Scope evaluation by the
    // *source* memory's namespace — same convention as `handle_link`
    // — so the same `[permissions.rules] action_type = "link"` rule
    // applies to both create-link and invalidate-link.
    {
        use crate::permissions::{Op, PermissionContext, Permissions};
        let link_ns = match db::get(conn, source_id) {
            Ok(Some(m)) => m.namespace,
            _ => crate::DEFAULT_NAMESPACE.to_string(),
        };
        let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), None)
            .map_err(|e| e.to_string())?;
        let ctx = PermissionContext {
            op: Op::MemoryLink,
            namespace: link_ns,
            agent_id,
            payload: json!({
                "source_id": source_id,
                "target_id": target_id,
                "relation": relation,
                "operation": "invalidate",
            }),
        };
        match Permissions::evaluate(&ctx, &[]) {
            crate::permissions::Decision::Allow | crate::permissions::Decision::Modify(_) => {}
            crate::permissions::Decision::Deny(reason) => {
                return Err(crate::governance::deny_message(
                    crate::governance::action_labels::KG_INVALIDATE,
                    crate::governance::DenyGate::PermissionRule,
                    &reason,
                ));
            }
            crate::permissions::Decision::Ask(prompt) => {
                return Ok(json!({
                    "status": "ask",
                    "reason": prompt,
                    "action": crate::governance::action_labels::KG_INVALIDATE,
                    "source_id": source_id,
                    "target_id": target_id,
                }));
            }
        }
    }

    match db::invalidate_link(conn, source_id, target_id, relation, valid_until)
        .map_err(|e| e.to_string())?
    {
        Some(res) => {
            // v0.7 J4 / G14 — emit `memory_link_invalidated` webhook
            // event AFTER the supersession is persisted. Mirrors the
            // `memory_link_created` dispatch in `handle_link`: pull
            // namespace + agent_id from the source memory so
            // subscribers see the canonical envelope, then flatten
            // the supersession-specific details (target/relation +
            // both timestamps) into the payload. This is the G14
            // audit-edge pattern — every invalidation surfaces as a
            // replayable event without requiring a separate audit
            // table on the SQLite path.
            let (event_namespace, event_agent_id) = match db::get(conn, source_id) {
                Ok(Some(mem)) => {
                    let owner = mem
                        .metadata
                        .get(param_names::AGENT_ID)
                        .and_then(|v| v.as_str())
                        .map(str::to_string);
                    (mem.namespace, owner)
                }
                _ => (crate::DEFAULT_NAMESPACE.to_string(), None),
            };
            let details = serde_json::to_value(crate::subscriptions::LinkInvalidatedEventDetails {
                target_id: target_id.to_string(),
                relation: relation.to_string(),
                valid_until: res.valid_until.clone(),
                previous_valid_until: res.previous_valid_until.clone(),
            })
            .ok();
            crate::subscriptions::dispatch_event_with_details(
                conn,
                crate::subscriptions::webhook_events::MEMORY_LINK_INVALIDATED,
                source_id,
                &event_namespace,
                event_agent_id.as_deref(),
                db_path,
                details,
            );

            Ok(json!({
                "found": true,
                "source_id": source_id,
                "target_id": target_id,
                "relation": relation,
                (field_names::VALID_UNTIL): res.valid_until,
                (field_names::PREVIOUS_VALID_UNTIL): res.previous_valid_until,
            }))
        }
        None => Ok(json!({
            "found": false,
            "source_id": source_id,
            "target_id": target_id,
            "relation": relation,
        })),
    }
}

#[cfg(test)]
mod d1_4_985_tests {
    //! D1.4 (#985) — schema-parity for `memory_kg_invalidate`.
    use super::*;
    use crate::mcp::d1_4_985_helpers::{
        assert_descriptions_match, assert_property_set_parity, derived_props_for,
    };

    #[test]
    fn memory_kg_invalidate_parity_985() {
        let derived = derived_props_for::<KgInvalidateRequest>();
        assert_property_set_parity("memory_kg_invalidate", &derived);
        assert_descriptions_match("memory_kg_invalidate", &derived);
    }

    #[test]
    fn memory_kg_invalidate_tool_metadata_985() {
        assert_eq!(KgInvalidateTool::name(), "memory_kg_invalidate");
        assert_eq!(KgInvalidateTool::family(), "graph");
    }
}