Skip to main content

cortex_memory/
trust_exchange.rs

1//! Field-level pai-axiom trust exchange admission for Cortex (ADR 0042 / 0043).
2//!
3//! This module consumes the typed envelopes from
4//! `cortex_core::axiom_trust` and runs the admission gate at the
5//! decomposed-field granularity. The existing
6//! [`crate::admission::AxiomMemoryAdmissionRequest`] still handles the
7//! generic ADR 0038 admission envelope; this module is the *receiver-side*
8//! field-level enforcement that the pai-axiom P6 acceptance request packet
9//! requires.
10//!
11//! ## Hard structural refusals
12//!
13//! - `lifecycle != candidate_only` → reject.
14//! - `same_loop_promotion_allowed == true` → reject.
15//! - `durable_truth_promotion == eligible_after_independent_validation` or
16//!   `full_execution_authority == eligible_after_independent_validation` →
17//!   reject (Cortex authority limit; ADR 0026 §4 hard wall).
18//! - Expired or revoked token → reject.
19//! - Missing required field-level contributor → reject.
20//!
21//! ## Quarantine paths
22//!
23//! - Quarantined or unknown quarantine state → quarantine with the named
24//!   `axiom.admission.quarantine.propagated` invariant.
25//! - Derived-from-quarantined per lineage → quarantine.
26//! - Target-domain validation required and not `Pass` → quarantine.
27//!
28//! Every `AdmitCandidate` decision carries an explicit `forbidden_uses`
29//! array — Cortex never lets AXIOM evidence imply durable truth.
30
31use chrono::{DateTime, Utc};
32use cortex_core::{
33    compose_policy_outcomes, ArtifactLifecycleState, AuthorityFeedbackLoop, AxiomExecutionTrust,
34    ContextProofStateValue, ContextRedactionStatus, CortexContextTrust, ExecutionPolicyResult,
35    NamedQuarantineOutputs, PolicyContribution, PolicyDecision, PolicyOutcome, QuarantineOutput,
36    RepoTrustResult, TargetDomainValidationResult, TokenRevocationResult, TrustExchangeFieldError,
37};
38use serde::{Deserialize, Serialize};
39
40/// Required lifecycle assertion for a pai-axiom trust exchange admission.
41///
42/// Cortex admits only `candidate_only` — any other lifecycle is a hard
43/// structural refusal.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum AdmissionLifecycle {
47    /// Candidate-only admission (the only admissible value).
48    CandidateOnly,
49    /// Validated lifecycle — not admissible at the Cortex receiver.
50    Validated,
51    /// Promoted lifecycle — not admissible at the Cortex receiver.
52    Promoted,
53    /// Stale lifecycle — not admissible at the Cortex receiver.
54    Stale,
55    /// Quarantined lifecycle — not admissible at the Cortex receiver.
56    Quarantined,
57    /// Lifecycle missing or unknown — not admissible.
58    Unknown,
59}
60
61impl AdmissionLifecycle {
62    /// Whether this lifecycle is the only admissible value.
63    #[must_use]
64    pub const fn is_candidate_only(self) -> bool {
65        matches!(self, Self::CandidateOnly)
66    }
67}
68
69/// Cortex-side admission request driven by the pai-axiom trust exchange
70/// envelopes (ADR 0042/0043).
71#[derive(Debug, Clone, PartialEq)]
72pub struct AxiomTrustExchangeAdmissionRequest {
73    /// Cortex context trust envelope (when supplied at the boundary).
74    pub cortex_context_trust: Option<CortexContextTrust>,
75    /// pai-axiom execution trust envelope.
76    pub axiom_execution_trust: AxiomExecutionTrust,
77    /// Authority feedback loop record (when supplied).
78    pub authority_feedback_loop: Option<AuthorityFeedbackLoop>,
79    /// Explicit lifecycle assertion supplied by the caller.
80    pub lifecycle: AdmissionLifecycle,
81    /// Timestamp the admission gate uses as "now" for staleness checks.
82    pub now: DateTime<Utc>,
83    /// Operator-supplied lineage marker: `true` when an upstream entry
84    /// in the artifact's lineage was already `Quarantined`.
85    pub derived_from_quarantined: bool,
86}
87
88impl AxiomTrustExchangeAdmissionRequest {
89    /// Construct a minimal admission request with `now = Utc::now()`.
90    #[must_use]
91    pub fn new(axiom_execution_trust: AxiomExecutionTrust, lifecycle: AdmissionLifecycle) -> Self {
92        Self {
93            cortex_context_trust: None,
94            axiom_execution_trust,
95            authority_feedback_loop: None,
96            lifecycle,
97            now: Utc::now(),
98            derived_from_quarantined: false,
99        }
100    }
101
102    /// Attach an optional Cortex context trust envelope.
103    #[must_use]
104    pub fn with_cortex_context_trust(mut self, ctx: CortexContextTrust) -> Self {
105        self.cortex_context_trust = Some(ctx);
106        self
107    }
108
109    /// Attach an optional authority feedback loop record.
110    #[must_use]
111    pub fn with_authority_feedback_loop(mut self, loop_record: AuthorityFeedbackLoop) -> Self {
112        self.authority_feedback_loop = Some(loop_record);
113        self
114    }
115
116    /// Override the gate's `now` reference (otherwise [`Utc::now`] at
117    /// construction time).
118    #[must_use]
119    pub const fn with_now(mut self, now: DateTime<Utc>) -> Self {
120        self.now = now;
121        self
122    }
123
124    /// Mark this admission request as derived from a quarantined lineage.
125    #[must_use]
126    pub const fn with_derived_from_quarantined(mut self, derived: bool) -> Self {
127        self.derived_from_quarantined = derived;
128        self
129    }
130
131    /// Compute the deterministic admission decision for this request.
132    #[must_use]
133    pub fn decide(&self) -> TrustExchangeAdmission {
134        let mut rejects: Vec<TrustExchangeFieldError> = Vec::new();
135        let mut quarantines: Vec<TrustExchangeFieldError> = Vec::new();
136        let mut named_quarantine_outputs = NamedQuarantineOutputs::default();
137
138        // 1. Structural lifecycle gate.
139        if !self.lifecycle.is_candidate_only() {
140            rejects.push(TrustExchangeFieldError::new(
141                "axiom.admission.lifecycle.must_be_candidate_only",
142                "Cortex admits pai-axiom trust exchange only as candidate_only",
143            ));
144        }
145
146        // 2. Structural same-loop refusal.
147        if let Some(loop_record) = &self.authority_feedback_loop {
148            if loop_record.violates_same_loop_invariant() {
149                rejects.push(TrustExchangeFieldError::new(
150                    "authority_feedback_loop.same_loop_promotion_must_be_false",
151                    "same_loop_promotion_allowed must be false at the Cortex receiver",
152                ));
153            }
154            if loop_record.claims_durable_authority() {
155                rejects.push(TrustExchangeFieldError::new(
156                    "authority_feedback_loop.authority_claims.over_authorized",
157                    "Cortex refuses durable_truth_promotion or full_execution_authority claims",
158                ));
159            }
160        }
161
162        // 3. Field-level validation. Pull errors into reject bucket.
163        if let Err(errors) = self.axiom_execution_trust.validate() {
164            rejects.extend(errors);
165        }
166        if let Some(ctx) = &self.cortex_context_trust {
167            if let Err(errors) = ctx.validate() {
168                rejects.extend(errors);
169            }
170        }
171        if let Some(loop_record) = &self.authority_feedback_loop {
172            if let Err(errors) = loop_record.validate() {
173                rejects.extend(errors);
174            }
175        }
176
177        // 4. Quarantine propagation on the cortex_context_trust side.
178        if let Some(ctx) = &self.cortex_context_trust {
179            if ctx.quarantine_state.propagates_quarantine() {
180                let invariant = "axiom.admission.quarantine.propagated".to_string();
181                let reason = format!(
182                    "cortex_context_trust.quarantine_state == {:?}",
183                    ctx.quarantine_state
184                );
185                quarantines.push(TrustExchangeFieldError::new(
186                    invariant.clone(),
187                    reason.clone(),
188                ));
189                named_quarantine_outputs.source_context = Some(
190                    QuarantineOutput::new(invariant, reason)
191                        .with_source_ref("cortex_context_trust"),
192                );
193            }
194            if matches!(ctx.redaction_state.status, ContextRedactionStatus::Redacted)
195                && ctx.redaction_state.blocks_critical_premise.unwrap_or(false)
196            {
197                quarantines.push(TrustExchangeFieldError::new(
198                    "cortex_context_trust.redaction_state.blocks_critical_premise",
199                    "redaction removed critical premise; treated as quarantine",
200                ));
201            }
202        }
203
204        // 5. Token revocation and expiry.
205        if self
206            .axiom_execution_trust
207            .token_scope
208            .revocation_result
209            .must_reject()
210        {
211            let invariant = match self.axiom_execution_trust.token_scope.revocation_result {
212                TokenRevocationResult::Revoked => "axiom_execution_trust.token_scope.revoked",
213                TokenRevocationResult::Inactive => "axiom_execution_trust.token_scope.inactive",
214                _ => "axiom_execution_trust.token_scope.invalid_state",
215            };
216            rejects.push(TrustExchangeFieldError::new(
217                invariant,
218                "capability token must be active for Cortex admission",
219            ));
220            named_quarantine_outputs.token_revocation = Some(QuarantineOutput::new(
221                invariant,
222                "capability token must be active for Cortex admission",
223            ));
224        }
225        if self.axiom_execution_trust.token_expired_at(self.now) {
226            rejects.push(TrustExchangeFieldError::new(
227                "axiom_execution_trust.token_scope.expired",
228                "capability token expires_at is in the past for the supplied now",
229            ));
230            named_quarantine_outputs.token_revocation = Some(QuarantineOutput::new(
231                "axiom_execution_trust.token_scope.expired",
232                "capability token is expired",
233            ));
234        }
235
236        // 6. Repo trust.
237        if matches!(
238            self.axiom_execution_trust.repo_trust.result,
239            RepoTrustResult::Untrusted
240        ) {
241            quarantines.push(TrustExchangeFieldError::new(
242                "axiom_execution_trust.repo_trust.untrusted",
243                "repo_trust.result == untrusted is propagated as quarantine",
244            ));
245            named_quarantine_outputs.repo_trust = Some(QuarantineOutput::new(
246                "axiom_execution_trust.repo_trust.untrusted",
247                "repo trust untrusted",
248            ));
249        }
250
251        // 7. Policy denial.
252        if matches!(
253            self.axiom_execution_trust.policy_decision.result,
254            ExecutionPolicyResult::Deny
255        ) {
256            rejects.push(TrustExchangeFieldError::new(
257                "axiom_execution_trust.policy_decision.deny",
258                "policy_decision.result == deny is a hard receiver refusal",
259            ));
260            named_quarantine_outputs.policy_denial = Some(QuarantineOutput::new(
261                "axiom_execution_trust.policy_decision.deny",
262                "policy denied",
263            ));
264        }
265
266        // 8. Target-domain validation.
267        if let Some(loop_record) = &self.authority_feedback_loop {
268            if loop_record.target_domain_validation.required
269                && !matches!(
270                    loop_record.target_domain_validation.result,
271                    TargetDomainValidationResult::Pass
272                )
273            {
274                quarantines.push(TrustExchangeFieldError::new(
275                    "authority_feedback_loop.target_domain_validation.not_pass",
276                    "target_domain_validation.result must be pass for clean admission",
277                ));
278                named_quarantine_outputs.target_validation = Some(QuarantineOutput::new(
279                    "authority_feedback_loop.target_domain_validation.not_pass",
280                    "target domain validation not pass",
281                ));
282            }
283
284            // Derived artifact quarantine.
285            if loop_record
286                .returned_artifacts
287                .iter()
288                .any(|a| matches!(a.lifecycle_state, ArtifactLifecycleState::Quarantined))
289            {
290                quarantines.push(TrustExchangeFieldError::new(
291                    "authority_feedback_loop.returned_artifacts.quarantined",
292                    "at least one returned artifact lifecycle_state == quarantined",
293                ));
294                named_quarantine_outputs.derived_artifact = Some(QuarantineOutput::new(
295                    "authority_feedback_loop.returned_artifacts.quarantined",
296                    "derived artifact quarantined",
297                ));
298            }
299
300            if loop_record.quarantine_state.propagates_quarantine() {
301                quarantines.push(TrustExchangeFieldError::new(
302                    "axiom.admission.quarantine.propagated",
303                    "authority_feedback_loop.quarantine_state propagates",
304                ));
305                named_quarantine_outputs.contradiction = Some(QuarantineOutput::new(
306                    "axiom.admission.quarantine.propagated",
307                    "feedback loop quarantine state",
308                ));
309            }
310        }
311
312        // 9. Operator-supplied lineage hint.
313        if self.derived_from_quarantined {
314            quarantines.push(TrustExchangeFieldError::new(
315                "axiom.admission.quarantine.derived_from_quarantined",
316                "operator marked admission lineage as derived_from_quarantined",
317            ));
318            named_quarantine_outputs.source_context = Some(QuarantineOutput::new(
319                "axiom.admission.quarantine.derived_from_quarantined",
320                "lineage trace indicates quarantined ancestor",
321            ));
322        }
323
324        // 10. Cortex context proof state contradicts the loop's quarantine guarantee.
325        if let Some(ctx) = &self.cortex_context_trust {
326            if matches!(
327                ctx.proof_state.state,
328                ContextProofStateValue::Failed | ContextProofStateValue::Missing
329            ) {
330                rejects.push(TrustExchangeFieldError::new(
331                    "cortex_context_trust.proof_state.state.unusable",
332                    "proof_state.state failed or missing is a hard refusal",
333                ));
334            }
335        }
336
337        let forbidden_uses = forbidden_uses_for_candidate();
338        let policy_decision = compose_decision_outcomes(&rejects, &quarantines);
339
340        if !rejects.is_empty() {
341            TrustExchangeAdmission::Reject {
342                rejects,
343                quarantines,
344                named_quarantine_outputs,
345                policy_decision,
346            }
347        } else if !quarantines.is_empty() {
348            TrustExchangeAdmission::Quarantine {
349                quarantines,
350                named_quarantine_outputs,
351                policy_decision,
352                forbidden_uses,
353            }
354        } else {
355            TrustExchangeAdmission::AdmitCandidate {
356                forbidden_uses,
357                policy_decision,
358            }
359        }
360    }
361}
362
363/// Final admission decision for a pai-axiom trust exchange admission.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub enum TrustExchangeAdmission {
366    /// Admitted only as a Cortex memory candidate.
367    AdmitCandidate {
368        /// Authority-bearing uses forbidden on every AdmitCandidate path.
369        forbidden_uses: Vec<ForbiddenUse>,
370        /// ADR 0026 composed policy decision.
371        policy_decision: PolicyDecision,
372    },
373    /// Quarantine path — record retained but cannot promote.
374    Quarantine {
375        /// Stable invariant failures triggering quarantine.
376        quarantines: Vec<TrustExchangeFieldError>,
377        /// Named per-source quarantine outputs (ADR 0042 §7).
378        named_quarantine_outputs: NamedQuarantineOutputs,
379        /// ADR 0026 composed policy decision.
380        policy_decision: PolicyDecision,
381        /// Forbidden uses still apply to quarantined records.
382        forbidden_uses: Vec<ForbiddenUse>,
383    },
384    /// Hard refusal — no record retained as candidate.
385    Reject {
386        /// Stable invariant failures triggering rejection.
387        rejects: Vec<TrustExchangeFieldError>,
388        /// Co-occurring quarantine signals (for diagnostics only).
389        quarantines: Vec<TrustExchangeFieldError>,
390        /// Named per-source quarantine outputs (ADR 0042 §7).
391        named_quarantine_outputs: NamedQuarantineOutputs,
392        /// ADR 0026 composed policy decision.
393        policy_decision: PolicyDecision,
394    },
395}
396
397impl TrustExchangeAdmission {
398    /// Stable machine-readable name of the decision branch.
399    #[must_use]
400    pub const fn decision_name(&self) -> &'static str {
401        match self {
402            Self::AdmitCandidate { .. } => "admit_candidate",
403            Self::Quarantine { .. } => "quarantine",
404            Self::Reject { .. } => "reject",
405        }
406    }
407
408    /// Returns the named quarantine outputs of the decision when present.
409    #[must_use]
410    pub fn named_quarantine_outputs(&self) -> Option<&NamedQuarantineOutputs> {
411        match self {
412            Self::AdmitCandidate { .. } => None,
413            Self::Quarantine {
414                named_quarantine_outputs,
415                ..
416            }
417            | Self::Reject {
418                named_quarantine_outputs,
419                ..
420            } => Some(named_quarantine_outputs),
421        }
422    }
423
424    /// Returns the composed ADR 0026 policy decision.
425    #[must_use]
426    pub fn policy_decision(&self) -> &PolicyDecision {
427        match self {
428            Self::AdmitCandidate {
429                policy_decision, ..
430            }
431            | Self::Quarantine {
432                policy_decision, ..
433            }
434            | Self::Reject {
435                policy_decision, ..
436            } => policy_decision,
437        }
438    }
439
440    /// Returns the stable invariant names contributing to this decision.
441    #[must_use]
442    pub fn invariants(&self) -> Vec<&str> {
443        match self {
444            Self::AdmitCandidate { .. } => Vec::new(),
445            Self::Quarantine { quarantines, .. } => {
446                quarantines.iter().map(|e| e.invariant.as_str()).collect()
447            }
448            Self::Reject {
449                rejects,
450                quarantines,
451                ..
452            } => rejects
453                .iter()
454                .chain(quarantines.iter())
455                .map(|e| e.invariant.as_str())
456                .collect(),
457        }
458    }
459
460    /// Returns the `forbidden_uses` carried on candidate or quarantined
461    /// records. Always present even on quarantine to preserve the
462    /// candidate-only invariant.
463    #[must_use]
464    pub fn forbidden_uses(&self) -> Option<&[ForbiddenUse]> {
465        match self {
466            Self::AdmitCandidate { forbidden_uses, .. }
467            | Self::Quarantine { forbidden_uses, .. } => Some(forbidden_uses),
468            Self::Reject { .. } => None,
469        }
470    }
471}
472
473/// Authority-bearing uses forbidden on every AdmitCandidate path.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
475#[serde(rename_all = "snake_case")]
476pub enum ForbiddenUse {
477    /// Durable promotion to Cortex truth.
478    DurablePromotion,
479    /// Release acceptance evidence.
480    ReleaseAcceptance,
481    /// Runtime authority claim.
482    RuntimeAuthority,
483    /// Cortex truth claim.
484    CortexTruth,
485    /// Trusted run-history claim.
486    TrustedHistory,
487}
488
489/// Forbidden uses array attached to every AdmitCandidate / Quarantine
490/// decision. Cortex never grants AXIOM evidence durable authority.
491#[must_use]
492pub fn forbidden_uses_for_candidate() -> Vec<ForbiddenUse> {
493    vec![
494        ForbiddenUse::DurablePromotion,
495        ForbiddenUse::ReleaseAcceptance,
496        ForbiddenUse::RuntimeAuthority,
497        ForbiddenUse::CortexTruth,
498        ForbiddenUse::TrustedHistory,
499    ]
500}
501
502fn compose_decision_outcomes(
503    rejects: &[TrustExchangeFieldError],
504    quarantines: &[TrustExchangeFieldError],
505) -> PolicyDecision {
506    let mut contributions: Vec<PolicyContribution> = Vec::new();
507    for err in rejects {
508        contributions.push(
509            PolicyContribution::new(
510                stable_policy_rule_id(&err.invariant),
511                PolicyOutcome::Reject,
512                err.reason.clone(),
513            )
514            .expect("stable invariant policy contribution is well-formed"),
515        );
516    }
517    for err in quarantines {
518        contributions.push(
519            PolicyContribution::new(
520                stable_policy_rule_id(&err.invariant),
521                PolicyOutcome::Quarantine,
522                err.reason.clone(),
523            )
524            .expect("stable invariant policy contribution is well-formed"),
525        );
526    }
527    if contributions.is_empty() {
528        contributions.push(
529            PolicyContribution::new(
530                "axiom.admission.trust_exchange.allow_candidate",
531                PolicyOutcome::Allow,
532                "pai-axiom trust exchange admitted as Cortex candidate only",
533            )
534            .expect("static policy contribution shape is valid"),
535        );
536    }
537    compose_policy_outcomes(contributions, None)
538}
539
540fn stable_policy_rule_id(invariant: &str) -> String {
541    // Policy rules use the same dot-pathed stable invariant id so operator
542    // dashboards can correlate ADR 0026 outcomes with pai-axiom acceptance
543    // tests without translation.
544    format!("axiom.admission.trust_exchange.{invariant}")
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use chrono::TimeZone;
551    use cortex_core::{
552        parse_axiom_execution_trust, parse_cortex_context_trust, AmplificationRisk,
553        ArtifactLifecycleState, AuthorityClaimStatus, ConfidenceCeiling, ContextQuarantineState,
554        ExecutionPolicyResult, FeedbackAuthorityClaims, FeedbackAxiomAction,
555        FeedbackInitiatingContext, FeedbackReturnedArtifact, RepoTrustResult, ReproducibilityLevel,
556        TargetDomainValidation, TargetDomainValidationResult, AUTHORITY_FEEDBACK_LOOP_SCHEMA,
557    };
558
559    const VALID_CTX: &str =
560        include_str!("../../cortex-core/tests/fixtures/pai-axiom/valid-cortex-context-trust.json");
561    const VALID_EXEC: &str =
562        include_str!("../../cortex-core/tests/fixtures/pai-axiom/valid-axiom-execution-trust.json");
563
564    fn valid_loop_record() -> AuthorityFeedbackLoop {
565        AuthorityFeedbackLoop {
566            schema: AUTHORITY_FEEDBACK_LOOP_SCHEMA.to_string(),
567            version: 1,
568            authority_feedback_loop_ref: Some("loop_ref".to_string()),
569            loop_id: "loop_valid".to_string(),
570            started_at: Utc.with_ymd_and_hms(2026, 5, 4, 18, 0, 0).unwrap(),
571            initiating_context: FeedbackInitiatingContext {
572                context_id: "ctx_valid".to_string(),
573                cortex_context_trust_ref: "ref://ctx".to_string(),
574            },
575            axiom_action: FeedbackAxiomAction {
576                action_id: "action_valid".to_string(),
577                axiom_execution_trust_ref: "ref://exec".to_string(),
578            },
579            returned_artifacts: vec![FeedbackReturnedArtifact {
580                artifact_id: "art_valid".to_string(),
581                lineage_ref: "lin_valid".to_string(),
582                lifecycle_state: ArtifactLifecycleState::Candidate,
583                reproducibility_level: ReproducibilityLevel::Observational,
584            }],
585            amplification_risk: AmplificationRisk::Low,
586            independent_evidence_refs: vec!["evi://ind".to_string()],
587            external_grounding_refs: vec!["gnd://ext".to_string()],
588            contradiction_scan_ref: "scan_ref".to_string(),
589            quarantine_state: ContextQuarantineState::Clear,
590            confidence_ceiling: ConfidenceCeiling::Advisory,
591            same_loop_promotion_allowed: false,
592            authority_claims: FeedbackAuthorityClaims {
593                durable_truth_promotion: AuthorityClaimStatus::Denied,
594                full_execution_authority: AuthorityClaimStatus::Denied,
595                review_required: true,
596            },
597            target_domain_validation: TargetDomainValidation {
598                required: true,
599                independent_validation_ref: Some("validation_ref".to_string()),
600                result: TargetDomainValidationResult::Pass,
601            },
602            residual_risk: vec![],
603        }
604    }
605
606    fn fixed_now() -> DateTime<Utc> {
607        Utc.with_ymd_and_hms(2026, 5, 12, 0, 0, 0).unwrap()
608    }
609
610    fn valid_request() -> AxiomTrustExchangeAdmissionRequest {
611        let exec = parse_axiom_execution_trust(VALID_EXEC).unwrap();
612        let ctx = parse_cortex_context_trust(VALID_CTX).unwrap();
613        AxiomTrustExchangeAdmissionRequest::new(exec, AdmissionLifecycle::CandidateOnly)
614            .with_cortex_context_trust(ctx)
615            .with_authority_feedback_loop(valid_loop_record())
616            .with_now(fixed_now())
617    }
618
619    #[test]
620    fn valid_request_admits_candidate_with_forbidden_uses() {
621        let decision = valid_request().decide();
622        assert_eq!(decision.decision_name(), "admit_candidate");
623        let forbidden = decision
624            .forbidden_uses()
625            .expect("candidate has forbidden_uses");
626        assert!(forbidden.contains(&ForbiddenUse::DurablePromotion));
627        assert!(forbidden.contains(&ForbiddenUse::ReleaseAcceptance));
628        assert!(forbidden.contains(&ForbiddenUse::RuntimeAuthority));
629        assert!(forbidden.contains(&ForbiddenUse::CortexTruth));
630        assert!(forbidden.contains(&ForbiddenUse::TrustedHistory));
631        assert_eq!(
632            decision.policy_decision().final_outcome,
633            PolicyOutcome::Allow
634        );
635    }
636
637    #[test]
638    fn lifecycle_not_candidate_only_rejects() {
639        let exec = parse_axiom_execution_trust(VALID_EXEC).unwrap();
640        let req = AxiomTrustExchangeAdmissionRequest::new(exec, AdmissionLifecycle::Validated)
641            .with_now(fixed_now());
642        let decision = req.decide();
643        assert_eq!(decision.decision_name(), "reject");
644        assert!(decision
645            .invariants()
646            .contains(&"axiom.admission.lifecycle.must_be_candidate_only"));
647    }
648
649    #[test]
650    fn same_loop_promotion_true_rejects_structurally() {
651        let mut req = valid_request();
652        if let Some(loop_record) = req.authority_feedback_loop.as_mut() {
653            loop_record.same_loop_promotion_allowed = true;
654        }
655        let decision = req.decide();
656        assert_eq!(decision.decision_name(), "reject");
657        assert!(decision
658            .invariants()
659            .contains(&"authority_feedback_loop.same_loop_promotion_must_be_false"));
660    }
661
662    #[test]
663    fn over_authorized_durable_truth_rejects() {
664        let mut req = valid_request();
665        if let Some(loop_record) = req.authority_feedback_loop.as_mut() {
666            loop_record.authority_claims.durable_truth_promotion =
667                AuthorityClaimStatus::EligibleAfterIndependentValidation;
668        }
669        let decision = req.decide();
670        assert_eq!(decision.decision_name(), "reject");
671        assert!(decision
672            .invariants()
673            .contains(&"authority_feedback_loop.authority_claims.over_authorized"));
674    }
675
676    #[test]
677    fn over_authorized_full_execution_authority_rejects() {
678        let mut req = valid_request();
679        if let Some(loop_record) = req.authority_feedback_loop.as_mut() {
680            loop_record.authority_claims.full_execution_authority =
681                AuthorityClaimStatus::EligibleAfterIndependentValidation;
682        }
683        let decision = req.decide();
684        assert_eq!(decision.decision_name(), "reject");
685        assert!(decision
686            .invariants()
687            .contains(&"authority_feedback_loop.authority_claims.over_authorized"));
688    }
689
690    #[test]
691    fn quarantine_state_propagated_quarantines() {
692        let mut req = valid_request();
693        req.cortex_context_trust
694            .as_mut()
695            .expect("ctx present")
696            .quarantine_state = ContextQuarantineState::Quarantined;
697        let decision = req.decide();
698        assert_eq!(decision.decision_name(), "quarantine");
699        assert!(decision
700            .invariants()
701            .contains(&"axiom.admission.quarantine.propagated"));
702        let outputs = decision.named_quarantine_outputs().unwrap();
703        assert!(outputs.source_context.is_some());
704        // Quarantine path STILL carries forbidden uses.
705        assert!(decision
706            .forbidden_uses()
707            .unwrap()
708            .contains(&ForbiddenUse::DurablePromotion));
709    }
710
711    #[test]
712    fn derived_from_quarantined_quarantines() {
713        let req = valid_request().with_derived_from_quarantined(true);
714        let decision = req.decide();
715        assert_eq!(decision.decision_name(), "quarantine");
716        assert!(decision
717            .invariants()
718            .contains(&"axiom.admission.quarantine.derived_from_quarantined"));
719    }
720
721    #[test]
722    fn target_domain_validation_not_pass_quarantines() {
723        let mut req = valid_request();
724        if let Some(loop_record) = req.authority_feedback_loop.as_mut() {
725            loop_record.target_domain_validation.result = TargetDomainValidationResult::Fail;
726        }
727        let decision = req.decide();
728        assert_eq!(decision.decision_name(), "quarantine");
729        assert!(decision
730            .invariants()
731            .contains(&"authority_feedback_loop.target_domain_validation.not_pass"));
732    }
733
734    #[test]
735    fn expired_token_rejects() {
736        let mut req = valid_request();
737        req.axiom_execution_trust.token_scope.expires_at =
738            Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
739        let decision = req.decide();
740        assert_eq!(decision.decision_name(), "reject");
741        assert!(decision
742            .invariants()
743            .contains(&"axiom_execution_trust.token_scope.expired"));
744    }
745
746    #[test]
747    fn revoked_token_rejects() {
748        let mut req = valid_request();
749        req.axiom_execution_trust.token_scope.revocation_result = TokenRevocationResult::Revoked;
750        let decision = req.decide();
751        assert_eq!(decision.decision_name(), "reject");
752        assert!(decision
753            .invariants()
754            .contains(&"axiom_execution_trust.token_scope.revoked"));
755    }
756
757    #[test]
758    fn inactive_token_rejects() {
759        let mut req = valid_request();
760        req.axiom_execution_trust.token_scope.revocation_result = TokenRevocationResult::Inactive;
761        let decision = req.decide();
762        assert_eq!(decision.decision_name(), "reject");
763        assert!(decision
764            .invariants()
765            .contains(&"axiom_execution_trust.token_scope.inactive"));
766    }
767
768    #[test]
769    fn untrusted_repo_quarantines() {
770        let mut req = valid_request();
771        req.axiom_execution_trust.repo_trust.result = RepoTrustResult::Untrusted;
772        let decision = req.decide();
773        assert_eq!(decision.decision_name(), "quarantine");
774        assert!(decision
775            .invariants()
776            .contains(&"axiom_execution_trust.repo_trust.untrusted"));
777    }
778
779    #[test]
780    fn deny_policy_rejects() {
781        let mut req = valid_request();
782        req.axiom_execution_trust.policy_decision.result = ExecutionPolicyResult::Deny;
783        let decision = req.decide();
784        assert_eq!(decision.decision_name(), "reject");
785        assert!(decision
786            .invariants()
787            .contains(&"axiom_execution_trust.policy_decision.deny"));
788    }
789}