use chrono::{DateTime, Utc};
use cortex_core::{
validate_summary_spans, AuditRecordId, CrossSessionSalience, EventId, MemoryId,
OutcomeMemoryRelation, PolicyContribution, PolicyDecision, PolicyOutcome, ProofClosureReport,
ProofState, SourceAuthority, SummarySpan, TemporalAuthorityReport,
};
use rusqlite::{params, OptionalExtension, Row};
use serde_json::Value;
use crate::{Pool, StoreError, StoreResult};
pub const ACCEPT_PROOF_CLOSURE_RULE_ID: &str = "memory.accept.proof_closure";
pub const ACCEPT_OPEN_CONTRADICTION_RULE_ID: &str = "memory.accept.open_contradiction";
pub const ACCEPT_SEMANTIC_TRUST_RULE_ID: &str = "memory.accept.semantic_trust";
pub const ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID: &str = "memory.accept.operator_temporal_use";
pub const OUTCOME_VALIDATION_SCOPE_RULE_ID: &str = "memory.outcome.validation_scope";
pub const OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID: &str =
"memory.outcome.validating_principal_tier";
pub const OUTCOME_EVIDENCE_REF_RULE_ID: &str = "memory.outcome.evidence_ref";
pub const OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT: &str =
"memory.outcome.utility_to_truth_promotion_unauthorized";
pub const CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD: u32 = 5;
pub const CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT: &str =
"memory.cross_session_use.repeated_unvalidated_weak_negative";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossSessionWeakNegativeStatus {
BelowThreshold,
SuppressedByValidation,
WeakNegativeAboveThreshold {
cross_session_use_count: u32,
},
}
impl CrossSessionWeakNegativeStatus {
#[must_use]
pub const fn is_weak_negative(self) -> bool {
matches!(self, Self::WeakNegativeAboveThreshold { .. })
}
#[must_use]
pub const fn invariant(self) -> Option<&'static str> {
match self {
Self::WeakNegativeAboveThreshold { .. } => {
Some(CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT)
}
_ => None,
}
}
}
#[must_use]
pub const fn cross_session_weak_negative_status(
cross_session_use_count: u32,
validation_epoch: u32,
) -> CrossSessionWeakNegativeStatus {
if cross_session_use_count <= CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD {
return CrossSessionWeakNegativeStatus::BelowThreshold;
}
if validation_epoch > 0 {
return CrossSessionWeakNegativeStatus::SuppressedByValidation;
}
CrossSessionWeakNegativeStatus::WeakNegativeAboveThreshold {
cross_session_use_count,
}
}
pub const V2_SUMMARY_SPAN_PROOF_RULE_ID: &str = "memory.v2.summary_span_proof";
pub const V2_CROSS_SESSION_SALIENCE_RULE_ID: &str = "memory.v2.cross_session_salience";
macro_rules! memory_select_sql {
($where_clause:literal) => {
concat!(
"SELECT id, memory_type, status, claim, source_episodes_json, source_events_json,
domains_json, salience_json, confidence, authority, applies_when_json,
does_not_apply_when_json, created_at, updated_at
FROM memories ",
$where_clause,
";"
)
};
}
#[derive(Debug, Clone, PartialEq)]
pub struct MemoryCandidate {
pub id: MemoryId,
pub memory_type: String,
pub claim: String,
pub source_episodes_json: Value,
pub source_events_json: Value,
pub domains_json: Value,
pub salience_json: Value,
pub confidence: f64,
pub authority: String,
pub applies_when_json: Value,
pub does_not_apply_when_json: Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MemoryRecord {
pub id: MemoryId,
pub memory_type: String,
pub status: String,
pub claim: String,
pub source_episodes_json: Value,
pub source_events_json: Value,
pub domains_json: Value,
pub salience_json: Value,
pub confidence: f64,
pub authority: String,
pub applies_when_json: Value,
pub does_not_apply_when_json: Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MemoryAcceptanceAudit {
pub id: AuditRecordId,
pub actor_json: Value,
pub reason: String,
pub source_refs_json: Value,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemorySessionUse {
pub memory_id: MemoryId,
pub session_id: String,
pub first_used_at: DateTime<Utc>,
pub last_used_at: DateTime<Utc>,
pub use_count: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutcomeMemoryRelationRecord {
pub outcome_ref: String,
pub memory_id: MemoryId,
pub relation: OutcomeMemoryRelation,
pub recorded_at: DateTime<Utc>,
pub source_event_id: Option<EventId>,
pub validation_scope: Option<String>,
pub validating_principal_id: Option<String>,
pub evidence_ref: Option<String>,
}
#[derive(Debug)]
pub struct MemoryRepo<'a> {
pool: &'a Pool,
}
impl<'a> MemoryRepo<'a> {
#[must_use]
pub const fn new(pool: &'a Pool) -> Self {
Self { pool }
}
pub fn insert_candidate(&self, memory: &MemoryCandidate) -> StoreResult<()> {
if json_array_empty(&memory.source_episodes_json)
&& json_array_empty(&memory.source_events_json)
{
return Err(StoreError::Validation(
"memory candidate requires episode or event lineage".into(),
));
}
self.pool.execute(
"INSERT INTO memories (
id, memory_type, status, claim, source_episodes_json, source_events_json,
domains_json, salience_json, confidence, authority, applies_when_json,
does_not_apply_when_json, created_at, updated_at
) VALUES (?1, ?2, 'candidate', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13);",
params![
memory.id.to_string(),
memory.memory_type,
memory.claim,
serde_json::to_string(&memory.source_episodes_json)?,
serde_json::to_string(&memory.source_events_json)?,
serde_json::to_string(&memory.domains_json)?,
serde_json::to_string(&memory.salience_json)?,
memory.confidence,
memory.authority,
serde_json::to_string(&memory.applies_when_json)?,
serde_json::to_string(&memory.does_not_apply_when_json)?,
memory.created_at.to_rfc3339(),
memory.updated_at.to_rfc3339(),
],
)?;
Ok(())
}
pub fn insert_candidate_with_v2_fields(
&self,
memory: &MemoryCandidate,
summary_spans: &[SummarySpan],
salience: &CrossSessionSalience,
policy: &PolicyDecision,
) -> StoreResult<()> {
require_policy_final_outcome(policy, "memory.v2.insert_candidate")?;
require_contributor_rule(policy, V2_SUMMARY_SPAN_PROOF_RULE_ID)?;
require_contributor_rule(policy, V2_CROSS_SESSION_SALIENCE_RULE_ID)?;
if json_array_empty(&memory.source_episodes_json)
&& json_array_empty(&memory.source_events_json)
{
return Err(StoreError::Validation(
"memory candidate requires episode or event lineage".into(),
));
}
if memory.memory_type.contains("summary") || !summary_spans.is_empty() {
validate_summary_spans(&memory.claim, summary_spans, |_| SourceAuthority::Derived)
.map_err(|err| {
StoreError::Validation(format!(
"memory summary_spans_json failed {}",
err.invariant()
))
})?;
}
validate_candidate_cross_session_salience(salience)?;
self.pool.execute(
"INSERT INTO memories (
id, memory_type, status, claim, source_episodes_json, source_events_json,
domains_json, salience_json, confidence, authority, applies_when_json,
does_not_apply_when_json, created_at, updated_at, summary_spans_json,
cross_session_use_count, first_used_at, last_cross_session_use_at,
last_validation_at, validation_epoch, blessed_until
) VALUES (
?1, ?2, 'candidate', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
?15, ?16, ?17, ?18, ?19, ?20
);",
params![
memory.id.to_string(),
memory.memory_type,
memory.claim,
serde_json::to_string(&memory.source_episodes_json)?,
serde_json::to_string(&memory.source_events_json)?,
serde_json::to_string(&memory.domains_json)?,
serde_json::to_string(&memory.salience_json)?,
memory.confidence,
memory.authority,
serde_json::to_string(&memory.applies_when_json)?,
serde_json::to_string(&memory.does_not_apply_when_json)?,
memory.created_at.to_rfc3339(),
memory.updated_at.to_rfc3339(),
serde_json::to_string(summary_spans)?,
i64::from(salience.cross_session_use_count),
salience.first_used_at.map(|value| value.to_rfc3339()),
salience
.last_cross_session_use_at
.map(|value| value.to_rfc3339()),
salience.last_validation_at.map(|value| value.to_rfc3339()),
i64::from(salience.validation_epoch),
salience.blessed_until.map(|value| value.to_rfc3339()),
],
)?;
Ok(())
}
pub fn record_session_use(&self, use_row: &MemorySessionUse) -> StoreResult<()> {
if use_row.session_id.trim().is_empty() {
return Err(StoreError::Validation(
"memory session use requires non-empty session_id".into(),
));
}
if use_row.use_count == 0 {
return Err(StoreError::Validation(
"memory session use requires positive use_count".into(),
));
}
if use_row.last_used_at < use_row.first_used_at {
return Err(StoreError::Validation(
"memory session use last_used_at cannot be earlier than first_used_at".into(),
));
}
let tx = self.pool.unchecked_transaction()?;
ensure_memory_exists(&tx, &use_row.memory_id)?;
tx.execute(
"INSERT INTO memory_session_uses (
memory_id, session_id, first_used_at, last_used_at, use_count
) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(memory_id, session_id) DO UPDATE SET
first_used_at = excluded.first_used_at,
last_used_at = excluded.last_used_at,
use_count = excluded.use_count;",
params![
use_row.memory_id.to_string(),
use_row.session_id.as_str(),
use_row.first_used_at.to_rfc3339(),
use_row.last_used_at.to_rfc3339(),
i64::from(use_row.use_count),
],
)?;
reconcile_memory_session_salience(&tx, &use_row.memory_id)?;
tx.commit()?;
Ok(())
}
pub fn record_outcome_relation(
&self,
relation: &OutcomeMemoryRelationRecord,
policy: Option<&PolicyDecision>,
) -> StoreResult<()> {
if relation.outcome_ref.trim().is_empty() {
return Err(StoreError::Validation(
"outcome memory relation requires non-empty outcome_ref".into(),
));
}
if relation.relation.advances_validation() {
let policy = policy.ok_or_else(|| {
StoreError::Validation(format!(
"{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires a composed PolicyDecision; caller skipped ADR 0026 composition",
))
})?;
require_policy_final_outcome_for_validation(policy)?;
require_contributor_rule_for_validation(policy, OUTCOME_VALIDATION_SCOPE_RULE_ID)?;
require_contributor_rule_for_validation(
policy,
OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
)?;
require_contributor_rule_for_validation(policy, OUTCOME_EVIDENCE_REF_RULE_ID)?;
require_scoped_validation_payload(relation)?;
} else if policy.is_some() {
return Err(StoreError::Validation(format!(
"{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: non-validation outcome relation must not carry a PolicyDecision (relation does not move the validation axis)",
)));
} else {
require_no_scoped_validation_payload(relation)?;
}
let tx = self.pool.unchecked_transaction()?;
ensure_memory_exists(&tx, &relation.memory_id)?;
let relation_wire = serde_json::to_value(relation.relation)?
.as_str()
.ok_or_else(|| {
StoreError::Validation("outcome memory relation did not serialize as string".into())
})?
.to_string();
tx.execute(
"INSERT INTO outcome_memory_relations (
outcome_ref, memory_id, relation, recorded_at, source_event_id,
validation_scope, validating_principal_id, evidence_ref
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(outcome_ref, memory_id, relation) DO UPDATE SET
recorded_at = excluded.recorded_at,
source_event_id = excluded.source_event_id,
validation_scope = excluded.validation_scope,
validating_principal_id = excluded.validating_principal_id,
evidence_ref = excluded.evidence_ref;",
params![
relation.outcome_ref.as_str(),
relation.memory_id.to_string(),
relation_wire,
relation.recorded_at.to_rfc3339(),
relation.source_event_id.as_ref().map(ToString::to_string),
relation.validation_scope.as_deref(),
relation.validating_principal_id.as_deref(),
relation.evidence_ref.as_deref(),
],
)?;
if relation.relation.advances_validation() {
tx.execute(
"UPDATE memories
SET last_validation_at = ?2,
validation_epoch = COALESCE(validation_epoch, 0) + 1
WHERE id = ?1;",
params![
relation.memory_id.to_string(),
relation.recorded_at.to_rfc3339()
],
)?;
}
tx.commit()?;
Ok(())
}
pub fn cross_session_weak_negative_status_for(
&self,
id: &MemoryId,
) -> StoreResult<Option<CrossSessionWeakNegativeStatus>> {
let row: Option<(Option<u32>, Option<u32>)> = self
.pool
.query_row(
"SELECT cross_session_use_count, validation_epoch FROM memories WHERE id = ?1;",
params![id.to_string()],
|row| Ok((row.get::<_, Option<u32>>(0)?, row.get::<_, Option<u32>>(1)?)),
)
.optional()?;
Ok(row.map(|(use_count, epoch)| {
cross_session_weak_negative_status(use_count.unwrap_or(0), epoch.unwrap_or(0))
}))
}
pub fn validation_epoch_for(&self, id: &MemoryId) -> StoreResult<Option<u32>> {
let row: Option<Option<u32>> = self
.pool
.query_row(
"SELECT validation_epoch FROM memories WHERE id = ?1;",
params![id.to_string()],
|row| row.get::<_, Option<u32>>(0),
)
.optional()?;
Ok(row.map(|epoch| epoch.unwrap_or(0)))
}
pub fn get_by_id(&self, id: &MemoryId) -> StoreResult<Option<MemoryRecord>> {
let row = self
.pool
.query_row(
memory_select_sql!("WHERE id = ?1"),
params![id.to_string()],
memory_row,
)
.optional()?;
row.map(TryInto::try_into).transpose()
}
pub fn fts5_search(&self, query: &str, limit: usize) -> StoreResult<Vec<(MemoryId, f32)>> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Err(StoreError::Validation(
"fts5_search: query must not be empty".into(),
));
}
if limit == 0 {
return Ok(Vec::new());
}
let match_expr = fts5_fuzzy_match_expression(trimmed).ok_or_else(|| {
StoreError::Validation(
"fts5_search: query produced no searchable trigrams after sanitization".into(),
)
})?;
let limit_i64 = i64::try_from(limit)
.map_err(|_| StoreError::Validation("fts5_search: limit exceeds i64 range".into()))?;
let mut stmt = self.pool.prepare(
"SELECT memory_id, rank \
FROM memories_fts \
WHERE memories_fts MATCH ?1 \
ORDER BY rank \
LIMIT ?2;",
)?;
let rows = stmt.query_map(params![match_expr, limit_i64], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
})?;
let mut hits = Vec::new();
for row in rows {
let (id_text, rank) = row?;
let id = id_text.parse::<MemoryId>().map_err(|err| {
StoreError::Validation(format!(
"fts5_search: memory_id mirror value `{id_text}` failed to parse: {err}"
))
})?;
hits.push((id, rank as f32));
}
Ok(hits)
}
pub fn get_candidate_by_id(&self, id: &MemoryId) -> StoreResult<Option<MemoryRecord>> {
let row = self
.pool
.query_row(
memory_select_sql!("WHERE id = ?1 AND status = 'candidate'"),
params![id.to_string()],
memory_row,
)
.optional()?;
row.map(TryInto::try_into).transpose()
}
pub fn list_candidates(&self) -> StoreResult<Vec<MemoryRecord>> {
self.list_by_status("candidate")
}
pub fn list_by_status(&self, status: &str) -> StoreResult<Vec<MemoryRecord>> {
let mut stmt = self.pool.prepare(memory_select_sql!(
"WHERE status = ?1 ORDER BY updated_at DESC, id"
))?;
let rows = stmt.query_map(params![status], memory_row)?;
let mut memories = Vec::new();
for row in rows {
memories.push(row?.try_into()?);
}
Ok(memories)
}
pub fn list_by_status_with_tags(
&self,
status: &str,
tags: &[String],
) -> StoreResult<Vec<MemoryRecord>> {
let mut unique_tags: Vec<String> = Vec::with_capacity(tags.len());
for tag in tags {
if !unique_tags.iter().any(|existing| existing == tag) {
unique_tags.push(tag.clone());
}
}
if unique_tags.is_empty() {
return self.list_by_status(status);
}
let placeholders = std::iter::repeat_n("?", unique_tags.len())
.collect::<Vec<_>>()
.join(",");
let where_clause = format!(
"WHERE status = ? AND id IN (
SELECT m.id FROM memories m, json_each(m.domains_json) je
WHERE je.value IN ({placeholders})
GROUP BY m.id
HAVING COUNT(DISTINCT je.value) = ?
) ORDER BY updated_at DESC, id"
);
let sql = format!(
"SELECT id, memory_type, status, claim, source_episodes_json, source_events_json, \
domains_json, salience_json, confidence, authority, applies_when_json, \
does_not_apply_when_json, created_at, updated_at \
FROM memories {where_clause};"
);
let mut stmt = self.pool.prepare(&sql)?;
let unique_count = i64::try_from(unique_tags.len()).map_err(|_| {
StoreError::Validation("list_by_status_with_tags: tag count exceeds i64 range".into())
})?;
let mut bind_values: Vec<rusqlite::types::Value> =
Vec::with_capacity(unique_tags.len() + 2);
bind_values.push(rusqlite::types::Value::Text(status.to_string()));
for tag in &unique_tags {
bind_values.push(rusqlite::types::Value::Text(tag.clone()));
}
bind_values.push(rusqlite::types::Value::Integer(unique_count));
let rows = stmt.query_map(rusqlite::params_from_iter(bind_values), memory_row)?;
let mut memories = Vec::new();
for row in rows {
memories.push(row?.try_into()?);
}
Ok(memories)
}
pub fn set_active(&self, id: &MemoryId, updated_at: DateTime<Utc>) -> StoreResult<()> {
let changed = self.pool.execute(
"UPDATE memories
SET status = 'active', updated_at = ?2
WHERE id = ?1;",
params![id.to_string(), updated_at.to_rfc3339()],
)?;
if changed == 0 {
return Err(StoreError::Validation(format!("memory {id} not found")));
}
Ok(())
}
pub fn set_pending_mcp_commit(&self, id: &MemoryId, now: DateTime<Utc>) -> StoreResult<()> {
let changed = self.pool.execute(
"UPDATE memories
SET status = 'pending_mcp_commit', updated_at = ?2
WHERE id = ?1 AND status = 'candidate';",
params![id.to_string(), now.to_rfc3339()],
)?;
if changed == 0 {
let current: Option<String> = self
.pool
.query_row(
"SELECT status FROM memories WHERE id = ?1;",
params![id.to_string()],
|row| row.get(0),
)
.optional()?;
return Err(StoreError::Validation(match current.as_deref() {
None => format!("memory {id} not found"),
Some(s) => format!("memory {id} is not a candidate: {s}"),
}));
}
Ok(())
}
pub fn commit_pending_mcp(
&self,
_session_receipt_id: &str,
now: DateTime<Utc>,
) -> StoreResult<usize> {
let changed = self.pool.execute(
"UPDATE memories
SET status = 'active', updated_at = ?1
WHERE status = 'pending_mcp_commit';",
params![now.to_rfc3339()],
)?;
Ok(changed)
}
pub fn max_sensitivity_for_active_memories(&self) -> StoreResult<String> {
let max_rank: Option<i64> = self
.pool
.query_row(
"SELECT MAX(CASE je.value \
WHEN 'sensitivity:high' THEN 3 \
WHEN 'sensitivity:medium' THEN 2 \
WHEN 'sensitivity:low' THEN 1 \
ELSE 0 \
END) \
FROM memories m, json_each(m.domains_json) je \
WHERE m.status = 'active' \
AND je.value IN ('sensitivity:high', 'sensitivity:medium', 'sensitivity:low');",
[],
|row| row.get(0),
)
.optional()?
.flatten();
let level = match max_rank {
Some(3) => "high",
Some(2) => "medium",
Some(1) => "low",
_ => "none",
};
Ok(level.to_string())
}
pub fn add_domain_tag(
&self,
id: &MemoryId,
tag: &str,
now: DateTime<Utc>,
) -> StoreResult<bool> {
if tag.trim().is_empty() {
return Err(StoreError::Validation(
"add_domain_tag: tag must not be empty".into(),
));
}
let tx = self.pool.unchecked_transaction()?;
let domains_raw: Option<String> = tx
.query_row(
"SELECT domains_json FROM memories WHERE id = ?1;",
params![id.to_string()],
|row| row.get(0),
)
.optional()?;
let domains_raw =
domains_raw.ok_or_else(|| StoreError::Validation(format!("memory {id} not found")))?;
let mut tags: Vec<String> = serde_json::from_str(&domains_raw).map_err(|err| {
StoreError::Validation(format!(
"add_domain_tag: domains_json for memory {id} is not a valid JSON array: {err}"
))
})?;
if tags.iter().any(|t| t == tag) {
return Ok(false);
}
tags.push(tag.to_string());
let new_domains = serde_json::to_string(&tags)?;
tx.execute(
"UPDATE memories SET domains_json = ?2, updated_at = ?3 WHERE id = ?1;",
params![id.to_string(), new_domains, now.to_rfc3339()],
)?;
tx.commit()?;
Ok(true)
}
pub fn remove_domain_tag(
&self,
id: &MemoryId,
tag: &str,
now: DateTime<Utc>,
) -> StoreResult<bool> {
if tag.trim().is_empty() {
return Err(StoreError::Validation(
"remove_domain_tag: tag must not be empty".into(),
));
}
let tx = self.pool.unchecked_transaction()?;
let domains_raw: Option<String> = tx
.query_row(
"SELECT domains_json FROM memories WHERE id = ?1;",
params![id.to_string()],
|row| row.get(0),
)
.optional()?;
let domains_raw =
domains_raw.ok_or_else(|| StoreError::Validation(format!("memory {id} not found")))?;
let mut tags: Vec<String> = serde_json::from_str(&domains_raw).map_err(|err| {
StoreError::Validation(format!(
"remove_domain_tag: domains_json for memory {id} is not a valid JSON array: {err}"
))
})?;
let before_len = tags.len();
tags.retain(|t| t != tag);
if tags.len() == before_len {
return Ok(false);
}
let new_domains = serde_json::to_string(&tags)?;
tx.execute(
"UPDATE memories SET domains_json = ?2, updated_at = ?3 WHERE id = ?1;",
params![id.to_string(), new_domains, now.to_rfc3339()],
)?;
tx.commit()?;
Ok(true)
}
pub fn accept_candidate(
&self,
id: &MemoryId,
updated_at: DateTime<Utc>,
audit: &MemoryAcceptanceAudit,
policy: &PolicyDecision,
) -> StoreResult<MemoryRecord> {
require_policy_final_outcome(policy, "memory.accept")?;
require_contributor_rule(policy, ACCEPT_PROOF_CLOSURE_RULE_ID)?;
require_contributor_rule(policy, ACCEPT_OPEN_CONTRADICTION_RULE_ID)?;
require_contributor_rule(policy, ACCEPT_SEMANTIC_TRUST_RULE_ID)?;
require_contributor_rule(policy, ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID)?;
require_attestation_not_break_glassed(
policy,
ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID,
"memory.accept",
)?;
let tx = self.pool.unchecked_transaction()?;
let before = tx
.query_row(
"SELECT status FROM memories WHERE id = ?1;",
params![id.to_string()],
|row| row.get::<_, String>(0),
)
.optional()?;
match before.as_deref() {
Some("candidate") => {}
Some(status) => {
return Err(StoreError::Validation(format!(
"memory {id} is not a candidate: {status}"
)));
}
None => {
return Err(StoreError::Validation(format!("memory {id} not found")));
}
}
tx.execute(
"UPDATE memories
SET status = 'active', updated_at = ?2
WHERE id = ?1 AND status = 'candidate';",
params![id.to_string(), updated_at.to_rfc3339()],
)?;
tx.execute(
"INSERT INTO audit_records (
id, operation, target_ref, before_hash, after_hash, reason,
actor_json, source_refs_json, created_at
) VALUES (?1, 'memory.accept', ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![
audit.id.to_string(),
id.to_string(),
"status:candidate",
"status:active",
audit.reason,
serde_json::to_string(&audit.actor_json)?,
serde_json::to_string(&audit.source_refs_json)?,
audit.created_at.to_rfc3339(),
],
)?;
let row = tx.query_row(
memory_select_sql!("WHERE id = ?1"),
params![id.to_string()],
memory_row,
)?;
tx.commit()?;
row.try_into()
}
}
fn json_array_empty(value: &Value) -> bool {
value.as_array().is_some_and(Vec::is_empty)
}
fn fts5_fuzzy_match_expression(query: &str) -> Option<String> {
const FUZZY_GRAM_WIDTH: usize = 4;
let mut grams: Vec<String> = Vec::new();
for raw_token in query.split_whitespace() {
let sanitised: String = raw_token
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
if sanitised.is_empty() {
continue;
}
if sanitised.len() < FUZZY_GRAM_WIDTH {
grams.push(sanitised);
continue;
}
let bytes = sanitised.as_bytes();
for start in 0..=bytes.len() - FUZZY_GRAM_WIDTH {
let gram = &sanitised[start..start + FUZZY_GRAM_WIDTH];
grams.push(gram.to_string());
}
}
if grams.is_empty() {
return None;
}
let mut unique = Vec::with_capacity(grams.len());
for gram in grams {
if !unique.contains(&gram) {
unique.push(gram);
}
}
let mut expression = String::new();
for (idx, gram) in unique.iter().enumerate() {
if idx > 0 {
expression.push_str(" OR ");
}
expression.push('"');
expression.push_str(gram);
expression.push('"');
}
Some(expression)
}
fn require_policy_final_outcome(policy: &PolicyDecision, surface: &str) -> StoreResult<()> {
match policy.final_outcome {
PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
PolicyOutcome::Quarantine | PolicyOutcome::Reject => Err(StoreError::Validation(format!(
"{surface} preflight: composed policy outcome {:?} blocks memory mutation",
policy.final_outcome,
))),
}
}
fn require_contributor_rule(policy: &PolicyDecision, rule_id: &str) -> StoreResult<()> {
let contains_rule = policy
.contributing
.iter()
.chain(policy.discarded.iter())
.any(|contribution| contribution.rule_id.as_str() == rule_id);
if contains_rule {
Ok(())
} else {
Err(StoreError::Validation(format!(
"policy decision missing required contributor `{rule_id}`; caller skipped ADR 0026 composition",
)))
}
}
fn require_policy_final_outcome_for_validation(policy: &PolicyDecision) -> StoreResult<()> {
match policy.final_outcome {
PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
PolicyOutcome::Quarantine | PolicyOutcome::Reject => Err(StoreError::Validation(format!(
"{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: composed policy outcome {:?} blocks Validated outcome relation write",
policy.final_outcome,
))),
}
}
fn require_contributor_rule_for_validation(
policy: &PolicyDecision,
rule_id: &str,
) -> StoreResult<()> {
let contains_rule = policy
.contributing
.iter()
.chain(policy.discarded.iter())
.any(|contribution| contribution.rule_id.as_str() == rule_id);
if contains_rule {
Ok(())
} else {
Err(StoreError::Validation(format!(
"{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: policy decision missing required contributor `{rule_id}` for Validated outcome relation",
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use cortex_core::compose_policy_outcomes;
fn relation_record(
relation: OutcomeMemoryRelation,
with_scope: bool,
) -> OutcomeMemoryRelationRecord {
OutcomeMemoryRelationRecord {
outcome_ref: "outcome:test".into(),
memory_id: "mem_01ARZ3NDEKTSV4RRFFQ69G5V40".parse().unwrap(),
relation,
recorded_at: DateTime::parse_from_rfc3339("2026-05-04T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
source_event_id: None,
validation_scope: if with_scope {
Some("scope:test".into())
} else {
None
},
validating_principal_id: if with_scope {
Some("principal:test-operator".into())
} else {
None
},
evidence_ref: if with_scope {
Some("aud:test".into())
} else {
None
},
}
}
fn seed_pool_with_memory() -> Pool {
let pool = rusqlite::Connection::open_in_memory().expect("open in-memory pool");
crate::migrate::apply_pending(&pool).expect("apply migrations");
pool.execute(
"INSERT INTO memories (
id, memory_type, status, claim, source_episodes_json, source_events_json,
domains_json, salience_json, confidence, authority, applies_when_json,
does_not_apply_when_json, created_at, updated_at, validation_epoch
) VALUES (
'mem_01ARZ3NDEKTSV4RRFFQ69G5V40', 'semantic', 'active', 'test memory',
'[]', '[\"evt_01ARZ3NDEKTSV4RRFFQ69G5V40\"]', '[]',
'{}', 0.7, 'candidate', '{}', '{}',
'2026-05-04T12:00:00Z', '2026-05-04T12:00:00Z', 0
);",
[],
)
.expect("seed memory");
pool
}
#[test]
fn record_outcome_relation_validated_refuses_when_policy_decision_is_missing() {
let pool = seed_pool_with_memory();
let repo = MemoryRepo::new(&pool);
let err = repo
.record_outcome_relation(
&relation_record(OutcomeMemoryRelation::Validated, true),
None,
)
.expect_err("missing policy decision must fail closed");
let msg = err.to_string();
assert!(
msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
"expected stable invariant in error: {msg}"
);
}
#[test]
fn record_outcome_relation_validated_refuses_when_policy_outcome_denies() {
let pool = seed_pool_with_memory();
let repo = MemoryRepo::new(&pool);
let deny_policy = compose_policy_outcomes(
vec![
PolicyContribution::new(
OUTCOME_VALIDATION_SCOPE_RULE_ID,
PolicyOutcome::Reject,
"test: validation scope rejected (untrusted tier)",
)
.unwrap(),
PolicyContribution::new(
OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
PolicyOutcome::Reject,
"test: principal trust tier below class gate",
)
.unwrap(),
PolicyContribution::new(
OUTCOME_EVIDENCE_REF_RULE_ID,
PolicyOutcome::Reject,
"test: evidence_ref does not cite a concrete row",
)
.unwrap(),
],
None,
);
let err = repo
.record_outcome_relation(
&relation_record(OutcomeMemoryRelation::Validated, true),
Some(&deny_policy),
)
.expect_err("deny policy must fail closed");
let msg = err.to_string();
assert!(
msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
"expected stable invariant in error: {msg}"
);
}
#[test]
fn record_outcome_relation_validated_refuses_missing_scoped_payload() {
let pool = seed_pool_with_memory();
let repo = MemoryRepo::new(&pool);
let err = repo
.record_outcome_relation(
&relation_record(OutcomeMemoryRelation::Validated, false),
Some(&record_outcome_relation_policy_decision_test_allow()),
)
.expect_err("missing scope payload must fail closed");
let msg = err.to_string();
assert!(
msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
"expected stable invariant in error: {msg}"
);
}
#[test]
fn record_outcome_relation_validated_refuses_missing_contributor_rule_ids() {
let pool = seed_pool_with_memory();
let repo = MemoryRepo::new(&pool);
let policy_missing_evidence = compose_policy_outcomes(
vec![
PolicyContribution::new(
OUTCOME_VALIDATION_SCOPE_RULE_ID,
PolicyOutcome::Allow,
"test: scope ok",
)
.unwrap(),
PolicyContribution::new(
OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
PolicyOutcome::Allow,
"test: tier ok",
)
.unwrap(),
],
None,
);
let err = repo
.record_outcome_relation(
&relation_record(OutcomeMemoryRelation::Validated, true),
Some(&policy_missing_evidence),
)
.expect_err("missing contributor must fail closed");
let msg = err.to_string();
assert!(
msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
"expected stable invariant in error: {msg}"
);
assert!(
msg.contains(OUTCOME_EVIDENCE_REF_RULE_ID),
"error must name the missing contributor: {msg}"
);
}
#[test]
fn record_outcome_relation_non_validation_relation_admits_without_policy() {
let pool = seed_pool_with_memory();
let repo = MemoryRepo::new(&pool);
repo.record_outcome_relation(&relation_record(OutcomeMemoryRelation::Used, false), None)
.expect("non-validation relation admits without policy");
let epoch = repo
.validation_epoch_for(&"mem_01ARZ3NDEKTSV4RRFFQ69G5V40".parse().unwrap())
.expect("read validation_epoch")
.expect("memory row exists");
assert_eq!(
epoch, 0,
"non-validation relation must not advance validation_epoch"
);
}
#[test]
fn record_outcome_relation_non_validation_relation_refuses_attached_policy_decision() {
let pool = seed_pool_with_memory();
let repo = MemoryRepo::new(&pool);
let err = repo
.record_outcome_relation(
&relation_record(OutcomeMemoryRelation::Used, false),
Some(&record_outcome_relation_policy_decision_test_allow()),
)
.expect_err("attached policy on non-validation must fail closed");
let msg = err.to_string();
assert!(
msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
"expected stable invariant in error: {msg}"
);
}
#[test]
fn record_outcome_relation_validated_advances_validation_epoch_on_happy_path() {
let pool = seed_pool_with_memory();
let repo = MemoryRepo::new(&pool);
repo.record_outcome_relation(
&relation_record(OutcomeMemoryRelation::Validated, true),
Some(&record_outcome_relation_policy_decision_test_allow()),
)
.expect("happy path admits the row");
let epoch = repo
.validation_epoch_for(&"mem_01ARZ3NDEKTSV4RRFFQ69G5V40".parse().unwrap())
.expect("read validation_epoch")
.expect("memory row exists");
assert_eq!(
epoch, 1,
"Validated relation must advance validation_epoch from 0 to 1"
);
}
#[test]
fn validation_epoch_for_returns_none_for_missing_memory() {
let pool = seed_pool_with_memory();
let repo = MemoryRepo::new(&pool);
let missing = repo
.validation_epoch_for(&"mem_01ARZ3NDEKTSV4RRFFQ69G5V4Z".parse().unwrap())
.expect("read validation_epoch");
assert!(missing.is_none());
}
#[test]
fn cross_session_weak_negative_status_thresholds_fire_only_when_unvalidated() {
assert!(matches!(
cross_session_weak_negative_status(CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD, 0),
CrossSessionWeakNegativeStatus::BelowThreshold
));
let status =
cross_session_weak_negative_status(CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD + 1, 0);
assert!(status.is_weak_negative());
assert_eq!(
status.invariant(),
Some(CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT)
);
assert!(matches!(
cross_session_weak_negative_status(CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD + 1, 1),
CrossSessionWeakNegativeStatus::SuppressedByValidation
));
}
#[test]
fn score_inputs_validation_reads_authoritative_epoch_not_salience_json_blob() {
fn validation_gate(salience_json_validation: f32, validation_epoch: u32) -> f32 {
let _ = salience_json_validation;
if validation_epoch > 0 {
1.0
} else {
0.0
}
}
assert_eq!(validation_gate(1.0, 0), 0.0);
assert_eq!(validation_gate(0.0, 1), 1.0);
assert_eq!(validation_gate(1.0, 1), 1.0);
}
}
fn require_scoped_validation_payload(relation: &OutcomeMemoryRelationRecord) -> StoreResult<()> {
if relation
.validation_scope
.as_deref()
.is_none_or(str::is_empty)
{
return Err(StoreError::Validation(format!(
"{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires non-empty validation_scope (ADR 0020 ยง6)",
)));
}
if relation
.validating_principal_id
.as_deref()
.is_none_or(str::is_empty)
{
return Err(StoreError::Validation(format!(
"{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires non-empty validating_principal_id (ADR 0020 ยง6)",
)));
}
if relation.evidence_ref.as_deref().is_none_or(str::is_empty) {
return Err(StoreError::Validation(format!(
"{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires non-empty evidence_ref (ADR 0020 ยง6)",
)));
}
Ok(())
}
fn require_no_scoped_validation_payload(relation: &OutcomeMemoryRelationRecord) -> StoreResult<()> {
if relation.validation_scope.is_some()
|| relation.validating_principal_id.is_some()
|| relation.evidence_ref.is_some()
{
return Err(StoreError::Validation(format!(
"{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: non-validation outcome relation must leave validation_scope/validating_principal_id/evidence_ref unset",
)));
}
Ok(())
}
fn require_attestation_not_break_glassed(
policy: &PolicyDecision,
rule_id: &str,
surface: &str,
) -> StoreResult<()> {
let attestation = policy
.contributing
.iter()
.chain(policy.discarded.iter())
.find(|contribution| contribution.rule_id.as_str() == rule_id)
.ok_or_else(|| {
StoreError::Validation(format!(
"{surface} preflight: required attestation contributor `{rule_id}` is absent from the policy decision",
))
})?;
match attestation.outcome {
PolicyOutcome::Allow | PolicyOutcome::Warn => Ok(()),
other => Err(StoreError::Validation(format!(
"{surface} preflight: attestation contributor `{rule_id}` returned {other:?}; ADR 0026 ยง4 forbids BreakGlass substituting for attestation",
))),
}
}
pub fn validate_candidate_cross_session_salience(
salience: &CrossSessionSalience,
) -> StoreResult<()> {
if salience.cross_session_use_count != 0 {
return Err(StoreError::Validation(format!(
"memory.v2.cross_session_salience preflight: candidate cross_session_use_count must be 0 at insert, observed {}",
salience.cross_session_use_count,
)));
}
if salience.first_used_at.is_some() {
return Err(StoreError::Validation(
"memory.v2.cross_session_salience preflight: candidate first_used_at must be unset at insert".into(),
));
}
if salience.last_cross_session_use_at.is_some() {
return Err(StoreError::Validation(
"memory.v2.cross_session_salience preflight: candidate last_cross_session_use_at must be unset at insert".into(),
));
}
if salience.last_validation_at.is_some() {
return Err(StoreError::Validation(
"memory.v2.cross_session_salience preflight: candidate last_validation_at must be unset at insert".into(),
));
}
if salience.validation_epoch != 0 {
return Err(StoreError::Validation(format!(
"memory.v2.cross_session_salience preflight: candidate validation_epoch must be 0 at insert, observed {}",
salience.validation_epoch,
)));
}
if salience.blessed_until.is_some() {
return Err(StoreError::Validation(
"memory.v2.cross_session_salience preflight: candidate blessed_until must be unset at insert (bless is an operator-attested post-admit action)".into(),
));
}
Ok(())
}
pub fn summary_span_proof_contribution<F>(
memory: &MemoryCandidate,
summary_spans: &[SummarySpan],
authority_fold: F,
) -> PolicyContribution
where
F: FnMut(&[EventId]) -> SourceAuthority,
{
let validation = if memory.memory_type.contains("summary") || !summary_spans.is_empty() {
validate_summary_spans(&memory.claim, summary_spans, authority_fold)
} else {
Ok(())
};
let (outcome, reason): (PolicyOutcome, String) = match validation {
Ok(()) => (
PolicyOutcome::Allow,
"summary spans satisfy ADR 0015 structural invariants".to_string(),
),
Err(err) => (
PolicyOutcome::Reject,
format!(
"summary spans violate ADR 0015 invariant `{}`",
err.invariant()
),
),
};
PolicyContribution::new(V2_SUMMARY_SPAN_PROOF_RULE_ID, outcome, reason)
.expect("v2 summary span proof contribution shape is statically valid")
}
#[must_use]
pub fn cross_session_salience_contribution(salience: &CrossSessionSalience) -> PolicyContribution {
let (outcome, reason): (PolicyOutcome, String) =
match validate_candidate_cross_session_salience(salience) {
Ok(()) => (
PolicyOutcome::Allow,
"candidate salience matches the ADR 0017 insert-time invariants".to_string(),
),
Err(err) => (PolicyOutcome::Reject, err.to_string()),
};
PolicyContribution::new(V2_CROSS_SESSION_SALIENCE_RULE_ID, outcome, reason)
.expect("v2 cross-session salience contribution shape is statically valid")
}
#[must_use]
pub fn accept_candidate_policy_decision_test_allow() -> PolicyDecision {
use cortex_core::compose_policy_outcomes;
compose_policy_outcomes(
vec![
PolicyContribution::new(
ACCEPT_PROOF_CLOSURE_RULE_ID,
PolicyOutcome::Allow,
"test fixture: supporting-memory proof closure verified",
)
.expect("static test contribution is valid"),
PolicyContribution::new(
ACCEPT_OPEN_CONTRADICTION_RULE_ID,
PolicyOutcome::Allow,
"test fixture: no open durable contradiction on candidate slot",
)
.expect("static test contribution is valid"),
PolicyContribution::new(
ACCEPT_SEMANTIC_TRUST_RULE_ID,
PolicyOutcome::Allow,
"test fixture: semantic trust posture satisfied",
)
.expect("static test contribution is valid"),
PolicyContribution::new(
ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID,
PolicyOutcome::Allow,
"test fixture: operator temporal authority is currently valid",
)
.expect("static test contribution is valid"),
],
None,
)
}
#[must_use]
pub fn accept_proof_closure_contribution(report: &ProofClosureReport) -> PolicyContribution {
let (outcome, reason): (PolicyOutcome, &'static str) = match report.state() {
ProofState::FullChainVerified => (
PolicyOutcome::Allow,
"supporting-memory proof closure is fully verified",
),
ProofState::Partial => (
PolicyOutcome::Quarantine,
"supporting-memory proof closure is partial; promotion fails closed",
),
ProofState::Broken => (
PolicyOutcome::Reject,
"supporting-memory proof closure is broken; promotion fails closed",
),
};
PolicyContribution::new(ACCEPT_PROOF_CLOSURE_RULE_ID, outcome, reason)
.expect("static proof closure contribution shape is valid")
}
#[must_use]
pub fn accept_open_contradiction_contribution(open_contradictions: usize) -> PolicyContribution {
let (outcome, reason): (PolicyOutcome, String) = if open_contradictions == 0 {
(
PolicyOutcome::Allow,
"no open durable contradiction touches the candidate slot".to_string(),
)
} else {
(
PolicyOutcome::Reject,
format!(
"{open_contradictions} open durable contradiction(s) touch the candidate slot; ADR 0024 forbids silent promotion"
),
)
};
PolicyContribution::new(ACCEPT_OPEN_CONTRADICTION_RULE_ID, outcome, reason)
.expect("static open contradiction contribution shape is valid")
}
#[must_use]
pub fn accept_operator_temporal_use_contribution(
report: &TemporalAuthorityReport,
) -> PolicyContribution {
let (outcome, reason): (PolicyOutcome, &'static str) = if report.valid_now {
(
PolicyOutcome::Allow,
"operator temporal authority is currently valid",
)
} else if report.valid_at_event_time {
(
PolicyOutcome::Quarantine,
"operator temporal authority is historical only; current use blocked",
)
} else {
(
PolicyOutcome::Reject,
"operator temporal authority was invalid at event time",
)
};
PolicyContribution::new(ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID, outcome, reason)
.expect("static operator temporal use contribution shape is valid")
}
#[must_use]
pub fn record_outcome_relation_policy_decision_test_allow() -> PolicyDecision {
use cortex_core::compose_policy_outcomes;
compose_policy_outcomes(
vec![
PolicyContribution::new(
OUTCOME_VALIDATION_SCOPE_RULE_ID,
PolicyOutcome::Allow,
"test fixture: validation scope declared and recorded on the relation row",
)
.expect("static test contribution is valid"),
PolicyContribution::new(
OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
PolicyOutcome::Allow,
"test fixture: validating principal trust tier meets ADR 0020 ยง5 gate",
)
.expect("static test contribution is valid"),
PolicyContribution::new(
OUTCOME_EVIDENCE_REF_RULE_ID,
PolicyOutcome::Allow,
"test fixture: evidence_ref cites concrete attestation/audit row",
)
.expect("static test contribution is valid"),
],
None,
)
}
#[must_use]
pub fn insert_candidate_v2_policy_decision_test_allow() -> PolicyDecision {
use cortex_core::compose_policy_outcomes;
compose_policy_outcomes(
vec![
PolicyContribution::new(
V2_SUMMARY_SPAN_PROOF_RULE_ID,
PolicyOutcome::Allow,
"test fixture: summary spans validated",
)
.expect("static test contribution is valid"),
PolicyContribution::new(
V2_CROSS_SESSION_SALIENCE_RULE_ID,
PolicyOutcome::Allow,
"test fixture: candidate salience matches insert-time invariants",
)
.expect("static test contribution is valid"),
],
None,
)
}
fn ensure_memory_exists(tx: &rusqlite::Transaction<'_>, id: &MemoryId) -> StoreResult<()> {
let exists = tx
.query_row(
"SELECT 1 FROM memories WHERE id = ?1;",
params![id.to_string()],
|_| Ok(()),
)
.optional()?
.is_some();
if !exists {
return Err(StoreError::Validation(format!("memory {id} not found")));
}
Ok(())
}
fn reconcile_memory_session_salience(
tx: &rusqlite::Transaction<'_>,
id: &MemoryId,
) -> StoreResult<()> {
tx.execute(
"UPDATE memories
SET cross_session_use_count = (
SELECT COALESCE(SUM(use_count), 0) FROM memory_session_uses WHERE memory_id = ?1
),
first_used_at = (
SELECT MIN(first_used_at) FROM memory_session_uses WHERE memory_id = ?1
),
last_cross_session_use_at = (
SELECT MAX(last_used_at) FROM memory_session_uses WHERE memory_id = ?1
),
validation_epoch = COALESCE(validation_epoch, 0)
WHERE id = ?1;",
params![id.to_string()],
)?;
Ok(())
}
#[derive(Debug)]
struct MemoryRow {
id: String,
memory_type: String,
status: String,
claim: String,
source_episodes_json: String,
source_events_json: String,
domains_json: String,
salience_json: String,
confidence: f64,
authority: String,
applies_when_json: String,
does_not_apply_when_json: String,
created_at: String,
updated_at: String,
}
fn memory_row(row: &Row<'_>) -> rusqlite::Result<MemoryRow> {
Ok(MemoryRow {
id: row.get(0)?,
memory_type: row.get(1)?,
status: row.get(2)?,
claim: row.get(3)?,
source_episodes_json: row.get(4)?,
source_events_json: row.get(5)?,
domains_json: row.get(6)?,
salience_json: row.get(7)?,
confidence: row.get(8)?,
authority: row.get(9)?,
applies_when_json: row.get(10)?,
does_not_apply_when_json: row.get(11)?,
created_at: row.get(12)?,
updated_at: row.get(13)?,
})
}
impl TryFrom<MemoryRow> for MemoryRecord {
type Error = StoreError;
fn try_from(row: MemoryRow) -> StoreResult<Self> {
Ok(Self {
id: row.id.parse()?,
memory_type: row.memory_type,
status: row.status,
claim: row.claim,
source_episodes_json: serde_json::from_str(&row.source_episodes_json)?,
source_events_json: serde_json::from_str(&row.source_events_json)?,
domains_json: serde_json::from_str(&row.domains_json)?,
salience_json: serde_json::from_str(&row.salience_json)?,
confidence: row.confidence,
authority: row.authority,
applies_when_json: serde_json::from_str(&row.applies_when_json)?,
does_not_apply_when_json: serde_json::from_str(&row.does_not_apply_when_json)?,
created_at: DateTime::parse_from_rfc3339(&row.created_at)?.with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.updated_at)?.with_timezone(&Utc),
})
}
}