crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `cortex_admit_axiom` MCP tool handler.
//!
//! Wraps the full three-envelope pai-axiom admission ceremony
//! (`AxiomTrustExchangeAdmissionRequest::decide`) and returns the verdict
//! inline. When `persist: true` and the decision is `admit_candidate`, the
//! admitted envelope is written to the store as a `pending_mcp_commit`
//! candidate (ADR 0047).
//!
//! Gates: [`GateId::SessionWrite`] — write-capable when `persist: true`.

use std::sync::{Arc, Mutex};

use chrono::Utc;
use cortex_core::{
    accepted_axiom_source_commits, is_axiom_source_commit_fresh, parse_authority_feedback_loop,
    parse_axiom_execution_trust, parse_cortex_context_trust, MemoryId,
    AXIOM_EXECUTION_TRUST_SOURCE_COMMIT_STALE_INVARIANT,
};
use cortex_memory::{
    AdmissionLifecycle, AxiomTrustExchangeAdmissionRequest, TrustExchangeAdmission,
};
use cortex_store::repo::{MemoryCandidate, MemoryRepo};
use cortex_store::Pool;
use serde_json::{json, Value};

use crate::tool_handler::{GateId, ToolError, ToolHandler};

/// MCP tool: `cortex_admit_axiom`.
///
/// Schema:
/// ```text
/// cortex_admit_axiom(
///   cortex_context_trust_json:    string,  // serialized cortex_context_trust envelope
///   axiom_execution_trust_json:   string,  // serialized axiom_execution_trust envelope
///   authority_feedback_loop_json: string,  // serialized authority_feedback_loop record
///   persist?:                     bool,    // default false; when true writes candidate
///                                          // to pending_mcp_commit (ADR 0047)
/// ) → {
///   decision:       "admit_candidate" | "reject" | "quarantine",
///   policy_outcome: string,
///   failing_edges:  [string],
///   forbidden_uses: [string],
///   persisted:      bool,
///   memory_id?:     string,   // present only when persist: true + admit_candidate
/// }
/// ```
#[derive(Debug)]
pub struct CortexAdmitAxiomTool {
    pool: Arc<Mutex<Pool>>,
}

impl CortexAdmitAxiomTool {
    /// Construct the tool over a shared, mutex-guarded store connection.
    #[must_use]
    pub fn new(pool: Arc<Mutex<Pool>>) -> Self {
        Self { pool }
    }
}

