1use std::sync::{Arc, Mutex, RwLock};
17
18use chrono::Utc;
19use cortex_core::{
20 compose_policy_outcomes, AuditRecordId, MemoryId, PolicyContribution, PolicyOutcome,
21};
22use cortex_memory::accept as memory_accept;
23use cortex_store::{
24 repo::{
25 memories::{
26 accept_open_contradiction_contribution, accept_proof_closure_contribution,
27 ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID, ACCEPT_SEMANTIC_TRUST_RULE_ID,
28 },
29 ContradictionRepo, MemoryAcceptanceAudit, MemoryRepo,
30 },
31 verify_memory_proof_closure, Pool,
32};
33use serde_json::{json, Value};
34use tracing::warn;
35
36use crate::tool_handler::{GateId, ToolError, ToolHandler};
37
38pub const MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT: &str = "memory.accept.auto_commit_mode";
41
42const ACCEPT_OPERATOR_TEMPORAL_AUTHORITY_WARN_NO_ATTESTATION_INVARIANT: &str =
46 "memory.accept.operator_temporal_authority.warn_no_attestation";
47
48#[derive(Debug)]
59pub struct CortexMemoryAcceptTool {
60 pool: Arc<Mutex<Pool>>,
61 pub session_token: Arc<RwLock<Option<String>>>,
67 pub auto_commit: bool,
72}
73
74impl CortexMemoryAcceptTool {
75 #[must_use]
78 pub fn new(
79 pool: Arc<Mutex<Pool>>,
80 session_token: Arc<RwLock<Option<String>>>,
81 auto_commit: bool,
82 ) -> Self {
83 Self {
84 pool,
85 session_token,
86 auto_commit,
87 }
88 }
89}
90
91impl ToolHandler for CortexMemoryAcceptTool {
92 fn name(&self) -> &'static str {
93 "cortex_memory_accept"
94 }
95
96 fn gate_set(&self) -> &'static [GateId] {
97 &[GateId::CommitWrite]
98 }
99
100 fn call(&self, params: Value) -> Result<Value, ToolError> {
101 let memory_id_str = params["memory_id"]
103 .as_str()
104 .filter(|s| !s.is_empty())
105 .ok_or_else(|| ToolError::InvalidParams("memory_id is required".into()))?;
106
107 let confirmation_token = params
109 .get("confirmation_token")
110 .and_then(|v| v.as_str())
111 .ok_or_else(|| {
112 ToolError::InvalidParams(
113 "required parameter `confirmation_token` is missing or not a string".into(),
114 )
115 })?
116 .to_owned();
117
118 if self.auto_commit {
120 warn!(
121 invariant = MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT,
122 "cortex_memory_accept: auto-commit mode — token check bypassed \
123 (CORTEX_MCP_AUTO_COMMIT=1) [{}]",
124 MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT,
125 );
126 } else {
127 if confirmation_token.is_empty() {
128 return Err(ToolError::InvalidParams(
129 "confirmation_token must not be empty".into(),
130 ));
131 }
132
133 let stored_token = self
134 .session_token
135 .read()
136 .map_err(|_| ToolError::Internal("session token lock poisoned".into()))?
137 .clone();
138
139 match stored_token {
140 None => {
141 warn!("cortex_memory_accept: session token not initialised — rejecting");
142 return Err(ToolError::Internal(
143 "server session token not initialised".into(),
144 ));
145 }
146 Some(ref server_token) => {
147 if !tokens_equal(&confirmation_token, server_token) {
148 return Err(ToolError::PolicyRejected(
149 "invalid confirmation token".into(),
150 ));
151 }
152 }
153 }
154 }
155
156 let memory_id: MemoryId = memory_id_str.parse().map_err(|err| {
158 ToolError::InvalidParams(format!("memory_id `{memory_id_str}` is invalid: {err}"))
159 })?;
160
161 tracing::info!("cortex_memory_accept via MCP: memory_id={}", memory_id);
162
163 let pool_guard = self
165 .pool
166 .lock()
167 .map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
168
169 let proof_report =
170 verify_memory_proof_closure(&pool_guard, &memory_id).map_err(|err| {
171 ToolError::Internal(format!(
172 "proof closure preflight failed for {memory_id}: {err}"
173 ))
174 })?;
175 let proof_contribution = accept_proof_closure_contribution(&proof_report);
176
177 let candidate_ref = memory_id.to_string();
178 let contradictions = ContradictionRepo::new(&pool_guard)
179 .list_open()
180 .map_err(|err| {
181 ToolError::Internal(format!(
182 "contradiction preflight failed for {memory_id}: {err}"
183 ))
184 })?;
185 let open_contradictions = contradictions
186 .iter()
187 .filter(|row| row.left_ref == candidate_ref || row.right_ref == candidate_ref)
188 .count();
189 let contradiction_contribution = accept_open_contradiction_contribution(open_contradictions);
190
191 let semantic_trust_contribution = PolicyContribution::new(
192 ACCEPT_SEMANTIC_TRUST_RULE_ID,
193 PolicyOutcome::Allow,
194 "mcp operator confirmation token validated: \
195 candidate passed lineage validation upstream",
196 )
197 .expect("static semantic trust contribution shape is valid");
198
199 let operator_temporal_use_contribution = PolicyContribution::new(
200 ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID,
201 PolicyOutcome::Warn,
202 format!(
203 "{ACCEPT_OPERATOR_TEMPORAL_AUTHORITY_WARN_NO_ATTESTATION_INVARIANT}: \
204 no operator attestation bound on this MCP surface; accepting at the honest floor",
205 ),
206 )
207 .expect("static operator temporal use contribution shape is valid");
208
209 let policy = compose_policy_outcomes(
210 vec![
211 proof_contribution,
212 contradiction_contribution,
213 semantic_trust_contribution,
214 operator_temporal_use_contribution,
215 ],
216 None,
217 );
218
219 let repo = MemoryRepo::new(&pool_guard);
221 let audit = MemoryAcceptanceAudit {
222 id: AuditRecordId::new(),
223 actor_json: json!({"kind": "mcp", "tool": "cortex_memory_accept"}),
224 reason: "operator accepted candidate memory via MCP confirmation token".to_string(),
225 source_refs_json: json!([memory_id.to_string()]),
226 created_at: Utc::now(),
227 };
228
229 let accepted_id =
230 memory_accept(&repo, &memory_id, Utc::now(), &audit, &policy, &proof_report)
231 .map_err(|err| ToolError::PolicyRejected(err.to_string()))?;
232
233 Ok(json!({
234 "accepted": true,
235 "memory_id": accepted_id.to_string(),
236 }))
237 }
238}
239
240fn tokens_equal(a: &str, b: &str) -> bool {
248 if a.len() != b.len() {
249 return false;
250 }
251 a.bytes()
252 .zip(b.bytes())
253 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
254 == 0
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn make_tool(token: Option<&str>) -> CortexMemoryAcceptTool {
262 make_tool_with_auto_commit(token, false)
263 }
264
265 fn make_tool_with_auto_commit(
266 token: Option<&str>,
267 auto_commit: bool,
268 ) -> CortexMemoryAcceptTool {
269 let pool = Arc::new(Mutex::new(
270 cortex_store::Pool::open_in_memory().expect("in-memory sqlite"),
271 ));
272 let session_token = Arc::new(RwLock::new(token.map(str::to_owned)));
273 CortexMemoryAcceptTool::new(pool, session_token, auto_commit)
274 }
275
276 #[test]
278 fn missing_confirmation_token_returns_invalid_params() {
279 let tool = make_tool(Some("correct-token"));
280 let err = tool
281 .call(serde_json::json!({"memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA"}))
282 .expect_err("must reject missing token");
283 assert!(
284 matches!(err, ToolError::InvalidParams(_)),
285 "expected InvalidParams, got: {err:?}"
286 );
287 }
288
289 #[test]
291 fn empty_confirmation_token_returns_invalid_params() {
292 let tool = make_tool(Some("correct-token"));
293 let err = tool
294 .call(serde_json::json!({
295 "memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
296 "confirmation_token": ""
297 }))
298 .expect_err("must reject empty token");
299 assert!(
300 matches!(err, ToolError::InvalidParams(_)),
301 "expected InvalidParams, got: {err:?}"
302 );
303 }
304
305 #[test]
307 fn wrong_token_returns_policy_rejected() {
308 let tool = make_tool(Some("correct-token"));
309 let err = tool
310 .call(serde_json::json!({
311 "memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
312 "confirmation_token": "wrong-token"
313 }))
314 .expect_err("must reject wrong token");
315 assert!(
316 matches!(err, ToolError::PolicyRejected(_)),
317 "expected PolicyRejected, got: {err:?}"
318 );
319 let msg = err.to_string();
320 assert!(
321 msg.contains("invalid confirmation token"),
322 "error must cite ADR 0047 §3 message: {msg}"
323 );
324 }
325
326 #[test]
328 fn uninitialised_server_token_returns_internal() {
329 let tool = make_tool(None);
330 let err = tool
331 .call(serde_json::json!({
332 "memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
333 "confirmation_token": "anything"
334 }))
335 .expect_err("must fail when server token is uninitialised");
336 assert!(
337 matches!(err, ToolError::Internal(_)),
338 "expected Internal, got: {err:?}"
339 );
340 }
341
342 #[test]
344 fn missing_memory_id_returns_invalid_params() {
345 let tool = make_tool(Some("tok"));
346 let err = tool
347 .call(serde_json::json!({"confirmation_token": "tok"}))
348 .expect_err("must reject missing memory_id");
349 assert!(
350 matches!(err, ToolError::InvalidParams(_)),
351 "expected InvalidParams, got: {err:?}"
352 );
353 }
354
355 #[test]
357 fn gate_set_declares_commit_write() {
358 let tool = make_tool(Some("tok"));
359 assert!(
360 tool.gate_set().contains(&GateId::CommitWrite),
361 "gate_set must include CommitWrite"
362 );
363 }
364
365 #[test]
367 fn gate_set_does_not_declare_session_write() {
368 let tool = make_tool(Some("tok"));
369 assert!(
370 !tool.gate_set().contains(&GateId::SessionWrite),
371 "gate_set must not include SessionWrite"
372 );
373 }
374
375 #[test]
377 fn tool_name_matches_schema_contract() {
378 let tool = make_tool(Some("tok"));
379 assert_eq!(tool.name(), "cortex_memory_accept");
380 }
381
382 #[test]
384 fn auto_commit_rejects_missing_token_param() {
385 let tool = make_tool_with_auto_commit(Some("server-tok"), true);
386 let err = tool
387 .call(serde_json::json!({"memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA"}))
388 .expect_err("missing param must still be rejected in auto_commit mode");
389 assert!(
390 matches!(err, ToolError::InvalidParams(_)),
391 "expected InvalidParams, got: {err:?}"
392 );
393 }
394
395 #[test]
397 fn auto_commit_invariant_constant_value() {
398 assert_eq!(
399 MEMORY_ACCEPT_AUTO_COMMIT_INVARIANT,
400 "memory.accept.auto_commit_mode"
401 );
402 }
403
404 #[test]
410 fn confirmed_bool_true_does_not_bypass_token_check() {
411 let tool = make_tool(Some("correct-token"));
412 let err = tool
414 .call(serde_json::json!({
415 "memory_id": "01JSVFAKEAAAAAAAAAAAAAAAAA",
416 "confirmed": true
417 }))
418 .expect_err("confirmed:true without token must be rejected");
419 assert!(
420 matches!(err, ToolError::InvalidParams(_)),
421 "expected InvalidParams (missing confirmation_token), got: {err:?}"
422 );
423 }
424}