Skip to main content

cortex_mcp/tools/
admit_axiom.rs

1//! `cortex_admit_axiom` MCP tool handler.
2//!
3//! Wraps the full three-envelope pai-axiom admission ceremony
4//! (`AxiomTrustExchangeAdmissionRequest::decide`) and returns the verdict
5//! inline. When `persist: true` and the decision is `admit_candidate`, the
6//! admitted envelope is written to the store as a `pending_mcp_commit`
7//! candidate (ADR 0047).
8//!
9//! Gates: [`GateId::SessionWrite`] — write-capable when `persist: true`.
10
11use std::sync::{Arc, Mutex};
12
13use chrono::Utc;
14use cortex_core::{
15    accepted_axiom_source_commits, is_axiom_source_commit_fresh, parse_authority_feedback_loop,
16    parse_axiom_execution_trust, parse_cortex_context_trust, MemoryId,
17    AXIOM_EXECUTION_TRUST_SOURCE_COMMIT_STALE_INVARIANT,
18};
19use cortex_memory::{
20    AdmissionLifecycle, AxiomTrustExchangeAdmissionRequest, TrustExchangeAdmission,
21};
22use cortex_store::repo::{MemoryCandidate, MemoryRepo};
23use cortex_store::Pool;
24use serde_json::{json, Value};
25
26use crate::tool_handler::{GateId, ToolError, ToolHandler};
27
28/// MCP tool: `cortex_admit_axiom`.
29///
30/// Schema:
31/// ```text
32/// cortex_admit_axiom(
33///   cortex_context_trust_json:    string,  // serialized cortex_context_trust envelope
34///   axiom_execution_trust_json:   string,  // serialized axiom_execution_trust envelope
35///   authority_feedback_loop_json: string,  // serialized authority_feedback_loop record
36///   persist?:                     bool,    // default false; when true writes candidate
37///                                          // to pending_mcp_commit (ADR 0047)
38/// ) → {
39///   decision:       "admit_candidate" | "reject" | "quarantine",
40///   policy_outcome: string,
41///   failing_edges:  [string],
42///   forbidden_uses: [string],
43///   persisted:      bool,
44///   memory_id?:     string,   // present only when persist: true + admit_candidate
45/// }
46/// ```
47#[derive(Debug)]
48pub struct CortexAdmitAxiomTool {
49    pool: Arc<Mutex<Pool>>,
50}
51
52impl CortexAdmitAxiomTool {
53    /// Construct the tool over a shared, mutex-guarded store connection.
54    #[must_use]
55    pub fn new(pool: Arc<Mutex<Pool>>) -> Self {
56        Self { pool }
57    }
58}
59
60impl ToolHandler for CortexAdmitAxiomTool {
61    fn name(&self) -> &'static str {
62        "cortex_admit_axiom"
63    }
64
65    fn gate_set(&self) -> &'static [GateId] {
66        &[GateId::SessionWrite]
67    }
68
69    fn call(&self, params: Value) -> Result<Value, ToolError> {
70        // ── 1. Extract params ────────────────────────────────────────────────
71        let exec_json = params["axiom_execution_trust_json"]
72            .as_str()
73            .filter(|s| !s.is_empty())
74            .ok_or_else(|| {
75                ToolError::InvalidParams("axiom_execution_trust_json is required".into())
76            })?;
77
78        let ctx_json = params["cortex_context_trust_json"]
79            .as_str()
80            .filter(|s| !s.is_empty())
81            .ok_or_else(|| {
82                ToolError::InvalidParams("cortex_context_trust_json is required".into())
83            })?;
84
85        let loop_json = params["authority_feedback_loop_json"]
86            .as_str()
87            .filter(|s| !s.is_empty())
88            .ok_or_else(|| {
89                ToolError::InvalidParams("authority_feedback_loop_json is required".into())
90            })?;
91
92        let persist = params["persist"].as_bool().unwrap_or(false);
93
94        // ── 2. Parse envelopes ───────────────────────────────────────────────
95        let exec = parse_axiom_execution_trust(exec_json).map_err(|err| {
96            ToolError::InvalidParams(format!(
97                "axiom_execution_trust_json schema error: {}",
98                err.reason
99            ))
100        })?;
101
102        let ctx = parse_cortex_context_trust(ctx_json).map_err(|err| {
103            ToolError::InvalidParams(format!(
104                "cortex_context_trust_json schema error: {}",
105                err.reason
106            ))
107        })?;
108
109        let loop_rec = parse_authority_feedback_loop(loop_json).map_err(|err| {
110            ToolError::InvalidParams(format!(
111                "authority_feedback_loop_json schema error: {}",
112                err.reason
113            ))
114        })?;
115
116        // ── 3. Freshness gate ────────────────────────────────────────────────
117        let accepted = accepted_axiom_source_commits();
118        if !is_axiom_source_commit_fresh(&exec.tool_provenance.source_commit, &accepted) {
119            return Err(ToolError::PolicyRejected(format!(
120                "{AXIOM_EXECUTION_TRUST_SOURCE_COMMIT_STALE_INVARIANT}: \
121                 tool_provenance.source_commit `{}` is not on the Cortex-side acceptance list",
122                exec.tool_provenance.source_commit,
123            )));
124        }
125
126        // ── 4. Build and run the admission request ───────────────────────────
127        let request = AxiomTrustExchangeAdmissionRequest::new(
128            exec.clone(),
129            AdmissionLifecycle::CandidateOnly,
130        )
131        .with_cortex_context_trust(ctx)
132        .with_authority_feedback_loop(loop_rec);
133
134        let decision = request.decide();
135
136        // ── 5. Map decision to response fields ───────────────────────────────
137        let policy = decision.policy_decision();
138        let policy_outcome = format!("{:?}", policy.final_outcome);
139        let failing_edges: Vec<&str> = decision.invariants();
140        let forbidden_uses: Vec<String> = decision
141            .forbidden_uses()
142            .map(|uses| {
143                uses.iter()
144                    .map(|u| {
145                        serde_json::to_value(u)
146                            .ok()
147                            .and_then(|v| v.as_str().map(ToOwned::to_owned))
148                            .unwrap_or_else(|| "unknown".to_string())
149                    })
150                    .collect()
151            })
152            .unwrap_or_default();
153
154        let decision_name = decision.decision_name();
155
156        // ── 6. Persist path (persist: true + admit_candidate) ────────────────
157        if persist {
158            if let TrustExchangeAdmission::AdmitCandidate { .. } = &decision {
159                let now = Utc::now();
160                let candidate_id = MemoryId::new();
161
162                let claim = format!(
163                    "{}: {}",
164                    exec.action_id,
165                    exec.source_anchors
166                        .first()
167                        .map(|a| a.r#ref.as_str())
168                        .unwrap_or("")
169                );
170
171                let source_anchors_json: Vec<Value> = exec
172                    .source_anchors
173                    .iter()
174                    .map(|a| {
175                        json!({
176                            "source_id": a.source_id,
177                            "source_type": serde_json::to_value(a.source_type)
178                                .unwrap_or(Value::Null),
179                            "ref": a.r#ref,
180                            "hash": a.hash,
181                        })
182                    })
183                    .collect();
184
185                let candidate = MemoryCandidate {
186                    id: candidate_id,
187                    memory_type: "semantic".to_string(),
188                    claim,
189                    source_episodes_json: json!([]),
190                    source_events_json: json!(exec.action_id),
191                    domains_json: json!([]),
192                    salience_json: json!({ "score": 0.5 }),
193                    confidence: 0.5,
194                    authority: "axiom_candidate".to_string(),
195                    applies_when_json: json!([]),
196                    does_not_apply_when_json: json!([]),
197                    created_at: now,
198                    updated_at: now,
199                };
200
201                let pool = self
202                    .pool
203                    .lock()
204                    .map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
205                let repo = MemoryRepo::new(&pool);
206
207                // Suppress unused-variable warning: source_anchors_json is
208                // captured for future use in a richer salience_json extension.
209                let _ = source_anchors_json;
210
211                repo.insert_candidate(&candidate).map_err(|err| {
212                    ToolError::Internal(format!("insert_candidate failed: {err}"))
213                })?;
214
215                repo.set_pending_mcp_commit(&candidate.id, now)
216                    .map_err(|err| {
217                        ToolError::Internal(format!("set_pending_mcp_commit failed: {err}"))
218                    })?;
219
220                tracing::info!(
221                    "cortex_admit_axiom: persisted candidate {} as pending_mcp_commit",
222                    candidate.id
223                );
224
225                return Ok(json!({
226                    "decision": decision_name,
227                    "policy_outcome": policy_outcome,
228                    "failing_edges": failing_edges,
229                    "forbidden_uses": forbidden_uses,
230                    "persisted": true,
231                    "memory_id": candidate.id.to_string(),
232                }));
233            }
234        }
235
236        // ── 7. Validate-only response ────────────────────────────────────────
237        Ok(json!({
238            "decision": decision_name,
239            "policy_outcome": policy_outcome,
240            "failing_edges": failing_edges,
241            "forbidden_uses": forbidden_uses,
242            "persisted": false,
243        }))
244    }
245}