1use chrono::{DateTime, Utc};
34use cortex_core::{
35 validate_summary_spans, AuditRecordId, CrossSessionSalience, EventId, MemoryId,
36 OutcomeMemoryRelation, PolicyContribution, PolicyDecision, PolicyOutcome, ProofClosureReport,
37 ProofState, SourceAuthority, SummarySpan, TemporalAuthorityReport,
38};
39use rusqlite::{params, OptionalExtension, Row};
40use serde_json::Value;
41
42use crate::{Pool, StoreError, StoreResult};
43
44pub const ACCEPT_PROOF_CLOSURE_RULE_ID: &str = "memory.accept.proof_closure";
49pub const ACCEPT_OPEN_CONTRADICTION_RULE_ID: &str = "memory.accept.open_contradiction";
54pub const ACCEPT_SEMANTIC_TRUST_RULE_ID: &str = "memory.accept.semantic_trust";
58pub const ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID: &str = "memory.accept.operator_temporal_use";
63
64pub const OUTCOME_VALIDATION_SCOPE_RULE_ID: &str = "memory.outcome.validation_scope";
70pub const OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID: &str =
74 "memory.outcome.validating_principal_tier";
75pub const OUTCOME_EVIDENCE_REF_RULE_ID: &str = "memory.outcome.evidence_ref";
79
80pub const OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT: &str =
87 "memory.outcome.utility_to_truth_promotion_unauthorized";
88
89pub const CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD: u32 = 5;
99
100pub const CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT: &str =
110 "memory.cross_session_use.repeated_unvalidated_weak_negative";
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum CrossSessionWeakNegativeStatus {
123 BelowThreshold,
126 SuppressedByValidation,
130 WeakNegativeAboveThreshold {
136 cross_session_use_count: u32,
138 },
139}
140
141impl CrossSessionWeakNegativeStatus {
142 #[must_use]
144 pub const fn is_weak_negative(self) -> bool {
145 matches!(self, Self::WeakNegativeAboveThreshold { .. })
146 }
147
148 #[must_use]
150 pub const fn invariant(self) -> Option<&'static str> {
151 match self {
152 Self::WeakNegativeAboveThreshold { .. } => {
153 Some(CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT)
154 }
155 _ => None,
156 }
157 }
158}
159
160#[must_use]
171pub const fn cross_session_weak_negative_status(
172 cross_session_use_count: u32,
173 validation_epoch: u32,
174) -> CrossSessionWeakNegativeStatus {
175 if cross_session_use_count <= CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD {
176 return CrossSessionWeakNegativeStatus::BelowThreshold;
177 }
178 if validation_epoch > 0 {
179 return CrossSessionWeakNegativeStatus::SuppressedByValidation;
180 }
181 CrossSessionWeakNegativeStatus::WeakNegativeAboveThreshold {
182 cross_session_use_count,
183 }
184}
185
186pub const V2_SUMMARY_SPAN_PROOF_RULE_ID: &str = "memory.v2.summary_span_proof";
191pub const V2_CROSS_SESSION_SALIENCE_RULE_ID: &str = "memory.v2.cross_session_salience";
198
199macro_rules! memory_select_sql {
200 ($where_clause:literal) => {
201 concat!(
202 "SELECT id, memory_type, status, claim, source_episodes_json, source_events_json,
203 domains_json, salience_json, confidence, authority, applies_when_json,
204 does_not_apply_when_json, created_at, updated_at
205 FROM memories ",
206 $where_clause,
207 ";"
208 )
209 };
210}
211
212#[derive(Debug, Clone, PartialEq)]
214pub struct MemoryCandidate {
215 pub id: MemoryId,
217 pub memory_type: String,
219 pub claim: String,
221 pub source_episodes_json: Value,
223 pub source_events_json: Value,
225 pub domains_json: Value,
227 pub salience_json: Value,
229 pub confidence: f64,
231 pub authority: String,
233 pub applies_when_json: Value,
235 pub does_not_apply_when_json: Value,
237 pub created_at: DateTime<Utc>,
239 pub updated_at: DateTime<Utc>,
241}
242
243#[derive(Debug, Clone, PartialEq)]
245pub struct MemoryRecord {
246 pub id: MemoryId,
248 pub memory_type: String,
250 pub status: String,
252 pub claim: String,
254 pub source_episodes_json: Value,
256 pub source_events_json: Value,
258 pub domains_json: Value,
260 pub salience_json: Value,
262 pub confidence: f64,
264 pub authority: String,
266 pub applies_when_json: Value,
268 pub does_not_apply_when_json: Value,
270 pub created_at: DateTime<Utc>,
272 pub updated_at: DateTime<Utc>,
274}
275
276#[derive(Debug, Clone, PartialEq)]
278pub struct MemoryAcceptanceAudit {
279 pub id: AuditRecordId,
281 pub actor_json: Value,
283 pub reason: String,
285 pub source_refs_json: Value,
287 pub created_at: DateTime<Utc>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct MemorySessionUse {
294 pub memory_id: MemoryId,
296 pub session_id: String,
298 pub first_used_at: DateTime<Utc>,
300 pub last_used_at: DateTime<Utc>,
302 pub use_count: u32,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct OutcomeMemoryRelationRecord {
314 pub outcome_ref: String,
316 pub memory_id: MemoryId,
318 pub relation: OutcomeMemoryRelation,
320 pub recorded_at: DateTime<Utc>,
322 pub source_event_id: Option<EventId>,
324 pub validation_scope: Option<String>,
327 pub validating_principal_id: Option<String>,
331 pub evidence_ref: Option<String>,
335}
336
337#[derive(Debug)]
339pub struct MemoryRepo<'a> {
340 pool: &'a Pool,
341}
342
343impl<'a> MemoryRepo<'a> {
344 #[must_use]
346 pub const fn new(pool: &'a Pool) -> Self {
347 Self { pool }
348 }
349
350 pub fn insert_candidate(&self, memory: &MemoryCandidate) -> StoreResult<()> {
355 let is_operator_note = memory.memory_type == "operator_note";
356 if !is_operator_note
357 && json_array_empty(&memory.source_episodes_json)
358 && json_array_empty(&memory.source_events_json)
359 {
360 return Err(StoreError::Validation(
361 "memory candidate requires episode or event lineage".into(),
362 ));
363 }
364
365 self.pool.execute(
366 "INSERT INTO memories (
367 id, memory_type, status, claim, source_episodes_json, source_events_json,
368 domains_json, salience_json, confidence, authority, applies_when_json,
369 does_not_apply_when_json, created_at, updated_at
370 ) VALUES (?1, ?2, 'candidate', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13);",
371 params![
372 memory.id.to_string(),
373 memory.memory_type,
374 memory.claim,
375 serde_json::to_string(&memory.source_episodes_json)?,
376 serde_json::to_string(&memory.source_events_json)?,
377 serde_json::to_string(&memory.domains_json)?,
378 serde_json::to_string(&memory.salience_json)?,
379 memory.confidence,
380 memory.authority,
381 serde_json::to_string(&memory.applies_when_json)?,
382 serde_json::to_string(&memory.does_not_apply_when_json)?,
383 memory.created_at.to_rfc3339(),
384 memory.updated_at.to_rfc3339(),
385 ],
386 )?;
387
388 Ok(())
389 }
390
391 pub fn insert_candidate_with_v2_fields(
421 &self,
422 memory: &MemoryCandidate,
423 summary_spans: &[SummarySpan],
424 salience: &CrossSessionSalience,
425 policy: &PolicyDecision,
426 ) -> StoreResult<()> {
427 require_policy_final_outcome(policy, "memory.v2.insert_candidate")?;
428 require_contributor_rule(policy, V2_SUMMARY_SPAN_PROOF_RULE_ID)?;
429 require_contributor_rule(policy, V2_CROSS_SESSION_SALIENCE_RULE_ID)?;
430
431 if json_array_empty(&memory.source_episodes_json)
432 && json_array_empty(&memory.source_events_json)
433 {
434 return Err(StoreError::Validation(
435 "memory candidate requires episode or event lineage".into(),
436 ));
437 }
438 if memory.memory_type.contains("summary") || !summary_spans.is_empty() {
439 validate_summary_spans(&memory.claim, summary_spans, |_| SourceAuthority::Derived)
440 .map_err(|err| {
441 StoreError::Validation(format!(
442 "memory summary_spans_json failed {}",
443 err.invariant()
444 ))
445 })?;
446 }
447 validate_candidate_cross_session_salience(salience)?;
448
449 self.pool.execute(
450 "INSERT INTO memories (
451 id, memory_type, status, claim, source_episodes_json, source_events_json,
452 domains_json, salience_json, confidence, authority, applies_when_json,
453 does_not_apply_when_json, created_at, updated_at, summary_spans_json,
454 cross_session_use_count, first_used_at, last_cross_session_use_at,
455 last_validation_at, validation_epoch, blessed_until
456 ) VALUES (
457 ?1, ?2, 'candidate', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
458 ?15, ?16, ?17, ?18, ?19, ?20
459 );",
460 params![
461 memory.id.to_string(),
462 memory.memory_type,
463 memory.claim,
464 serde_json::to_string(&memory.source_episodes_json)?,
465 serde_json::to_string(&memory.source_events_json)?,
466 serde_json::to_string(&memory.domains_json)?,
467 serde_json::to_string(&memory.salience_json)?,
468 memory.confidence,
469 memory.authority,
470 serde_json::to_string(&memory.applies_when_json)?,
471 serde_json::to_string(&memory.does_not_apply_when_json)?,
472 memory.created_at.to_rfc3339(),
473 memory.updated_at.to_rfc3339(),
474 serde_json::to_string(summary_spans)?,
475 i64::from(salience.cross_session_use_count),
476 salience.first_used_at.map(|value| value.to_rfc3339()),
477 salience
478 .last_cross_session_use_at
479 .map(|value| value.to_rfc3339()),
480 salience.last_validation_at.map(|value| value.to_rfc3339()),
481 i64::from(salience.validation_epoch),
482 salience.blessed_until.map(|value| value.to_rfc3339()),
483 ],
484 )?;
485
486 Ok(())
487 }
488
489 pub fn record_session_use(&self, use_row: &MemorySessionUse) -> StoreResult<()> {
493 if use_row.session_id.trim().is_empty() {
494 return Err(StoreError::Validation(
495 "memory session use requires non-empty session_id".into(),
496 ));
497 }
498 if use_row.use_count == 0 {
499 return Err(StoreError::Validation(
500 "memory session use requires positive use_count".into(),
501 ));
502 }
503 if use_row.last_used_at < use_row.first_used_at {
504 return Err(StoreError::Validation(
505 "memory session use last_used_at cannot be earlier than first_used_at".into(),
506 ));
507 }
508
509 let tx = self.pool.unchecked_transaction()?;
510 ensure_memory_exists(&tx, &use_row.memory_id)?;
511 tx.execute(
512 "INSERT INTO memory_session_uses (
513 memory_id, session_id, first_used_at, last_used_at, use_count
514 ) VALUES (?1, ?2, ?3, ?4, ?5)
515 ON CONFLICT(memory_id, session_id) DO UPDATE SET
516 first_used_at = excluded.first_used_at,
517 last_used_at = excluded.last_used_at,
518 use_count = excluded.use_count;",
519 params![
520 use_row.memory_id.to_string(),
521 use_row.session_id.as_str(),
522 use_row.first_used_at.to_rfc3339(),
523 use_row.last_used_at.to_rfc3339(),
524 i64::from(use_row.use_count),
525 ],
526 )?;
527 reconcile_memory_session_salience(&tx, &use_row.memory_id)?;
528 tx.commit()?;
529
530 Ok(())
531 }
532
533 pub fn record_outcome_relation(
560 &self,
561 relation: &OutcomeMemoryRelationRecord,
562 policy: Option<&PolicyDecision>,
563 ) -> StoreResult<()> {
564 if relation.outcome_ref.trim().is_empty() {
565 return Err(StoreError::Validation(
566 "outcome memory relation requires non-empty outcome_ref".into(),
567 ));
568 }
569
570 if relation.relation.advances_validation() {
571 let policy = policy.ok_or_else(|| {
575 StoreError::Validation(format!(
576 "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires a composed PolicyDecision; caller skipped ADR 0026 composition",
577 ))
578 })?;
579 require_policy_final_outcome_for_validation(policy)?;
580 require_contributor_rule_for_validation(policy, OUTCOME_VALIDATION_SCOPE_RULE_ID)?;
581 require_contributor_rule_for_validation(
582 policy,
583 OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
584 )?;
585 require_contributor_rule_for_validation(policy, OUTCOME_EVIDENCE_REF_RULE_ID)?;
586 require_scoped_validation_payload(relation)?;
587 } else if policy.is_some() {
588 return Err(StoreError::Validation(format!(
589 "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: non-validation outcome relation must not carry a PolicyDecision (relation does not move the validation axis)",
590 )));
591 } else {
592 require_no_scoped_validation_payload(relation)?;
593 }
594
595 let tx = self.pool.unchecked_transaction()?;
596 ensure_memory_exists(&tx, &relation.memory_id)?;
597 let relation_wire = serde_json::to_value(relation.relation)?
598 .as_str()
599 .ok_or_else(|| {
600 StoreError::Validation("outcome memory relation did not serialize as string".into())
601 })?
602 .to_string();
603 tx.execute(
604 "INSERT INTO outcome_memory_relations (
605 outcome_ref, memory_id, relation, recorded_at, source_event_id,
606 validation_scope, validating_principal_id, evidence_ref
607 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
608 ON CONFLICT(outcome_ref, memory_id, relation) DO UPDATE SET
609 recorded_at = excluded.recorded_at,
610 source_event_id = excluded.source_event_id,
611 validation_scope = excluded.validation_scope,
612 validating_principal_id = excluded.validating_principal_id,
613 evidence_ref = excluded.evidence_ref;",
614 params![
615 relation.outcome_ref.as_str(),
616 relation.memory_id.to_string(),
617 relation_wire,
618 relation.recorded_at.to_rfc3339(),
619 relation.source_event_id.as_ref().map(ToString::to_string),
620 relation.validation_scope.as_deref(),
621 relation.validating_principal_id.as_deref(),
622 relation.evidence_ref.as_deref(),
623 ],
624 )?;
625 if relation.relation.advances_validation() {
626 tx.execute(
627 "UPDATE memories
628 SET last_validation_at = ?2,
629 validation_epoch = COALESCE(validation_epoch, 0) + 1
630 WHERE id = ?1;",
631 params![
632 relation.memory_id.to_string(),
633 relation.recorded_at.to_rfc3339()
634 ],
635 )?;
636 }
637 tx.commit()?;
638
639 Ok(())
640 }
641
642 pub fn cross_session_weak_negative_status_for(
649 &self,
650 id: &MemoryId,
651 ) -> StoreResult<Option<CrossSessionWeakNegativeStatus>> {
652 let row: Option<(Option<u32>, Option<u32>)> = self
653 .pool
654 .query_row(
655 "SELECT cross_session_use_count, validation_epoch FROM memories WHERE id = ?1;",
656 params![id.to_string()],
657 |row| Ok((row.get::<_, Option<u32>>(0)?, row.get::<_, Option<u32>>(1)?)),
658 )
659 .optional()?;
660 Ok(row.map(|(use_count, epoch)| {
661 cross_session_weak_negative_status(use_count.unwrap_or(0), epoch.unwrap_or(0))
662 }))
663 }
664
665 pub fn validation_epoch_for(&self, id: &MemoryId) -> StoreResult<Option<u32>> {
680 let row: Option<Option<u32>> = self
681 .pool
682 .query_row(
683 "SELECT validation_epoch FROM memories WHERE id = ?1;",
684 params![id.to_string()],
685 |row| row.get::<_, Option<u32>>(0),
686 )
687 .optional()?;
688 Ok(row.map(|epoch| epoch.unwrap_or(0)))
689 }
690
691 pub fn get_by_id(&self, id: &MemoryId) -> StoreResult<Option<MemoryRecord>> {
693 let row = self
694 .pool
695 .query_row(
696 memory_select_sql!("WHERE id = ?1"),
697 params![id.to_string()],
698 memory_row,
699 )
700 .optional()?;
701
702 row.map(TryInto::try_into).transpose()
703 }
704
705 pub fn fts5_search(&self, query: &str, limit: usize) -> StoreResult<Vec<(MemoryId, f32)>> {
753 let trimmed = query.trim();
754 if trimmed.is_empty() {
755 return Err(StoreError::Validation(
756 "fts5_search: query must not be empty".into(),
757 ));
758 }
759 if limit == 0 {
760 return Ok(Vec::new());
761 }
762
763 let match_expr = fts5_fuzzy_match_expression(trimmed).ok_or_else(|| {
764 StoreError::Validation(
765 "fts5_search: query produced no searchable trigrams after sanitization".into(),
766 )
767 })?;
768
769 let limit_i64 = i64::try_from(limit)
770 .map_err(|_| StoreError::Validation("fts5_search: limit exceeds i64 range".into()))?;
771
772 let mut stmt = self.pool.prepare(
773 "SELECT memory_id, rank \
774 FROM memories_fts \
775 WHERE memories_fts MATCH ?1 \
776 ORDER BY rank \
777 LIMIT ?2;",
778 )?;
779 let rows = stmt.query_map(params![match_expr, limit_i64], |row| {
780 Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
781 })?;
782
783 let mut hits = Vec::new();
784 for row in rows {
785 let (id_text, rank) = row?;
786 let id = id_text.parse::<MemoryId>().map_err(|err| {
787 StoreError::Validation(format!(
788 "fts5_search: memory_id mirror value `{id_text}` failed to parse: {err}"
789 ))
790 })?;
791 hits.push((id, rank as f32));
792 }
793 Ok(hits)
794 }
795
796 pub fn get_candidate_by_id(&self, id: &MemoryId) -> StoreResult<Option<MemoryRecord>> {
798 let row = self
799 .pool
800 .query_row(
801 memory_select_sql!("WHERE id = ?1 AND status = 'candidate'"),
802 params![id.to_string()],
803 memory_row,
804 )
805 .optional()?;
806
807 row.map(TryInto::try_into).transpose()
808 }
809
810 pub fn list_candidates(&self) -> StoreResult<Vec<MemoryRecord>> {
812 self.list_by_status("candidate")
813 }
814
815 pub fn list_by_status(&self, status: &str) -> StoreResult<Vec<MemoryRecord>> {
817 let mut stmt = self.pool.prepare(memory_select_sql!(
818 "WHERE status = ?1 ORDER BY updated_at DESC, id"
819 ))?;
820 let rows = stmt.query_map(params![status], memory_row)?;
821
822 let mut memories = Vec::new();
823 for row in rows {
824 memories.push(row?.try_into()?);
825 }
826 Ok(memories)
827 }
828
829 pub fn list_by_status_with_tags(
842 &self,
843 status: &str,
844 tags: &[String],
845 ) -> StoreResult<Vec<MemoryRecord>> {
846 let mut unique_tags: Vec<String> = Vec::with_capacity(tags.len());
850 for tag in tags {
851 if !unique_tags.iter().any(|existing| existing == tag) {
852 unique_tags.push(tag.clone());
853 }
854 }
855
856 if unique_tags.is_empty() {
857 return self.list_by_status(status);
858 }
859
860 let placeholders = std::iter::repeat_n("?", unique_tags.len())
866 .collect::<Vec<_>>()
867 .join(",");
868 let where_clause = format!(
869 "WHERE status = ? AND id IN (
870 SELECT m.id FROM memories m, json_each(m.domains_json) je
871 WHERE je.value IN ({placeholders})
872 GROUP BY m.id
873 HAVING COUNT(DISTINCT je.value) = ?
874 ) ORDER BY updated_at DESC, id"
875 );
876 let sql = format!(
877 "SELECT id, memory_type, status, claim, source_episodes_json, source_events_json, \
878 domains_json, salience_json, confidence, authority, applies_when_json, \
879 does_not_apply_when_json, created_at, updated_at \
880 FROM memories {where_clause};"
881 );
882
883 let mut stmt = self.pool.prepare(&sql)?;
884 let unique_count = i64::try_from(unique_tags.len()).map_err(|_| {
885 StoreError::Validation("list_by_status_with_tags: tag count exceeds i64 range".into())
886 })?;
887 let mut bind_values: Vec<rusqlite::types::Value> =
888 Vec::with_capacity(unique_tags.len() + 2);
889 bind_values.push(rusqlite::types::Value::Text(status.to_string()));
890 for tag in &unique_tags {
891 bind_values.push(rusqlite::types::Value::Text(tag.clone()));
892 }
893 bind_values.push(rusqlite::types::Value::Integer(unique_count));
894 let rows = stmt.query_map(rusqlite::params_from_iter(bind_values), memory_row)?;
895
896 let mut memories = Vec::new();
897 for row in rows {
898 memories.push(row?.try_into()?);
899 }
900 Ok(memories)
901 }
902
903 pub fn set_active(&self, id: &MemoryId, updated_at: DateTime<Utc>) -> StoreResult<()> {
905 let changed = self.pool.execute(
906 "UPDATE memories
907 SET status = 'active', updated_at = ?2
908 WHERE id = ?1;",
909 params![id.to_string(), updated_at.to_rfc3339()],
910 )?;
911
912 if changed == 0 {
913 return Err(StoreError::Validation(format!("memory {id} not found")));
914 }
915
916 Ok(())
917 }
918
919 pub fn set_pending_mcp_commit(&self, id: &MemoryId, now: DateTime<Utc>) -> StoreResult<()> {
926 let changed = self.pool.execute(
927 "UPDATE memories
928 SET status = 'pending_mcp_commit', updated_at = ?2
929 WHERE id = ?1 AND status = 'candidate';",
930 params![id.to_string(), now.to_rfc3339()],
931 )?;
932
933 if changed == 0 {
934 let current: Option<String> = self
936 .pool
937 .query_row(
938 "SELECT status FROM memories WHERE id = ?1;",
939 params![id.to_string()],
940 |row| row.get(0),
941 )
942 .optional()?;
943 return Err(StoreError::Validation(match current.as_deref() {
944 None => format!("memory {id} not found"),
945 Some(s) => format!("memory {id} is not a candidate: {s}"),
946 }));
947 }
948
949 Ok(())
950 }
951
952 pub fn commit_pending_mcp(
959 &self,
960 _session_receipt_id: &str,
961 now: DateTime<Utc>,
962 ) -> StoreResult<usize> {
963 let changed = self.pool.execute(
964 "UPDATE memories
965 SET status = 'active', updated_at = ?1
966 WHERE status = 'pending_mcp_commit';",
967 params![now.to_rfc3339()],
968 )?;
969 Ok(changed)
970 }
971
972 pub fn max_sensitivity_for_active_memories(&self) -> StoreResult<String> {
988 let max_rank: Option<i64> = self
992 .pool
993 .query_row(
994 "SELECT MAX(CASE je.value \
995 WHEN 'sensitivity:high' THEN 3 \
996 WHEN 'sensitivity:medium' THEN 2 \
997 WHEN 'sensitivity:low' THEN 1 \
998 ELSE 0 \
999 END) \
1000 FROM memories m, json_each(m.domains_json) je \
1001 WHERE m.status = 'active' \
1002 AND je.value IN ('sensitivity:high', 'sensitivity:medium', 'sensitivity:low');",
1003 [],
1004 |row| row.get(0),
1005 )
1006 .optional()?
1007 .flatten();
1008
1009 let level = match max_rank {
1010 Some(3) => "high",
1011 Some(2) => "medium",
1012 Some(1) => "low",
1013 _ => "none",
1014 };
1015 Ok(level.to_string())
1016 }
1017
1018 pub fn add_domain_tag(
1025 &self,
1026 id: &MemoryId,
1027 tag: &str,
1028 now: DateTime<Utc>,
1029 ) -> StoreResult<bool> {
1030 if tag.trim().is_empty() {
1031 return Err(StoreError::Validation(
1032 "add_domain_tag: tag must not be empty".into(),
1033 ));
1034 }
1035 let tx = self.pool.unchecked_transaction()?;
1036 let domains_raw: Option<String> = tx
1037 .query_row(
1038 "SELECT domains_json FROM memories WHERE id = ?1;",
1039 params![id.to_string()],
1040 |row| row.get(0),
1041 )
1042 .optional()?;
1043 let domains_raw =
1044 domains_raw.ok_or_else(|| StoreError::Validation(format!("memory {id} not found")))?;
1045 let mut tags: Vec<String> = serde_json::from_str(&domains_raw).map_err(|err| {
1046 StoreError::Validation(format!(
1047 "add_domain_tag: domains_json for memory {id} is not a valid JSON array: {err}"
1048 ))
1049 })?;
1050 if tags.iter().any(|t| t == tag) {
1051 return Ok(false);
1052 }
1053 tags.push(tag.to_string());
1054 let new_domains = serde_json::to_string(&tags)?;
1055 tx.execute(
1056 "UPDATE memories SET domains_json = ?2, updated_at = ?3 WHERE id = ?1;",
1057 params![id.to_string(), new_domains, now.to_rfc3339()],
1058 )?;
1059 tx.commit()?;
1060 Ok(true)
1061 }
1062
1063 pub fn remove_domain_tag(
1070 &self,
1071 id: &MemoryId,
1072 tag: &str,
1073 now: DateTime<Utc>,
1074 ) -> StoreResult<bool> {
1075 if tag.trim().is_empty() {
1076 return Err(StoreError::Validation(
1077 "remove_domain_tag: tag must not be empty".into(),
1078 ));
1079 }
1080 let tx = self.pool.unchecked_transaction()?;
1081 let domains_raw: Option<String> = tx
1082 .query_row(
1083 "SELECT domains_json FROM memories WHERE id = ?1;",
1084 params![id.to_string()],
1085 |row| row.get(0),
1086 )
1087 .optional()?;
1088 let domains_raw =
1089 domains_raw.ok_or_else(|| StoreError::Validation(format!("memory {id} not found")))?;
1090 let mut tags: Vec<String> = serde_json::from_str(&domains_raw).map_err(|err| {
1091 StoreError::Validation(format!(
1092 "remove_domain_tag: domains_json for memory {id} is not a valid JSON array: {err}"
1093 ))
1094 })?;
1095 let before_len = tags.len();
1096 tags.retain(|t| t != tag);
1097 if tags.len() == before_len {
1098 return Ok(false);
1099 }
1100 let new_domains = serde_json::to_string(&tags)?;
1101 tx.execute(
1102 "UPDATE memories SET domains_json = ?2, updated_at = ?3 WHERE id = ?1;",
1103 params![id.to_string(), new_domains, now.to_rfc3339()],
1104 )?;
1105 tx.commit()?;
1106 Ok(true)
1107 }
1108
1109 pub fn accept_candidate(
1135 &self,
1136 id: &MemoryId,
1137 updated_at: DateTime<Utc>,
1138 audit: &MemoryAcceptanceAudit,
1139 policy: &PolicyDecision,
1140 ) -> StoreResult<MemoryRecord> {
1141 require_policy_final_outcome(policy, "memory.accept")?;
1142 require_contributor_rule(policy, ACCEPT_PROOF_CLOSURE_RULE_ID)?;
1143 require_contributor_rule(policy, ACCEPT_OPEN_CONTRADICTION_RULE_ID)?;
1144 require_contributor_rule(policy, ACCEPT_SEMANTIC_TRUST_RULE_ID)?;
1145 require_contributor_rule(policy, ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID)?;
1146 require_attestation_not_break_glassed(
1147 policy,
1148 ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID,
1149 "memory.accept",
1150 )?;
1151
1152 let tx = self.pool.unchecked_transaction()?;
1153 let before = tx
1154 .query_row(
1155 "SELECT status FROM memories WHERE id = ?1;",
1156 params![id.to_string()],
1157 |row| row.get::<_, String>(0),
1158 )
1159 .optional()?;
1160
1161 match before.as_deref() {
1162 Some("candidate") => {}
1163 Some(status) => {
1164 return Err(StoreError::Validation(format!(
1165 "memory {id} is not a candidate: {status}"
1166 )));
1167 }
1168 None => {
1169 return Err(StoreError::Validation(format!("memory {id} not found")));
1170 }
1171 }
1172
1173 tx.execute(
1174 "UPDATE memories
1175 SET status = 'active', updated_at = ?2
1176 WHERE id = ?1 AND status = 'candidate';",
1177 params![id.to_string(), updated_at.to_rfc3339()],
1178 )?;
1179 tx.execute(
1180 "INSERT INTO audit_records (
1181 id, operation, target_ref, before_hash, after_hash, reason,
1182 actor_json, source_refs_json, created_at
1183 ) VALUES (?1, 'memory.accept', ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
1184 params![
1185 audit.id.to_string(),
1186 id.to_string(),
1187 "status:candidate",
1188 "status:active",
1189 audit.reason,
1190 serde_json::to_string(&audit.actor_json)?,
1191 serde_json::to_string(&audit.source_refs_json)?,
1192 audit.created_at.to_rfc3339(),
1193 ],
1194 )?;
1195
1196 let row = tx.query_row(
1197 memory_select_sql!("WHERE id = ?1"),
1198 params![id.to_string()],
1199 memory_row,
1200 )?;
1201 tx.commit()?;
1202
1203 row.try_into()
1204 }
1205}
1206
1207fn json_array_empty(value: &Value) -> bool {
1208 value.as_array().is_some_and(Vec::is_empty)
1209}
1210
1211fn fts5_fuzzy_match_expression(query: &str) -> Option<String> {
1218 const FUZZY_GRAM_WIDTH: usize = 4;
1224
1225 let mut grams: Vec<String> = Vec::new();
1226 for raw_token in query.split_whitespace() {
1227 let sanitised: String = raw_token
1228 .chars()
1229 .filter(|character| character.is_ascii_alphanumeric())
1230 .collect::<String>()
1231 .to_ascii_lowercase();
1232 if sanitised.is_empty() {
1233 continue;
1234 }
1235
1236 if sanitised.len() < FUZZY_GRAM_WIDTH {
1237 grams.push(sanitised);
1241 continue;
1242 }
1243
1244 let bytes = sanitised.as_bytes();
1250 for start in 0..=bytes.len() - FUZZY_GRAM_WIDTH {
1251 let gram = &sanitised[start..start + FUZZY_GRAM_WIDTH];
1254 grams.push(gram.to_string());
1255 }
1256 }
1257
1258 if grams.is_empty() {
1259 return None;
1260 }
1261
1262 let mut unique = Vec::with_capacity(grams.len());
1265 for gram in grams {
1266 if !unique.contains(&gram) {
1267 unique.push(gram);
1268 }
1269 }
1270
1271 let mut expression = String::new();
1277 for (idx, gram) in unique.iter().enumerate() {
1278 if idx > 0 {
1279 expression.push_str(" OR ");
1280 }
1281 expression.push('"');
1282 expression.push_str(gram);
1283 expression.push('"');
1284 }
1285 Some(expression)
1286}
1287
1288fn require_policy_final_outcome(policy: &PolicyDecision, surface: &str) -> StoreResult<()> {
1289 match policy.final_outcome {
1290 PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
1291 PolicyOutcome::Quarantine | PolicyOutcome::Reject => Err(StoreError::Validation(format!(
1292 "{surface} preflight: composed policy outcome {:?} blocks memory mutation",
1293 policy.final_outcome,
1294 ))),
1295 }
1296}
1297
1298fn require_contributor_rule(policy: &PolicyDecision, rule_id: &str) -> StoreResult<()> {
1299 let contains_rule = policy
1300 .contributing
1301 .iter()
1302 .chain(policy.discarded.iter())
1303 .any(|contribution| contribution.rule_id.as_str() == rule_id);
1304 if contains_rule {
1305 Ok(())
1306 } else {
1307 Err(StoreError::Validation(format!(
1308 "policy decision missing required contributor `{rule_id}`; caller skipped ADR 0026 composition",
1309 )))
1310 }
1311}
1312
1313fn require_policy_final_outcome_for_validation(policy: &PolicyDecision) -> StoreResult<()> {
1314 match policy.final_outcome {
1318 PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
1319 PolicyOutcome::Quarantine | PolicyOutcome::Reject => Err(StoreError::Validation(format!(
1320 "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: composed policy outcome {:?} blocks Validated outcome relation write",
1321 policy.final_outcome,
1322 ))),
1323 }
1324}
1325
1326fn require_contributor_rule_for_validation(
1327 policy: &PolicyDecision,
1328 rule_id: &str,
1329) -> StoreResult<()> {
1330 let contains_rule = policy
1331 .contributing
1332 .iter()
1333 .chain(policy.discarded.iter())
1334 .any(|contribution| contribution.rule_id.as_str() == rule_id);
1335 if contains_rule {
1336 Ok(())
1337 } else {
1338 Err(StoreError::Validation(format!(
1339 "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: policy decision missing required contributor `{rule_id}` for Validated outcome relation",
1340 )))
1341 }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::*;
1347 use cortex_core::compose_policy_outcomes;
1348
1349 fn relation_record(
1350 relation: OutcomeMemoryRelation,
1351 with_scope: bool,
1352 ) -> OutcomeMemoryRelationRecord {
1353 OutcomeMemoryRelationRecord {
1354 outcome_ref: "outcome:test".into(),
1355 memory_id: "mem_01ARZ3NDEKTSV4RRFFQ69G5V40".parse().unwrap(),
1356 relation,
1357 recorded_at: DateTime::parse_from_rfc3339("2026-05-04T12:00:00Z")
1358 .unwrap()
1359 .with_timezone(&Utc),
1360 source_event_id: None,
1361 validation_scope: if with_scope {
1362 Some("scope:test".into())
1363 } else {
1364 None
1365 },
1366 validating_principal_id: if with_scope {
1367 Some("principal:test-operator".into())
1368 } else {
1369 None
1370 },
1371 evidence_ref: if with_scope {
1372 Some("aud:test".into())
1373 } else {
1374 None
1375 },
1376 }
1377 }
1378
1379 fn seed_pool_with_memory() -> Pool {
1380 let pool = rusqlite::Connection::open_in_memory().expect("open in-memory pool");
1381 crate::migrate::apply_pending(&pool).expect("apply migrations");
1382 pool.execute(
1383 "INSERT INTO memories (
1384 id, memory_type, status, claim, source_episodes_json, source_events_json,
1385 domains_json, salience_json, confidence, authority, applies_when_json,
1386 does_not_apply_when_json, created_at, updated_at, validation_epoch
1387 ) VALUES (
1388 'mem_01ARZ3NDEKTSV4RRFFQ69G5V40', 'semantic', 'active', 'test memory',
1389 '[]', '[\"evt_01ARZ3NDEKTSV4RRFFQ69G5V40\"]', '[]',
1390 '{}', 0.7, 'candidate', '{}', '{}',
1391 '2026-05-04T12:00:00Z', '2026-05-04T12:00:00Z', 0
1392 );",
1393 [],
1394 )
1395 .expect("seed memory");
1396 pool
1397 }
1398
1399 #[test]
1400 fn record_outcome_relation_validated_refuses_when_policy_decision_is_missing() {
1401 let pool = seed_pool_with_memory();
1402 let repo = MemoryRepo::new(&pool);
1403
1404 let err = repo
1405 .record_outcome_relation(
1406 &relation_record(OutcomeMemoryRelation::Validated, true),
1407 None,
1408 )
1409 .expect_err("missing policy decision must fail closed");
1410
1411 let msg = err.to_string();
1412 assert!(
1413 msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
1414 "expected stable invariant in error: {msg}"
1415 );
1416 }
1417
1418 #[test]
1419 fn record_outcome_relation_validated_refuses_when_policy_outcome_denies() {
1420 let pool = seed_pool_with_memory();
1421 let repo = MemoryRepo::new(&pool);
1422
1423 let deny_policy = compose_policy_outcomes(
1426 vec![
1427 PolicyContribution::new(
1428 OUTCOME_VALIDATION_SCOPE_RULE_ID,
1429 PolicyOutcome::Reject,
1430 "test: validation scope rejected (untrusted tier)",
1431 )
1432 .unwrap(),
1433 PolicyContribution::new(
1434 OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
1435 PolicyOutcome::Reject,
1436 "test: principal trust tier below class gate",
1437 )
1438 .unwrap(),
1439 PolicyContribution::new(
1440 OUTCOME_EVIDENCE_REF_RULE_ID,
1441 PolicyOutcome::Reject,
1442 "test: evidence_ref does not cite a concrete row",
1443 )
1444 .unwrap(),
1445 ],
1446 None,
1447 );
1448
1449 let err = repo
1450 .record_outcome_relation(
1451 &relation_record(OutcomeMemoryRelation::Validated, true),
1452 Some(&deny_policy),
1453 )
1454 .expect_err("deny policy must fail closed");
1455
1456 let msg = err.to_string();
1457 assert!(
1458 msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
1459 "expected stable invariant in error: {msg}"
1460 );
1461 }
1462
1463 #[test]
1464 fn record_outcome_relation_validated_refuses_missing_scoped_payload() {
1465 let pool = seed_pool_with_memory();
1466 let repo = MemoryRepo::new(&pool);
1467
1468 let err = repo
1469 .record_outcome_relation(
1470 &relation_record(OutcomeMemoryRelation::Validated, false),
1471 Some(&record_outcome_relation_policy_decision_test_allow()),
1472 )
1473 .expect_err("missing scope payload must fail closed");
1474
1475 let msg = err.to_string();
1476 assert!(
1477 msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
1478 "expected stable invariant in error: {msg}"
1479 );
1480 }
1481
1482 #[test]
1483 fn record_outcome_relation_validated_refuses_missing_contributor_rule_ids() {
1484 let pool = seed_pool_with_memory();
1485 let repo = MemoryRepo::new(&pool);
1486 let policy_missing_evidence = compose_policy_outcomes(
1487 vec![
1488 PolicyContribution::new(
1489 OUTCOME_VALIDATION_SCOPE_RULE_ID,
1490 PolicyOutcome::Allow,
1491 "test: scope ok",
1492 )
1493 .unwrap(),
1494 PolicyContribution::new(
1495 OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
1496 PolicyOutcome::Allow,
1497 "test: tier ok",
1498 )
1499 .unwrap(),
1500 ],
1502 None,
1503 );
1504
1505 let err = repo
1506 .record_outcome_relation(
1507 &relation_record(OutcomeMemoryRelation::Validated, true),
1508 Some(&policy_missing_evidence),
1509 )
1510 .expect_err("missing contributor must fail closed");
1511
1512 let msg = err.to_string();
1513 assert!(
1514 msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
1515 "expected stable invariant in error: {msg}"
1516 );
1517 assert!(
1518 msg.contains(OUTCOME_EVIDENCE_REF_RULE_ID),
1519 "error must name the missing contributor: {msg}"
1520 );
1521 }
1522
1523 #[test]
1524 fn record_outcome_relation_non_validation_relation_admits_without_policy() {
1525 let pool = seed_pool_with_memory();
1526 let repo = MemoryRepo::new(&pool);
1527
1528 repo.record_outcome_relation(&relation_record(OutcomeMemoryRelation::Used, false), None)
1531 .expect("non-validation relation admits without policy");
1532
1533 let epoch = repo
1534 .validation_epoch_for(&"mem_01ARZ3NDEKTSV4RRFFQ69G5V40".parse().unwrap())
1535 .expect("read validation_epoch")
1536 .expect("memory row exists");
1537 assert_eq!(
1538 epoch, 0,
1539 "non-validation relation must not advance validation_epoch"
1540 );
1541 }
1542
1543 #[test]
1544 fn record_outcome_relation_non_validation_relation_refuses_attached_policy_decision() {
1545 let pool = seed_pool_with_memory();
1546 let repo = MemoryRepo::new(&pool);
1547
1548 let err = repo
1553 .record_outcome_relation(
1554 &relation_record(OutcomeMemoryRelation::Used, false),
1555 Some(&record_outcome_relation_policy_decision_test_allow()),
1556 )
1557 .expect_err("attached policy on non-validation must fail closed");
1558
1559 let msg = err.to_string();
1560 assert!(
1561 msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
1562 "expected stable invariant in error: {msg}"
1563 );
1564 }
1565
1566 #[test]
1567 fn record_outcome_relation_validated_advances_validation_epoch_on_happy_path() {
1568 let pool = seed_pool_with_memory();
1569 let repo = MemoryRepo::new(&pool);
1570
1571 repo.record_outcome_relation(
1572 &relation_record(OutcomeMemoryRelation::Validated, true),
1573 Some(&record_outcome_relation_policy_decision_test_allow()),
1574 )
1575 .expect("happy path admits the row");
1576
1577 let epoch = repo
1578 .validation_epoch_for(&"mem_01ARZ3NDEKTSV4RRFFQ69G5V40".parse().unwrap())
1579 .expect("read validation_epoch")
1580 .expect("memory row exists");
1581 assert_eq!(
1582 epoch, 1,
1583 "Validated relation must advance validation_epoch from 0 to 1"
1584 );
1585 }
1586
1587 #[test]
1588 fn validation_epoch_for_returns_none_for_missing_memory() {
1589 let pool = seed_pool_with_memory();
1590 let repo = MemoryRepo::new(&pool);
1591 let missing = repo
1592 .validation_epoch_for(&"mem_01ARZ3NDEKTSV4RRFFQ69G5V4Z".parse().unwrap())
1593 .expect("read validation_epoch");
1594 assert!(missing.is_none());
1595 }
1596
1597 #[test]
1598 fn cross_session_weak_negative_status_thresholds_fire_only_when_unvalidated() {
1599 assert!(matches!(
1601 cross_session_weak_negative_status(CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD, 0),
1602 CrossSessionWeakNegativeStatus::BelowThreshold
1603 ));
1604 let status =
1606 cross_session_weak_negative_status(CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD + 1, 0);
1607 assert!(status.is_weak_negative());
1608 assert_eq!(
1609 status.invariant(),
1610 Some(CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT)
1611 );
1612 assert!(matches!(
1614 cross_session_weak_negative_status(CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD + 1, 1),
1615 CrossSessionWeakNegativeStatus::SuppressedByValidation
1616 ));
1617 }
1618
1619 #[test]
1620 fn score_inputs_validation_reads_authoritative_epoch_not_salience_json_blob() {
1621 fn validation_gate(salience_json_validation: f32, validation_epoch: u32) -> f32 {
1629 let _ = salience_json_validation;
1632 if validation_epoch > 0 {
1633 1.0
1634 } else {
1635 0.0
1636 }
1637 }
1638 assert_eq!(validation_gate(1.0, 0), 0.0);
1639 assert_eq!(validation_gate(0.0, 1), 1.0);
1640 assert_eq!(validation_gate(1.0, 1), 1.0);
1641 }
1642}
1643
1644fn require_scoped_validation_payload(relation: &OutcomeMemoryRelationRecord) -> StoreResult<()> {
1645 if relation
1646 .validation_scope
1647 .as_deref()
1648 .is_none_or(str::is_empty)
1649 {
1650 return Err(StoreError::Validation(format!(
1651 "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires non-empty validation_scope (ADR 0020 §6)",
1652 )));
1653 }
1654 if relation
1655 .validating_principal_id
1656 .as_deref()
1657 .is_none_or(str::is_empty)
1658 {
1659 return Err(StoreError::Validation(format!(
1660 "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires non-empty validating_principal_id (ADR 0020 §6)",
1661 )));
1662 }
1663 if relation.evidence_ref.as_deref().is_none_or(str::is_empty) {
1664 return Err(StoreError::Validation(format!(
1665 "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires non-empty evidence_ref (ADR 0020 §6)",
1666 )));
1667 }
1668 Ok(())
1669}
1670
1671fn require_no_scoped_validation_payload(relation: &OutcomeMemoryRelationRecord) -> StoreResult<()> {
1672 if relation.validation_scope.is_some()
1673 || relation.validating_principal_id.is_some()
1674 || relation.evidence_ref.is_some()
1675 {
1676 return Err(StoreError::Validation(format!(
1677 "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: non-validation outcome relation must leave validation_scope/validating_principal_id/evidence_ref unset",
1678 )));
1679 }
1680 Ok(())
1681}
1682
1683fn require_attestation_not_break_glassed(
1684 policy: &PolicyDecision,
1685 rule_id: &str,
1686 surface: &str,
1687) -> StoreResult<()> {
1688 let attestation = policy
1707 .contributing
1708 .iter()
1709 .chain(policy.discarded.iter())
1710 .find(|contribution| contribution.rule_id.as_str() == rule_id)
1711 .ok_or_else(|| {
1712 StoreError::Validation(format!(
1713 "{surface} preflight: required attestation contributor `{rule_id}` is absent from the policy decision",
1714 ))
1715 })?;
1716 match attestation.outcome {
1717 PolicyOutcome::Allow | PolicyOutcome::Warn => Ok(()),
1718 other => Err(StoreError::Validation(format!(
1719 "{surface} preflight: attestation contributor `{rule_id}` returned {other:?}; ADR 0026 §4 forbids BreakGlass substituting for attestation",
1720 ))),
1721 }
1722}
1723
1724pub fn validate_candidate_cross_session_salience(
1738 salience: &CrossSessionSalience,
1739) -> StoreResult<()> {
1740 if salience.cross_session_use_count != 0 {
1741 return Err(StoreError::Validation(format!(
1742 "memory.v2.cross_session_salience preflight: candidate cross_session_use_count must be 0 at insert, observed {}",
1743 salience.cross_session_use_count,
1744 )));
1745 }
1746 if salience.first_used_at.is_some() {
1747 return Err(StoreError::Validation(
1748 "memory.v2.cross_session_salience preflight: candidate first_used_at must be unset at insert".into(),
1749 ));
1750 }
1751 if salience.last_cross_session_use_at.is_some() {
1752 return Err(StoreError::Validation(
1753 "memory.v2.cross_session_salience preflight: candidate last_cross_session_use_at must be unset at insert".into(),
1754 ));
1755 }
1756 if salience.last_validation_at.is_some() {
1757 return Err(StoreError::Validation(
1758 "memory.v2.cross_session_salience preflight: candidate last_validation_at must be unset at insert".into(),
1759 ));
1760 }
1761 if salience.validation_epoch != 0 {
1762 return Err(StoreError::Validation(format!(
1763 "memory.v2.cross_session_salience preflight: candidate validation_epoch must be 0 at insert, observed {}",
1764 salience.validation_epoch,
1765 )));
1766 }
1767 if salience.blessed_until.is_some() {
1768 return Err(StoreError::Validation(
1769 "memory.v2.cross_session_salience preflight: candidate blessed_until must be unset at insert (bless is an operator-attested post-admit action)".into(),
1770 ));
1771 }
1772 Ok(())
1773}
1774
1775pub fn summary_span_proof_contribution<F>(
1791 memory: &MemoryCandidate,
1792 summary_spans: &[SummarySpan],
1793 authority_fold: F,
1794) -> PolicyContribution
1795where
1796 F: FnMut(&[EventId]) -> SourceAuthority,
1797{
1798 let validation = if memory.memory_type.contains("summary") || !summary_spans.is_empty() {
1799 validate_summary_spans(&memory.claim, summary_spans, authority_fold)
1800 } else {
1801 Ok(())
1802 };
1803
1804 let (outcome, reason): (PolicyOutcome, String) = match validation {
1805 Ok(()) => (
1806 PolicyOutcome::Allow,
1807 "summary spans satisfy ADR 0015 structural invariants".to_string(),
1808 ),
1809 Err(err) => (
1810 PolicyOutcome::Reject,
1811 format!(
1812 "summary spans violate ADR 0015 invariant `{}`",
1813 err.invariant()
1814 ),
1815 ),
1816 };
1817
1818 PolicyContribution::new(V2_SUMMARY_SPAN_PROOF_RULE_ID, outcome, reason)
1819 .expect("v2 summary span proof contribution shape is statically valid")
1820}
1821
1822#[must_use]
1834pub fn cross_session_salience_contribution(salience: &CrossSessionSalience) -> PolicyContribution {
1835 let (outcome, reason): (PolicyOutcome, String) =
1836 match validate_candidate_cross_session_salience(salience) {
1837 Ok(()) => (
1838 PolicyOutcome::Allow,
1839 "candidate salience matches the ADR 0017 insert-time invariants".to_string(),
1840 ),
1841 Err(err) => (PolicyOutcome::Reject, err.to_string()),
1842 };
1843
1844 PolicyContribution::new(V2_CROSS_SESSION_SALIENCE_RULE_ID, outcome, reason)
1845 .expect("v2 cross-session salience contribution shape is statically valid")
1846}
1847
1848#[must_use]
1860pub fn accept_candidate_policy_decision_test_allow() -> PolicyDecision {
1861 use cortex_core::compose_policy_outcomes;
1862 compose_policy_outcomes(
1863 vec![
1864 PolicyContribution::new(
1865 ACCEPT_PROOF_CLOSURE_RULE_ID,
1866 PolicyOutcome::Allow,
1867 "test fixture: supporting-memory proof closure verified",
1868 )
1869 .expect("static test contribution is valid"),
1870 PolicyContribution::new(
1871 ACCEPT_OPEN_CONTRADICTION_RULE_ID,
1872 PolicyOutcome::Allow,
1873 "test fixture: no open durable contradiction on candidate slot",
1874 )
1875 .expect("static test contribution is valid"),
1876 PolicyContribution::new(
1877 ACCEPT_SEMANTIC_TRUST_RULE_ID,
1878 PolicyOutcome::Allow,
1879 "test fixture: semantic trust posture satisfied",
1880 )
1881 .expect("static test contribution is valid"),
1882 PolicyContribution::new(
1883 ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID,
1884 PolicyOutcome::Allow,
1885 "test fixture: operator temporal authority is currently valid",
1886 )
1887 .expect("static test contribution is valid"),
1888 ],
1889 None,
1890 )
1891}
1892
1893#[must_use]
1902pub fn accept_proof_closure_contribution(report: &ProofClosureReport) -> PolicyContribution {
1903 let (outcome, reason): (PolicyOutcome, &'static str) = match report.state() {
1904 ProofState::FullChainVerified => (
1905 PolicyOutcome::Allow,
1906 "supporting-memory proof closure is fully verified",
1907 ),
1908 ProofState::Partial => (
1909 PolicyOutcome::Quarantine,
1910 "supporting-memory proof closure is partial; promotion fails closed",
1911 ),
1912 ProofState::Broken => (
1913 PolicyOutcome::Reject,
1914 "supporting-memory proof closure is broken; promotion fails closed",
1915 ),
1916 };
1917 PolicyContribution::new(ACCEPT_PROOF_CLOSURE_RULE_ID, outcome, reason)
1918 .expect("static proof closure contribution shape is valid")
1919}
1920
1921#[must_use]
1934pub fn accept_open_contradiction_contribution(open_contradictions: usize) -> PolicyContribution {
1935 let (outcome, reason): (PolicyOutcome, String) = if open_contradictions == 0 {
1936 (
1937 PolicyOutcome::Allow,
1938 "no open durable contradiction touches the candidate slot".to_string(),
1939 )
1940 } else {
1941 (
1942 PolicyOutcome::Reject,
1943 format!(
1944 "{open_contradictions} open durable contradiction(s) touch the candidate slot; ADR 0024 forbids silent promotion"
1945 ),
1946 )
1947 };
1948 PolicyContribution::new(ACCEPT_OPEN_CONTRADICTION_RULE_ID, outcome, reason)
1949 .expect("static open contradiction contribution shape is valid")
1950}
1951
1952#[must_use]
1967pub fn accept_operator_temporal_use_contribution(
1968 report: &TemporalAuthorityReport,
1969) -> PolicyContribution {
1970 let (outcome, reason): (PolicyOutcome, &'static str) = if report.valid_now {
1971 (
1972 PolicyOutcome::Allow,
1973 "operator temporal authority is currently valid",
1974 )
1975 } else if report.valid_at_event_time {
1976 (
1977 PolicyOutcome::Quarantine,
1978 "operator temporal authority is historical only; current use blocked",
1979 )
1980 } else {
1981 (
1982 PolicyOutcome::Reject,
1983 "operator temporal authority was invalid at event time",
1984 )
1985 };
1986 PolicyContribution::new(ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID, outcome, reason)
1987 .expect("static operator temporal use contribution shape is valid")
1988}
1989
1990#[must_use]
2003pub fn record_outcome_relation_policy_decision_test_allow() -> PolicyDecision {
2004 use cortex_core::compose_policy_outcomes;
2005 compose_policy_outcomes(
2006 vec![
2007 PolicyContribution::new(
2008 OUTCOME_VALIDATION_SCOPE_RULE_ID,
2009 PolicyOutcome::Allow,
2010 "test fixture: validation scope declared and recorded on the relation row",
2011 )
2012 .expect("static test contribution is valid"),
2013 PolicyContribution::new(
2014 OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
2015 PolicyOutcome::Allow,
2016 "test fixture: validating principal trust tier meets ADR 0020 §5 gate",
2017 )
2018 .expect("static test contribution is valid"),
2019 PolicyContribution::new(
2020 OUTCOME_EVIDENCE_REF_RULE_ID,
2021 PolicyOutcome::Allow,
2022 "test fixture: evidence_ref cites concrete attestation/audit row",
2023 )
2024 .expect("static test contribution is valid"),
2025 ],
2026 None,
2027 )
2028}
2029
2030#[must_use]
2039pub fn insert_candidate_v2_policy_decision_test_allow() -> PolicyDecision {
2040 use cortex_core::compose_policy_outcomes;
2041 compose_policy_outcomes(
2042 vec![
2043 PolicyContribution::new(
2044 V2_SUMMARY_SPAN_PROOF_RULE_ID,
2045 PolicyOutcome::Allow,
2046 "test fixture: summary spans validated",
2047 )
2048 .expect("static test contribution is valid"),
2049 PolicyContribution::new(
2050 V2_CROSS_SESSION_SALIENCE_RULE_ID,
2051 PolicyOutcome::Allow,
2052 "test fixture: candidate salience matches insert-time invariants",
2053 )
2054 .expect("static test contribution is valid"),
2055 ],
2056 None,
2057 )
2058}
2059
2060fn ensure_memory_exists(tx: &rusqlite::Transaction<'_>, id: &MemoryId) -> StoreResult<()> {
2061 let exists = tx
2062 .query_row(
2063 "SELECT 1 FROM memories WHERE id = ?1;",
2064 params![id.to_string()],
2065 |_| Ok(()),
2066 )
2067 .optional()?
2068 .is_some();
2069 if !exists {
2070 return Err(StoreError::Validation(format!("memory {id} not found")));
2071 }
2072 Ok(())
2073}
2074
2075fn reconcile_memory_session_salience(
2076 tx: &rusqlite::Transaction<'_>,
2077 id: &MemoryId,
2078) -> StoreResult<()> {
2079 tx.execute(
2080 "UPDATE memories
2081 SET cross_session_use_count = (
2082 SELECT COALESCE(SUM(use_count), 0) FROM memory_session_uses WHERE memory_id = ?1
2083 ),
2084 first_used_at = (
2085 SELECT MIN(first_used_at) FROM memory_session_uses WHERE memory_id = ?1
2086 ),
2087 last_cross_session_use_at = (
2088 SELECT MAX(last_used_at) FROM memory_session_uses WHERE memory_id = ?1
2089 ),
2090 validation_epoch = COALESCE(validation_epoch, 0)
2091 WHERE id = ?1;",
2092 params![id.to_string()],
2093 )?;
2094 Ok(())
2095}
2096
2097#[derive(Debug)]
2098struct MemoryRow {
2099 id: String,
2100 memory_type: String,
2101 status: String,
2102 claim: String,
2103 source_episodes_json: String,
2104 source_events_json: String,
2105 domains_json: String,
2106 salience_json: String,
2107 confidence: f64,
2108 authority: String,
2109 applies_when_json: String,
2110 does_not_apply_when_json: String,
2111 created_at: String,
2112 updated_at: String,
2113}
2114
2115fn memory_row(row: &Row<'_>) -> rusqlite::Result<MemoryRow> {
2116 Ok(MemoryRow {
2117 id: row.get(0)?,
2118 memory_type: row.get(1)?,
2119 status: row.get(2)?,
2120 claim: row.get(3)?,
2121 source_episodes_json: row.get(4)?,
2122 source_events_json: row.get(5)?,
2123 domains_json: row.get(6)?,
2124 salience_json: row.get(7)?,
2125 confidence: row.get(8)?,
2126 authority: row.get(9)?,
2127 applies_when_json: row.get(10)?,
2128 does_not_apply_when_json: row.get(11)?,
2129 created_at: row.get(12)?,
2130 updated_at: row.get(13)?,
2131 })
2132}
2133
2134impl TryFrom<MemoryRow> for MemoryRecord {
2135 type Error = StoreError;
2136
2137 fn try_from(row: MemoryRow) -> StoreResult<Self> {
2138 Ok(Self {
2139 id: row.id.parse()?,
2140 memory_type: row.memory_type,
2141 status: row.status,
2142 claim: row.claim,
2143 source_episodes_json: serde_json::from_str(&row.source_episodes_json)?,
2144 source_events_json: serde_json::from_str(&row.source_events_json)?,
2145 domains_json: serde_json::from_str(&row.domains_json)?,
2146 salience_json: serde_json::from_str(&row.salience_json)?,
2147 confidence: row.confidence,
2148 authority: row.authority,
2149 applies_when_json: serde_json::from_str(&row.applies_when_json)?,
2150 does_not_apply_when_json: serde_json::from_str(&row.does_not_apply_when_json)?,
2151 created_at: DateTime::parse_from_rfc3339(&row.created_at)?.with_timezone(&Utc),
2152 updated_at: DateTime::parse_from_rfc3339(&row.updated_at)?.with_timezone(&Utc),
2153 })
2154 }
2155}