use std::sync::{Arc, Mutex, RwLock};
use chrono::Utc;
use cortex_core::{
compose_policy_outcomes, AuditRecordId, MemoryId, PolicyContribution, PolicyOutcome,
};
use cortex_memory::accept as memory_accept;
use cortex_store::{
repo::{
memories::{
accept_open_contradiction_contribution, accept_proof_closure_contribution,
ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID, ACCEPT_SEMANTIC_TRUST_RULE_ID,
},
ContradictionRepo, MemoryAcceptanceAudit, MemoryRepo,
},
verify_memory_proof_closure, Pool,
};
use serde_json::{json, Value};
use tracing::warn;
use crate::tool_handler::{GateId, ToolError, ToolHandler};
pub const MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT: &str = "memory.accept.auto_commit_mode";
const ACCEPT_OPERATOR_TEMPORAL_AUTHORITY_WARN_NO_ATTESTATION_INVARIANT: &str =
"memory.accept.operator_temporal_authority.warn_no_attestation";
#[derive(Debug)]
pub struct CortexMemoryAcceptTool {
pool: Arc<Mutex<Pool>>,
pub session_token: Arc<RwLock<Option<String>>>,
pub auto_commit: bool,
}
impl CortexMemoryAcceptTool {
#[must_use]
pub fn new(
pool: Arc<Mutex<Pool>>,
session_token: Arc<RwLock<Option<String>>>,
auto_commit: bool,
) -> Self {
Self {
pool,
session_token,
auto_commit,
}
}
}
impl ToolHandler for CortexMemoryAcceptTool {
fn name(&self) -> &'static str {
"cortex_memory_accept"
}
fn gate_set(&self) -> &'static [GateId] {
&[GateId::CommitWrite]
}
fn call(&self, params: Value) -> Result<Value, ToolError> {
let memory_id_str = params["memory_id"]
.as_str()
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::InvalidParams("memory_id is required".into()))?;
let confirmation_token = params
.get("confirmation_token")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParams(
"required parameter `confirmation_token` is missing or not a string".into(),
)
})?
.to_owned();
if self.auto_commit {
warn!(
invariant = MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT,
"cortex_memory_accept: auto-commit mode — token check bypassed \
(CORTEX_MCP_AUTO_COMMIT=1) [{}]",
MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT,
);
} else {
if confirmation_token.is_empty() {
return Err(ToolError::InvalidParams(
"confirmation_token must not be empty".into(),
));
}
let stored_token = self
.session_token
.read()
.map_err(|_| ToolError::Internal("session token lock poisoned".into()))?
.clone();
match stored_token {
None => {
warn!("cortex_memory_accept: session token not initialised — rejecting");
return Err(ToolError::Internal(
"server session token not initialised".into(),
));
}
Some(ref server_token) => {
if !tokens_equal(&confirmation_token, server_token) {
return Err(ToolError::PolicyRejected(
"invalid confirmation token".into(),
));
}
}
}
}
let memory_id: MemoryId = memory_id_str.parse().map_err(|err| {
ToolError::InvalidParams(format!("memory_id `{memory_id_str}` is invalid: {err}"))
})?;
tracing::info!("cortex_memory_accept via MCP: memory_id={}", memory_id);
let pool_guard = self
.pool
.lock()
.map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
let proof_report =
verify_memory_proof_closure(&pool_guard, &memory_id).map_err(|err| {
ToolError::Internal(format!(
"proof closure preflight failed for {memory_id}: {err}"
))
})?;
let proof_contribution = accept_proof_closure_contribution(&proof_report);
let candidate_ref = memory_id.to_string();
let contradictions = ContradictionRepo::new(&pool_guard)
.list_open()
.map_err(|err| {
ToolError::Internal(format!(
"contradiction preflight failed for {memory_id}: {err}"
))
})?;
let open_contradictions = contradictions
.iter()
.filter(|row| row.left_ref == candidate_ref || row.right_ref == candidate_ref)
.count();
let contradiction_contribution = accept_open_contradiction_contribution(open_contradictions);
let semantic_trust_contribution = PolicyContribution::new(
ACCEPT_SEMANTIC_TRUST_RULE_ID,
PolicyOutcome::Allow,
"mcp operator confirmation token validated: \
candidate passed lineage validation upstream",
)
.expect("static semantic trust contribution shape is valid");
let operator_temporal_use_contribution = PolicyContribution::new(
ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID,
PolicyOutcome::Warn,
format!(
"{ACCEPT_OPERATOR_TEMPORAL_AUTHORITY_WARN_NO_ATTESTATION_INVARIANT}: \
no operator attestation bound on this MCP surface; accepting at the honest floor",
),
)
.expect("static operator temporal use contribution shape is valid");
let policy = compose_policy_outcomes(
vec![
proof_contribution,
contradiction_contribution,
semantic_trust_contribution,
operator_temporal_use_contribution,
],
None,
);
let repo = MemoryRepo::new(&pool_guard);
let audit = MemoryAcceptanceAudit {
id: AuditRecordId::new(),
actor_json: json!({"kind": "mcp", "tool": "cortex_memory_accept"}),
reason: "operator accepted candidate memory via MCP confirmation token".to_string(),
source_refs_json: json!([memory_id.to_string()]),
created_at: Utc::now(),
};
let accepted_id =
memory_accept(&repo, &memory_id, Utc::now(), &audit, &policy, &proof_report)
.map_err(|err| ToolError::PolicyRejected(err.to_string()))?;
Ok(json!({
"accepted": true,
"memory_id": accepted_id.to_string(),
}))
}
}
fn tokens_equal(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.bytes()
.zip(b.bytes())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool(token: Option<&str>) -> CortexMemoryAcceptTool {
make_tool_with_auto_commit(token, false)
}
fn make_tool_with_auto_commit(
token: Option<&str>,
auto_commit: bool,
) -> CortexMemoryAcceptTool {
let pool = Arc::new(Mutex::new(
cortex_store::Pool::open_in_memory().expect("in-memory sqlite"),
));
let session_token = Arc::new(RwLock::new(token.map(str::to_owned)));
CortexMemoryAcceptTool::new(pool, session_token, auto_commit)
}
#[test]
fn missing_confirmation_token_returns_invalid_params() {
let tool = make_tool(Some("correct-token"));
let err = tool
.call(serde_json::json!({"memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA"}))
.expect_err("must reject missing token");
assert!(
matches!(err, ToolError::InvalidParams(_)),
"expected InvalidParams, got: {err:?}"
);
}
#[test]
fn empty_confirmation_token_returns_invalid_params() {
let tool = make_tool(Some("correct-token"));
let err = tool
.call(serde_json::json!({
"memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
"confirmation_token": ""
}))
.expect_err("must reject empty token");
assert!(
matches!(err, ToolError::InvalidParams(_)),
"expected InvalidParams, got: {err:?}"
);
}
#[test]
fn wrong_token_returns_policy_rejected() {
let tool = make_tool(Some("correct-token"));
let err = tool
.call(serde_json::json!({
"memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
"confirmation_token": "wrong-token"
}))
.expect_err("must reject wrong token");
assert!(
matches!(err, ToolError::PolicyRejected(_)),
"expected PolicyRejected, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("invalid confirmation token"),
"error must cite ADR 0047 §3 message: {msg}"
);
}
#[test]
fn uninitialised_server_token_returns_internal() {
let tool = make_tool(None);
let err = tool
.call(serde_json::json!({
"memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
"confirmation_token": "anything"
}))
.expect_err("must fail when server token is uninitialised");
assert!(
matches!(err, ToolError::Internal(_)),
"expected Internal, got: {err:?}"
);
}
#[test]
fn missing_memory_id_returns_invalid_params() {
let tool = make_tool(Some("tok"));
let err = tool
.call(serde_json::json!({"confirmation_token": "tok"}))
.expect_err("must reject missing memory_id");
assert!(
matches!(err, ToolError::InvalidParams(_)),
"expected InvalidParams, got: {err:?}"
);
}
#[test]
fn gate_set_declares_commit_write() {
let tool = make_tool(Some("tok"));
assert!(
tool.gate_set().contains(&GateId::CommitWrite),
"gate_set must include CommitWrite"
);
}
#[test]
fn gate_set_does_not_declare_session_write() {
let tool = make_tool(Some("tok"));
assert!(
!tool.gate_set().contains(&GateId::SessionWrite),
"gate_set must not include SessionWrite"
);
}
#[test]
fn tool_name_matches_schema_contract() {
let tool = make_tool(Some("tok"));
assert_eq!(tool.name(), "cortex_memory_accept");
}
#[test]
fn auto_commit_rejects_missing_token_param() {
let tool = make_tool_with_auto_commit(Some("server-tok"), true);
let err = tool
.call(serde_json::json!({"memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA"}))
.expect_err("missing param must still be rejected in auto_commit mode");
assert!(
matches!(err, ToolError::InvalidParams(_)),
"expected InvalidParams, got: {err:?}"
);
}
#[test]
fn auto_commit_invariant_constant_value() {
assert_eq!(
MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT,
"memory.accept.auto_commit_mode"
);
}
#[test]
fn confirmed_bool_true_does_not_bypass_token_check() {
let tool = make_tool(Some("correct-token"));
let err = tool
.call(serde_json::json!({
"memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
"confirmed": true
}))
.expect_err("confirmed:true without token must be rejected");
assert!(
matches!(err, ToolError::InvalidParams(_)),
"expected InvalidParams (missing confirmation_token), got: {err:?}"
);
}
}