Skip to main content

cortex_reflect/
admission.rs

1//! Reflection-to-memory admission checks.
2//!
3//! Reflection can propose Cortex memories, but it cannot bypass the memory
4//! admission boundary. Valid reflection JSON is still treated as an evidence
5//! submission until the deterministic admission request admits it as a
6//! candidate-only memory.
7
8use std::fmt;
9
10use cortex_core::ProofState as CoreProofState;
11use cortex_memory::{
12    AdmissionDecision, AdmissionProofState, AdmissionRejectionReason, AxiomImportClass,
13    AxiomMemoryAdmissionRequest, CandidateState, ContradictionScan, DurableAdmissionRefusal,
14    EvidenceClass, PhaseContext, RedactionStatus, SourceAnchor, SourceAnchorKind, ToolProvenance,
15    AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT,
16};
17
18use crate::schema::{MemoryCandidate, SessionReflection};
19
20/// Stable invariant key surfaced when the reflection durable-write gate
21/// refuses durable promotion because the typed cross-axis
22/// [`cortex_core::ProofClosureReport`] for at least one reflected memory
23/// is not [`cortex_core::ProofState::FullChainVerified`].
24///
25/// ADR 0036 forbids reflection-origin candidate creation to land as a
26/// durable write when the proof closure is `Partial`, `Broken`, or
27/// `Unknown`. The reflect admission layer fails closed before any
28/// orchestrator routes the reflection to a durable persistence surface.
29pub const REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT: &str =
30    "cortex_reflect.admission.proof_closure";
31
32/// Reflection admission outcome for non-admitted memory candidates.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ReflectionAdmissionDisposition {
35    /// The candidate is unsafe to retain as a reflection memory.
36    Reject,
37    /// The candidate is not admissible, but the raw reflection should be retained.
38    Quarantine,
39}
40
41impl fmt::Display for ReflectionAdmissionDisposition {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::Reject => f.write_str("reject"),
45            Self::Quarantine => f.write_str("quarantine"),
46        }
47    }
48}
49
50/// Admission failure for one reflected memory candidate.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ReflectionAdmissionError {
53    /// Zero-based index into `SessionReflection::memory_candidates`.
54    pub memory_index: usize,
55    /// Whether the gate rejected or quarantined the candidate.
56    pub disposition: ReflectionAdmissionDisposition,
57    /// Deterministic admission reasons.
58    pub reasons: Vec<AdmissionRejectionReason>,
59}
60
61impl fmt::Display for ReflectionAdmissionError {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        let memory_index = self.memory_index;
64        let disposition = self.disposition;
65        let reasons = &self.reasons;
66        write!(
67            f,
68            "memory_candidates[{memory_index}] admission {disposition}: {reasons:?}"
69        )
70    }
71}
72
73impl std::error::Error for ReflectionAdmissionError {}
74
75/// Validate that every reflected memory candidate can enter Cortex only as a
76/// candidate admitted by the AXIOM-to-Cortex memory gate.
77pub fn validate_reflection_admission(
78    reflection: &SessionReflection,
79    adapter_id: &str,
80    raw_hash: &str,
81) -> Result<(), ReflectionAdmissionError> {
82    for (memory_index, memory) in reflection.memory_candidates.iter().enumerate() {
83        let request = admission_request_for_memory(reflection, memory, adapter_id, raw_hash);
84        match request.admission_decision() {
85            AdmissionDecision::AdmitCandidate => {}
86            AdmissionDecision::Reject { reasons } => {
87                return Err(ReflectionAdmissionError {
88                    memory_index,
89                    disposition: ReflectionAdmissionDisposition::Reject,
90                    reasons,
91                });
92            }
93            AdmissionDecision::Quarantine { reasons } => {
94                return Err(ReflectionAdmissionError {
95                    memory_index,
96                    disposition: ReflectionAdmissionDisposition::Quarantine,
97                    reasons,
98                });
99            }
100        }
101    }
102
103    Ok(())
104}
105
106/// Refusal returned by [`require_reflection_durable_admission_allowed`].
107///
108/// Carries the index of the memory candidate that failed the durable-write
109/// gate plus the underlying [`DurableAdmissionRefusal`] from the AXIOM
110/// admission proof-closure check. The stable invariant
111/// [`REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT`] is surfaced in the
112/// `Display` impl so log lines can be grepped for the invariant key.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ReflectionDurableAdmissionRefusal {
115    /// Zero-based index into `SessionReflection::memory_candidates`.
116    pub memory_index: usize,
117    /// AXIOM admission durable refusal carrying the observed proof state.
118    pub axiom_refusal: DurableAdmissionRefusal,
119}
120
121impl fmt::Display for ReflectionDurableAdmissionRefusal {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        let memory_index = self.memory_index;
124        let axiom_refusal = &self.axiom_refusal;
125        write!(
126            f,
127            "invariant={REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT} memory_candidates[{memory_index}] {axiom_refusal}"
128        )
129    }
130}
131
132impl std::error::Error for ReflectionDurableAdmissionRefusal {}
133
134/// ADR 0036 fail-closed gate at the reflection durable-write boundary.
135///
136/// `validate_reflection_admission` answers "is this reflection's memory
137/// candidate set safe to retain as a candidate?". The durable gate
138/// answers the *stronger* question "is the typed cross-axis proof closure
139/// for each candidate fully verified, so the orchestrator may promote it
140/// to a durable write?".
141///
142/// Returns `Ok(())` only when every reflected memory's
143/// [`AxiomMemoryAdmissionRequest::require_durable_admission_allowed`]
144/// passes. On first failure, returns
145/// [`ReflectionDurableAdmissionRefusal`] carrying the failing memory
146/// index. The stable invariant
147/// [`REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT`] documents the gate
148/// for downstream consumers.
149///
150/// The print boundary (read-only diagnostic surfaces that render
151/// reflection JSON without persisting) is intentionally **not** gated
152/// here — only callers that perform a durable write of the reflected
153/// memory candidates should invoke this function.
154pub fn require_reflection_durable_admission_allowed(
155    reflection: &SessionReflection,
156    adapter_id: &str,
157    raw_hash: &str,
158) -> Result<(), ReflectionDurableAdmissionRefusal> {
159    for (memory_index, memory) in reflection.memory_candidates.iter().enumerate() {
160        let request = admission_request_for_memory(reflection, memory, adapter_id, raw_hash);
161        if let Err(axiom_refusal) = request.require_durable_admission_allowed() {
162            return Err(ReflectionDurableAdmissionRefusal {
163                memory_index,
164                axiom_refusal,
165            });
166        }
167    }
168    Ok(())
169}
170
171/// Return the observed [`CoreProofState`] for the reflected memory at
172/// `memory_index`. Useful for surfaces that want to record the proof
173/// state on a refusal without re-running the admission request builder.
174#[must_use]
175pub fn reflection_memory_proof_state(
176    reflection: &SessionReflection,
177    memory_index: usize,
178    adapter_id: &str,
179    raw_hash: &str,
180) -> Option<CoreProofState> {
181    let memory = reflection.memory_candidates.get(memory_index)?;
182    let request = admission_request_for_memory(reflection, memory, adapter_id, raw_hash);
183    Some(request.proof_closure_report().state())
184}
185
186/// Re-export of the upstream stable invariant key for callers that want
187/// to grep for either the reflection-level or the upstream AXIOM-level
188/// invariant.
189#[must_use]
190pub const fn axiom_admission_proof_closure_invariant() -> &'static str {
191    AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT
192}
193
194/// Build the deterministic admission request for one reflected memory.
195#[must_use]
196pub fn admission_request_for_memory(
197    reflection: &SessionReflection,
198    memory: &MemoryCandidate,
199    adapter_id: &str,
200    raw_hash: &str,
201) -> AxiomMemoryAdmissionRequest {
202    let source_anchors = source_anchors_for_memory(reflection, memory);
203    let proof_state = if source_anchors.is_empty() {
204        AdmissionProofState::Unknown
205    } else {
206        AdmissionProofState::Partial
207    };
208
209    AxiomMemoryAdmissionRequest {
210        candidate_state: CandidateState::Candidate,
211        evidence_class: EvidenceClass::Inferred,
212        phase_context: PhaseContext::Check,
213        tool_provenance: ToolProvenance::new(
214            format!("cortex-reflect:{adapter_id}"),
215            raw_hash,
216            AxiomImportClass::AgentProcedure,
217        ),
218        source_anchors,
219        redaction_status: RedactionStatus::Abstracted,
220        proof_state,
221        contradiction_scan: contradiction_scan_for_reflection(reflection),
222        explicit_non_promotion: true,
223    }
224}
225
226fn source_anchors_for_memory(
227    reflection: &SessionReflection,
228    memory: &MemoryCandidate,
229) -> Vec<SourceAnchor> {
230    memory
231        .source_episode_indexes
232        .iter()
233        .filter_map(|idx| reflection.episode_candidates.get(*idx))
234        .flat_map(|episode| &episode.source_event_ids)
235        .map(|event_id| SourceAnchor::new(event_id.to_string(), SourceAnchorKind::Event))
236        .collect()
237}
238
239fn contradiction_scan_for_reflection(reflection: &SessionReflection) -> ContradictionScan {
240    if reflection.contradictions.is_empty() {
241        ContradictionScan::ScannedClean
242    } else {
243        ContradictionScan::OpenContradictions(
244            reflection
245                .contradictions
246                .iter()
247                .enumerate()
248                .map(|(idx, _)| format!("reflection.contradictions[{idx}]"))
249                .collect(),
250        )
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use cortex_core::TraceId;
257    use cortex_memory::{AdmissionDecision, AdmissionRejectionReason};
258
259    use super::*;
260    use crate::schema::{EpisodeCandidate, InitialSalience, MemoryType};
261
262    fn valid_reflection() -> SessionReflection {
263        SessionReflection {
264            trace_id: "trc_01ARZ3NDEKTSV4RRFFQ69G5FAV"
265                .parse::<TraceId>()
266                .expect("valid trace id"),
267            episode_candidates: vec![EpisodeCandidate {
268                summary: "Reflection summary".to_string(),
269                source_event_ids: vec!["evt_01ARZ3NDEKTSV4RRFFQ69G5FAV"
270                    .parse()
271                    .expect("valid event id")],
272                domains: vec!["agents".to_string()],
273                entities: vec!["Cortex".to_string()],
274                candidate_meaning: Some("candidate meaning".to_string()),
275                confidence: 0.8,
276            }],
277            memory_candidates: vec![MemoryCandidate {
278                memory_type: MemoryType::Strategic,
279                claim: "Reflection memory remains candidate-only.".to_string(),
280                source_episode_indexes: vec![0],
281                applies_when: vec!["reflecting".to_string()],
282                does_not_apply_when: vec!["promoting".to_string()],
283                confidence: 0.8,
284                initial_salience: InitialSalience {
285                    reusability: 0.5,
286                    consequence: 0.5,
287                    emotional_charge: 0.0,
288                },
289            }],
290            contradictions: Vec::new(),
291            doctrine_suggestions: Vec::new(),
292        }
293    }
294
295    #[test]
296    fn reflected_memory_builds_candidate_only_admission_request() {
297        let reflection = valid_reflection();
298        let request = admission_request_for_memory(
299            &reflection,
300            &reflection.memory_candidates[0],
301            "fixed",
302            "hash_01",
303        );
304
305        assert_eq!(request.candidate_state, CandidateState::Candidate);
306        assert_eq!(request.evidence_class, EvidenceClass::Inferred);
307        assert_eq!(request.phase_context, PhaseContext::Check);
308        assert_eq!(request.redaction_status, RedactionStatus::Abstracted);
309        assert_eq!(request.proof_state, AdmissionProofState::Partial);
310        assert!(request.explicit_non_promotion);
311        assert_eq!(request.source_anchors.len(), 1);
312        assert_eq!(
313            request.admission_decision(),
314            AdmissionDecision::AdmitCandidate
315        );
316    }
317
318    #[test]
319    fn reflected_memory_without_source_anchor_fails_admission() {
320        let mut reflection = valid_reflection();
321        reflection.episode_candidates[0].source_event_ids.clear();
322
323        let err = validate_reflection_admission(&reflection, "fixed", "hash_01")
324            .expect_err("missing anchors must fail admission");
325
326        assert_eq!(err.memory_index, 0);
327        assert_eq!(err.disposition, ReflectionAdmissionDisposition::Reject);
328        assert!(err
329            .reasons
330            .contains(&AdmissionRejectionReason::SourceAnchorRequired));
331        assert!(err
332            .reasons
333            .contains(&AdmissionRejectionReason::ProofStateRequired));
334    }
335
336    #[test]
337    fn reflected_memory_with_open_contradiction_is_quarantined() {
338        let mut reflection = valid_reflection();
339        reflection
340            .contradictions
341            .push(serde_json::json!({"claim": "conflict"}));
342
343        let err = validate_reflection_admission(&reflection, "fixed", "hash_01")
344            .expect_err("open contradictions must fail admission");
345
346        assert_eq!(err.memory_index, 0);
347        assert_eq!(err.disposition, ReflectionAdmissionDisposition::Quarantine);
348        assert_eq!(
349            err.reasons,
350            vec![AdmissionRejectionReason::OpenContradiction]
351        );
352    }
353
354    // =========================================================================
355    // Commit B — ADR 0036 reflection durable-write gate
356    //
357    // `validate_reflection_admission` answers the candidate-admission
358    // question (per ADR 0038 admission rules). The durable-write gate
359    // answers the stronger ADR 0036 question: is the typed cross-axis
360    // proof closure FullChainVerified for every memory the orchestrator
361    // is about to persist? The stable invariant is
362    // `REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT`. The print boundary
363    // is intentionally not gated; only durable-write callers invoke
364    // `require_reflection_durable_admission_allowed`.
365    // =========================================================================
366
367    #[test]
368    fn reflection_durable_gate_refuses_default_partial_proof_state() {
369        // Default reflection envelope has source anchors → AdmissionProofState::Partial,
370        // which maps to CoreProofState::Partial. ADR 0036 forbids durable
371        // promotion at Partial.
372        let reflection = valid_reflection();
373        let err = require_reflection_durable_admission_allowed(&reflection, "fixed", "hash_01")
374            .expect_err("default reflection envelope is Partial; durable gate must refuse");
375        assert_eq!(err.memory_index, 0);
376        assert_eq!(err.axiom_refusal.proof_state, CoreProofState::Partial);
377        assert!(
378            err.to_string()
379                .contains(REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT),
380            "refusal must carry stable invariant: {err}"
381        );
382        assert!(
383            err.to_string()
384                .contains(AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT),
385            "refusal must also surface the upstream AXIOM admission invariant: {err}"
386        );
387    }
388
389    #[test]
390    fn reflection_durable_gate_refuses_when_lineage_is_missing() {
391        // Without anchors, AdmissionProofState becomes Unknown.
392        let mut reflection = valid_reflection();
393        reflection.episode_candidates[0].source_event_ids.clear();
394
395        let err = require_reflection_durable_admission_allowed(&reflection, "fixed", "hash_01")
396            .expect_err("missing lineage must refuse durable promotion");
397        assert_eq!(err.memory_index, 0);
398        // Unknown maps to Partial in the typed report (see admission docs).
399        assert_eq!(err.axiom_refusal.proof_state, CoreProofState::Partial);
400    }
401
402    #[test]
403    fn reflection_memory_proof_state_helper_returns_observed_state() {
404        let reflection = valid_reflection();
405        let state = reflection_memory_proof_state(&reflection, 0, "fixed", "hash_01")
406            .expect("memory exists");
407        // Default reflection lineage → Partial typed proof state.
408        assert_eq!(state, CoreProofState::Partial);
409
410        // Out-of-bounds returns None rather than panicking, so callers
411        // can defensively probe.
412        assert!(reflection_memory_proof_state(&reflection, 99, "fixed", "hash_01").is_none());
413    }
414
415    #[test]
416    fn reflection_durable_gate_invariant_key_is_stable() {
417        assert_eq!(
418            REFLECTION_ADMISSION_PROOF_CLOSURE_INVARIANT,
419            "cortex_reflect.admission.proof_closure"
420        );
421        assert_eq!(
422            axiom_admission_proof_closure_invariant(),
423            AXIOM_ADMISSION_PROOF_CLOSURE_INVARIANT
424        );
425    }
426}