1use 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
14pub const LIFECYCLE_ACCEPT_PROOF_CLOSURE_INVARIANT: &str =
22 "cortex_memory.lifecycle.accept.proof_closure";
23
24pub type LifecycleResult<T> = Result<T, LifecycleError>;
26
27#[derive(Debug)]
29pub enum LifecycleError {
30 Store(StoreError),
32 Validation(String),
34 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#[derive(Debug, Clone, Copy)]
83pub struct AcceptCandidateRequest<'a> {
84 pub candidate: &'a MemoryCandidate,
86 pub audit: &'a MemoryAcceptanceAudit,
88 pub policy: &'a PolicyDecision,
91 pub proof_closure: &'a ProofClosureReport,
97}
98
99pub 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
138pub 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
167fn 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
178pub 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 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 #[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}