Skip to main content

cortex_memory/
lifecycle.rs

1//! Memory candidate acceptance rules.
2//!
3//! The store layer owns durable writes; this module owns the memory-domain gate
4//! before those writes are attempted.
5
6use std::error::Error;
7use std::fmt;
8
9use chrono::{DateTime, Utc};
10use cortex_core::{MemoryId, PolicyDecision, ProofClosureReport, ProofState};
11use cortex_store::repo::{MemoryAcceptanceAudit, MemoryCandidate, MemoryRepo};
12use cortex_store::StoreError;
13
14/// Stable invariant key surfaced when the lifecycle layer refuses an accept
15/// because the supplied [`ProofClosureReport`] is not fully verified.
16///
17/// ADR 0036 forbids a durable candidate -> active mutation when the proof
18/// closure is `Partial` or `Broken`. The lifecycle layer fails closed before
19/// any store boundary work happens, so any caller (CLI, future API, tests)
20/// inherits the gate.
21pub const LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT: &str =
22    "cortex_memory.lifecycle.accept.proof_closure";
23
24/// Result type for memory lifecycle operations.
25pub type LifecycleResult<T> = Result<T, LifecycleError>;
26
27/// Errors raised by memory lifecycle domain logic.
28#[derive(Debug)]
29pub enum LifecycleError {
30    /// Store boundary failed.
31    Store(StoreError),
32    /// Candidate lineage or state is invalid.
33    Validation(String),
34    /// ADR 0036 proof closure refusal: the supplied [`ProofClosureReport`]
35    /// was not [`ProofState::FullChainVerified`] and the lifecycle layer
36    /// will not promote a partial- or broken-proof candidate to Active.
37    /// The associated [`ProofState`] is the observed state.
38    ProofClosureRefusal(ProofState),
39}
40
41impl fmt::Display for LifecycleError {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::Store(err) => write!(f, "store error: {err}"),
45            Self::Validation(message) => write!(f, "validation failed: {message}"),
46            Self::ProofClosureRefusal(state) => write!(
47                f,
48                "invariant={LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT} proof closure must be FullChainVerified for durable accept; observed {state:?}"
49            ),
50        }
51    }
52}
53
54impl Error for LifecycleError {
55    fn source(&self) -> Option<&(dyn Error + 'static)> {
56        match self {
57            Self::Store(err) => Some(err),
58            Self::Validation(_) | Self::ProofClosureRefusal(_) => None,
59        }
60    }
61}
62
63impl From<StoreError> for LifecycleError {
64    fn from(err: StoreError) -> Self {
65        Self::Store(err)
66    }
67}
68
69/// Request to accept a candidate already materialized by the caller.
70///
71/// ADR 0026 §2 requires every candidate -> active mutation to compose through
72/// the policy lattice. The composed [`PolicyDecision`] is supplied by the
73/// caller (lifecycle layer assembles AXIOM admission, proof closure, conflict
74/// resolution, and operator temporal-authority contributors) and forwarded to
75/// the store boundary.
76///
77/// ADR 0036 also requires the caller to supply a typed
78/// [`ProofClosureReport`] for the candidate's lineage. The lifecycle layer
79/// refuses durable promotion when the report is not
80/// [`ProofState::FullChainVerified`]; the gate fires under the stable
81/// invariant [`LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT`].
82#[derive(Debug, Clone, Copy)]
83pub struct AcceptCandidateRequest<'a> {
84    /// Candidate row to insert and activate.
85    pub candidate: &'a MemoryCandidate,
86    /// Required audit row supplied by the caller.
87    pub audit: &'a MemoryAcceptanceAudit,
88    /// Composed acceptance policy decision (ADR 0026 §2). Must satisfy
89    /// [`MemoryRepo::accept_candidate`]'s contributor envelope.
90    pub policy: &'a PolicyDecision,
91    /// Typed proof closure report (ADR 0036 §3). The lifecycle layer fails
92    /// closed when this is not [`ProofState::FullChainVerified`]. The
93    /// caller is responsible for computing the report from real lineage
94    /// (e.g. `cortex_store::verify_memory_proof_closure`) — the lifecycle
95    /// layer never invents a "trusted" report on the caller's behalf.
96    pub proof_closure: &'a ProofClosureReport,
97}
98
99/// Accept a previously persisted candidate by id after validating stored
100/// lineage and composing the ADR 0026 acceptance policy.
101///
102/// `proof_closure` MUST be [`ProofState::FullChainVerified`]; the lifecycle
103/// layer refuses with [`LifecycleError::ProofClosureRefusal`] otherwise and
104/// no store boundary call is made. The stable invariant
105/// [`LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT`] documents the refusal.
106pub fn accept(
107    memories: &MemoryRepo<'_>,
108    candidate_id: &MemoryId,
109    updated_at: DateTime<Utc>,
110    audit: &MemoryAcceptanceAudit,
111    policy: &PolicyDecision,
112    proof_closure: &ProofClosureReport,
113) -> LifecycleResult<MemoryId> {
114    require_full_proof_closure(proof_closure)?;
115
116    let candidate = memories.get_candidate_by_id(candidate_id)?.ok_or_else(|| {
117        LifecycleError::Validation(format!("memory {candidate_id} is not a candidate"))
118    })?;
119
120    if candidate
121        .source_episodes_json
122        .as_array()
123        .is_some_and(|items| !items.is_empty())
124        || candidate
125            .source_events_json
126            .as_array()
127            .is_some_and(|items| !items.is_empty())
128    {
129        let accepted = memories.accept_candidate(candidate_id, updated_at, audit, policy)?;
130        return Ok(accepted.id);
131    }
132
133    Err(LifecycleError::Validation(
134        "memory candidate requires non-empty episode or event lineage".into(),
135    ))
136}
137
138/// Accept a candidate after validating lineage before any store write.
139///
140/// The durable candidate -> active transition and audit write are delegated to
141/// `cortex-store` so the two effects occur in one transaction, and the
142/// composed [`PolicyDecision`] is forwarded to the store boundary so the
143/// ADR 0026 contributor envelope fails closed on `Reject` / `Quarantine`.
144///
145/// `request.proof_closure` MUST be [`ProofState::FullChainVerified`]; the
146/// lifecycle layer refuses with [`LifecycleError::ProofClosureRefusal`]
147/// otherwise and no insert or accept is attempted. The stable invariant
148/// [`LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT`] documents the refusal.
149pub fn accept_candidate(
150    memories: &MemoryRepo<'_>,
151    request: AcceptCandidateRequest<'_>,
152) -> LifecycleResult<MemoryId> {
153    require_full_proof_closure(request.proof_closure)?;
154    validate_candidate_lineage(request.candidate)?;
155
156    memories.insert_candidate(request.candidate)?;
157    memories.accept_candidate(
158        &request.candidate.id,
159        request.candidate.updated_at,
160        request.audit,
161        request.policy,
162    )?;
163
164    Ok(request.candidate.id)
165}
166
167/// Fail closed before any durable write if the proof closure is not
168/// [`ProofState::FullChainVerified`]. The lifecycle layer is the
169/// fail-closed line for ADR 0036 at the memory candidate -> active surface.
170fn require_full_proof_closure(report: &ProofClosureReport) -> LifecycleResult<()> {
171    if report.is_full_chain_verified() {
172        Ok(())
173    } else {
174        Err(LifecycleError::ProofClosureRefusal(report.state()))
175    }
176}
177
178/// Validate the minimum lineage invariant for a memory candidate.
179///
180/// At least one of `source_episodes_json` or `source_events_json` must be a
181/// non-empty JSON array. Missing (`null`) or empty lineage fails closed.
182pub fn validate_candidate_lineage(candidate: &MemoryCandidate) -> LifecycleResult<()> {
183    if candidate
184        .source_episodes_json
185        .as_array()
186        .is_some_and(|items| !items.is_empty())
187        || candidate
188            .source_events_json
189            .as_array()
190            .is_some_and(|items| !items.is_empty())
191    {
192        return Ok(());
193    }
194
195    Err(LifecycleError::Validation(
196        "memory candidate requires non-empty episode or event lineage".into(),
197    ))
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use cortex_core::{
204        AuditRecordId, FailingEdge, ProofClosureReport, ProofEdgeFailure, ProofEdgeKind,
205    };
206    use cortex_store::repo::memories::accept_candidate_policy_decision_test_allow;
207    use cortex_store::{Pool, INITIAL_MIGRATION_SQL};
208    use serde_json::json;
209
210    fn full_proof_closure() -> ProofClosureReport {
211        // Test fixture: a fully-verified report with no failing edges.
212        // Production callers compute this via
213        // `cortex_store::verify_memory_proof_closure`.
214        ProofClosureReport::full_chain_verified(Vec::new())
215    }
216
217    fn partial_proof_closure() -> ProofClosureReport {
218        ProofClosureReport::from_edges(
219            Vec::new(),
220            vec![FailingEdge::missing(
221                ProofEdgeKind::LineageClosure,
222                "memory:test",
223                "test fixture: lineage axis observed but unresolved",
224            )],
225        )
226    }
227
228    fn broken_proof_closure() -> ProofClosureReport {
229        ProofClosureReport::from_edges(
230            Vec::new(),
231            vec![FailingEdge::broken(
232                ProofEdgeKind::HashChain,
233                "event:a",
234                "event:b",
235                ProofEdgeFailure::Mismatch,
236                "test fixture: hash chain mismatch",
237            )],
238        )
239    }
240
241    fn test_pool() -> Pool {
242        let pool = Pool::open_in_memory().expect("open in-memory sqlite");
243        pool.execute_batch(INITIAL_MIGRATION_SQL)
244            .expect("run initial migration");
245        pool
246    }
247
248    fn candidate(has_event_lineage: bool) -> MemoryCandidate {
249        MemoryCandidate {
250            id: "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap(),
251            memory_type: "semantic".into(),
252            claim: "Cortex memories require lineage.".into(),
253            source_episodes_json: Default::default(),
254            source_events_json: if has_event_lineage {
255                "[\"evt_01ARZ3NDEKTSV4RRFFQ69G5FAV\"]".parse().unwrap()
256            } else {
257                Default::default()
258            },
259            domains_json: Default::default(),
260            salience_json: Default::default(),
261            confidence: 0.7,
262            authority: "candidate".into(),
263            applies_when_json: Default::default(),
264            does_not_apply_when_json: Default::default(),
265            created_at: "1970-01-01T00:00:00Z".parse().unwrap(),
266            updated_at: "1970-01-01T00:00:00Z".parse().unwrap(),
267        }
268    }
269
270    #[test]
271    fn lineage_validation_rejects_missing_or_empty_sources() {
272        assert!(validate_candidate_lineage(&candidate(false)).is_err());
273        assert!(validate_candidate_lineage(&candidate(true)).is_ok());
274    }
275
276    #[test]
277    fn accept_candidate_rejects_empty_lineage_before_any_write() {
278        let pool = test_pool();
279        let memories = MemoryRepo::new(&pool);
280        let policy = accept_candidate_policy_decision_test_allow();
281        let proof_closure = full_proof_closure();
282        let request = AcceptCandidateRequest {
283            candidate: &candidate(false),
284            audit: &acceptance_audit(),
285            policy: &policy,
286            proof_closure: &proof_closure,
287        };
288
289        assert!(accept_candidate(&memories, request).is_err());
290
291        let count: i64 = pool
292            .query_row("SELECT COUNT(*) FROM memories;", [], |row| row.get(0))
293            .unwrap();
294        assert_eq!(count, 0);
295    }
296
297    fn acceptance_audit() -> MemoryAcceptanceAudit {
298        MemoryAcceptanceAudit {
299            id: AuditRecordId::new(),
300            actor_json: json!({"kind": "test"}),
301            reason: "unit test accept".into(),
302            source_refs_json: json!(["evt_01ARZ3NDEKTSV4RRFFQ69G5FAV"]),
303            created_at: "1970-01-01T00:00:05Z".parse().unwrap(),
304        }
305    }
306
307    #[test]
308    fn accept_candidate_inserts_active_memory_and_optional_audit() {
309        let pool = test_pool();
310        let memories = MemoryRepo::new(&pool);
311        let mut candidate = candidate(true);
312        candidate.updated_at = "1970-01-01T00:00:05Z".parse().unwrap();
313        let audit = acceptance_audit();
314        let policy = accept_candidate_policy_decision_test_allow();
315        let proof_closure = full_proof_closure();
316
317        let accepted = accept_candidate(
318            &memories,
319            AcceptCandidateRequest {
320                candidate: &candidate,
321                audit: &audit,
322                policy: &policy,
323                proof_closure: &proof_closure,
324            },
325        )
326        .expect("accept candidate with lineage");
327
328        assert_eq!(accepted, candidate.id);
329        let status: String = pool
330            .query_row(
331                "SELECT status FROM memories WHERE id = ?1;",
332                [candidate.id.to_string()],
333                |row| row.get(0),
334            )
335            .unwrap();
336        assert_eq!(status, "active");
337        let audit_count: i64 = pool
338            .query_row(
339                "SELECT COUNT(*) FROM audit_records WHERE target_ref = ?1;",
340                [candidate.id.to_string()],
341                |row| row.get(0),
342            )
343            .unwrap();
344        assert_eq!(audit_count, 1);
345    }
346
347    #[test]
348    fn id_only_accept_uses_stored_candidate_and_audit_transaction() {
349        let pool = test_pool();
350        let memories = MemoryRepo::new(&pool);
351        let mut candidate = candidate(true);
352        candidate.updated_at = "1970-01-01T00:00:03Z".parse().unwrap();
353        memories
354            .insert_candidate(&candidate)
355            .expect("insert candidate");
356        let audit = acceptance_audit();
357        let proof_closure = full_proof_closure();
358
359        let accepted = accept(
360            &memories,
361            &candidate.id,
362            "1970-01-01T00:00:06Z".parse().unwrap(),
363            &audit,
364            &accept_candidate_policy_decision_test_allow(),
365            &proof_closure,
366        )
367        .expect("accept stored candidate by id");
368
369        assert_eq!(accepted, candidate.id);
370        assert_eq!(
371            memories
372                .get_by_id(&candidate.id)
373                .unwrap()
374                .expect("accepted memory exists")
375                .status,
376            "active"
377        );
378    }
379
380    // =========================================================================
381    // Commit B — ADR 0036 library-level proof closure gate (lifecycle::accept)
382    //
383    // The lifecycle layer is the fail-closed line for ADR 0036 at the
384    // candidate -> active surface. Any caller (CLI, future API, tests) that
385    // routes through `accept` or `accept_candidate` MUST supply a
386    // `ProofClosureReport`; the lifecycle layer refuses with the stable
387    // invariant `LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT` when the report
388    // is not FullChainVerified, and no store boundary call is attempted.
389    // =========================================================================
390
391    #[test]
392    fn accept_candidate_refuses_partial_proof_closure_before_any_write() {
393        let pool = test_pool();
394        let memories = MemoryRepo::new(&pool);
395        let candidate = candidate(true);
396        let policy = accept_candidate_policy_decision_test_allow();
397        let proof_closure = partial_proof_closure();
398
399        let err = accept_candidate(
400            &memories,
401            AcceptCandidateRequest {
402                candidate: &candidate,
403                audit: &acceptance_audit(),
404                policy: &policy,
405                proof_closure: &proof_closure,
406            },
407        )
408        .expect_err("partial proof closure must refuse");
409
410        match err {
411            LifecycleError::ProofClosureRefusal(state) => {
412                assert_eq!(state, ProofState::Partial);
413                assert!(err
414                    .to_string()
415                    .contains(LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT));
416            }
417            other => panic!("expected ProofClosureRefusal, got {other:?}"),
418        }
419
420        let count: i64 = pool
421            .query_row("SELECT COUNT(*) FROM memories;", [], |row| row.get(0))
422            .unwrap();
423        assert_eq!(count, 0, "no row may be written on proof closure refusal");
424    }
425
426    #[test]
427    fn accept_candidate_refuses_broken_proof_closure_before_any_write() {
428        let pool = test_pool();
429        let memories = MemoryRepo::new(&pool);
430        let candidate = candidate(true);
431        let policy = accept_candidate_policy_decision_test_allow();
432        let proof_closure = broken_proof_closure();
433
434        let err = accept_candidate(
435            &memories,
436            AcceptCandidateRequest {
437                candidate: &candidate,
438                audit: &acceptance_audit(),
439                policy: &policy,
440                proof_closure: &proof_closure,
441            },
442        )
443        .expect_err("broken proof closure must refuse");
444
445        match err {
446            LifecycleError::ProofClosureRefusal(state) => {
447                assert_eq!(state, ProofState::Broken);
448            }
449            other => panic!("expected ProofClosureRefusal, got {other:?}"),
450        }
451    }
452
453    #[test]
454    fn id_only_accept_refuses_partial_proof_closure_before_any_write() {
455        let pool = test_pool();
456        let memories = MemoryRepo::new(&pool);
457        let candidate = candidate(true);
458        memories
459            .insert_candidate(&candidate)
460            .expect("insert candidate");
461        let audit = acceptance_audit();
462        let proof_closure = partial_proof_closure();
463
464        let err = accept(
465            &memories,
466            &candidate.id,
467            "1970-01-01T00:00:06Z".parse().unwrap(),
468            &audit,
469            &accept_candidate_policy_decision_test_allow(),
470            &proof_closure,
471        )
472        .expect_err("partial proof closure must refuse");
473
474        match err {
475            LifecycleError::ProofClosureRefusal(state) => {
476                assert_eq!(state, ProofState::Partial);
477            }
478            other => panic!("expected ProofClosureRefusal, got {other:?}"),
479        }
480        assert_eq!(
481            memories
482                .get_by_id(&candidate.id)
483                .unwrap()
484                .expect("candidate still exists")
485                .status,
486            "candidate",
487            "candidate must remain in candidate state after refusal"
488        );
489    }
490
491    #[test]
492    fn lifecycle_accept_proof_closure_invariant_key_is_stable() {
493        assert_eq!(
494            LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT,
495            "cortex_memory.lifecycle.accept.proof_closure"
496        );
497    }
498}