use std::sync::{Arc, Mutex};
use cortex_context::{
ContextPackBuilder, ContextRefCandidate, ContextRefId, ExclusionReason, Sensitivity,
};
use cortex_core::{AuthorityClass, ClaimCeiling, PolicyOutcome, RuntimeMode};
use cortex_retrieval::{
resolve_conflicts, AuthorityLevel, AuthorityProofHint, ConflictingMemoryInput, ProofClosureHint,
};
use cortex_store::proof::verify_memory_proof_closure;
use cortex_store::repo::{ContradictionRepo, MemoryRepo};
use cortex_store::Pool;
use serde_json::json;
use crate::tool_handler::{GateId, ToolError, ToolHandler};
const MCP_TASK_SENTINEL: &str = "mcp_context_request";
const DEFAULT_MAX_TOKENS: usize = 4096;
#[derive(Debug)]
pub struct CortexContextTool {
pub pool: Arc<Mutex<Pool>>,
}
impl ToolHandler for CortexContextTool {
fn name(&self) -> &'static str {
"cortex_context"
}
fn gate_set(&self) -> &'static [GateId] {
&[GateId::ContextRead]
}
fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
let domains = extract_domains(¶ms)?;
let include_doctrine = params
.get("include_doctrine")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if include_doctrine {
tracing::warn!(
"cortex_context: include_doctrine=true requested but doctrine wiring is \
deferred in the MCP path; proceeding without doctrine entries"
);
}
let _ = params.get("session_id");
let pool = self
.pool
.lock()
.map_err(|e| ToolError::Internal(format!("pool lock poisoned: {e}")))?;
let repo = MemoryRepo::new(&pool);
let active = repo.list_by_status("active").map_err(|e| {
tracing::error!(error = %e, "cortex_context: failed to read active memories");
ToolError::Internal(format!("failed to read active memories: {e}"))
})?;
let active: Vec<_> = active
.into_iter()
.filter(|m| m.status != "pending_mcp_commit")
.collect();
let filtered: Vec<_> = if domains.is_empty() {
active.iter().collect()
} else {
active
.iter()
.filter(|m| {
let mem_domains: Vec<String> = m
.domains_json
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
domains.iter().any(|d| mem_domains.contains(d))
})
.collect()
};
gate_contradictions(&pool, &active).map_err(|e| {
tracing::warn!(error = %e, "cortex_context: contradiction gate blocked");
ToolError::PolicyRejected(e)
})?;
let mut builder = ContextPackBuilder::new(MCP_TASK_SENTINEL, DEFAULT_MAX_TOKENS);
let mut exclusions = Vec::new();
for memory in &filtered {
let proof = verify_memory_proof_closure(&pool, &memory.id).map_err(|e| {
tracing::error!(
memory_id = %memory.id,
error = %e,
"cortex_context: proof closure check failed"
);
ToolError::Internal(format!("proof closure check failed for {}: {e}", memory.id))
})?;
if let Err(e) = proof.require_current_use_allowed() {
let detail = e.to_string();
tracing::warn!(
memory_id = %memory.id,
error = %detail,
"cortex_context: memory excluded from default context use"
);
builder = builder.exclude_ref(
ContextRefId::Memory {
memory_id: memory.id,
},
ExclusionReason::Other,
format!("default context use blocked: {detail}"),
Sensitivity::Internal,
);
exclusions.push(default_use_exclusion(memory.id.to_string(), detail));
continue;
}
builder = builder.select_ref(
ContextRefCandidate::new(
ContextRefId::Memory {
memory_id: memory.id,
},
memory.claim.clone(),
)
.with_claim_metadata(
RuntimeMode::LocalUnsigned,
AuthorityClass::Derived,
proof.state().into(),
ClaimCeiling::LocalUnsigned,
)
.with_sensitivity(Sensitivity::Internal),
);
}
let pack = builder.build().map_err(|e| {
tracing::error!(error = %e, "cortex_context: context pack build failed");
ToolError::Internal(format!("context pack build failed: {e}"))
})?;
let policy = pack.policy_decision();
match policy.final_outcome {
PolicyOutcome::Reject | PolicyOutcome::Quarantine | PolicyOutcome::BreakGlass => {
tracing::warn!(
outcome = ?policy.final_outcome,
"cortex_context: pack policy rejected"
);
return Err(ToolError::PolicyRejected(format!(
"context pack policy outcome: {:?}",
policy.final_outcome
)));
}
PolicyOutcome::Allow | PolicyOutcome::Warn => {}
}
let entries: Vec<serde_json::Value> = pack
.selected_refs
.iter()
.map(|r| {
json!({
"id": ref_id_string(&r.ref_id),
"summary": r.summary,
"claim_ceiling": format!("{:?}", r.claim_ceiling),
"scope": r.scope,
})
})
.collect();
let token_count = pack.selection_audit.estimated_tokens;
let redacted_count = pack.exclusions.len();
Ok(json!({
"pack_id": pack.context_pack_id.to_string(),
"entries": entries,
"token_count": token_count,
"redacted_count": redacted_count,
"excluded_count": pack.exclusions.len(),
"exclusions": exclusions,
}))
}
}
fn default_use_exclusion(memory_id: String, detail: String) -> serde_json::Value {
json!({
"id": memory_id,
"reason": "proof_closure_current_use_blocked",
"detail": detail,
"resolution": {
"schema": "cortex_refusal_resolution.v1",
"kind": "policy_rejected",
"summary": "Memory was excluded from the context pack, but safe context is still returned.",
"detail": "proof closure does not currently permit default context use",
"next_actions": [
"Inspect this memory with cortex_memory_list or cortex_search.",
"If the memory is stale or wrong, mark its outcome with cortex_memory_outcome.",
"If the memory is still useful, repair or re-admit it with valid source evidence through the normal Cortex admission flow.",
"Re-run cortex_context; the memory will remain excluded until proof closure permits current use."
]
}
})
}
fn extract_domains(params: &serde_json::Value) -> Result<Vec<String>, ToolError> {
match params.get("domains") {
None => Ok(Vec::new()),
Some(serde_json::Value::Array(arr)) => {
let domains: Option<Vec<String>> =
arr.iter().map(|v| v.as_str().map(str::to_owned)).collect();
domains.ok_or_else(|| {
ToolError::InvalidParams("domains must be an array of strings".to_string())
})
}
Some(_) => Err(ToolError::InvalidParams(
"domains must be an array of strings".to_string(),
)),
}
}
fn ref_id_string(ref_id: &ContextRefId) -> String {
match ref_id {
ContextRefId::Memory { memory_id } => memory_id.to_string(),
ContextRefId::Principle { principle_id } => principle_id.to_string(),
ContextRefId::Event { event_id } => event_id.to_string(),
}
}
fn gate_contradictions(
pool: &Pool,
memories: &[cortex_store::repo::MemoryRecord],
) -> Result<(), String> {
use std::collections::{BTreeMap, BTreeSet};
let active_by_id: BTreeMap<String, &cortex_store::repo::MemoryRecord> =
memories.iter().map(|m| (m.id.to_string(), m)).collect();
let contradictions = ContradictionRepo::new(pool)
.list_open()
.map_err(|e| format!("failed to read open contradictions: {e}"))?;
let mut affected_ids: BTreeSet<String> = BTreeSet::new();
let mut conflict_edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for c in contradictions {
let left_active = active_by_id.contains_key(&c.left_ref);
let right_active = active_by_id.contains_key(&c.right_ref);
if !left_active && !right_active {
continue;
}
if !(left_active && right_active) {
return Err(format!(
"open contradiction {} references unavailable memory and cannot be resolved \
for default context-pack use",
c.id
));
}
affected_ids.insert(c.left_ref.clone());
affected_ids.insert(c.right_ref.clone());
conflict_edges
.entry(c.left_ref.clone())
.or_default()
.insert(c.right_ref.clone());
conflict_edges
.entry(c.right_ref)
.or_default()
.insert(c.left_ref);
}
if affected_ids.is_empty() {
return Ok(());
}
let inputs: Vec<ConflictingMemoryInput> = affected_ids
.iter()
.filter_map(|id| active_by_id.get(id.as_str()).copied())
.map(|m| {
ConflictingMemoryInput::new(
m.id.to_string(),
Some(m.id.to_string()),
m.claim.clone(),
AuthorityProofHint {
authority: authority_level(&m.authority),
proof: ProofClosureHint::FullChainVerified,
},
)
.with_conflicts(
conflict_edges
.get(&m.id.to_string())
.map(|ids| ids.iter().cloned().collect())
.unwrap_or_default(),
)
})
.collect();
let output = resolve_conflicts(&inputs, &[]);
output
.require_default_use_allowed()
.map_err(|e| format!("open contradiction blocks default context-pack use: {e}"))
}
fn authority_level(authority: &str) -> AuthorityLevel {
match authority {
"user" | "operator" => AuthorityLevel::High,
"tool" | "system" => AuthorityLevel::Medium,
_ => AuthorityLevel::Low,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use cortex_core::{AuditRecordId, MemoryId};
use cortex_store::migrate::apply_pending;
use cortex_store::repo::memories::accept_candidate_policy_decision_test_allow;
use cortex_store::repo::{MemoryAcceptanceAudit, MemoryCandidate, MemoryRepo};
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
#[test]
fn extract_domains_defaults_to_empty() {
let domains = extract_domains(&json!({})).unwrap();
assert!(domains.is_empty());
}
#[test]
fn extract_domains_accepts_string_array() {
let domains = extract_domains(&json!({"domains": ["arch", "memory"]})).unwrap();
assert_eq!(domains, vec!["arch", "memory"]);
}
#[test]
fn extract_domains_rejects_non_array() {
let err = extract_domains(&json!({"domains": "arch"})).unwrap_err();
assert!(matches!(err, ToolError::InvalidParams(_)));
}
#[test]
fn extract_domains_rejects_non_string_elements() {
let err = extract_domains(&json!({"domains": [1, 2]})).unwrap_err();
assert!(matches!(err, ToolError::InvalidParams(_)));
}
#[test]
fn gate_set_is_correct() {
use crate::tool_handler::GateId;
let gates: &[GateId] = &[GateId::ContextRead];
assert_eq!(gates.len(), 1);
}
fn test_pool() -> Pool {
let pool = Connection::open_in_memory().expect("open in-memory sqlite");
apply_pending(&pool).expect("apply migrations");
pool
}
fn memory(
id: &str,
memory_type: &str,
claim: &str,
source_events_json: serde_json::Value,
) -> MemoryCandidate {
MemoryCandidate {
id: id.parse().expect("memory id"),
memory_type: memory_type.into(),
claim: claim.into(),
source_episodes_json: json!([]),
source_events_json,
domains_json: json!(["ux"]),
salience_json: json!({"score": 0.7}),
confidence: 0.8,
authority: "operator".into(),
applies_when_json: json!(["testing cortex_context refusal UX"]),
does_not_apply_when_json: json!([]),
created_at: Utc.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap(),
updated_at: Utc.with_ymd_and_hms(2026, 5, 18, 12, 0, 0).unwrap(),
}
}
fn activate(repo: &MemoryRepo<'_>, id: &MemoryId) {
let now = Utc.with_ymd_and_hms(2026, 5, 18, 12, 1, 0).unwrap();
let audit = MemoryAcceptanceAudit {
id: AuditRecordId::new(),
actor_json: json!({"kind": "test"}),
reason: "test fixture activation".into(),
source_refs_json: json!([]),
created_at: now,
};
repo.accept_candidate(
id,
now,
&audit,
&accept_candidate_policy_decision_test_allow(),
)
.expect("activate candidate");
}
#[test]
fn context_skips_unusable_memory_and_returns_resolution_exclusion() {
let pool = test_pool();
{
let repo = MemoryRepo::new(&pool);
let bad = memory(
"mem_01JVD8N3J9CN9TQ91M7A49V9AA",
"semantic",
"This memory has missing proof lineage and must not block the whole pack.",
json!(["evt_01JVD8N3J9CN9TQ91M7A49V9AA"]),
);
let good = memory(
"mem_01JVD8N3J9CN9TQ91M7A49V9AB",
"operator_note",
"Operator note remains available because it is self-attesting.",
json!([]),
);
repo.insert_candidate(&bad).expect("insert bad memory");
repo.insert_candidate(&good).expect("insert good memory");
activate(&repo, &bad.id);
activate(&repo, &good.id);
}
let tool = CortexContextTool {
pool: Arc::new(Mutex::new(pool)),
};
let result = tool.call(json!({})).expect(
"unusable memories should be excluded with remediation, not hard-refuse the pack",
);
assert_eq!(result["entries"].as_array().expect("entries").len(), 1);
assert_eq!(result["excluded_count"], 1);
assert_eq!(
result["exclusions"][0]["id"],
"mem_01JVD8N3J9CN9TQ91M7A49V9AA"
);
assert_eq!(
result["exclusions"][0]["resolution"]["schema"],
"cortex_refusal_resolution.v1"
);
assert!(
result["exclusions"][0]["resolution"]["next_actions"]
.as_array()
.is_some_and(|actions| !actions.is_empty()),
"exclusion should carry concrete next actions: {result}"
);
}
}