use std::error::Error;
use std::fmt;
use chrono::{DateTime, Utc};
use cortex_core::{MemoryId, PolicyDecision, ProofClosureReport, ProofState};
use cortex_store::repo::{MemoryAcceptanceAudit, MemoryCandidate, MemoryRepo};
use cortex_store::StoreError;
pub const LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT: &str =
"cortex_memory.lifecycle.accept.proof_closure";
pub type LifecycleResult<T> = Result<T, LifecycleError>;
#[derive(Debug)]
pub enum LifecycleError {
Store(StoreError),
Validation(String),
ProofClosureRefusal(ProofState),
}
impl fmt::Display for LifecycleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Store(err) => write!(f, "store error: {err}"),
Self::Validation(message) => write!(f, "validation failed: {message}"),
Self::ProofClosureRefusal(state) => write!(
f,
"invariant={LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT} proof closure must be FullChainVerified for durable accept; observed {state:?}"
),
}
}
}
impl Error for LifecycleError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Store(err) => Some(err),
Self::Validation(_) | Self::ProofClosureRefusal(_) => None,
}
}
}
impl From<StoreError> for LifecycleError {
fn from(err: StoreError) -> Self {
Self::Store(err)
}
}
#[derive(Debug, Clone, Copy)]
pub struct AcceptCandidateRequest<'a> {
pub candidate: &'a MemoryCandidate,
pub audit: &'a MemoryAcceptanceAudit,
pub policy: &'a PolicyDecision,
pub proof_closure: &'a ProofClosureReport,
}
pub fn accept(
memories: &MemoryRepo<'_>,
candidate_id: &MemoryId,
updated_at: DateTime<Utc>,
audit: &MemoryAcceptanceAudit,
policy: &PolicyDecision,
proof_closure: &ProofClosureReport,
) -> LifecycleResult<MemoryId> {
require_full_proof_closure(proof_closure)?;
let candidate = memories.get_candidate_by_id(candidate_id)?.ok_or_else(|| {
LifecycleError::Validation(format!("memory {candidate_id} is not a candidate"))
})?;
if candidate
.source_episodes_json
.as_array()
.is_some_and(|items| !items.is_empty())
|| candidate
.source_events_json
.as_array()
.is_some_and(|items| !items.is_empty())
{
let accepted = memories.accept_candidate(candidate_id, updated_at, audit, policy)?;
return Ok(accepted.id);
}
Err(LifecycleError::Validation(
"memory candidate requires non-empty episode or event lineage".into(),
))
}
pub fn accept_candidate(
memories: &MemoryRepo<'_>,
request: AcceptCandidateRequest<'_>,
) -> LifecycleResult<MemoryId> {
require_full_proof_closure(request.proof_closure)?;
validate_candidate_lineage(request.candidate)?;
memories.insert_candidate(request.candidate)?;
memories.accept_candidate(
&request.candidate.id,
request.candidate.updated_at,
request.audit,
request.policy,
)?;
Ok(request.candidate.id)
}
fn require_full_proof_closure(report: &ProofClosureReport) -> LifecycleResult<()> {
if report.is_full_chain_verified() {
Ok(())
} else {
Err(LifecycleError::ProofClosureRefusal(report.state()))
}
}
pub fn validate_candidate_lineage(candidate: &MemoryCandidate) -> LifecycleResult<()> {
if candidate
.source_episodes_json
.as_array()
.is_some_and(|items| !items.is_empty())
|| candidate
.source_events_json
.as_array()
.is_some_and(|items| !items.is_empty())
{
return Ok(());
}
Err(LifecycleError::Validation(
"memory candidate requires non-empty episode or event lineage".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use cortex_core::{
AuditRecordId, FailingEdge, ProofClosureReport, ProofEdgeFailure, ProofEdgeKind,
};
use cortex_store::repo::memories::accept_candidate_policy_decision_test_allow;
use cortex_store::{Pool, INITIAL_MIGRATION_SQL};
use serde_json::json;
fn full_proof_closure() -> ProofClosureReport {
ProofClosureReport::full_chain_verified(Vec::new())
}
fn partial_proof_closure() -> ProofClosureReport {
ProofClosureReport::from_edges(
Vec::new(),
vec![FailingEdge::missing(
ProofEdgeKind::LineageClosure,
"memory:test",
"test fixture: lineage axis observed but unresolved",
)],
)
}
fn broken_proof_closure() -> ProofClosureReport {
ProofClosureReport::from_edges(
Vec::new(),
vec![FailingEdge::broken(
ProofEdgeKind::HashChain,
"event:a",
"event:b",
ProofEdgeFailure::Mismatch,
"test fixture: hash chain mismatch",
)],
)
}
fn test_pool() -> Pool {
let pool = Pool::open_in_memory().expect("open in-memory sqlite");
pool.execute_batch(INITIAL_MIGRATION_SQL)
.expect("run initial migration");
pool
}
fn candidate(has_event_lineage: bool) -> MemoryCandidate {
MemoryCandidate {
id: "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap(),
memory_type: "semantic".into(),
claim: "Cortex memories require lineage.".into(),
source_episodes_json: Default::default(),
source_events_json: if has_event_lineage {
"[\"evt_01ARZ3NDEKTSV4RRFFQ69G5FAV\"]".parse().unwrap()
} else {
Default::default()
},
domains_json: Default::default(),
salience_json: Default::default(),
confidence: 0.7,
authority: "candidate".into(),
applies_when_json: Default::default(),
does_not_apply_when_json: Default::default(),
created_at: "1970-01-01T00:00:00Z".parse().unwrap(),
updated_at: "1970-01-01T00:00:00Z".parse().unwrap(),
}
}
#[test]
fn lineage_validation_rejects_missing_or_empty_sources() {
assert!(validate_candidate_lineage(&candidate(false)).is_err());
assert!(validate_candidate_lineage(&candidate(true)).is_ok());
}
#[test]
fn accept_candidate_rejects_empty_lineage_before_any_write() {
let pool = test_pool();
let memories = MemoryRepo::new(&pool);
let policy = accept_candidate_policy_decision_test_allow();
let proof_closure = full_proof_closure();
let request = AcceptCandidateRequest {
candidate: &candidate(false),
audit: &acceptance_audit(),
policy: &policy,
proof_closure: &proof_closure,
};
assert!(accept_candidate(&memories, request).is_err());
let count: i64 = pool
.query_row("SELECT COUNT(*) FROM memories;", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
}
fn acceptance_audit() -> MemoryAcceptanceAudit {
MemoryAcceptanceAudit {
id: AuditRecordId::new(),
actor_json: json!({"kind": "test"}),
reason: "unit test accept".into(),
source_refs_json: json!(["evt_01ARZ3NDEKTSV4RRFFQ69G5FAV"]),
created_at: "1970-01-01T00:00:05Z".parse().unwrap(),
}
}
#[test]
fn accept_candidate_inserts_active_memory_and_optional_audit() {
let pool = test_pool();
let memories = MemoryRepo::new(&pool);
let mut candidate = candidate(true);
candidate.updated_at = "1970-01-01T00:00:05Z".parse().unwrap();
let audit = acceptance_audit();
let policy = accept_candidate_policy_decision_test_allow();
let proof_closure = full_proof_closure();
let accepted = accept_candidate(
&memories,
AcceptCandidateRequest {
candidate: &candidate,
audit: &audit,
policy: &policy,
proof_closure: &proof_closure,
},
)
.expect("accept candidate with lineage");
assert_eq!(accepted, candidate.id);
let status: String = pool
.query_row(
"SELECT status FROM memories WHERE id = ?1;",
[candidate.id.to_string()],
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "active");
let audit_count: i64 = pool
.query_row(
"SELECT COUNT(*) FROM audit_records WHERE target_ref = ?1;",
[candidate.id.to_string()],
|row| row.get(0),
)
.unwrap();
assert_eq!(audit_count, 1);
}
#[test]
fn id_only_accept_uses_stored_candidate_and_audit_transaction() {
let pool = test_pool();
let memories = MemoryRepo::new(&pool);
let mut candidate = candidate(true);
candidate.updated_at = "1970-01-01T00:00:03Z".parse().unwrap();
memories
.insert_candidate(&candidate)
.expect("insert candidate");
let audit = acceptance_audit();
let proof_closure = full_proof_closure();
let accepted = accept(
&memories,
&candidate.id,
"1970-01-01T00:00:06Z".parse().unwrap(),
&audit,
&accept_candidate_policy_decision_test_allow(),
&proof_closure,
)
.expect("accept stored candidate by id");
assert_eq!(accepted, candidate.id);
assert_eq!(
memories
.get_by_id(&candidate.id)
.unwrap()
.expect("accepted memory exists")
.status,
"active"
);
}
#[test]
fn accept_candidate_refuses_partial_proof_closure_before_any_write() {
let pool = test_pool();
let memories = MemoryRepo::new(&pool);
let candidate = candidate(true);
let policy = accept_candidate_policy_decision_test_allow();
let proof_closure = partial_proof_closure();
let err = accept_candidate(
&memories,
AcceptCandidateRequest {
candidate: &candidate,
audit: &acceptance_audit(),
policy: &policy,
proof_closure: &proof_closure,
},
)
.expect_err("partial proof closure must refuse");
match err {
LifecycleError::ProofClosureRefusal(state) => {
assert_eq!(state, ProofState::Partial);
assert!(err
.to_string()
.contains(LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT));
}
other => panic!("expected ProofClosureRefusal, got {other:?}"),
}
let count: i64 = pool
.query_row("SELECT COUNT(*) FROM memories;", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0, "no row may be written on proof closure refusal");
}
#[test]
fn accept_candidate_refuses_broken_proof_closure_before_any_write() {
let pool = test_pool();
let memories = MemoryRepo::new(&pool);
let candidate = candidate(true);
let policy = accept_candidate_policy_decision_test_allow();
let proof_closure = broken_proof_closure();
let err = accept_candidate(
&memories,
AcceptCandidateRequest {
candidate: &candidate,
audit: &acceptance_audit(),
policy: &policy,
proof_closure: &proof_closure,
},
)
.expect_err("broken proof closure must refuse");
match err {
LifecycleError::ProofClosureRefusal(state) => {
assert_eq!(state, ProofState::Broken);
}
other => panic!("expected ProofClosureRefusal, got {other:?}"),
}
}
#[test]
fn id_only_accept_refuses_partial_proof_closure_before_any_write() {
let pool = test_pool();
let memories = MemoryRepo::new(&pool);
let candidate = candidate(true);
memories
.insert_candidate(&candidate)
.expect("insert candidate");
let audit = acceptance_audit();
let proof_closure = partial_proof_closure();
let err = accept(
&memories,
&candidate.id,
"1970-01-01T00:00:06Z".parse().unwrap(),
&audit,
&accept_candidate_policy_decision_test_allow(),
&proof_closure,
)
.expect_err("partial proof closure must refuse");
match err {
LifecycleError::ProofClosureRefusal(state) => {
assert_eq!(state, ProofState::Partial);
}
other => panic!("expected ProofClosureRefusal, got {other:?}"),
}
assert_eq!(
memories
.get_by_id(&candidate.id)
.unwrap()
.expect("candidate still exists")
.status,
"candidate",
"candidate must remain in candidate state after refusal"
);
}
#[test]
fn lifecycle_accept_proof_closure_invariant_key_is_stable() {
assert_eq!(
LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT,
"cortex_memory.lifecycle.accept.proof_closure"
);
}
}