use camino::Utf8PathBuf;
use cordance_core::receipt::CortexReceiptV1Candidate;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::mcp::error::{McpToolError, McpToolResult};
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
pub struct CortexReceiptParams {
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub dry_run: bool,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct CortexReceiptOutput {
pub schema: String,
#[schemars(with = "Option<String>")]
pub written_path: Option<Utf8PathBuf>,
#[schemars(with = "serde_json::Value")]
pub receipt: CortexReceiptV1Candidate,
}
pub fn receipt(
target: &Utf8PathBuf,
cfg: &Config,
dry_run: bool,
) -> McpToolResult<CortexReceiptOutput> {
let pack = super::pack::build_pack_for_cortex_receipt(target, cfg)?;
let receipt = cordance_cortex::build_receipt(&pack)
.map_err(|e| McpToolError::internal_redacted("receipt build", e))?;
assert_authority_invariants(&receipt)?;
let written_path = if dry_run {
None
} else {
let out_path = target.join(".cordance").join("cortex-receipt.json");
let json = serde_json::to_string_pretty(&receipt)
.map_err(|e| McpToolError::internal_redacted("receipt serialise", e))?;
cordance_core::fs::safe_write_with_mkdir(out_path.as_std_path(), json.as_bytes())
.map_err(McpToolError::from_io)?;
Some(out_path)
};
Ok(CortexReceiptOutput {
schema: cordance_core::schema::CORDANCE_CORTEX_RECEIPT_V1_CANDIDATE.to_string(),
written_path,
receipt,
})
}
fn assert_authority_invariants(r: &CortexReceiptV1Candidate) -> McpToolResult<()> {
let b = &r.authority_boundary;
if !b.candidate_only
|| b.cortex_truth_allowed
|| b.cortex_admission_allowed
|| b.durable_promotion_allowed
|| b.memory_promotion_allowed
|| b.doctrine_promotion_allowed
|| b.trusted_history_allowed
|| b.release_acceptance_allowed
|| b.runtime_authority_allowed
{
return Err(McpToolError::Internal(
"authority boundary widened — refused (Cordance never grants Cortex authority)".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DoctrineConfig;
use cordance_core::receipt::AuthorityBoundary;
fn temp_utf8_dir() -> (tempfile::TempDir, Utf8PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.expect("tempdir path must be utf8");
(dir, path)
}
fn fixture_receipt() -> CortexReceiptV1Candidate {
use chrono::Utc;
use cordance_core::receipt::{
ExecutionTrust, OperatorApproval, ReceiptBody, RuntimeIntegrity, SourceContext,
TruthCeiling,
};
CortexReceiptV1Candidate::new(
cordance_core::schema::CORDANCE_CORTEX_RECEIPT_V1_CANDIDATE.into(),
1,
"candidate_only".into(),
"cortex context-pack admit-cordance".into(),
AuthorityBoundary::candidate_only(),
ReceiptBody::new(
"test".into(),
Utc::now(),
"candidate_only".into(),
"partial_structural_evidence".into(),
"repo_only_no_runtime_write".into(),
"not_bound_for_cortex_promotion".into(),
"test".into(),
SourceContext::new(
"test".into(),
TruthCeiling::CandidateEvidenceOnly,
"not_cleared_by_boundary_crossing".into(),
),
ExecutionTrust::new(
"local_candidate_only".into(),
"deny_authority_grant".into(),
"not_field_level_bound_for_cortex".into(),
"not_field_level_bound_for_cortex".into(),
),
RuntimeIntegrity::new(false, false, "not_requested".into()),
OperatorApproval::new(
true,
"not_supplied_for_cortex_promotion".into(),
"not_supplied_for_cortex_promotion".into(),
),
vec![],
vec![],
vec![],
vec![],
),
vec![],
)
}
#[test]
fn invariants_pass_for_candidate_only_boundary() {
let r = fixture_receipt();
assert!(assert_authority_invariants(&r).is_ok());
}
#[test]
fn invariants_fail_when_boundary_widens() {
let mut r = fixture_receipt();
r.authority_boundary.cortex_truth_allowed = true;
let err = assert_authority_invariants(&r).expect_err("widening must be refused");
match err {
McpToolError::Internal(msg) => assert!(msg.contains("authority")),
other => panic!("expected Internal, got {other:?}"),
}
}
#[test]
fn mcp_receipt_omits_pack_cortex_receipt_noop_warning() {
let (_target_dir, target) = temp_utf8_dir();
let (_doctrine_dir, doctrine_root) = temp_utf8_dir();
let cfg = Config {
doctrine: DoctrineConfig {
source: doctrine_root.as_str().to_string(),
fallback_repo: "https://nonexistent.example.invalid/doctrine".into(),
pin_commit: "auto".into(),
},
..Default::default()
};
let output = receipt(&target, &cfg, true).expect("MCP receipt should build");
let body = &output.receipt.cordance_execution_receipt_v1;
let needle = "cortex-receipt requested via --targets";
assert!(
output.written_path.is_none(),
"dry-run receipt must not write to disk"
);
assert!(
!body
.allowed_claim_language
.iter()
.any(|claim| claim.contains(needle)),
"MCP cortex receipt must not pollute allowed claims: {:?}",
body.allowed_claim_language
);
assert!(
!body.residual_risk.iter().any(|risk| risk.contains(needle)),
"MCP cortex receipt must not pollute residual risk: {:?}",
body.residual_risk
);
}
}