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;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct KgInvalidateRequest {
pub source_id: String,
pub target_id: String,
pub relation: String,
#[serde(default)]
pub valid_until: Option<String>,
}
#[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()
}
}
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())?;
}
{
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) => {
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 {
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");
}
}