Skip to main content

cortex_memory/
admission.rs

1//! AXIOM-to-Cortex memory admission scaffolds.
2//!
3//! AXIOM runtime output is an evidence submission. It can enter Cortex only as
4//! a memory candidate and only with provenance, anchors, redaction, proof, and
5//! contradiction posture made explicit.
6
7use cortex_core::{
8    compose_policy_outcomes, evaluate_semantic_trust, BoundaryQuarantineState,
9    CapabilityTokenDecision, FailingEdge, PaiAxiomExecutionReceiptV1, PolicyContribution,
10    PolicyDecision, PolicyOutcome, ProofClosureReport, ProofEdge, ProofEdgeFailure, ProofEdgeKind,
11    ProofState as CoreProofState, ProvenanceClass, RuntimeIntegrityState, SemanticTrustInput,
12    SemanticTrustReport, SemanticUse,
13};
14use serde::{Deserialize, Serialize};
15
16/// Stable invariant key surfaced when the AXIOM admission durable gate
17/// refuses to permit durable promotion because the supplied
18/// [`ProofState`] is not [`ProofState::FullChainVerified`].
19///
20/// ADR 0036 forbids a durable AXIOM-origin candidate creation when the
21/// envelope's proof closure is `Partial`, `Broken`, or `Unknown`. The
22/// admission layer fails closed before any caller routes the envelope to
23/// the lifecycle layer, so all callers (CLI + future API + tests) inherit
24/// the gate.
25pub const AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT: &str =
26    "cortex_memory.admission.axiom.proof_closure";
27
28/// ADR 0026 rule id for the AXIOM admission proof-closure contributor.
29pub const AXIOM_ADMISSION_PROOF_CLOSURE_RULE_ID: &str = "memory.admission.axiom.proof_closure";
30
31/// Result type for admission validation helpers.
32pub type AdmissionValidationResult<T> = Result<T, Vec<AdmissionRejectionReason>>;
33
34/// Result type for parsing a generic AXIOM memory admission envelope.
35pub type AdmissionEnvelopeResult<T> = Result<T, AdmissionEnvelopeError>;
36
37/// Request to admit AXIOM output as a Cortex memory candidate.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct AxiomMemoryAdmissionRequest {
40    /// Admission is permitted only for candidate state.
41    pub candidate_state: CandidateState,
42    /// What kind of evidence AXIOM is submitting.
43    pub evidence_class: EvidenceClass,
44    /// AXIOM phase/context that produced the submitted content.
45    pub phase_context: PhaseContext,
46    /// Tool/runtime provenance for the submitted content.
47    pub tool_provenance: ToolProvenance,
48    /// Concrete source anchors backing the proposed memory.
49    pub source_anchors: Vec<SourceAnchor>,
50    /// Explicit redaction posture before durable memory admission.
51    pub redaction_status: RedactionStatus,
52    /// Current proof closure state for the candidate lineage.
53    pub proof_state: ProofState,
54    /// Contradiction scan result for the claim or belief slot.
55    pub contradiction_scan: ContradictionScan,
56    /// Must be true: admission never promotes to Active, Principle, or Doctrine.
57    pub explicit_non_promotion: bool,
58}
59
60impl AxiomMemoryAdmissionRequest {
61    /// Parse a generic ADR 0038 AXIOM memory admission envelope.
62    ///
63    /// This helper is intentionally parse-only: callers must still inspect
64    /// [`Self::admission_decision`] before any persistence. Malformed JSON,
65    /// missing required fields, or unsupported enum values never produce an
66    /// admission request.
67    pub fn from_json_envelope(input: &str) -> AdmissionEnvelopeResult<Self> {
68        serde_json::from_str(input).map_err(|err| AdmissionEnvelopeError::InvalidEnvelope {
69            message: err.to_string(),
70        })
71    }
72
73    /// Validate every admission invariant and collect all observed failures.
74    pub fn validate(&self) -> AdmissionValidationResult<()> {
75        let mut reasons = Vec::new();
76        push_err(&mut reasons, require_candidate_state(self.candidate_state));
77        push_err(
78            &mut reasons,
79            require_admissible_evidence(self.evidence_class),
80        );
81        push_err(
82            &mut reasons,
83            require_axiom_origin_is_not_product_spec(&self.tool_provenance),
84        );
85        push_err(&mut reasons, require_source_anchors(&self.source_anchors));
86        push_err(
87            &mut reasons,
88            require_redaction_status(self.redaction_status),
89        );
90        push_err(&mut reasons, require_usable_proof_state(self.proof_state));
91        push_err(
92            &mut reasons,
93            require_contradiction_scan(&self.contradiction_scan),
94        );
95        push_err(
96            &mut reasons,
97            require_explicit_non_promotion(self.explicit_non_promotion),
98        );
99        push_err(&mut reasons, require_phase_context(self.phase_context));
100
101        if reasons.is_empty() {
102            Ok(())
103        } else {
104            Err(reasons)
105        }
106    }
107
108    /// Produce the deterministic admission decision for this request.
109    #[must_use]
110    pub fn admission_decision(&self) -> AdmissionDecision {
111        match self.validate() {
112            Ok(()) => AdmissionDecision::AdmitCandidate,
113            Err(reasons)
114                if reasons
115                    .iter()
116                    .any(AdmissionRejectionReason::requires_rejection) =>
117            {
118                AdmissionDecision::Reject { reasons }
119            }
120            Err(reasons) => AdmissionDecision::Quarantine { reasons },
121        }
122    }
123
124    /// Produce the ADR 0026 policy decision corresponding to this admission
125    /// decision.
126    #[must_use]
127    pub fn policy_decision(&self) -> PolicyDecision {
128        match self.admission_decision() {
129            AdmissionDecision::AdmitCandidate => compose_policy_outcomes(
130                vec![PolicyContribution::new(
131                    "memory.admission.allow",
132                    PolicyOutcome::Allow,
133                    "AXIOM memory submission passed admission gates",
134                )
135                .expect("static policy contribution is valid")],
136                None,
137            ),
138            AdmissionDecision::Reject { reasons } | AdmissionDecision::Quarantine { reasons } => {
139                let contributions = reasons
140                    .into_iter()
141                    .map(|reason| {
142                        PolicyContribution::new(
143                            reason.policy_rule_id(),
144                            reason.policy_outcome(),
145                            reason.policy_reason(),
146                        )
147                        .expect("static policy contribution is valid")
148                    })
149                    .collect();
150                compose_policy_outcomes(contributions, None)
151            }
152        }
153    }
154
155    /// Map the envelope's declared [`ProofState`] to a typed core
156    /// [`ProofClosureReport`].
157    ///
158    /// ADR 0036 carries the proof-closure axis as a typed report rather
159    /// than a free-form enum. The admission envelope reports a
160    /// caller-declared [`ProofState`]; this helper lifts it into the
161    /// typed report so downstream durable-write surfaces (lifecycle,
162    /// reflect) compose the same shape they would compose for a
163    /// store-derived report.
164    ///
165    /// The mapping is:
166    /// - [`ProofState::FullChainVerified`] -> empty failing edges,
167    ///   [`cortex_core::ProofState::FullChainVerified`]
168    /// - [`ProofState::Partial`] -> one missing-edge failure (lineage
169    ///   axis), maps to [`cortex_core::ProofState::Partial`]
170    /// - [`ProofState::Broken`] -> one broken hash-chain edge, maps to
171    ///   [`cortex_core::ProofState::Broken`]
172    /// - [`ProofState::Unknown`] -> one unresolved lineage edge, maps to
173    ///   [`cortex_core::ProofState::Partial`] (Unknown is not a clean
174    ///   `FullChainVerified`; the durable gate refuses it the same way
175    ///   it refuses Partial)
176    #[must_use]
177    pub fn proof_closure_report(&self) -> ProofClosureReport {
178        let axis_anchor = "axiom.admission";
179        let axis_target = "proof_closure";
180        match self.proof_state {
181            ProofState::FullChainVerified => {
182                ProofClosureReport::full_chain_verified(vec![ProofEdge::new(
183                    ProofEdgeKind::LineageClosure,
184                    axis_anchor,
185                    axis_target,
186                )
187                .with_evidence_ref("envelope.proof_state=full_chain_verified")])
188            }
189            ProofState::Partial => ProofClosureReport::from_edges(
190                Vec::new(),
191                vec![FailingEdge::missing(
192                    ProofEdgeKind::LineageClosure,
193                    axis_anchor,
194                    "envelope proof_state is Partial; ADR 0036 forbids durable promotion",
195                )],
196            ),
197            ProofState::Broken => ProofClosureReport::from_edges(
198                Vec::new(),
199                vec![FailingEdge::broken(
200                    ProofEdgeKind::HashChain,
201                    axis_anchor,
202                    axis_target,
203                    ProofEdgeFailure::Mismatch,
204                    "envelope proof_state is Broken; ADR 0036 fails closed",
205                )],
206            ),
207            ProofState::Unknown => ProofClosureReport::from_edges(
208                Vec::new(),
209                vec![FailingEdge::unresolved(
210                    ProofEdgeKind::LineageClosure,
211                    axis_anchor,
212                    "envelope proof_state is Unknown; ADR 0036 forbids durable promotion",
213                )],
214            ),
215        }
216    }
217
218    /// ADR 0036 contributor for AXIOM admission proof closure.
219    ///
220    /// The contributor mirrors [`ProofClosureReport::policy_decision`]:
221    /// - [`CoreProofState::FullChainVerified`] -> [`PolicyOutcome::Allow`]
222    /// - [`CoreProofState::Partial`] -> [`PolicyOutcome::Quarantine`]
223    /// - [`CoreProofState::Broken`] -> [`PolicyOutcome::Reject`]
224    #[must_use]
225    pub fn proof_closure_contribution(&self) -> PolicyContribution {
226        let report = self.proof_closure_report();
227        let (outcome, reason): (PolicyOutcome, &'static str) = match report.state() {
228            CoreProofState::FullChainVerified => (
229                PolicyOutcome::Allow,
230                "AXIOM admission envelope proof closure is fully verified",
231            ),
232            CoreProofState::Partial => (
233                PolicyOutcome::Quarantine,
234                "AXIOM admission envelope proof closure is partial; ADR 0036 forbids durable promotion",
235            ),
236            CoreProofState::Broken => (
237                PolicyOutcome::Reject,
238                "AXIOM admission envelope proof closure is broken; ADR 0036 fails closed",
239            ),
240        };
241        PolicyContribution::new(AXIOM_ADMISSION_PROOF_CLOSURE_RULE_ID, outcome, reason)
242            .expect("static admission proof closure contribution is valid")
243    }
244
245    /// Refuse durable promotion if the envelope's proof closure is not
246    /// [`CoreProofState::FullChainVerified`].
247    ///
248    /// ADR 0036 forbids a durable candidate -> active mutation for
249    /// AXIOM-origin envelopes when the proof closure is `Partial`,
250    /// `Broken`, or `Unknown`. The clean admission decision
251    /// ([`AdmissionDecision::AdmitCandidate`]) is necessary but not
252    /// sufficient: the durable-write boundary is where ADR 0036 fires.
253    ///
254    /// Callers should invoke this immediately before routing an admitted
255    /// envelope to a durable-write surface (e.g. the lifecycle layer).
256    /// The refusal carries the stable invariant
257    /// [`AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT`] and the observed
258    /// [`CoreProofState`].
259    pub fn require_durable_admission_allowed(&self) -> Result<(), DurableAdmissionRefusal> {
260        let report = self.proof_closure_report();
261        if report.is_full_chain_verified() {
262            Ok(())
263        } else {
264            Err(DurableAdmissionRefusal {
265                proof_state: report.state(),
266            })
267        }
268    }
269
270    /// Evaluate ADR 0039 semantic trust for the admission request without
271    /// changing candidate-only admission or explicit non-promotion gates.
272    #[must_use]
273    pub fn semantic_trust_report(
274        &self,
275        input: AdmissionSemanticTrustInput,
276    ) -> AdmissionSemanticTrustReport {
277        let provenance_class = self.semantic_provenance_class();
278        let unresolved_unknowns =
279            input.unresolved_semantic_unknowns || self.has_semantic_unknowns();
280        let semantic_input = SemanticTrustInput::new(input.intended_use)
281            .with_provenance([provenance_class])
282            .with_independent_source_families(input.independent_source_families)
283            .with_falsification_evidence(input.falsification_evidence)
284            .with_unresolved_unknowns(unresolved_unknowns);
285
286        AdmissionSemanticTrustReport {
287            intended_use: input.intended_use,
288            provenance_class,
289            semantic_trust: evaluate_semantic_trust(&semantic_input),
290            admission_decision: self.admission_decision(),
291            explicit_non_promotion: self.explicit_non_promotion,
292        }
293    }
294
295    /// Map this AXIOM admission request to the closest ADR 0039 provenance
296    /// class using only fields already present at the memory-admission boundary.
297    #[must_use]
298    pub fn semantic_provenance_class(&self) -> ProvenanceClass {
299        match self.evidence_class {
300            EvidenceClass::Unknown => ProvenanceClass::UnknownProvenance,
301            EvidenceClass::Simulated => ProvenanceClass::SimulatedOrHypothetical,
302            EvidenceClass::Claimed => ProvenanceClass::ExternalClaimed,
303            EvidenceClass::Inferred => ProvenanceClass::SummaryDerived,
304            EvidenceClass::Observed => self.observed_semantic_provenance_class(),
305        }
306    }
307
308    fn observed_semantic_provenance_class(&self) -> ProvenanceClass {
309        match (
310            self.phase_context,
311            self.tool_provenance.import_class,
312            self.tool_provenance.tool_name.is_empty(),
313            self.tool_provenance.invocation_id.is_empty(),
314        ) {
315            (PhaseContext::WorkRecord, AxiomImportClass::AgentProcedure, _, _) => {
316                ProvenanceClass::RuntimeDerived
317            }
318            // Import classification is not actor attestation. Operator-protocol
319            // scaffolding can be observed as a tool/input artifact, but it must
320            // not manufacture an operator-attested semantic provenance class.
321            (_, AxiomImportClass::OperatorProtocol, false, false) => ProvenanceClass::ToolObserved,
322            (_, _, false, false) => ProvenanceClass::ToolObserved,
323            _ => ProvenanceClass::RuntimeDerived,
324        }
325    }
326
327    fn has_semantic_unknowns(&self) -> bool {
328        matches!(self.evidence_class, EvidenceClass::Unknown)
329            || matches!(self.phase_context, PhaseContext::Unknown)
330            || matches!(self.proof_state, ProofState::Unknown | ProofState::Broken)
331            || matches!(
332                self.contradiction_scan,
333                ContradictionScan::Incomplete | ContradictionScan::NotScanned
334            )
335            || self.source_anchors.is_empty()
336            || self
337                .source_anchors
338                .iter()
339                .any(|anchor| anchor.reference.trim().is_empty())
340    }
341}
342
343/// Caller-supplied semantic trust context that is not present in the ADR 0038
344/// admission envelope.
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub struct AdmissionSemanticTrustInput {
347    /// Intended authority surface for this semantic evaluation.
348    pub intended_use: SemanticUse,
349    /// Independent source-family count computed by the caller.
350    pub independent_source_families: u16,
351    /// Whether falsification/counterexample evidence is attached.
352    pub falsification_evidence: bool,
353    /// Whether semantic unknowns remain outside the structural admission fields.
354    pub unresolved_semantic_unknowns: bool,
355}
356
357impl AdmissionSemanticTrustInput {
358    /// Construct input for one intended use.
359    #[must_use]
360    pub const fn new(intended_use: SemanticUse) -> Self {
361        Self {
362            intended_use,
363            independent_source_families: 0,
364            falsification_evidence: false,
365            unresolved_semantic_unknowns: true,
366        }
367    }
368
369    /// Attach caller-computed independent source-family count.
370    #[must_use]
371    pub const fn with_independent_source_families(mut self, count: u16) -> Self {
372        self.independent_source_families = count;
373        self
374    }
375
376    /// Attach falsification evidence state.
377    #[must_use]
378    pub const fn with_falsification_evidence(mut self, present: bool) -> Self {
379        self.falsification_evidence = present;
380        self
381    }
382
383    /// Attach unresolved semantic unknown state.
384    #[must_use]
385    pub const fn with_unresolved_semantic_unknowns(mut self, present: bool) -> Self {
386        self.unresolved_semantic_unknowns = present;
387        self
388    }
389}
390
391/// Admission-side semantic trust report.
392///
393/// `admission_decision` and `explicit_non_promotion` remain separate from
394/// `semantic_trust` so semantic allowance cannot promote or activate memory.
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct AdmissionSemanticTrustReport {
397    /// Intended authority surface evaluated.
398    pub intended_use: SemanticUse,
399    /// Provenance class mapped from the admission request.
400    pub provenance_class: ProvenanceClass,
401    /// Cortex semantic trust report computed from existing core types.
402    pub semantic_trust: SemanticTrustReport,
403    /// Candidate-only admission decision from ADR 0038 validation.
404    pub admission_decision: AdmissionDecision,
405    /// Admission non-promotion flag, reported separately from semantic trust.
406    pub explicit_non_promotion: bool,
407}
408
409/// Failure while parsing a generic AXIOM memory admission envelope.
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub enum AdmissionEnvelopeError {
412    /// JSON was malformed, required fields were missing, or a field value was unsupported.
413    InvalidEnvelope {
414        /// Stable human-readable parse/schema error.
415        message: String,
416    },
417}
418
419/// Refusal payload returned by
420/// [`AxiomMemoryAdmissionRequest::require_durable_admission_allowed`].
421///
422/// Carries the observed [`CoreProofState`] (the typed cross-axis state from
423/// [`ProofClosureReport::state`]) and surfaces the stable invariant
424/// [`AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT`] in its `Display` impl so a
425/// log line carrying this error can be grepped for the invariant key.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct DurableAdmissionRefusal {
428    /// Observed cross-axis proof state at the durable-write boundary.
429    pub proof_state: CoreProofState,
430}
431
432impl std::fmt::Display for DurableAdmissionRefusal {
433    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434        let proof_state = self.proof_state;
435        write!(
436            f,
437            "invariant={AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT} AXIOM admission durable gate refused: proof closure must be FullChainVerified; observed {proof_state:?}"
438        )
439    }
440}
441
442impl std::error::Error for DurableAdmissionRefusal {}
443
444impl From<PaiAxiomExecutionReceiptV1> for AxiomMemoryAdmissionRequest {
445    fn from(receipt: PaiAxiomExecutionReceiptV1) -> Self {
446        let first_tool = receipt.tool_provenance.first();
447        let tool_name = first_tool
448            .map(|tool| tool.tool_name.clone())
449            .unwrap_or_else(|| "pai-axiom".to_string());
450        let invocation_id = first_tool
451            .map(|tool| tool.invocation_id.clone())
452            .unwrap_or_else(|| receipt.runtime_id.clone());
453        let evidence_class = receipt_evidence_class(&receipt);
454        let proof_state = receipt_proof_state(&receipt);
455        let explicit_non_promotion = receipt.explicit_non_promotion
456            && receipt.quarantine_state != BoundaryQuarantineState::Contaminated;
457        let source_anchors = receipt
458            .source_anchors
459            .into_iter()
460            .map(|anchor| SourceAnchor::new(anchor.reference, SourceAnchorKind::Artifact))
461            .collect();
462
463        Self {
464            candidate_state: CandidateState::Candidate,
465            evidence_class,
466            phase_context: PhaseContext::WorkRecord,
467            tool_provenance: ToolProvenance::new(
468                tool_name,
469                invocation_id,
470                AxiomImportClass::AgentProcedure,
471            ),
472            source_anchors,
473            redaction_status: RedactionStatus::Abstracted,
474            proof_state,
475            contradiction_scan: ContradictionScan::Incomplete,
476            explicit_non_promotion,
477        }
478    }
479}
480
481fn receipt_evidence_class(receipt: &PaiAxiomExecutionReceiptV1) -> EvidenceClass {
482    if receipt.tool_provenance.is_empty()
483        || matches!(
484            receipt.capability_token_state.decision,
485            CapabilityTokenDecision::Rejected
486                | CapabilityTokenDecision::Expired
487                | CapabilityTokenDecision::Revoked
488        )
489    {
490        EvidenceClass::Claimed
491    } else {
492        EvidenceClass::Observed
493    }
494}
495
496fn receipt_proof_state(receipt: &PaiAxiomExecutionReceiptV1) -> ProofState {
497    if matches!(
498        receipt.quarantine_state,
499        BoundaryQuarantineState::Contaminated
500    ) || matches!(
501        receipt.capability_token_state.decision,
502        CapabilityTokenDecision::Rejected
503            | CapabilityTokenDecision::Expired
504            | CapabilityTokenDecision::Revoked
505    ) || matches!(
506        receipt.execution_trust_state.runtime_integrity,
507        RuntimeIntegrityState::Compromised
508    ) {
509        ProofState::Broken
510    } else if matches!(
511        receipt.execution_trust_state.runtime_integrity,
512        RuntimeIntegrityState::VerifiedRelease | RuntimeIntegrityState::VerifiedProvenance
513    ) && matches!(
514        receipt.capability_token_state.decision,
515        CapabilityTokenDecision::Allowed | CapabilityTokenDecision::Warned
516    ) {
517        ProofState::Partial
518    } else {
519        ProofState::Unknown
520    }
521}
522
523/// Candidate lifecycle state declared by the caller.
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
525#[serde(rename_all = "snake_case")]
526pub enum CandidateState {
527    /// Proposed memory only; normal accept gate is still required.
528    Candidate,
529    /// Already active memory. AXIOM admission must reject this.
530    Active,
531    /// Principle candidate or principle state. AXIOM admission must reject this.
532    Principle,
533    /// Doctrine or promotion state. AXIOM admission must reject this.
534    Doctrine,
535    /// Unknown or omitted state.
536    Unknown,
537}
538
539/// Evidence class carried by the AXIOM submission.
540#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "snake_case")]
542pub enum EvidenceClass {
543    /// Directly observed evidence.
544    Observed,
545    /// Derived from named premises.
546    Inferred,
547    /// Claimed by a source or runtime, not independently verified.
548    Claimed,
549    /// Hypothetical, dry-run, or modelled output.
550    Simulated,
551    /// Missing or unclassified evidence class.
552    Unknown,
553}
554
555/// Runtime phase/context that produced the admission request.
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(rename_all = "snake_case")]
558pub enum PhaseContext {
559    /// AXIOM MODEL phase output.
560    Model,
561    /// AXIOM ACT phase output.
562    Act,
563    /// AXIOM CHECK phase output.
564    Check,
565    /// Imported work record or handoff.
566    WorkRecord,
567    /// Missing or unclassified phase/context.
568    Unknown,
569}
570
571/// ADR 0034 import classes for any imported `pai-axiom` scaffold or convention.
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
573#[serde(rename_all = "snake_case")]
574pub enum AxiomImportClass {
575    /// Operator-facing protocol.
576    OperatorProtocol,
577    /// Agent procedure, not Cortex product semantics.
578    AgentProcedure,
579    /// Product specification. Admission rejects this without a Cortex authority path.
580    ProductSpecification,
581    /// Test or eval input.
582    TestEvalInput,
583    /// Historical reference only.
584    HistoricalReference,
585}
586
587/// Tool/runtime provenance carried with the admission request.
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
589pub struct ToolProvenance {
590    /// Runtime or tool name that emitted the submission.
591    pub tool_name: String,
592    /// Invocation/run identifier supplied by the caller.
593    pub invocation_id: String,
594    /// Import class required by ADR 0034.
595    pub import_class: AxiomImportClass,
596}
597
598impl ToolProvenance {
599    /// Construct provenance with the required ADR 0034 classification.
600    #[must_use]
601    pub fn new(
602        tool_name: impl Into<String>,
603        invocation_id: impl Into<String>,
604        import_class: AxiomImportClass,
605    ) -> Self {
606        Self {
607            tool_name: tool_name.into(),
608            invocation_id: invocation_id.into(),
609            import_class,
610        }
611    }
612}
613
614/// Source anchor backing a proposed memory candidate.
615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616pub struct SourceAnchor {
617    /// Stable source reference such as event id, trace id, audit id, or artifact hash.
618    pub reference: String,
619    /// Source kind for deterministic validation/explanation.
620    pub kind: SourceAnchorKind,
621}
622
623impl SourceAnchor {
624    /// Construct a source anchor.
625    #[must_use]
626    pub fn new(reference: impl Into<String>, kind: SourceAnchorKind) -> Self {
627        Self {
628            reference: reference.into(),
629            kind,
630        }
631    }
632}
633
634/// Allowed source anchor categories.
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
636#[serde(rename_all = "snake_case")]
637pub enum SourceAnchorKind {
638    /// Immutable Cortex event.
639    Event,
640    /// Trace id or trace-level source.
641    Trace,
642    /// Episode id or episode-level source.
643    Episode,
644    /// Audit record or proof record.
645    Audit,
646    /// External artifact hash/reference.
647    Artifact,
648}
649
650/// Redaction posture of the submitted content.
651#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
652#[serde(rename_all = "snake_case")]
653pub enum RedactionStatus {
654    /// Content has been redacted for normal memory handling.
655    Redacted,
656    /// Content has been abstracted to references/summaries.
657    Abstracted,
658    /// Raw content is present under explicit operator mode.
659    OperatorRawOptIn,
660    /// Raw content without an explicit operator opt-in.
661    RawUnredacted,
662    /// Missing redaction status.
663    Unknown,
664}
665
666/// Proof closure state for the submitted candidate.
667#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
668#[serde(rename_all = "snake_case")]
669pub enum ProofState {
670    /// Full chain is verified.
671    FullChainVerified,
672    /// Chain is incomplete but named.
673    Partial,
674    /// Chain is broken.
675    Broken,
676    /// Proof state was not supplied.
677    Unknown,
678}
679
680/// Contradiction scan result for the proposed claim or belief slot.
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(rename_all = "snake_case")]
683pub enum ContradictionScan {
684    /// Scan completed and found no open contradiction.
685    ScannedClean,
686    /// Scan completed and found open contradiction references.
687    OpenContradictions(Vec<String>),
688    /// Scan completed but the result is incomplete.
689    Incomplete,
690    /// No scan was performed.
691    NotScanned,
692}
693
694/// Deterministic admission decision.
695#[derive(Debug, Clone, PartialEq, Eq)]
696pub enum AdmissionDecision {
697    /// Request may be persisted as a candidate only.
698    AdmitCandidate,
699    /// Request is unsafe to admit.
700    Reject {
701        /// Rejection reasons collected during validation.
702        reasons: Vec<AdmissionRejectionReason>,
703    },
704    /// Request is not admissible, but should be retained for review.
705    Quarantine {
706        /// Quarantine reasons collected during validation.
707        reasons: Vec<AdmissionRejectionReason>,
708    },
709}
710
711/// Reasons an AXIOM admission request cannot be admitted as a clean candidate.
712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
713pub enum AdmissionRejectionReason {
714    /// Admission target is not Candidate.
715    CandidateStateRequired,
716    /// Evidence class was missing or unknown.
717    EvidenceClassRequired,
718    /// Tool name or invocation id was missing.
719    ToolProvenanceRequired,
720    /// Imported AXIOM material tried to act as Cortex product specification.
721    ProductSpecificationImportRejected,
722    /// At least one source anchor is required.
723    SourceAnchorRequired,
724    /// A source anchor reference was blank.
725    SourceAnchorBlank,
726    /// Redaction status was missing or raw without operator opt-in.
727    RedactionStatusRequired,
728    /// Proof state was missing or broken.
729    ProofStateRequired,
730    /// Contradiction scan was not performed.
731    ContradictionScanRequired,
732    /// Open contradiction blocks clean admission.
733    OpenContradiction,
734    /// Admission request did not explicitly deny promotion.
735    ExplicitNonPromotionRequired,
736    /// AXIOM phase/context was missing.
737    PhaseContextRequired,
738}
739
740impl AdmissionRejectionReason {
741    const fn requires_rejection(&self) -> bool {
742        matches!(
743            self,
744            Self::CandidateStateRequired
745                | Self::ProductSpecificationImportRejected
746                | Self::RedactionStatusRequired
747                | Self::ProofStateRequired
748                | Self::ExplicitNonPromotionRequired
749        )
750    }
751
752    const fn policy_outcome(self) -> PolicyOutcome {
753        if self.requires_rejection() {
754            PolicyOutcome::Reject
755        } else {
756            PolicyOutcome::Quarantine
757        }
758    }
759
760    const fn policy_rule_id(self) -> &'static str {
761        match self {
762            Self::CandidateStateRequired => "memory.admission.candidate_state_required",
763            Self::EvidenceClassRequired => "memory.admission.evidence_class_required",
764            Self::ToolProvenanceRequired => "memory.admission.tool_provenance_required",
765            Self::ProductSpecificationImportRejected => {
766                "memory.admission.product_specification_import_rejected"
767            }
768            Self::SourceAnchorRequired => "memory.admission.source_anchor_required",
769            Self::SourceAnchorBlank => "memory.admission.source_anchor_blank",
770            Self::RedactionStatusRequired => "memory.admission.redaction_status_required",
771            Self::ProofStateRequired => "memory.admission.proof_state_required",
772            Self::ContradictionScanRequired => "memory.admission.contradiction_scan_required",
773            Self::OpenContradiction => "memory.admission.open_contradiction",
774            Self::ExplicitNonPromotionRequired => {
775                "memory.admission.explicit_non_promotion_required"
776            }
777            Self::PhaseContextRequired => "memory.admission.phase_context_required",
778        }
779    }
780
781    const fn policy_reason(self) -> &'static str {
782        match self {
783            Self::CandidateStateRequired => "AXIOM admission target must remain Candidate",
784            Self::EvidenceClassRequired => "AXIOM admission requires classified evidence",
785            Self::ToolProvenanceRequired => "AXIOM admission requires tool provenance",
786            Self::ProductSpecificationImportRejected => {
787                "AXIOM import cannot become Cortex product specification through memory admission"
788            }
789            Self::SourceAnchorRequired => "AXIOM admission requires at least one source anchor",
790            Self::SourceAnchorBlank => "AXIOM admission source anchors must not be blank",
791            Self::RedactionStatusRequired => {
792                "AXIOM admission requires redacted, abstracted, or operator-opted raw content"
793            }
794            Self::ProofStateRequired => "AXIOM admission requires supplied non-broken proof state",
795            Self::ContradictionScanRequired => {
796                "AXIOM admission requires completed contradiction scan"
797            }
798            Self::OpenContradiction => {
799                "AXIOM admission found open contradiction and must not enter clean candidate path"
800            }
801            Self::ExplicitNonPromotionRequired => "AXIOM admission requires explicit non-promotion",
802            Self::PhaseContextRequired => "AXIOM admission requires known AXIOM phase context",
803        }
804    }
805}
806
807/// Require that the target remains a memory candidate.
808pub fn require_candidate_state(state: CandidateState) -> Result<(), AdmissionRejectionReason> {
809    if state == CandidateState::Candidate {
810        Ok(())
811    } else {
812        Err(AdmissionRejectionReason::CandidateStateRequired)
813    }
814}
815
816/// Require a classified evidence submission.
817pub fn require_admissible_evidence(
818    evidence_class: EvidenceClass,
819) -> Result<(), AdmissionRejectionReason> {
820    if evidence_class == EvidenceClass::Unknown {
821        Err(AdmissionRejectionReason::EvidenceClassRequired)
822    } else {
823        Ok(())
824    }
825}
826
827/// Require tool provenance and reject product-specification imports at this gate.
828pub fn require_axiom_origin_is_not_product_spec(
829    provenance: &ToolProvenance,
830) -> Result<(), AdmissionRejectionReason> {
831    if provenance.tool_name.trim().is_empty() || provenance.invocation_id.trim().is_empty() {
832        return Err(AdmissionRejectionReason::ToolProvenanceRequired);
833    }
834
835    if provenance.import_class == AxiomImportClass::ProductSpecification {
836        return Err(AdmissionRejectionReason::ProductSpecificationImportRejected);
837    }
838
839    Ok(())
840}
841
842/// Require one or more non-blank source anchors.
843pub fn require_source_anchors(anchors: &[SourceAnchor]) -> Result<(), AdmissionRejectionReason> {
844    if anchors.is_empty() {
845        return Err(AdmissionRejectionReason::SourceAnchorRequired);
846    }
847
848    if anchors
849        .iter()
850        .any(|anchor| anchor.reference.trim().is_empty())
851    {
852        return Err(AdmissionRejectionReason::SourceAnchorBlank);
853    }
854
855    Ok(())
856}
857
858/// Require an explicit non-leaking redaction posture.
859pub fn require_redaction_status(
860    redaction_status: RedactionStatus,
861) -> Result<(), AdmissionRejectionReason> {
862    match redaction_status {
863        RedactionStatus::Redacted
864        | RedactionStatus::Abstracted
865        | RedactionStatus::OperatorRawOptIn => Ok(()),
866        RedactionStatus::RawUnredacted | RedactionStatus::Unknown => {
867            Err(AdmissionRejectionReason::RedactionStatusRequired)
868        }
869    }
870}
871
872/// Require proof state that is not missing or broken.
873pub fn require_usable_proof_state(proof_state: ProofState) -> Result<(), AdmissionRejectionReason> {
874    match proof_state {
875        ProofState::FullChainVerified | ProofState::Partial => Ok(()),
876        ProofState::Broken | ProofState::Unknown => {
877            Err(AdmissionRejectionReason::ProofStateRequired)
878        }
879    }
880}
881
882/// Require a contradiction scan before clean admission.
883pub fn require_contradiction_scan(
884    contradiction_scan: &ContradictionScan,
885) -> Result<(), AdmissionRejectionReason> {
886    match contradiction_scan {
887        ContradictionScan::ScannedClean => Ok(()),
888        ContradictionScan::OpenContradictions(_) => {
889            Err(AdmissionRejectionReason::OpenContradiction)
890        }
891        ContradictionScan::Incomplete | ContradictionScan::NotScanned => {
892            Err(AdmissionRejectionReason::ContradictionScanRequired)
893        }
894    }
895}
896
897/// Require explicit statement that admission is not promotion.
898pub fn require_explicit_non_promotion(
899    explicit_non_promotion: bool,
900) -> Result<(), AdmissionRejectionReason> {
901    if explicit_non_promotion {
902        Ok(())
903    } else {
904        Err(AdmissionRejectionReason::ExplicitNonPromotionRequired)
905    }
906}
907
908/// Require a known AXIOM phase or work-record context.
909pub fn require_phase_context(phase_context: PhaseContext) -> Result<(), AdmissionRejectionReason> {
910    if phase_context == PhaseContext::Unknown {
911        Err(AdmissionRejectionReason::PhaseContextRequired)
912    } else {
913        Ok(())
914    }
915}
916
917fn push_err(
918    reasons: &mut Vec<AdmissionRejectionReason>,
919    result: Result<(), AdmissionRejectionReason>,
920) {
921    if let Err(reason) = result {
922        reasons.push(reason);
923    }
924}
925
926#[cfg(test)]
927mod tests {
928    use cortex_core::{
929        BoundaryQuarantineState, BoundarySourceAnchor, BoundaryToolInvocation, BoundaryToolOutcome,
930        CapabilityTokenDecision, CapabilityTokenState, ClaimCeiling, ExecutionTrustState,
931        OperatorApprovalState, PaiAxiomExecutionReceiptV1, ProvenanceClass, RuntimeIntegrityState,
932        SemanticTrustClass, SemanticUse,
933    };
934
935    use super::*;
936
937    fn token_state(decision: CapabilityTokenDecision) -> CapabilityTokenState {
938        CapabilityTokenState {
939            decision,
940            valid_structure: true,
941            audience_bound: true,
942            scope_bound: true,
943            operation_bound: true,
944            not_expired: true,
945            not_revoked: true,
946            policy_allowed: true,
947            attestation_linked: true,
948        }
949    }
950
951    fn receipt(decision: CapabilityTokenDecision) -> PaiAxiomExecutionReceiptV1 {
952        let mut receipt = PaiAxiomExecutionReceiptV1::new(
953            "axiom-runtime:v3.1/run_01",
954            token_state(decision),
955            ExecutionTrustState {
956                runtime_integrity: RuntimeIntegrityState::VerifiedRelease,
957                evidence_ref: Some("release:verified".to_string()),
958            },
959            OperatorApprovalState::ApprovedBound,
960        );
961        receipt.tool_provenance.push(BoundaryToolInvocation {
962            tool_name: "cargo".to_string(),
963            invocation_id: "tool_01".to_string(),
964            input_ref: Some("cmd:cargo test".to_string()),
965            output_ref: Some("log:passed".to_string()),
966            outcome: BoundaryToolOutcome::Succeeded,
967        });
968        receipt.source_anchors.push(BoundarySourceAnchor {
969            reference: "artifact:log:passed".to_string(),
970            kind: "artifact".to_string(),
971        });
972        receipt
973    }
974
975    fn valid_request() -> AxiomMemoryAdmissionRequest {
976        AxiomMemoryAdmissionRequest {
977            candidate_state: CandidateState::Candidate,
978            evidence_class: EvidenceClass::Observed,
979            phase_context: PhaseContext::Check,
980            tool_provenance: ToolProvenance::new(
981                "axiom-runtime",
982                "run_01",
983                AxiomImportClass::AgentProcedure,
984            ),
985            source_anchors: vec![SourceAnchor::new(
986                "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV",
987                SourceAnchorKind::Event,
988            )],
989            redaction_status: RedactionStatus::Abstracted,
990            proof_state: ProofState::Partial,
991            contradiction_scan: ContradictionScan::ScannedClean,
992            explicit_non_promotion: true,
993        }
994    }
995
996    #[test]
997    fn axiom_submission_requires_candidate_state_and_tool_provenance() {
998        let mut request = valid_request();
999        request.candidate_state = CandidateState::Active;
1000        request.tool_provenance.invocation_id = " ".into();
1001
1002        let reasons = request
1003            .validate()
1004            .expect_err("request must fail validation");
1005
1006        assert!(reasons.contains(&AdmissionRejectionReason::CandidateStateRequired));
1007        assert!(reasons.contains(&AdmissionRejectionReason::ToolProvenanceRequired));
1008    }
1009
1010    #[test]
1011    fn attempted_direct_active_admission_rejects() {
1012        let mut request = valid_request();
1013        request.candidate_state = CandidateState::Active;
1014
1015        assert_eq!(
1016            request.admission_decision(),
1017            AdmissionDecision::Reject {
1018                reasons: vec![AdmissionRejectionReason::CandidateStateRequired],
1019            }
1020        );
1021    }
1022
1023    #[test]
1024    fn missing_tool_provenance_is_quarantined() {
1025        let mut request = valid_request();
1026        request.tool_provenance.tool_name = " ".into();
1027        request.tool_provenance.invocation_id.clear();
1028
1029        assert_eq!(
1030            request.admission_decision(),
1031            AdmissionDecision::Quarantine {
1032                reasons: vec![AdmissionRejectionReason::ToolProvenanceRequired],
1033            }
1034        );
1035    }
1036
1037    #[test]
1038    fn narrative_without_source_anchors_is_quarantined() {
1039        let mut request = valid_request();
1040        request.source_anchors.clear();
1041
1042        assert_eq!(
1043            request.admission_decision(),
1044            AdmissionDecision::Quarantine {
1045                reasons: vec![AdmissionRejectionReason::SourceAnchorRequired],
1046            }
1047        );
1048    }
1049
1050    #[test]
1051    fn admitted_axiom_derived_memory_remains_candidate_only() {
1052        let request = valid_request();
1053
1054        assert_eq!(
1055            request.admission_decision(),
1056            AdmissionDecision::AdmitCandidate
1057        );
1058        assert_eq!(
1059            request.policy_decision().final_outcome,
1060            PolicyOutcome::Allow
1061        );
1062        assert_eq!(request.candidate_state, CandidateState::Candidate);
1063        assert!(request.explicit_non_promotion);
1064    }
1065
1066    #[test]
1067    fn missing_explicit_non_promotion_rejects() {
1068        let mut request = valid_request();
1069        request.explicit_non_promotion = false;
1070
1071        assert_eq!(
1072            request.admission_decision(),
1073            AdmissionDecision::Reject {
1074                reasons: vec![AdmissionRejectionReason::ExplicitNonPromotionRequired],
1075            }
1076        );
1077    }
1078
1079    #[test]
1080    fn product_spec_import_and_raw_unredacted_content_reject() {
1081        let mut request = valid_request();
1082        request.tool_provenance.import_class = AxiomImportClass::ProductSpecification;
1083        request.redaction_status = RedactionStatus::RawUnredacted;
1084
1085        let decision = request.admission_decision();
1086
1087        match decision {
1088            AdmissionDecision::Reject { reasons } => {
1089                assert!(
1090                    reasons.contains(&AdmissionRejectionReason::ProductSpecificationImportRejected)
1091                );
1092                assert!(reasons.contains(&AdmissionRejectionReason::RedactionStatusRequired));
1093            }
1094            other => panic!("expected reject, got {other:?}"),
1095        }
1096    }
1097
1098    #[test]
1099    fn open_contradiction_cannot_be_cleanly_admitted() {
1100        let mut request = valid_request();
1101        request.contradiction_scan = ContradictionScan::OpenContradictions(vec!["ctr_01".into()]);
1102
1103        assert_eq!(
1104            request.admission_decision(),
1105            AdmissionDecision::Quarantine {
1106                reasons: vec![AdmissionRejectionReason::OpenContradiction],
1107            }
1108        );
1109        let policy = request.policy_decision();
1110        assert_eq!(policy.final_outcome, PolicyOutcome::Quarantine);
1111        assert_eq!(
1112            policy.contributing[0].rule_id.as_str(),
1113            "memory.admission.open_contradiction"
1114        );
1115    }
1116
1117    #[test]
1118    fn unscanned_contradictions_are_quarantined() {
1119        let mut request = valid_request();
1120        request.contradiction_scan = ContradictionScan::NotScanned;
1121
1122        assert_eq!(
1123            request.admission_decision(),
1124            AdmissionDecision::Quarantine {
1125                reasons: vec![AdmissionRejectionReason::ContradictionScanRequired],
1126            }
1127        );
1128    }
1129
1130    #[test]
1131    fn rejection_reasons_compose_to_policy_reject() {
1132        let mut request = valid_request();
1133        request.candidate_state = CandidateState::Doctrine;
1134        request.explicit_non_promotion = false;
1135
1136        let policy = request.policy_decision();
1137
1138        assert_eq!(policy.final_outcome, PolicyOutcome::Reject);
1139        assert_eq!(policy.contributing.len(), 2);
1140        assert!(policy.contributing.iter().any(|contribution| {
1141            contribution.rule_id.as_str() == "memory.admission.explicit_non_promotion_required"
1142        }));
1143    }
1144
1145    #[test]
1146    fn execution_receipt_enters_candidate_admission_only() {
1147        let request = AxiomMemoryAdmissionRequest::from(receipt(CapabilityTokenDecision::Allowed));
1148
1149        assert_eq!(request.candidate_state, CandidateState::Candidate);
1150        assert_eq!(request.evidence_class, EvidenceClass::Observed);
1151        assert_eq!(request.phase_context, PhaseContext::WorkRecord);
1152        assert_eq!(request.proof_state, ProofState::Partial);
1153        assert!(request.explicit_non_promotion);
1154
1155        assert_eq!(
1156            request.admission_decision(),
1157            AdmissionDecision::Quarantine {
1158                reasons: vec![AdmissionRejectionReason::ContradictionScanRequired],
1159            }
1160        );
1161    }
1162
1163    #[test]
1164    fn runtime_only_axiom_receipt_cannot_pass_high_force_semantic_use() {
1165        let mut request =
1166            AxiomMemoryAdmissionRequest::from(receipt(CapabilityTokenDecision::Allowed));
1167        request.contradiction_scan = ContradictionScan::ScannedClean;
1168
1169        let report = request.semantic_trust_report(
1170            AdmissionSemanticTrustInput::new(SemanticUse::HighForceDoctrine)
1171                .with_independent_source_families(2)
1172                .with_falsification_evidence(true)
1173                .with_unresolved_semantic_unknowns(false),
1174        );
1175
1176        assert_eq!(report.provenance_class, ProvenanceClass::RuntimeDerived);
1177        assert_eq!(
1178            report.semantic_trust.semantic_trust,
1179            SemanticTrustClass::SingleFamily
1180        );
1181        assert_eq!(report.semantic_trust.policy_outcome, PolicyOutcome::Reject);
1182        assert_eq!(report.admission_decision, AdmissionDecision::AdmitCandidate);
1183        assert!(report.explicit_non_promotion);
1184    }
1185
1186    #[test]
1187    fn admission_unknowns_warn_for_candidate_and_quarantine_for_default_context() {
1188        let mut request = valid_request();
1189        request.evidence_class = EvidenceClass::Unknown;
1190
1191        let candidate_report = request.semantic_trust_report(
1192            AdmissionSemanticTrustInput::new(SemanticUse::CandidateMemory)
1193                .with_unresolved_semantic_unknowns(true),
1194        );
1195        assert_eq!(
1196            candidate_report.provenance_class,
1197            ProvenanceClass::UnknownProvenance
1198        );
1199        assert_eq!(
1200            candidate_report.semantic_trust.semantic_trust,
1201            SemanticTrustClass::Unknown
1202        );
1203        assert_eq!(
1204            candidate_report.semantic_trust.policy_outcome,
1205            PolicyOutcome::Warn
1206        );
1207        assert_eq!(
1208            candidate_report.admission_decision,
1209            AdmissionDecision::Quarantine {
1210                reasons: vec![AdmissionRejectionReason::EvidenceClassRequired],
1211            }
1212        );
1213
1214        let context_report = request.semantic_trust_report(
1215            AdmissionSemanticTrustInput::new(SemanticUse::DefaultContext)
1216                .with_unresolved_semantic_unknowns(true),
1217        );
1218        assert_eq!(
1219            context_report.semantic_trust.policy_outcome,
1220            PolicyOutcome::Quarantine
1221        );
1222        assert_eq!(
1223            context_report.semantic_trust.claim_ceiling,
1224            ClaimCeiling::DevOnly
1225        );
1226    }
1227
1228    #[test]
1229    fn observed_admission_passes_high_force_only_with_corroboration_and_falsification() {
1230        let tool_request = valid_request();
1231
1232        let missing_falsification = tool_request.semantic_trust_report(
1233            AdmissionSemanticTrustInput::new(SemanticUse::HighForceDoctrine)
1234                .with_independent_source_families(2)
1235                .with_falsification_evidence(false)
1236                .with_unresolved_semantic_unknowns(false),
1237        );
1238        assert_eq!(
1239            missing_falsification.provenance_class,
1240            ProvenanceClass::ToolObserved
1241        );
1242        assert_eq!(
1243            missing_falsification.semantic_trust.policy_outcome,
1244            PolicyOutcome::Reject
1245        );
1246
1247        let corroborated = tool_request.semantic_trust_report(
1248            AdmissionSemanticTrustInput::new(SemanticUse::HighForceDoctrine)
1249                .with_independent_source_families(2)
1250                .with_falsification_evidence(true)
1251                .with_unresolved_semantic_unknowns(false),
1252        );
1253        assert_eq!(
1254            corroborated.semantic_trust.semantic_trust,
1255            SemanticTrustClass::FalsificationTested
1256        );
1257        assert_eq!(
1258            corroborated.semantic_trust.policy_outcome,
1259            PolicyOutcome::Allow
1260        );
1261        assert_eq!(
1262            corroborated.semantic_trust.claim_ceiling,
1263            ClaimCeiling::AuthorityGrade
1264        );
1265
1266        let mut operator_protocol_request = valid_request();
1267        operator_protocol_request.tool_provenance.import_class = AxiomImportClass::OperatorProtocol;
1268        let operator_protocol_report = operator_protocol_request.semantic_trust_report(
1269            AdmissionSemanticTrustInput::new(SemanticUse::HighForceDoctrine)
1270                .with_independent_source_families(2)
1271                .with_falsification_evidence(true)
1272                .with_unresolved_semantic_unknowns(false),
1273        );
1274        assert_eq!(
1275            operator_protocol_report.provenance_class,
1276            ProvenanceClass::ToolObserved
1277        );
1278        assert_eq!(
1279            operator_protocol_report.semantic_trust.policy_outcome,
1280            PolicyOutcome::Allow
1281        );
1282    }
1283
1284    #[test]
1285    fn semantic_allowance_does_not_replace_explicit_non_promotion() {
1286        let mut request = valid_request();
1287        request.tool_provenance.import_class = AxiomImportClass::OperatorProtocol;
1288        request.explicit_non_promotion = false;
1289
1290        let report = request.semantic_trust_report(
1291            AdmissionSemanticTrustInput::new(SemanticUse::HighForceDoctrine)
1292                .with_independent_source_families(2)
1293                .with_falsification_evidence(true)
1294                .with_unresolved_semantic_unknowns(false),
1295        );
1296
1297        assert_eq!(report.semantic_trust.policy_outcome, PolicyOutcome::Allow);
1298        assert_eq!(
1299            report.admission_decision,
1300            AdmissionDecision::Reject {
1301                reasons: vec![AdmissionRejectionReason::ExplicitNonPromotionRequired],
1302            }
1303        );
1304        assert!(!report.explicit_non_promotion);
1305    }
1306
1307    #[test]
1308    fn rejected_or_contaminated_receipt_fails_closed() {
1309        let mut receipt = receipt(CapabilityTokenDecision::Revoked);
1310        receipt.quarantine_state = BoundaryQuarantineState::Contaminated;
1311
1312        let request = AxiomMemoryAdmissionRequest::from(receipt);
1313
1314        assert_eq!(request.candidate_state, CandidateState::Candidate);
1315        assert_eq!(request.proof_state, ProofState::Broken);
1316        assert!(!request.explicit_non_promotion);
1317        match request.admission_decision() {
1318            AdmissionDecision::Reject { reasons } => {
1319                assert!(reasons.contains(&AdmissionRejectionReason::ProofStateRequired));
1320                assert!(reasons.contains(&AdmissionRejectionReason::ExplicitNonPromotionRequired));
1321            }
1322            other => panic!("expected reject, got {other:?}"),
1323        }
1324    }
1325
1326    #[test]
1327    fn receipt_without_tool_provenance_is_not_clean_admission() {
1328        let mut receipt = receipt(CapabilityTokenDecision::Allowed);
1329        receipt.tool_provenance.clear();
1330        receipt.runtime_id.clear();
1331
1332        let request = AxiomMemoryAdmissionRequest::from(receipt);
1333
1334        assert_eq!(request.evidence_class, EvidenceClass::Claimed);
1335        match request.admission_decision() {
1336            AdmissionDecision::Reject { reasons } | AdmissionDecision::Quarantine { reasons } => {
1337                assert!(reasons.contains(&AdmissionRejectionReason::ToolProvenanceRequired));
1338            }
1339            AdmissionDecision::AdmitCandidate => {
1340                panic!("missing provenance must not admit cleanly")
1341            }
1342        }
1343    }
1344
1345    #[test]
1346    fn generic_axiom_import_envelope_parses_to_candidate_request() {
1347        let json = serde_json::json!({
1348            "candidate_state": "candidate",
1349            "evidence_class": "observed",
1350            "phase_context": "check",
1351            "tool_provenance": {
1352                "tool_name": "codex",
1353                "invocation_id": "run_01",
1354                "import_class": "agent_procedure"
1355            },
1356            "source_anchors": [{
1357                "reference": "cmd:cargo test -p cortex-memory admission --lib",
1358                "kind": "artifact"
1359            }],
1360            "redaction_status": "abstracted",
1361            "proof_state": "partial",
1362            "contradiction_scan": "scanned_clean",
1363            "explicit_non_promotion": true
1364        })
1365        .to_string();
1366
1367        let request = AxiomMemoryAdmissionRequest::from_json_envelope(&json)
1368            .expect("valid generic import envelope parses");
1369
1370        assert_eq!(request.candidate_state, CandidateState::Candidate);
1371        assert_eq!(request.evidence_class, EvidenceClass::Observed);
1372        assert_eq!(
1373            request.admission_decision(),
1374            AdmissionDecision::AdmitCandidate
1375        );
1376    }
1377
1378    #[test]
1379    fn generic_axiom_import_envelope_rejects_unsupported_evidence_class() {
1380        let json = serde_json::json!({
1381            "candidate_state": "candidate",
1382            "evidence_class": "operator_truth",
1383            "phase_context": "check",
1384            "tool_provenance": {
1385                "tool_name": "codex",
1386                "invocation_id": "run_01",
1387                "import_class": "agent_procedure"
1388            },
1389            "source_anchors": [{
1390                "reference": "cmd:cargo test",
1391                "kind": "artifact"
1392            }],
1393            "redaction_status": "abstracted",
1394            "proof_state": "partial",
1395            "contradiction_scan": "scanned_clean",
1396            "explicit_non_promotion": true
1397        })
1398        .to_string();
1399
1400        let err = AxiomMemoryAdmissionRequest::from_json_envelope(&json)
1401            .expect_err("unsupported evidence class must not parse");
1402
1403        match err {
1404            AdmissionEnvelopeError::InvalidEnvelope { message } => {
1405                assert!(message.contains("operator_truth"), "message: {message}");
1406            }
1407        }
1408    }
1409
1410    #[test]
1411    fn generic_axiom_import_envelope_without_required_fields_cannot_admit() {
1412        let json = serde_json::json!({
1413            "candidate_state": "candidate",
1414            "evidence_class": "observed",
1415            "phase_context": "check",
1416            "tool_provenance": {
1417                "tool_name": "codex",
1418                "invocation_id": "run_01",
1419                "import_class": "agent_procedure"
1420            },
1421            "redaction_status": "abstracted",
1422            "proof_state": "partial",
1423            "contradiction_scan": "scanned_clean",
1424            "explicit_non_promotion": true
1425        })
1426        .to_string();
1427
1428        let err = AxiomMemoryAdmissionRequest::from_json_envelope(&json)
1429            .expect_err("missing source_anchors must not parse");
1430
1431        match err {
1432            AdmissionEnvelopeError::InvalidEnvelope { message } => {
1433                assert!(message.contains("source_anchors"), "message: {message}");
1434            }
1435        }
1436    }
1437
1438    #[test]
1439    fn generic_axiom_import_envelope_with_unscanned_contradictions_quarantines() {
1440        let json = serde_json::json!({
1441            "candidate_state": "candidate",
1442            "evidence_class": "observed",
1443            "phase_context": "check",
1444            "tool_provenance": {
1445                "tool_name": "codex",
1446                "invocation_id": "run_01",
1447                "import_class": "agent_procedure"
1448            },
1449            "source_anchors": [{
1450                "reference": "cmd:cargo test",
1451                "kind": "artifact"
1452            }],
1453            "redaction_status": "abstracted",
1454            "proof_state": "partial",
1455            "contradiction_scan": "not_scanned",
1456            "explicit_non_promotion": true
1457        })
1458        .to_string();
1459
1460        let request = AxiomMemoryAdmissionRequest::from_json_envelope(&json)
1461            .expect("known not_scanned posture parses");
1462
1463        assert_eq!(
1464            request.admission_decision(),
1465            AdmissionDecision::Quarantine {
1466                reasons: vec![AdmissionRejectionReason::ContradictionScanRequired],
1467            }
1468        );
1469    }
1470
1471    // =========================================================================
1472    // Commit B — ADR 0036 AXIOM admission durable-write gate
1473    //
1474    // The admission layer maps its envelope `ProofState` to a typed
1475    // `ProofClosureReport`, composes a policy contribution, and fails
1476    // closed at `require_durable_admission_allowed` when the cross-axis
1477    // proof state is not `FullChainVerified`. The stable invariant is
1478    // `AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT`. The pre-existing
1479    // `admission_decision()` path is unchanged; the durable gate is a
1480    // separate ADR 0036 fail-closed line at the durable-write boundary.
1481    // =========================================================================
1482
1483    #[test]
1484    fn admission_proof_closure_report_maps_full_to_full_chain_verified() {
1485        let mut request = valid_request();
1486        request.proof_state = ProofState::FullChainVerified;
1487        let report = request.proof_closure_report();
1488        assert_eq!(report.state(), CoreProofState::FullChainVerified);
1489        assert!(report.failing_edges().is_empty());
1490    }
1491
1492    #[test]
1493    fn admission_proof_closure_report_maps_partial_and_unknown_to_partial() {
1494        for state in [ProofState::Partial, ProofState::Unknown] {
1495            let mut request = valid_request();
1496            request.proof_state = state;
1497            let report = request.proof_closure_report();
1498            assert_eq!(
1499                report.state(),
1500                CoreProofState::Partial,
1501                "{state:?} must map to typed Partial"
1502            );
1503            assert!(!report.is_full_chain_verified());
1504            assert!(!report.is_broken());
1505        }
1506    }
1507
1508    #[test]
1509    fn admission_proof_closure_report_maps_broken_to_broken() {
1510        let mut request = valid_request();
1511        request.proof_state = ProofState::Broken;
1512        let report = request.proof_closure_report();
1513        assert_eq!(report.state(), CoreProofState::Broken);
1514        assert!(report.is_broken());
1515    }
1516
1517    #[test]
1518    fn admission_proof_closure_contribution_outcomes_track_proof_state() {
1519        for (state, expected) in [
1520            (ProofState::FullChainVerified, PolicyOutcome::Allow),
1521            (ProofState::Partial, PolicyOutcome::Quarantine),
1522            (ProofState::Unknown, PolicyOutcome::Quarantine),
1523            (ProofState::Broken, PolicyOutcome::Reject),
1524        ] {
1525            let mut request = valid_request();
1526            request.proof_state = state;
1527            let contribution = request.proof_closure_contribution();
1528            assert_eq!(contribution.outcome, expected, "for {state:?}");
1529            assert_eq!(
1530                contribution.rule_id.as_str(),
1531                AXIOM_ADMISSION_PROOF_CLOSURE_RULE_ID
1532            );
1533        }
1534    }
1535
1536    #[test]
1537    fn admission_durable_gate_allows_full_chain_verified_only() {
1538        let mut request = valid_request();
1539        request.proof_state = ProofState::FullChainVerified;
1540        request
1541            .require_durable_admission_allowed()
1542            .expect("FullChainVerified must pass the durable gate");
1543    }
1544
1545    #[test]
1546    fn admission_durable_gate_refuses_partial_with_stable_invariant() {
1547        for state in [ProofState::Partial, ProofState::Unknown, ProofState::Broken] {
1548            let mut request = valid_request();
1549            request.proof_state = state;
1550            let err = request
1551                .require_durable_admission_allowed()
1552                .expect_err("non-full-chain proof state must refuse");
1553            assert!(
1554                err.to_string()
1555                    .contains(AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT),
1556                "refusal must carry stable invariant for {state:?}: {err}"
1557            );
1558        }
1559    }
1560
1561    #[test]
1562    fn admission_proof_closure_invariant_keys_are_stable() {
1563        assert_eq!(
1564            AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT,
1565            "cortex_memory.admission.axiom.proof_closure"
1566        );
1567        assert_eq!(
1568            AXIOM_ADMISSION_PROOF_CLOSURE_RULE_ID,
1569            "memory.admission.axiom.proof_closure"
1570        );
1571    }
1572}