impl ToolHandler for CortexAdmitAxiomTool {
    fn name(&self) -> &'static str {
        "cortex_admit_axiom"
    }

    fn gate_set(&self) -> &'static [GateId] {
        &[GateId::SessionWrite]
    }

    fn call(&self, params: Value) -> Result<Value, ToolError> {
        // ── 1. Extract params ────────────────────────────────────────────────
        let exec_json = params["axiom_execution_trust_json"]
            .as_str()
            .filter(|s| !s.is_empty())
            .ok_or_else(|| {
                ToolError::InvalidParams("axiom_execution_trust_json is required".into())
            })?;

        let ctx_json = params["cortex_context_trust_json"]
            .as_str()
            .filter(|s| !s.is_empty())
            .ok_or_else(|| {
                ToolError::InvalidParams("cortex_context_trust_json is required".into())
            })?;

        let loop_json = params["authority_feedback_loop_json"]
            .as_str()
            .filter(|s| !s.is_empty())
            .ok_or_else(|| {
                ToolError::InvalidParams("authority_feedback_loop_json is required".into())
            })?;

        let persist = params["persist"].as_bool().unwrap_or(false);

        // ── 2. Parse envelopes ───────────────────────────────────────────────
        let exec = parse_axiom_execution_trust(exec_json).map_err(|err| {
            ToolError::InvalidParams(format!(
                "axiom_execution_trust_json schema error: {}",
                err.reason
            ))
        })?;

        let ctx = parse_cortex_context_trust(ctx_json).map_err(|err| {
            ToolError::InvalidParams(format!(
                "cortex_context_trust_json schema error: {}",
                err.reason
            ))
        })?;

        let loop_rec = parse_authority_feedback_loop(loop_json).map_err(|err| {
            ToolError::InvalidParams(format!(
                "authority_feedback_loop_json schema error: {}",
                err.reason
            ))
        })?;

        // ── 3. Freshness gate ────────────────────────────────────────────────
        let accepted = accepted_axiom_source_commits();
        if !is_axiom_source_commit_fresh(&exec.tool_provenance.source_commit, &accepted) {
            return Err(ToolError::PolicyRejected(format!(
                "{AXIOM_EXECUTION_TRUST_SOURCE_COMMIT_STALE_INVARIANT}: \
                 tool_provenance.source_commit `{}` is not on the Cortex-side acceptance list",
                exec.tool_provenance.source_commit,
            )));
        }

        // ── 4. Build and run the admission request ───────────────────────────
        let request = AxiomTrustExchangeAdmissionRequest::new(
            exec.clone(),
            AdmissionLifecycle::CandidateOnly,
        )
        .with_cortex_context_trust(ctx)
        .with_authority_feedback_loop(loop_rec);

        let decision = request.decide();

        // ── 5. Map decision to response fields ───────────────────────────────
        let policy = decision.policy_decision();
        let policy_outcome = format!("{:?}", policy.final_outcome);
        let failing_edges: Vec<&str> = decision.invariants();
        let forbidden_uses: Vec<String> = decision
            .forbidden_uses()
            .map(|uses| {
                uses.iter()
                    .map(|u| {
                        serde_json::to_value(u)
                            .ok()
                            .and_then(|v| v.as_str().map(ToOwned::to_owned))
                            .unwrap_or_else(|| "unknown".to_string())
                    })
                    .collect()
            })
            .unwrap_or_default();

        let decision_name = decision.decision_name();

        // ── 6. Persist path (persist: true + admit_candidate) ────────────────
        if persist {
            if let TrustExchangeAdmission::AdmitCandidate { .. } = &decision {
                let now = Utc::now();
                let candidate_id = MemoryId::new();

                let claim = format!(
                    "{}: {}",
                    exec.action_id,
                    exec.source_anchors
                        .first()
                        .map(|a| a.r#ref.as_str())
                        .unwrap_or("")
                );

                let source_anchors_json: Vec<Value> = exec
                    .source_anchors
                    .iter()
                    .map(|a| {
                        json!({
                            "source_id": a.source_id,
                            "source_type": serde_json::to_value(a.source_type)
                                .unwrap_or(Value::Null),
                            "ref": a.r#ref,
                            "hash": a.hash,
                        })
                    })
                    .collect();

                let candidate = MemoryCandidate {
                    id: candidate_id,
                    memory_type: "semantic".to_string(),
                    claim,
                    source_episodes_json: json!([]),
                    source_events_json: json!(exec.action_id),
                    domains_json: json!([]),
                    salience_json: json!({ "score": 0.5 }),
                    confidence: 0.5,
                    authority: "axiom_candidate".to_string(),
                    applies_when_json: json!([]),
                    does_not_apply_when_json: json!([]),
                    created_at: now,
                    updated_at: now,
                };

                let pool = self
                    .pool
                    .lock()
                    .map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
                let repo = MemoryRepo::new(&pool);

                // Suppress unused-variable warning: source_anchors_json is
                // captured for future use in a richer salience_json extension.
                let _ = source_anchors_json;

                repo.insert_candidate(&candidate).map_err(|err| {
                    ToolError::Internal(format!("insert_candidate failed: {err}"))
                })?;

                repo.set_pending_mcp_commit(&candidate.id, now)
                    .map_err(|err| {
                        ToolError::Internal(format!("set_pending_mcp_commit failed: {err}"))
                    })?;

                tracing::info!(
                    "cortex_admit_axiom: persisted candidate {} as pending_mcp_commit",
                    candidate.id
                );

                return Ok(json!({
                    "decision": decision_name,
                    "policy_outcome": policy_outcome,
                    "failing_edges": failing_edges,
                    "forbidden_uses": forbidden_uses,
                    "persisted": true,
                    "memory_id": candidate.id.to_string(),
                }));
            }
        }

        // ── 7. Validate-only response ────────────────────────────────────────
        Ok(json!({
            "decision": decision_name,
            "policy_outcome": policy_outcome,
            "failing_edges": failing_edges,
            "forbidden_uses": forbidden_uses,
            "persisted": false,
        }))
    }
}