1use std::sync::{Arc, Mutex};
13
14use cortex_context::{
15 ContextPackBuilder, ContextRefCandidate, ContextRefId, ExclusionReason, Sensitivity,
16};
17use cortex_core::{AuthorityClass, ClaimCeiling, PolicyOutcome, RuntimeMode};
18use cortex_retrieval::{
19 resolve_conflicts, AuthorityLevel, AuthorityProofHint, ConflictingMemoryInput, ProofClosureHint,
20};
21use cortex_store::proof::verify_memory_proof_closure;
22use cortex_store::repo::{ContradictionRepo, MemoryRepo};
23use cortex_store::Pool;
24use serde_json::json;
25
26use crate::tool_handler::{GateId, ToolError, ToolHandler};
27
28const MCP_TASK_SENTINEL: &str = "mcp_context_request";
34
35const DEFAULT_MAX_TOKENS: usize = 4096;
37
38#[derive(Debug)]
52pub struct CortexContextTool {
53 pub pool: Arc<Mutex<Pool>>,
56}
57
58impl ToolHandler for CortexContextTool {
59 fn name(&self) -> &'static str {
60 "cortex_context"
61 }
62
63 fn gate_set(&self) -> &'static [GateId] {
64 &[GateId::ContextRead]
65 }
66
67 fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
68 let domains = extract_domains(¶ms)?;
70 let include_doctrine = params
71 .get("include_doctrine")
72 .and_then(|v| v.as_bool())
73 .unwrap_or(false);
74
75 if include_doctrine {
76 tracing::warn!(
79 "cortex_context: include_doctrine=true requested but doctrine wiring is \
80 deferred in the MCP path; proceeding without doctrine entries"
81 );
82 }
83
84 let _ = params.get("session_id");
86
87 let pool = self
89 .pool
90 .lock()
91 .map_err(|e| ToolError::Internal(format!("pool lock poisoned: {e}")))?;
92 let repo = MemoryRepo::new(&pool);
93 let active = repo.list_by_status("active").map_err(|e| {
94 tracing::error!(error = %e, "cortex_context: failed to read active memories");
95 ToolError::Internal(format!("failed to read active memories: {e}"))
96 })?;
97
98 let active: Vec<_> = active
102 .into_iter()
103 .filter(|m| m.status != "pending_mcp_commit")
104 .collect();
105
106 let filtered: Vec<_> = if domains.is_empty() {
108 active.iter().collect()
109 } else {
110 active
111 .iter()
112 .filter(|m| {
113 let mem_domains: Vec<String> = m
114 .domains_json
115 .as_array()
116 .map(|arr| {
117 arr.iter()
118 .filter_map(|v| v.as_str().map(str::to_owned))
119 .collect()
120 })
121 .unwrap_or_default();
122 domains.iter().any(|d| mem_domains.contains(d))
123 })
124 .collect()
125 };
126
127 gate_contradictions(&pool, &active).map_err(|e| {
129 tracing::warn!(error = %e, "cortex_context: contradiction gate blocked");
130 ToolError::PolicyRejected(e)
131 })?;
132
133 let mut builder = ContextPackBuilder::new(MCP_TASK_SENTINEL, DEFAULT_MAX_TOKENS);
135
136 let mut exclusions = Vec::new();
137
138 for memory in &filtered {
139 let proof = verify_memory_proof_closure(&pool, &memory.id).map_err(|e| {
140 tracing::error!(
141 memory_id = %memory.id,
142 error = %e,
143 "cortex_context: proof closure check failed"
144 );
145 ToolError::Internal(format!("proof closure check failed for {}: {e}", memory.id))
146 })?;
147
148 if let Err(e) = proof.require_current_use_allowed() {
149 let detail = e.to_string();
150 tracing::warn!(
151 memory_id = %memory.id,
152 error = %detail,
153 "cortex_context: memory excluded from default context use"
154 );
155 builder = builder.exclude_ref(
156 ContextRefId::Memory {
157 memory_id: memory.id,
158 },
159 ExclusionReason::Other,
160 format!("default context use blocked: {detail}"),
161 Sensitivity::Internal,
162 );
163 exclusions.push(default_use_exclusion(memory.id.to_string(), detail));
164 continue;
165 }
166
167 builder = builder.select_ref(
168 ContextRefCandidate::new(
169 ContextRefId::Memory {
170 memory_id: memory.id,
171 },
172 memory.claim.clone(),
173 )
174 .with_claim_metadata(
175 RuntimeMode::LocalUnsigned,
176 AuthorityClass::Derived,
177 proof.state().into(),
178 ClaimCeiling::LocalUnsigned,
179 )
180 .with_sensitivity(Sensitivity::Internal),
181 );
182 }
183
184 let pack = builder.build().map_err(|e| {
185 tracing::error!(error = %e, "cortex_context: context pack build failed");
186 ToolError::Internal(format!("context pack build failed: {e}"))
187 })?;
188
189 let policy = pack.policy_decision();
191 match policy.final_outcome {
192 PolicyOutcome::Reject | PolicyOutcome::Quarantine | PolicyOutcome::BreakGlass => {
193 tracing::warn!(
194 outcome = ?policy.final_outcome,
195 "cortex_context: pack policy rejected"
196 );
197 return Err(ToolError::PolicyRejected(format!(
198 "context pack policy outcome: {:?}",
199 policy.final_outcome
200 )));
201 }
202 PolicyOutcome::Allow | PolicyOutcome::Warn => {}
203 }
204
205 let entries: Vec<serde_json::Value> = pack
207 .selected_refs
208 .iter()
209 .map(|r| {
210 json!({
211 "id": ref_id_string(&r.ref_id),
212 "summary": r.summary,
213 "claim_ceiling": format!("{:?}", r.claim_ceiling),
214 "scope": r.scope,
215 })
216 })
217 .collect();
218
219 let token_count = pack.selection_audit.estimated_tokens;
220 let redacted_count = pack.exclusions.len();
221
222 Ok(json!({
223 "pack_id": pack.context_pack_id.to_string(),
224 "entries": entries,
225 "token_count": token_count,
226 "redacted_count": redacted_count,
227 "excluded_count": pack.exclusions.len(),
228 "exclusions": exclusions,
229 }))
230 }
231}
232
233fn default_use_exclusion(memory_id: String, detail: String) -> serde_json::Value {
234 json!({
235 "id": memory_id,
236 "reason": "proof_closure_current_use_blocked",
237 "detail": detail,
238 "resolution": {
239 "schema": "cortex_refusal_resolution.v1",
240 "kind": "policy_rejected",
241 "summary": "Memory was excluded from the context pack, but safe context is still returned.",
242 "detail": "proof closure does not currently permit default context use",
243 "next_actions": [
244 "Inspect this memory with cortex_memory_list or cortex_search.",
245 "If the memory is stale or wrong, mark its outcome with cortex_memory_outcome.",
246 "If the memory is still useful, repair or re-admit it with valid source evidence through the normal Cortex admission flow.",
247 "Re-run cortex_context; the memory will remain excluded until proof closure permits current use."
248 ]
249 }
250 })
251}
252
253fn extract_domains(params: &serde_json::Value) -> Result<Vec<String>, ToolError> {
254 match params.get("domains") {
255 None => Ok(Vec::new()),
256 Some(serde_json::Value::Array(arr)) => {
257 let domains: Option<Vec<String>> =
258 arr.iter().map(|v| v.as_str().map(str::to_owned)).collect();
259 domains.ok_or_else(|| {
260 ToolError::InvalidParams("domains must be an array of strings".to_string())
261 })
262 }
263 Some(_) => Err(ToolError::InvalidParams(
264 "domains must be an array of strings".to_string(),
265 )),
266 }
267}
268
269fn ref_id_string(ref_id: &ContextRefId) -> String {
270 match ref_id {
271 ContextRefId::Memory { memory_id } => memory_id.to_string(),
272 ContextRefId::Principle { principle_id } => principle_id.to_string(),
273 ContextRefId::Event { event_id } => event_id.to_string(),
274 }
275}
276
277fn gate_contradictions(
282 pool: &Pool,
283 memories: &[cortex_store::repo::MemoryRecord],
284) -> Result<(), String> {
285 use std::collections::{BTreeMap, BTreeSet};
286
287 let active_by_id: BTreeMap<String, &cortex_store::repo::MemoryRecord> =
288 memories.iter().map(|m| (m.id.to_string(), m)).collect();
289
290 let contradictions = ContradictionRepo::new(pool)
291 .list_open()
292 .map_err(|e| format!("failed to read open contradictions: {e}"))?;
293
294 let mut affected_ids: BTreeSet<String> = BTreeSet::new();
295 let mut conflict_edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
296
297 for c in contradictions {
298 let left_active = active_by_id.contains_key(&c.left_ref);
299 let right_active = active_by_id.contains_key(&c.right_ref);
300 if !left_active && !right_active {
301 continue;
302 }
303 if !(left_active && right_active) {
304 return Err(format!(
305 "open contradiction {} references unavailable memory and cannot be resolved \
306 for default context-pack use",
307 c.id
308 ));
309 }
310 affected_ids.insert(c.left_ref.clone());
311 affected_ids.insert(c.right_ref.clone());
312 conflict_edges
313 .entry(c.left_ref.clone())
314 .or_default()
315 .insert(c.right_ref.clone());
316 conflict_edges
317 .entry(c.right_ref)
318 .or_default()
319 .insert(c.left_ref);
320 }
321
322 if affected_ids.is_empty() {
323 return Ok(());
324 }
325
326 let inputs: Vec<ConflictingMemoryInput> = affected_ids
327 .iter()
328 .filter_map(|id| active_by_id.get(id.as_str()).copied())
329 .map(|m| {
330 ConflictingMemoryInput::new(
331 m.id.to_string(),
332 Some(m.id.to_string()),
333 m.claim.clone(),
334 AuthorityProofHint {
335 authority: authority_level(&m.authority),
336 proof: ProofClosureHint::FullChainVerified,
337 },
338 )
339 .with_conflicts(
340 conflict_edges
341 .get(&m.id.to_string())
342 .map(|ids| ids.iter().cloned().collect())
343 .unwrap_or_default(),
344 )
345 })
346 .collect();
347
348 let output = resolve_conflicts(&inputs, &[]);
349 output
350 .require_default_use_allowed()
351 .map_err(|e| format!("open contradiction blocks default context-pack use: {e}"))
352}
353
354fn authority_level(authority: &str) -> AuthorityLevel {
355 match authority {
356 "user" | "operator" => AuthorityLevel::High,
357 "tool" | "system" => AuthorityLevel::Medium,
358 _ => AuthorityLevel::Low,
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use chrono::{TimeZone, Utc};
366 use cortex_core::{AuditRecordId, MemoryId};
367 use cortex_store::migrate::apply_pending;
368 use cortex_store::repo::memories::accept_candidate_policy_decision_test_allow;
369 use cortex_store::repo::{MemoryAcceptanceAudit, MemoryCandidate, MemoryRepo};
370 use rusqlite::Connection;
371 use std::sync::{Arc, Mutex};
372
373 #[test]
374 fn extract_domains_defaults_to_empty() {
375 let domains = extract_domains(&json!({})).unwrap();
376 assert!(domains.is_empty());
377 }
378
379 #[test]
380 fn extract_domains_accepts_string_array() {
381 let domains = extract_domains(&json!({"domains": ["arch", "memory"]})).unwrap();
382 assert_eq!(domains, vec!["arch", "memory"]);
383 }
384
385 #[test]
386 fn extract_domains_rejects_non_array() {
387 let err = extract_domains(&json!({"domains": "arch"})).unwrap_err();
388 assert!(matches!(err, ToolError::InvalidParams(_)));
389 }
390
391 #[test]
392 fn extract_domains_rejects_non_string_elements() {
393 let err = extract_domains(&json!({"domains": [1, 2]})).unwrap_err();
394 assert!(matches!(err, ToolError::InvalidParams(_)));
395 }
396
397 #[test]
398 fn gate_set_is_correct() {
399 use crate::tool_handler::GateId;
400 let gates: &[GateId] = &[GateId::ContextRead];
401 assert_eq!(gates.len(), 1);
402 }
403
404 fn test_pool() -> Pool {
405 let pool = Connection::open_in_memory().expect("open in-memory sqlite");
406 apply_pending(&pool).expect("apply migrations");
407 pool
408 }
409
410 fn memory(
411 id: &str,
412 memory_type: &str,
413 claim: &str,
414 source_events_json: serde_json::Value,
415 ) -> MemoryCandidate {
416 MemoryCandidate {
417 id: id.parse().expect("memory id"),
418 memory_type: memory_type.into(),
419 claim: claim.into(),
420 source_episodes_json: json!([]),
421 source_events_json,
422 domains_json: json!(["ux"]),
423 salience_json: json!({"score": 0.7}),
424 confidence: 0.8,
425 authority: "operator".into(),
426 applies_when_json: json!(["testing cortex_context refusal UX"]),
427 does_not_apply_when_json: json!([]),
428 created_at: Utc.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap(),
429 updated_at: Utc.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap(),
430 }
431 }
432
433 fn activate(repo: &MemoryRepo<'_>, id: &MemoryId) {
434 let now = Utc.with_ymd_and_hms(2026, 5, 18, 12, 1, 0).unwrap();
435 let audit = MemoryAcceptanceAudit {
436 id: AuditRecordId::new(),
437 actor_json: json!({"kind": "test"}),
438 reason: "test fixture activation".into(),
439 source_refs_json: json!([]),
440 created_at: now,
441 };
442 repo.accept_candidate(
443 id,
444 now,
445 &audit,
446 &accept_candidate_policy_decision_test_allow(),
447 )
448 .expect("activate candidate");
449 }
450
451 #[test]
452 fn context_skips_unusable_memory_and_returns_resolution_exclusion() {
453 let pool = test_pool();
454 {
455 let repo = MemoryRepo::new(&pool);
456 let bad = memory(
457 "mem_01JVD8N3J9CN9TQ91M7A49V9AA",
458 "semantic",
459 "This memory has missing proof lineage and must not block the whole pack.",
460 json!(["evt_01JVD8N3J9CN9TQ91M7A49V9AA"]),
461 );
462 let good = memory(
463 "mem_01JVD8N3J9CN9TQ91M7A49V9AB",
464 "operator_note",
465 "Operator note remains available because it is self-attesting.",
466 json!([]),
467 );
468 repo.insert_candidate(&bad).expect("insert bad memory");
469 repo.insert_candidate(&good).expect("insert good memory");
470 activate(&repo, &bad.id);
471 activate(&repo, &good.id);
472 }
473
474 let tool = CortexContextTool {
475 pool: Arc::new(Mutex::new(pool)),
476 };
477 let result = tool.call(json!({})).expect(
478 "unusable memories should be excluded with remediation, not hard-refuse the pack",
479 );
480
481 assert_eq!(result["entries"].as_array().expect("entries").len(), 1);
482 assert_eq!(result["excluded_count"], 1);
483 assert_eq!(
484 result["exclusions"][0]["id"],
485 "mem_01JVD8N3J9CN9TQ91M7A49V9AA"
486 );
487 assert_eq!(
488 result["exclusions"][0]["resolution"]["schema"],
489 "cortex_refusal_resolution.v1"
490 );
491 assert!(
492 result["exclusions"][0]["resolution"]["next_actions"]
493 .as_array()
494 .is_some_and(|actions| !actions.is_empty()),
495 "exclusion should carry concrete next actions: {result}"
496 );
497 }
498}