#![allow(clippy::too_many_lines)]
use crate::models::field_names;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{Connection, params};
use std::collections::HashMap;
use std::path::Path;
const SQL_DELETE_MEMORY_BY_ID: &str = "DELETE FROM memories WHERE id = ?1";
const SQL_DELETE_NAMESPACE_META_BY_STANDARD_ID: &str =
"DELETE FROM namespace_meta WHERE standard_id = ?1";
const SQL_MEMORY_EXISTS_COUNT: &str = "SELECT COUNT(*) > 0 FROM memories WHERE id = ?1";
const SQL_MEMORY_EXISTS: &str = "SELECT EXISTS(SELECT 1 FROM memories WHERE id = ?1)";
const SQL_SELECT_MEMORY_ROW_BY_ID: &str = "SELECT * FROM memories WHERE id = ?1";
const SQL_LIST_BASE: &str = "SELECT * FROM memories WHERE (expires_at IS NULL OR expires_at > ?)";
const SQL_LIST_ORDER_LIMIT: &str = " ORDER BY priority DESC, updated_at DESC LIMIT ? OFFSET ?";
#[must_use]
pub fn truncate_to_microseconds(t: DateTime<Utc>) -> DateTime<Utc> {
use chrono::Timelike;
let micros = t.nanosecond() / 1_000;
t.with_nanosecond(micros * 1_000).unwrap_or(t)
}
use crate::models::{
AGENTS_NAMESPACE, AgentRegistration, Approval, ApproverType, ConfidenceSource, DuplicateCheck,
DuplicateMatch, GovernanceDecision, GovernanceLevel, GovernancePolicy, GovernedAction,
MAX_NAMESPACE_DEPTH, Memory, MemoryKind, MemoryLink, NamespaceCount, PROMOTION_THRESHOLD,
PendingAction, SourceSpan, Stats, Taxonomy, TaxonomyNode, Tier, TierCount, namespace_ancestors,
};
mod error;
pub use error::{LINK_CYCLE_ERR_PREFIX, LINK_PERMISSION_DENIED_ERR_PREFIX, LinkEnd, StorageError};
pub static GOVERNANCE_PRE_WRITE: std::sync::OnceLock<
Box<dyn Fn(&Memory) -> std::result::Result<(), String> + Send + Sync>,
> = std::sync::OnceLock::new();
#[derive(Debug, Clone)]
pub struct GovernanceRefusal {
pub reason: String,
}
impl std::fmt::Display for GovernanceRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "governance-refused: {}", self.reason)
}
}
impl std::error::Error for GovernanceRefusal {}
#[inline]
pub(crate) fn consult_governance_pre_write(mem: &Memory) -> Result<()> {
if let Some(hook) = GOVERNANCE_PRE_WRITE.get() {
if let Err(reason) = hook(mem) {
return Err(anyhow::Error::new(GovernanceRefusal { reason }));
}
}
Ok(())
}
type VisibilityPrefixes = (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
fn compute_visibility_prefixes(as_agent: Option<&str>) -> VisibilityPrefixes {
let Some(ns) = as_agent else {
return (None, None, None, None);
};
let ancestors = namespace_ancestors(ns);
let p = ancestors.first().cloned();
let t = ancestors.get(1).cloned();
let u = ancestors.get(2).cloned();
let o = ancestors.get(3).cloned();
(p, t, u, o)
}
fn is_visible(mem: &Memory, prefixes: &VisibilityPrefixes) -> bool {
use crate::models::namespace::MemoryScope;
let (p, t, u, o) = prefixes;
if p.is_none() {
return true;
}
let Some(scope) = mem
.metadata
.get(crate::META_KEY_SCOPE)
.and_then(|v| v.as_str())
.map_or(Some(MemoryScope::default()), MemoryScope::from_str)
else {
return false;
};
match scope {
MemoryScope::Collective => true,
MemoryScope::Private => p.as_ref().is_some_and(|ns| &mem.namespace == ns),
MemoryScope::Team => matches_subtree(&mem.namespace, t.as_deref()),
MemoryScope::Unit => matches_subtree(&mem.namespace, u.as_deref()),
MemoryScope::Org => matches_subtree(&mem.namespace, o.as_deref()),
}
}
fn matches_subtree(namespace: &str, prefix: Option<&str>) -> bool {
match prefix {
None => false,
Some(p) => namespace == p || namespace.starts_with(&format!("{p}/")),
}
}
fn archived_source_clause(include_archived: bool, table_alias: &str) -> &'static str {
if include_archived {
""
} else {
match table_alias {
"m" => {
"AND NOT (\
m.atomised_into IS NOT NULL AND m.atomised_into > 0 \
AND json_extract(m.metadata, '$.atomisation_archived_at') IS NOT NULL\
)"
}
"memories" => {
"AND NOT (\
memories.atomised_into IS NOT NULL AND memories.atomised_into > 0 \
AND json_extract(memories.metadata, '$.atomisation_archived_at') IS NOT NULL\
)"
}
_ => "",
}
}
}
fn is_archived_source(mem: &Memory) -> bool {
mem.metadata
.get(field_names::ATOMISATION_ARCHIVED_AT)
.is_some_and(|v| !v.is_null())
}
fn visibility_clause(start: usize, table_alias: &str) -> String {
let private_ph = start;
let team_ph = start + 1;
let unit_ph = start + 2;
let org_ph = start + 3;
let ta = table_alias;
format!(
"AND (\
?{private_ph} IS NULL \
OR {ta}.scope_idx = 'collective' \
OR ({ta}.scope_idx = 'private' AND {ta}.namespace = ?{private_ph}) \
OR ({ta}.scope_idx = 'team' AND ?{team_ph} IS NOT NULL AND ({ta}.namespace = ?{team_ph} OR {ta}.namespace LIKE replace(replace(?{team_ph}, '%', '\\%'), '_', '\\_') || '/%' ESCAPE '\\')) \
OR ({ta}.scope_idx = 'unit' AND ?{unit_ph} IS NOT NULL AND ({ta}.namespace = ?{unit_ph} OR {ta}.namespace LIKE replace(replace(?{unit_ph}, '%', '\\%'), '_', '\\_') || '/%' ESCAPE '\\')) \
OR ({ta}.scope_idx = 'org' AND ?{org_ph} IS NOT NULL AND ({ta}.namespace = ?{org_ph} OR {ta}.namespace LIKE replace(replace(?{org_ph}, '%', '\\%'), '_', '\\_') || '/%' ESCAPE '\\'))\
)"
)
}
fn escape_like_pattern(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' | '%' | '_' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
pub(crate) mod connection;
pub mod migration_meta;
pub mod migrations;
pub(crate) mod reflect;
pub use connection::open;
pub use connection::{DEFAULT_DB_MMAP_SIZE_BYTES, set_db_mmap_size};
pub use migrations::current_schema_version_for_tests;
pub use migrations::pre_migration_backup_infix_for_tests;
pub use reflect::{
ReflectError, ReflectHookDecision, ReflectHooks, ReflectInput, ReflectOutcome,
canonical_cbor_reflection_depth_exceeded, reflect, reflect_with_hooks,
};
#[allow(unused_imports)]
pub(crate) use reflect::emit_reflection_depth_exceeded_audit;
pub(crate) fn row_to_memory(row: &rusqlite::Row) -> rusqlite::Result<Memory> {
let row_id: String = row.get("id")?;
let tags_json: String = row.get("tags")?;
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
let tier_str: String = row.get("tier")?;
let tier = Tier::from_str(&tier_str).unwrap_or(Tier::Mid);
let metadata_str: String = row
.get::<_, String>("metadata")
.unwrap_or_else(|_| "{}".to_string());
let metadata: serde_json::Value = serde_json::from_str(&metadata_str).unwrap_or_else(|e| {
tracing::warn!(
row_id = %row_id,
column = "metadata",
error = %e,
"corrupt metadata in DB row, defaulting to {{}}"
);
crate::metrics::record_corrupt_provenance("metadata");
serde_json::json!({})
});
let citations = match row.get::<_, String>("citations").ok() {
Some(s) => match serde_json::from_str::<Vec<crate::models::Citation>>(&s) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
row_id = %row_id,
column = "citations",
error = %e,
"corrupt citations JSON in DB row, defaulting to []"
);
crate::metrics::record_corrupt_provenance("citations");
Vec::new()
}
},
None => Vec::new(),
};
let source_span: Option<SourceSpan> = row
.get::<_, Option<String>>(field_names::SOURCE_SPAN)
.unwrap_or(None)
.and_then(|s| match serde_json::from_str::<SourceSpan>(&s) {
Ok(span) => Some(span),
Err(e) => {
tracing::warn!(
row_id = %row_id,
column = field_names::SOURCE_SPAN,
error = %e,
"corrupt source_span JSON in DB row, defaulting to None"
);
crate::metrics::record_corrupt_provenance(field_names::SOURCE_SPAN);
None
}
});
let confidence_signals = row
.get::<_, Option<String>>(field_names::CONFIDENCE_SIGNALS)
.unwrap_or(None)
.and_then(
|s| match serde_json::from_str::<crate::models::ConfidenceSignals>(&s) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(
row_id = %row_id,
column = field_names::CONFIDENCE_SIGNALS,
error = %e,
"corrupt confidence_signals JSON in DB row, defaulting to None"
);
crate::metrics::record_corrupt_provenance(field_names::CONFIDENCE_SIGNALS);
None
}
},
);
Ok(Memory {
id: row_id,
tier,
namespace: row.get("namespace")?,
title: row.get("title")?,
content: row.get("content")?,
tags,
priority: row.get("priority")?,
confidence: row.get(field_names::CONFIDENCE).unwrap_or(1.0),
source: row.get("source").unwrap_or_else(|_| "api".to_string()),
access_count: row.get(field_names::ACCESS_COUNT)?,
created_at: row.get(field_names::CREATED_AT)?,
updated_at: row.get(field_names::UPDATED_AT)?,
last_accessed_at: row.get(field_names::LAST_ACCESSED_AT)?,
expires_at: row.get(field_names::EXPIRES_AT)?,
metadata,
reflection_depth: row.get(field_names::REFLECTION_DEPTH).unwrap_or(0_i32),
memory_kind: row
.get::<_, String>(field_names::MEMORY_KIND)
.ok()
.and_then(|s| crate::models::MemoryKind::from_str(&s))
.unwrap_or_default(),
entity_id: row.get::<_, Option<String>>("entity_id").unwrap_or(None),
persona_version: row
.get::<_, Option<i32>>(field_names::PERSONA_VERSION)
.unwrap_or(None),
citations,
source_uri: row
.get::<_, Option<String>>(field_names::SOURCE_URI)
.unwrap_or(None),
source_span,
confidence_source: row
.get::<_, String>(field_names::CONFIDENCE_SOURCE)
.ok()
.and_then(|s| crate::models::ConfidenceSource::from_str(&s))
.unwrap_or_default(),
confidence_signals,
confidence_decayed_at: row
.get::<_, Option<String>>(field_names::CONFIDENCE_DECAYED_AT)
.unwrap_or(None),
version: row.get::<_, i64>("version").unwrap_or(1),
})
}
pub(crate) fn extract_mentioned_entity_id(mem: &Memory) -> Option<String> {
if mem.memory_kind != MemoryKind::Reflection {
return None;
}
if let Some(eid) = mem
.metadata
.get("entity_id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Some(eid.to_string());
}
if let Some(start) = mem.title.find("[entity:") {
let rest = &mem.title[start + "[entity:".len()..];
if let Some(end) = rest.find(']') {
let extracted = rest[..end].trim();
if !extracted.is_empty() {
return Some(extracted.to_string());
}
}
}
None
}
pub fn insert(conn: &Connection, mem: &Memory) -> Result<String> {
consult_governance_pre_write(mem)?;
let tags_json = serde_json::to_string(&mem.tags)?;
let metadata_json = serde_json::to_string(&mem.metadata)?;
let citations_json = serde_json::to_string(&mem.citations)?;
let source_span_json = match mem.source_span {
Some(span) => Some(serde_json::to_string(&span)?),
None => None,
};
let confidence_signals_json = match &mem.confidence_signals {
Some(s) => Some(serde_json::to_string(s)?),
None => None,
};
let mentioned_entity_id = extract_mentioned_entity_id(mem);
let mut insert_stmt = conn.prepare_cached(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, last_accessed_at, expires_at, metadata, reflection_depth, memory_kind, entity_id, persona_version, citations, source_uri, source_span, confidence_source, confidence_signals, confidence_decayed_at, mentioned_entity_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26)
ON CONFLICT(title, namespace) DO UPDATE SET
content = excluded.content,
tags = excluded.tags,
priority = MAX(memories.priority, excluded.priority),
confidence = MAX(memories.confidence, excluded.confidence),
source = excluded.source,
tier = CASE WHEN excluded.tier = 'long' THEN 'long'
WHEN memories.tier = 'long' THEN 'long'
WHEN excluded.tier = 'mid' THEN 'mid'
ELSE memories.tier END,
updated_at = excluded.updated_at,
expires_at = CASE WHEN excluded.tier = 'long' OR memories.tier = 'long' THEN NULL
ELSE COALESCE(excluded.expires_at, memories.expires_at) END,
-- Preserve metadata.agent_id across upsert (NHI provenance is immutable).
metadata = CASE
WHEN json_extract(memories.metadata, '$.agent_id') IS NOT NULL
THEN json_set(
excluded.metadata,
'$.agent_id',
json_extract(memories.metadata, '$.agent_id')
)
ELSE excluded.metadata
END,
-- v0.7.0 Task 1/8 — recursion depth takes the max across upsert
-- so a subsequent reflection at higher depth doesn't lose its
-- provenance signal when re-stored at the same (title, namespace).
reflection_depth = MAX(memories.reflection_depth, excluded.reflection_depth),
-- v0.7.0 L1-1 — kind is sticky: once Reflection, always Reflection.
-- An upsert of an observation onto an existing reflection row must
-- not downgrade the kind (reflect is not reversible by re-store).
-- v0.7.0 QW-2 — Persona is also sticky once set; the engine
-- writes new versions via fresh rows under a unique
-- `__persona_<entity>_v<n>` title rather than upsert.
memory_kind = CASE WHEN memories.memory_kind = 'reflection' THEN 'reflection'
WHEN memories.memory_kind = 'persona' THEN 'persona'
ELSE excluded.memory_kind END,
-- v0.7.0 QW-2 — entity_id + persona_version stay attached to
-- the row they were minted with (Persona-kind upserts use
-- versioned titles so the conflict path is exercised only
-- on accidental same-title collisions).
entity_id = COALESCE(memories.entity_id, excluded.entity_id),
persona_version = COALESCE(memories.persona_version, excluded.persona_version),
-- v0.7.0 Form 4 — fact-provenance: when the incoming row
-- carries a non-empty citations array, replace the stored
-- value (caller re-asserted provenance); otherwise keep
-- the existing value (silent merge would lose freshly-cited
-- evidence). source_uri / source_span follow COALESCE
-- semantics so a new write that omits them does not blank
-- out existing provenance pointers.
citations = CASE WHEN excluded.citations = '[]'
THEN memories.citations
ELSE excluded.citations END,
source_uri = COALESCE(excluded.source_uri, memories.source_uri),
source_span = COALESCE(excluded.source_span, memories.source_span),
-- v0.7.0 Form 5 — confidence-provenance follows the same
-- shape as Form 4 columns: explicit non-default replaces;
-- caller_provided + NULL signals keep the existing
-- provenance signal so a re-store doesn't blank out an
-- auto-derived or calibrated value.
confidence_source = CASE WHEN excluded.confidence_source != 'caller_provided'
THEN excluded.confidence_source
ELSE memories.confidence_source END,
confidence_signals = COALESCE(excluded.confidence_signals, memories.confidence_signals),
confidence_decayed_at = COALESCE(excluded.confidence_decayed_at, memories.confidence_decayed_at),
-- v0.7.0 polish PERF-8 (#781) — denormalised mention tag.
-- COALESCE keeps any pre-existing tag (re-write that
-- omits the structured entity_id metadata should NOT
-- blank out the indexed column) while letting a fresh
-- extraction populate previously-NULL rows.
mentioned_entity_id = COALESCE(excluded.mentioned_entity_id, memories.mentioned_entity_id),
-- #1632 — upsert-merge IS a mutation (content/tags/priority
-- can change), so the Gap-1 optimistic-concurrency counter
-- bumps here exactly like db::update. Pre-#1632 a re-store
-- rewrote content while version stood still, so a stale
-- If-Match could overwrite the merge invisibly. The decay
-- sweep remains the only documented non-bumping mutator
-- (tests/non_version_bumping_sites_1036.rs).
version = memories.version + 1
RETURNING id",
)?;
let actual_id: String = insert_stmt.query_row(
params![
mem.id,
mem.tier.as_str(),
mem.namespace,
mem.title,
mem.content,
tags_json,
mem.priority,
mem.confidence,
mem.source,
mem.access_count,
mem.created_at,
mem.updated_at,
mem.last_accessed_at,
mem.effective_expires_at(),
metadata_json,
mem.reflection_depth,
mem.memory_kind.as_str(),
mem.entity_id,
mem.persona_version,
citations_json,
mem.source_uri,
source_span_json,
mem.confidence_source.as_str(),
confidence_signals_json,
mem.confidence_decayed_at,
mentioned_entity_id,
],
|r| r.get(0),
)?;
Ok(actual_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictMode {
Error,
Merge,
Version,
}
#[derive(Debug)]
pub struct ConflictError {
pub existing_id: String,
pub title: String,
pub namespace: String,
}
impl std::fmt::Display for ConflictError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"CONFLICT: memory with title '{}' already exists in namespace '{}' \
(existing id: {})",
self.title, self.namespace, self.existing_id
)
}
}
impl std::error::Error for ConflictError {}
pub fn capture_turn_idempotent(
conn: &Connection,
write: &crate::models::CaptureTurnWrite,
) -> std::result::Result<crate::models::CaptureTurnResult, String> {
use rusqlite::OptionalExtension;
let existing: Option<String> = conn
.prepare_cached(
"SELECT memory_id FROM transcript_line_dedup \
WHERE host_session_id IS NOT NULL \
AND host_session_id = ?1 \
AND host_turn_index = ?2",
)
.and_then(|mut stmt| {
stmt.query_row(
params![&write.host_session_id, write.host_turn_index],
|row| row.get(0),
)
.optional()
})
.map_err(|e| format!("DEDUP_QUERY_FAILED: {e}"))?;
if let Some(memory_id) = existing {
return Ok(crate::models::CaptureTurnResult {
memory_id,
dedup_hit: true,
});
}
conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)
.map_err(|e| format!("TX_BEGIN_FAILED: {e}"))?;
let tx_result = (|| -> std::result::Result<String, String> {
let inserted_id =
insert(conn, &write.memory).map_err(|e| format!("MEMORY_INSERT_FAILED: {e}"))?;
conn.prepare_cached(
"INSERT INTO transcript_line_dedup \
(sha256, memory_id, host_kind, transcript_path, \
host_session_id, host_turn_index, recovered_at) \
VALUES (?1, ?2, ?3, NULL, ?4, ?5, ?6)",
)
.and_then(|mut stmt| {
stmt.execute(params![
write.sha256,
inserted_id,
write.host_kind,
write.host_session_id,
write.host_turn_index,
write.recovered_at_ms,
])
})
.map_err(|e| format!("DEDUP_INSERT_FAILED: {e}"))?;
crate::signed_events::append_signed_event_no_tx(conn, &write.signed_event)
.map_err(|e| format!("SIGNED_EVENTS_APPEND_FAILED: {e}"))?;
Ok(inserted_id)
})();
match tx_result {
Ok(memory_id) => {
conn.execute_batch(connection::SQL_COMMIT)
.map_err(|e| format!("TX_COMMIT_FAILED: {e}"))?;
Ok(crate::models::CaptureTurnResult {
memory_id,
dedup_hit: false,
})
}
Err(e) => {
let _ = conn.execute_batch(connection::SQL_ROLLBACK);
Err(e)
}
}
}
pub fn insert_with_conflict(conn: &Connection, mem: &Memory, mode: ConflictMode) -> Result<String> {
match mode {
ConflictMode::Merge => insert(conn, mem),
ConflictMode::Error => {
consult_governance_pre_write(mem)?;
if let Some(existing_id) = find_by_title_namespace(conn, &mem.title, &mem.namespace)? {
return Err(ConflictError {
existing_id,
title: mem.title.clone(),
namespace: mem.namespace.clone(),
}
.into());
}
let tags_json = serde_json::to_string(&mem.tags)?;
let metadata_json = serde_json::to_string(&mem.metadata)?;
let citations_json = serde_json::to_string(&mem.citations)?;
let source_span_json = match mem.source_span {
Some(span) => Some(serde_json::to_string(&span)?),
None => None,
};
let confidence_signals_json = match &mem.confidence_signals {
Some(s) => Some(serde_json::to_string(s)?),
None => None,
};
let mentioned_entity_id = extract_mentioned_entity_id(mem);
let actual_id: String = conn.query_row(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, last_accessed_at, expires_at, metadata, reflection_depth, memory_kind, entity_id, persona_version, citations, source_uri, source_span, confidence_source, confidence_signals, confidence_decayed_at, mentioned_entity_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26)
RETURNING id",
params![
mem.id, mem.tier.as_str(), mem.namespace, mem.title, mem.content,
tags_json, mem.priority, mem.confidence, mem.source, mem.access_count,
mem.created_at, mem.updated_at, mem.last_accessed_at, mem.effective_expires_at(),
metadata_json, mem.reflection_depth, mem.memory_kind.as_str(),
mem.entity_id, mem.persona_version,
citations_json, mem.source_uri, source_span_json,
mem.confidence_source.as_str(), confidence_signals_json, mem.confidence_decayed_at,
mentioned_entity_id,
],
|r| r.get(0),
).map_err(|e| {
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") {
anyhow::Error::new(ConflictError {
existing_id: String::new(),
title: mem.title.clone(),
namespace: mem.namespace.clone(),
})
} else {
e.into()
}
})?;
Ok(actual_id)
}
ConflictMode::Version => {
let resolved_title = next_versioned_title(conn, &mem.title, &mem.namespace)?;
let mut versioned = mem.clone();
versioned.title = resolved_title;
insert(conn, &versioned)
}
}
}
pub fn get(conn: &Connection, id: &str) -> Result<Option<Memory>> {
let mut stmt = conn.prepare_cached(SQL_SELECT_MEMORY_ROW_BY_ID)?;
let mut rows = stmt.query_map(params![id], row_to_memory)?;
match rows.next() {
Some(Ok(m)) => Ok(Some(m)),
Some(Err(e)) => Err(e.into()),
None => Ok(None),
}
}
pub fn get_many(conn: &Connection, ids: &[String]) -> Result<HashMap<String, Memory>> {
let mut out: HashMap<String, Memory> = HashMap::with_capacity(ids.len());
if ids.is_empty() {
return Ok(out);
}
const CHUNK: usize = 500;
for chunk in ids.chunks(CHUNK) {
let placeholders = std::iter::repeat("?")
.take(chunk.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!("SELECT * FROM memories WHERE id IN ({placeholders})");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(chunk.iter()), row_to_memory)?;
for r in rows {
let mem = r?;
out.insert(mem.id.clone(), mem);
}
}
Ok(out)
}
pub fn get_by_prefix(conn: &Connection, prefix: &str) -> Result<Option<Memory>> {
let escaped = prefix.replace('%', "\\%").replace('_', "\\_");
let pattern = format!("{escaped}%");
let mut stmt = conn.prepare("SELECT * FROM memories WHERE id LIKE ?1 ESCAPE '\\'")?;
let rows: Vec<Memory> = stmt
.query_map(params![pattern], row_to_memory)?
.filter_map(Result::ok)
.collect();
match rows.len() {
0 => Ok(None),
1 => Ok(Some(rows.into_iter().next().expect("len checked"))),
_ => {
let ids: Vec<String> = rows.iter().map(|m| m.id.clone()).collect();
Err(anyhow::Error::new(StorageError::AmbiguousIdPrefix {
prefix: prefix.to_string(),
candidates: ids,
}))
}
}
}
pub fn resolve_id(conn: &Connection, id: &str) -> Result<Option<Memory>> {
if let Some(mem) = get(conn, id)? {
return Ok(Some(mem));
}
get_by_prefix(conn, id)
}
pub fn touch(conn: &Connection, id: &str, short_extend: i64, mid_extend: i64) -> Result<()> {
let now = Utc::now();
let now_str = now.to_rfc3339();
let short_expires = (now + chrono::Duration::seconds(short_extend)).to_rfc3339();
let mid_expires = (now + chrono::Duration::seconds(mid_extend)).to_rfc3339();
conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
let result = (|| -> Result<()> {
conn.execute(
"UPDATE memories SET
access_count = MIN(access_count + 1, 1000000),
last_accessed_at = ?1,
expires_at = CASE
WHEN tier = 'long' THEN expires_at
WHEN tier = 'short' AND expires_at IS NOT NULL THEN MAX(expires_at, ?2)
WHEN tier = 'mid' AND expires_at IS NOT NULL THEN MAX(expires_at, ?3)
ELSE expires_at
END
WHERE id = ?4",
params![now_str, short_expires, mid_expires, id],
)?;
conn.execute(
"UPDATE memories SET tier = 'long', expires_at = NULL, updated_at = ?1
WHERE id = ?2 AND tier = 'mid' AND access_count >= ?3",
params![now_str, id, PROMOTION_THRESHOLD],
)?;
conn.execute(
"UPDATE memories SET priority = MIN(priority + 1, 10)
WHERE id = ?1 AND access_count > 0 AND access_count % 10 = 0 AND priority < 10",
params![id],
)?;
Ok(())
})();
match result {
Ok(()) => {
conn.execute_batch(connection::SQL_COMMIT)?;
Ok(())
}
Err(e) => {
if let Err(rb) = conn.execute_batch(connection::SQL_ROLLBACK) {
tracing::error!("ROLLBACK failed in touch: {}", rb);
}
Err(e)
}
}
}
pub fn touch_many(
conn: &Connection,
ids: &[&str],
short_extend: i64,
mid_extend: i64,
) -> Result<usize> {
if ids.is_empty() {
return Ok(0);
}
let now = Utc::now();
let now_str = now.to_rfc3339();
let short_expires = (now + chrono::Duration::seconds(short_extend)).to_rfc3339();
let mid_expires = (now + chrono::Duration::seconds(mid_extend)).to_rfc3339();
conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
let result = (|| -> Result<()> {
let mut bump_stmt = conn.prepare_cached(
"UPDATE memories SET
access_count = MIN(access_count + 1, 1000000),
last_accessed_at = ?1,
expires_at = CASE
WHEN tier = 'long' THEN expires_at
WHEN tier = 'short' AND expires_at IS NOT NULL THEN MAX(expires_at, ?2)
WHEN tier = 'mid' AND expires_at IS NOT NULL THEN MAX(expires_at, ?3)
ELSE expires_at
END
WHERE id = ?4",
)?;
let mut promote_stmt = conn.prepare_cached(
"UPDATE memories SET tier = 'long', expires_at = NULL, updated_at = ?1
WHERE id = ?2 AND tier = 'mid' AND access_count >= ?3",
)?;
let mut priority_stmt = conn.prepare_cached(
"UPDATE memories SET priority = MIN(priority + 1, 10)
WHERE id = ?1 AND access_count > 0 AND access_count % 10 = 0 AND priority < 10",
)?;
for id in ids {
bump_stmt.execute(params![now_str, short_expires, mid_expires, id])?;
promote_stmt.execute(params![now_str, id, PROMOTION_THRESHOLD])?;
priority_stmt.execute(params![id])?;
}
Ok(())
})();
match result {
Ok(()) => {
conn.execute_batch(connection::SQL_COMMIT)?;
Ok(ids.len())
}
Err(e) => {
if let Err(rb) = conn.execute_batch(connection::SQL_ROLLBACK) {
tracing::error!("ROLLBACK failed in touch_many: {}", rb);
}
Err(e)
}
}
}
#[allow(clippy::too_many_arguments)]
#[derive(Debug, Clone)]
pub struct VersionConflict {
pub id: String,
pub expected: i64,
pub current: i64,
}
impl std::fmt::Display for VersionConflict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"CONFLICT: memory {} expected_version={} but stored version={}",
self.id, self.expected, self.current
)
}
}
impl std::error::Error for VersionConflict {}
#[allow(clippy::too_many_arguments)]
pub fn update(
conn: &Connection,
id: &str,
title: Option<&str>,
content: Option<&str>,
tier: Option<&Tier>,
namespace: Option<&str>,
tags: Option<&Vec<String>>,
priority: Option<i32>,
confidence: Option<f64>,
expires_at: Option<&str>,
metadata: Option<&serde_json::Value>,
) -> Result<(bool, bool)> {
update_with_expected_version(
conn, id, title, content, tier, namespace, tags, priority, confidence, expires_at,
metadata, None, None,
)
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub fn update_with_expected_version(
conn: &Connection,
id: &str,
title: Option<&str>,
content: Option<&str>,
tier: Option<&Tier>,
namespace: Option<&str>,
tags: Option<&Vec<String>>,
priority: Option<i32>,
confidence: Option<f64>,
expires_at: Option<&str>,
metadata: Option<&serde_json::Value>,
source_uri: Option<&str>,
expected_version: Option<i64>,
) -> Result<(bool, bool)> {
let mut stmt = conn.prepare_cached(SQL_SELECT_MEMORY_ROW_BY_ID)?;
let mut rows = stmt.query_map(params![id], row_to_memory)?;
let Some(Ok(existing)) = rows.next() else {
return Ok((false, false));
};
drop(rows);
drop(stmt);
if let Some(expected) = expected_version
&& existing.version != expected
{
return Err(VersionConflict {
id: existing.id.clone(),
expected,
current: existing.version,
}
.into());
}
let new_title = title.unwrap_or(&existing.title);
let new_content = content.unwrap_or(&existing.content);
let content_changed = new_title != existing.title || new_content != existing.content;
let effective_tier = match (tier, &existing.tier) {
(Some(requested), existing_tier) => match (existing_tier, requested) {
(Tier::Long, _) => &Tier::Long, (Tier::Mid, Tier::Short) => &Tier::Mid, (_, requested) => requested, },
(None, existing_tier) => existing_tier,
};
let namespace = namespace.unwrap_or(&existing.namespace);
let tags = tags.unwrap_or(&existing.tags);
let priority = priority.unwrap_or(existing.priority);
let confidence = confidence.unwrap_or(existing.confidence);
let expires_at = match expires_at {
Some("" | "null") => None,
Some(v) => Some(v),
None => existing.expires_at.as_deref(),
};
let metadata = metadata.unwrap_or(&existing.metadata);
let governed = Memory {
tier: effective_tier.clone(),
namespace: namespace.to_string(),
title: new_title.to_string(),
content: new_content.to_string(),
tags: tags.clone(),
priority,
confidence,
expires_at: expires_at.map(str::to_string),
metadata: metadata.clone(),
source_uri: source_uri
.map(str::to_string)
.or_else(|| existing.source_uri.clone()),
..existing.clone()
};
consult_governance_pre_write(&governed)?;
let tags_json = serde_json::to_string(tags)?;
let metadata_json = serde_json::to_string(metadata)?;
let now = Utc::now().to_rfc3339();
let update_res = conn.execute(
"UPDATE memories SET tier=?1, namespace=?2, title=?3, content=?4, tags=?5, priority=?6, confidence=?7, updated_at=?8, expires_at=?9, metadata=?10, source_uri = COALESCE(?11, source_uri), version = version + 1
WHERE id=?12 AND (?13 IS NULL OR version = ?13)",
params![effective_tier.as_str(), namespace, new_title, new_content, tags_json, priority, confidence, now, expires_at, metadata_json, source_uri, id, expected_version],
);
match update_res {
Ok(0) => {
if let Some(expected) = expected_version {
let current_version: Option<i64> = conn
.query_row(
"SELECT version FROM memories WHERE id = ?1",
params![id],
|r| r.get(0),
)
.ok();
if let Some(current) = current_version {
return Err(VersionConflict {
id: id.to_string(),
expected,
current,
}
.into());
}
}
Ok((false, false))
}
Ok(_) => Ok((true, content_changed)),
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
let other: Option<String> = conn
.query_row(
"SELECT id FROM memories WHERE title = ?1 AND namespace = ?2 AND id != ?3",
params![new_title, namespace, id],
|r| r.get(0),
)
.ok();
if let Some(other_id) = other {
return Err(anyhow::Error::new(StorageError::UniqueConflict {
reason: format!(
"title '{new_title}' already exists in namespace '{namespace}' (memory {other_id})"
),
}));
}
Err(anyhow::anyhow!("update failed with constraint violation"))
}
Err(e) => Err(e.into()),
}
}
#[derive(Debug, Clone)]
pub struct SupersedeResult {
pub archived_id: String,
pub new_id: String,
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub fn update_with_archive_on_supersede(
conn: &Connection,
id: &str,
title: Option<&str>,
content: Option<&str>,
tier: Option<&Tier>,
namespace: Option<&str>,
tags: Option<&Vec<String>>,
priority: Option<i32>,
confidence: Option<f64>,
expires_at: Option<&str>,
metadata: Option<&serde_json::Value>,
source_uri: Option<&str>,
expected_version: Option<i64>,
edit_source: crate::models::EditSource,
) -> Result<SupersedeResult> {
let mut stmt = conn.prepare_cached(SQL_SELECT_MEMORY_ROW_BY_ID)?;
let mut rows = stmt.query_map(params![id], row_to_memory)?;
let Some(Ok(existing)) = rows.next() else {
return Err(anyhow::Error::new(StorageError::MemoryNotFound {
id: id.to_string(),
role: None,
}));
};
drop(rows);
drop(stmt);
if let Some(expected) = expected_version
&& existing.version != expected
{
return Err(VersionConflict {
id: existing.id.clone(),
expected,
current: existing.version,
}
.into());
}
let new_id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let new_title = title.unwrap_or(&existing.title).to_string();
let new_content = content.unwrap_or(&existing.content).to_string();
let new_tier = match (tier, &existing.tier) {
(Some(requested), existing_tier) => match (existing_tier, requested) {
(Tier::Long, _) => Tier::Long,
(Tier::Mid, Tier::Short) => Tier::Mid,
(_, r) => r.clone(),
},
(None, existing_tier) => existing_tier.clone(),
};
let new_namespace = namespace.unwrap_or(&existing.namespace).to_string();
let new_tags = tags.cloned().unwrap_or_else(|| existing.tags.clone());
let new_priority = priority.unwrap_or(existing.priority);
let new_confidence = confidence.unwrap_or(existing.confidence);
let new_expires = match expires_at {
Some("" | "null") => None,
Some(v) => Some(v.to_string()),
None => existing.expires_at.clone(),
};
let new_source_uri = match source_uri {
Some(uri) => Some(uri.to_string()),
None => existing.source_uri.clone(),
};
let mut new_metadata = metadata
.cloned()
.unwrap_or_else(|| existing.metadata.clone());
if let serde_json::Value::Object(ref mut m) = new_metadata {
m.insert(
"edit_source".to_string(),
serde_json::Value::String(edit_source.as_str().to_string()),
);
m.insert(
field_names::SUPERSEDED_ID.to_string(),
serde_json::Value::String(existing.id.clone()),
);
}
let archived_id = existing.id.clone();
let mut new_mem = existing.clone();
new_mem.id = new_id.clone();
new_mem.title = new_title;
new_mem.content = new_content;
new_mem.tier = new_tier;
new_mem.namespace = new_namespace;
new_mem.tags = new_tags;
new_mem.priority = new_priority;
new_mem.confidence = new_confidence;
new_mem.expires_at = new_expires;
new_mem.metadata = new_metadata;
new_mem.source_uri = new_source_uri;
new_mem.created_at = now.clone();
new_mem.updated_at = now.clone();
new_mem.access_count = 0;
new_mem.last_accessed_at = None;
new_mem.version = crate::models::default_memory_version();
consult_governance_pre_write(&new_mem)?;
conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
let tx_result = (|| -> Result<()> {
let moved = archive_memory_no_tx(conn, &archived_id, Some("superseded"))?;
if !moved {
return Err(anyhow::Error::new(StorageError::ArchiveSupersedeFailed {
archived_id: archived_id.clone(),
}));
}
insert(conn, &new_mem)?;
Ok(())
})();
match tx_result {
Ok(()) => conn.execute_batch(connection::SQL_COMMIT)?,
Err(e) => {
let _ = conn.execute_batch(connection::SQL_ROLLBACK);
return Err(e);
}
}
Ok(SupersedeResult {
archived_id,
new_id,
})
}
pub fn delete(conn: &Connection, id: &str) -> Result<bool> {
conn.execute(SQL_DELETE_NAMESPACE_META_BY_STANDARD_ID, params![id])?;
let changed = conn.execute(SQL_DELETE_MEMORY_BY_ID, params![id])?;
Ok(changed > 0)
}
pub fn archive_memory(conn: &Connection, id: &str, reason: Option<&str>) -> Result<bool> {
conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
let result = archive_memory_no_tx(conn, id, reason);
match result {
Ok(moved) => {
conn.execute_batch(connection::SQL_COMMIT)?;
Ok(moved)
}
Err(e) => {
let _ = conn.execute_batch(connection::SQL_ROLLBACK);
Err(e)
}
}
}
pub(crate) fn archive_memory_no_tx(
conn: &Connection,
id: &str,
reason: Option<&str>,
) -> Result<bool> {
let now = Utc::now().to_rfc3339();
let reason = reason.unwrap_or("archive");
let result = (|| -> Result<bool> {
let exists: bool = conn
.query_row(SQL_MEMORY_EXISTS_COUNT, params![id], |r| r.get(0))
.unwrap_or(false);
if !exists {
return Ok(false);
}
conn.execute(
"INSERT OR REPLACE INTO archived_memories
(id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, archived_at, archive_reason, metadata,
embedding, embedding_dim, original_tier, original_expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version)
SELECT id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, ?1, ?2, metadata,
embedding, embedding_dim, tier, expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
FROM memories WHERE id = ?3",
params![now, reason, id],
)?;
conn.execute(SQL_DELETE_NAMESPACE_META_BY_STANDARD_ID, params![id])?;
let removed = conn.execute(SQL_DELETE_MEMORY_BY_ID, params![id])?;
Ok(removed > 0)
})();
result
}
pub fn archive_memory_for_caller(
conn: &Connection,
id: &str,
reason: Option<&str>,
caller: &str,
) -> Result<bool> {
let now = Utc::now().to_rfc3339();
let reason = reason.unwrap_or("archive");
conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
let result = (|| -> Result<bool> {
let owned: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM memories \
WHERE id = ?1 \
AND ( \
json_extract(metadata, '$.agent_id') = ?2 OR \
json_extract(metadata, '$.target_agent_id') = ?2 OR \
json_extract(metadata, '$.agent_id') IS NULL OR \
json_extract(metadata, '$.agent_id') = '' \
)",
params![id, caller],
|r| r.get(0),
)
.unwrap_or(false);
if !owned {
return Ok(false);
}
conn.execute(
"INSERT OR REPLACE INTO archived_memories
(id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, archived_at, archive_reason, metadata,
embedding, embedding_dim, original_tier, original_expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version)
SELECT id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, ?1, ?2, metadata,
embedding, embedding_dim, tier, expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
FROM memories WHERE id = ?3",
params![now, reason, id],
)?;
conn.execute(SQL_DELETE_NAMESPACE_META_BY_STANDARD_ID, params![id])?;
let removed = conn.execute(SQL_DELETE_MEMORY_BY_ID, params![id])?;
Ok(removed > 0)
})();
match result {
Ok(moved) => {
conn.execute_batch(connection::SQL_COMMIT)?;
Ok(moved)
}
Err(e) => {
let _ = conn.execute_batch(connection::SQL_ROLLBACK);
Err(e)
}
}
}
fn forget_fts_query(pat: &str) -> String {
sanitize_fts_query(pat, false)
}
pub fn forget_count(
conn: &Connection,
namespace: Option<&str>,
pattern: Option<&str>,
tier: Option<&Tier>,
) -> Result<usize> {
if pattern.is_none() && namespace.is_none() && tier.is_none() {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: crate::errors::msg::FORGET_FILTER_REQUIRED.to_string(),
}));
}
if let Some(pat) = pattern {
let fts_query = forget_fts_query(pat);
let tier_str = tier.map(|t| t.as_str().to_string());
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM memories WHERE rowid IN (
SELECT m.rowid FROM memories_fts fts
JOIN memories m ON m.rowid = fts.rowid
WHERE memories_fts MATCH ?1
AND (?2 IS NULL OR m.namespace = ?2)
AND (?3 IS NULL OR m.tier = ?3)
)",
params![fts_query, namespace, tier_str],
|r| r.get(0),
)?;
return Ok(usize::try_from(count).unwrap_or(0));
}
let tier_str = tier.map(|t| t.as_str().to_string());
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM memories WHERE (?1 IS NULL OR namespace = ?1) AND (?2 IS NULL OR tier = ?2)",
params![namespace, tier_str],
|r| r.get(0),
)?;
Ok(usize::try_from(count).unwrap_or(0))
}
pub fn forget(
conn: &Connection,
namespace: Option<&str>,
pattern: Option<&str>,
tier: Option<&Tier>,
archive: bool,
) -> Result<usize> {
if pattern.is_none() && namespace.is_none() && tier.is_none() {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: crate::errors::msg::FORGET_FILTER_REQUIRED.to_string(),
}));
}
if archive {
let now = Utc::now().to_rfc3339();
if let Some(pat) = pattern {
let fts_query = forget_fts_query(pat);
let tier_str = tier.map(|t| t.as_str().to_string());
conn.execute(
"INSERT OR REPLACE INTO archived_memories
(id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, archived_at, archive_reason, metadata,
embedding, embedding_dim, original_tier, original_expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version)
SELECT id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, ?4, 'forget', metadata,
embedding, embedding_dim, tier, expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
FROM memories WHERE rowid IN (
SELECT m.rowid FROM memories_fts fts
JOIN memories m ON m.rowid = fts.rowid
WHERE memories_fts MATCH ?1
AND (?2 IS NULL OR m.namespace = ?2)
AND (?3 IS NULL OR m.tier = ?3)
)",
params![fts_query, namespace, tier_str, now],
)?;
} else {
let tier_str = tier.map(|t| t.as_str().to_string());
conn.execute(
"INSERT OR REPLACE INTO archived_memories
(id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, archived_at, archive_reason, metadata,
embedding, embedding_dim, original_tier, original_expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version)
SELECT id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, ?3, 'forget', metadata,
embedding, embedding_dim, tier, expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
FROM memories WHERE (?1 IS NULL OR namespace = ?1) AND (?2 IS NULL OR tier = ?2)",
params![namespace, tier_str, now],
)?;
}
}
if let Some(pat) = pattern {
let fts_query = forget_fts_query(pat);
let tier_str = tier.map(|t| t.as_str().to_string());
let deleted = conn.execute(
"DELETE FROM memories WHERE rowid IN (
SELECT m.rowid FROM memories_fts fts
JOIN memories m ON m.rowid = fts.rowid
WHERE memories_fts MATCH ?1
AND (?2 IS NULL OR m.namespace = ?2)
AND (?3 IS NULL OR m.tier = ?3)
)",
params![fts_query, namespace, tier_str],
)?;
return Ok(deleted);
}
let tier_str = tier.map(|t| t.as_str().to_string());
let deleted = conn.execute(
"DELETE FROM memories WHERE (?1 IS NULL OR namespace = ?1) AND (?2 IS NULL OR tier = ?2)",
params![namespace, tier_str],
)?;
Ok(deleted)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ForgetMatch {
pub id: String,
pub title: String,
pub namespace: String,
pub tier: String,
}
pub fn forget_matches(
conn: &Connection,
namespace: Option<&str>,
pattern: Option<&str>,
tier: Option<&Tier>,
limit: usize,
) -> Result<Vec<ForgetMatch>> {
if pattern.is_none() && namespace.is_none() && tier.is_none() {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: crate::errors::msg::FORGET_FILTER_REQUIRED.to_string(),
}));
}
let tier_str = tier.map(|t| t.as_str().to_string());
let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
let row_to_match = |row: &rusqlite::Row<'_>| -> rusqlite::Result<ForgetMatch> {
Ok(ForgetMatch {
id: row.get(0)?,
title: row.get(1)?,
namespace: row.get(2)?,
tier: row.get(3)?,
})
};
if let Some(pat) = pattern {
let fts_query = forget_fts_query(pat);
let mut stmt = conn.prepare(
"SELECT m.id, m.title, m.namespace, m.tier
FROM memories_fts fts
JOIN memories m ON m.rowid = fts.rowid
WHERE memories_fts MATCH ?1
AND (?2 IS NULL OR m.namespace = ?2)
AND (?3 IS NULL OR m.tier = ?3)
ORDER BY m.rowid
LIMIT ?4",
)?;
let rows = stmt
.query_map(
params![fts_query, namespace, tier_str, limit_i64],
row_to_match,
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
return Ok(rows);
}
let mut stmt = conn.prepare(
"SELECT id, title, namespace, tier FROM memories
WHERE (?1 IS NULL OR namespace = ?1) AND (?2 IS NULL OR tier = ?2)
ORDER BY rowid
LIMIT ?3",
)?;
let rows = stmt
.query_map(params![namespace, tier_str, limit_i64], row_to_match)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
#[allow(clippy::too_many_arguments)]
#[must_use]
pub fn build_list_query(
namespace: Option<&str>,
tier: Option<&Tier>,
min_priority: Option<i32>,
now: &str,
since: Option<&str>,
until: Option<&str>,
tags_filter: Option<&str>,
agent_id: Option<&str>,
limit: usize,
offset: usize,
) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
let mut sql = String::from(SQL_LIST_BASE);
let mut params_vec: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(now.to_string())];
if let Some(ns) = namespace {
sql.push_str(" AND namespace = ?");
params_vec.push(Box::new(ns.to_string()));
}
if let Some(t) = tier {
sql.push_str(" AND tier = ?");
params_vec.push(Box::new(t.as_str().to_string()));
}
if let Some(p) = min_priority {
sql.push_str(" AND priority >= ?");
params_vec.push(Box::new(p));
}
if let Some(s) = since {
sql.push_str(" AND created_at >= ?");
params_vec.push(Box::new(s.to_string()));
}
if let Some(u) = until {
sql.push_str(" AND created_at <= ?");
params_vec.push(Box::new(u.to_string()));
}
if let Some(tag) = tags_filter {
sql.push_str(
" AND EXISTS (SELECT 1 FROM json_each(memories.tags) WHERE json_each.value = ?)",
);
params_vec.push(Box::new(tag.to_string()));
}
if let Some(a) = agent_id {
sql.push_str(" AND agent_id_idx = ?");
params_vec.push(Box::new(a.to_string()));
}
sql.push_str(SQL_LIST_ORDER_LIMIT);
params_vec.push(Box::new(limit));
params_vec.push(Box::new(offset));
(sql, params_vec)
}
#[allow(clippy::too_many_arguments)]
pub fn list(
conn: &Connection,
namespace: Option<&str>,
tier: Option<&Tier>,
limit: usize,
offset: usize,
min_priority: Option<i32>,
since: Option<&str>,
until: Option<&str>,
tags_filter: Option<&str>,
agent_id: Option<&str>,
) -> Result<Vec<Memory>> {
let now = Utc::now().to_rfc3339();
let (sql, params_vec) = build_list_query(
namespace,
tier,
min_priority,
&now,
since,
until,
tags_filter,
agent_id,
limit,
offset,
);
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_vec.iter().map(std::convert::AsRef::as_ref).collect();
let mut stmt = conn.prepare_cached(&sql)?;
let rows = stmt.query_map(params_refs.as_slice(), row_to_memory)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
#[allow(dead_code)] pub(crate) fn memories_by_kind(
conn: &Connection,
kind: &crate::models::MemoryKind,
) -> Result<Vec<Memory>> {
let now = Utc::now().to_rfc3339();
let mut stmt = conn.prepare(
"SELECT * FROM memories
WHERE memory_kind = ?1
AND (expires_at IS NULL OR expires_at > ?2)
ORDER BY priority DESC, updated_at DESC",
)?;
let rows = stmt.query_map(params![kind.as_str(), now], row_to_memory)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
#[allow(clippy::too_many_arguments)]
pub fn search(
conn: &Connection,
query: &str,
namespace: Option<&str>,
tier: Option<&Tier>,
limit: usize,
min_priority: Option<i32>,
since: Option<&str>,
until: Option<&str>,
tags_filter: Option<&str>,
agent_id: Option<&str>,
as_agent: Option<&str>,
include_archived: bool,
) -> Result<Vec<Memory>> {
search_with_source_uri(
conn,
query,
namespace,
tier,
limit,
min_priority,
since,
until,
tags_filter,
agent_id,
as_agent,
include_archived,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn search_with_source_uri(
conn: &Connection,
query: &str,
namespace: Option<&str>,
tier: Option<&Tier>,
limit: usize,
min_priority: Option<i32>,
since: Option<&str>,
until: Option<&str>,
tags_filter: Option<&str>,
agent_id: Option<&str>,
as_agent: Option<&str>,
include_archived: bool,
source_uri: Option<&str>,
) -> Result<Vec<Memory>> {
let now = Utc::now().to_rfc3339();
let tier_str = tier.map(|t| t.as_str().to_string());
let fts_query = sanitize_fts_query(query, false);
let (vis_p, vis_t, vis_u, vis_o) = compute_visibility_prefixes(as_agent);
let archived_fragment = archived_source_clause(include_archived, "m");
let source_uri_fragment = if source_uri.is_some() {
"AND m.source_uri = ?15"
} else {
""
};
let sql = format!(
"SELECT m.id, m.tier, m.namespace, m.title, m.content, m.tags, m.priority,
m.confidence, m.source, m.access_count, m.created_at, m.updated_at,
m.last_accessed_at, m.expires_at, m.metadata, m.reflection_depth,
m.memory_kind, m.entity_id, m.persona_version,
m.citations, m.source_uri, m.source_span,
m.confidence_source, m.confidence_signals, m.confidence_decayed_at
FROM memories_fts fts
JOIN memories m ON m.rowid = fts.rowid
WHERE memories_fts MATCH ?1
AND (?2 IS NULL OR m.namespace = ?2)
AND (?3 IS NULL OR m.tier = ?3)
AND (?4 IS NULL OR m.priority >= ?4)
AND (m.expires_at IS NULL OR m.expires_at > ?5)
AND (?6 IS NULL OR m.created_at >= ?6)
AND (?7 IS NULL OR m.created_at <= ?7)
AND (?8 IS NULL OR EXISTS (SELECT 1 FROM json_each(m.tags) WHERE json_each.value = ?8))
AND (?10 IS NULL OR m.agent_id_idx = ?10)
{archived_fragment}
{source_uri_fragment}
{vis}
ORDER BY (fts.rank * -1)
+ (m.priority * 0.5)
+ (MIN(m.access_count, 50) * 0.1)
+ (m.confidence * 2.0)
+ (1.0 / (1.0 + (julianday('now') - julianday(m.updated_at)) * 0.1))
DESC
LIMIT ?9",
vis = visibility_clause(11, "m"),
);
let mut stmt = conn.prepare(&sql)?;
let rows = if let Some(uri) = source_uri {
stmt.query_map(
params![
fts_query,
namespace,
tier_str,
min_priority,
now,
since,
until,
tags_filter,
limit,
agent_id,
vis_p,
vis_t,
vis_u,
vis_o,
uri,
],
row_to_memory,
)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
} else {
stmt.query_map(
params![
fts_query,
namespace,
tier_str,
min_priority,
now,
since,
until,
tags_filter,
limit,
agent_id,
vis_p,
vis_t,
vis_u,
vis_o,
],
row_to_memory,
)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
};
rows
}
pub fn list_by_source_uri(
conn: &Connection,
source_uri: &str,
namespace: Option<&str>,
limit: Option<usize>,
as_agent: Option<&str>,
) -> Result<Vec<Memory>> {
let cap = limit.unwrap_or(LIST_DEFAULT_CAP).min(LIST_MAX_LIMIT);
let (vis_p, vis_t, vis_u, vis_o) = compute_visibility_prefixes(as_agent);
let sql = format!(
"SELECT m.id, m.tier, m.namespace, m.title, m.content, m.tags, m.priority,
m.confidence, m.source, m.access_count, m.created_at, m.updated_at,
m.last_accessed_at, m.expires_at, m.metadata, m.reflection_depth,
m.memory_kind, m.entity_id, m.persona_version,
m.citations, m.source_uri, m.source_span,
m.confidence_source, m.confidence_signals, m.confidence_decayed_at,
m.version
FROM memories m
WHERE m.source_uri = ?1
AND (?2 IS NULL OR m.namespace = ?2)
{vis}
ORDER BY m.created_at ASC
LIMIT ?3",
vis = visibility_clause(4, "m"),
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(
params![
source_uri,
namespace,
i64::try_from(cap).unwrap_or(i64::MAX),
vis_p,
vis_t,
vis_u,
vis_o,
],
row_to_memory,
)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
#[must_use]
pub fn proximity_boost(agent_ns: &str, memory_ns: &str) -> f64 {
let agent_depth = crate::models::namespace_depth(agent_ns);
let memory_depth = crate::models::namespace_depth(memory_ns);
let distance = agent_depth.saturating_sub(memory_depth);
#[allow(clippy::cast_precision_loss)]
let d = distance as f64;
1.0 / (1.0 + d * 0.3)
}
fn hierarchy_in_clause(namespace: Option<&str>) -> (Option<String>, bool) {
let Some(ns) = namespace else {
return (None, false);
};
if !ns.contains('/') {
return (None, false);
}
if let Some(cached) = hierarchy_cache_get(ns) {
return (Some(cached), true);
}
let ancestors = crate::models::namespace_ancestors(ns);
if ancestors.is_empty() {
return (None, false);
}
let quoted: Vec<String> = ancestors
.iter()
.map(|a| format!("'{}'", a.replace('\'', "''")))
.collect();
let fragment = format!("AND m.namespace IN ({})", quoted.join(","));
hierarchy_cache_put(ns, &fragment);
(Some(fragment), true)
}
const HIERARCHY_CACHE_MAX: usize = 256;
fn hierarchy_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, String>> {
static CACHE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, String>>> =
std::sync::OnceLock::new();
CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
fn hierarchy_cache_get(ns: &str) -> Option<String> {
let cache = hierarchy_cache().lock().ok()?;
cache.get(ns).cloned()
}
fn hierarchy_cache_put(ns: &str, fragment: &str) {
let Ok(mut cache) = hierarchy_cache().lock() else {
return;
};
if cache.len() >= HIERARCHY_CACHE_MAX {
if let Some(k) = cache.keys().next().cloned() {
cache.remove(&k);
}
}
cache.insert(ns.to_string(), fragment.to_string());
}
#[cfg(test)]
fn hierarchy_cache_clear_for_tests() {
if let Ok(mut cache) = hierarchy_cache().lock() {
cache.clear();
}
}
fn apply_proximity_boost(scored: Vec<(Memory, f64)>, agent_ns: &str) -> Vec<(Memory, f64)> {
let mut boosted: Vec<(Memory, f64)> = scored
.into_iter()
.map(|(mem, score)| {
let boost = proximity_boost(agent_ns, &mem.namespace);
(mem, score * boost)
})
.collect();
boosted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
boosted
}
#[must_use]
pub fn count_tokens_cl100k(text: &str) -> usize {
use std::sync::OnceLock;
static BPE: OnceLock<Option<tiktoken_rs::CoreBPE>> = OnceLock::new();
let bpe = BPE.get_or_init(|| tiktoken_rs::cl100k_base().ok());
if let Some(bpe) = bpe.as_ref() {
bpe.encode_with_special_tokens(text).len()
} else {
text.len() / 4
}
}
#[must_use]
pub fn count_memory_tokens(mem: &Memory) -> usize {
count_tokens_cl100k(&mem.content)
}
#[must_use]
pub fn estimate_memory_tokens(mem: &Memory) -> usize {
count_memory_tokens(mem)
}
#[derive(Debug, Clone)]
pub struct BudgetOutcome {
pub tokens_used: usize,
pub tokens_remaining: Option<usize>,
pub memories_dropped: usize,
pub budget_overflow: bool,
}
#[must_use]
pub fn apply_token_budget(
scored: Vec<(Memory, f64)>,
budget_tokens: Option<usize>,
) -> (Vec<(Memory, f64)>, BudgetOutcome) {
let total_candidates = scored.len();
if budget_tokens == Some(0) {
return (
Vec::new(),
BudgetOutcome {
tokens_used: 0,
tokens_remaining: Some(0),
memories_dropped: total_candidates,
budget_overflow: false,
},
);
}
if budget_tokens.is_none() {
let mut used: usize = 0;
let mut out: Vec<(Memory, f64)> = Vec::with_capacity(scored.len());
for (mem, score) in scored {
used = used.saturating_add(mem.content.len() / 4);
out.push((mem, score));
}
return (
out,
BudgetOutcome {
tokens_used: used,
tokens_remaining: None,
memories_dropped: 0,
budget_overflow: false,
},
);
}
let mut used: usize = 0;
let mut out: Vec<(Memory, f64)> = Vec::with_capacity(scored.len());
let mut overflow = false;
for (mem, score) in scored {
let cost = count_memory_tokens(&mem);
if let Some(budget) = budget_tokens
&& used.saturating_add(cost) > budget
{
if out.is_empty() {
used = used.saturating_add(cost);
out.push((mem, score));
overflow = true;
}
break;
}
used = used.saturating_add(cost);
out.push((mem, score));
}
let dropped = total_candidates.saturating_sub(out.len());
let tokens_remaining = budget_tokens.map(|b| b.saturating_sub(used));
(
out,
BudgetOutcome {
tokens_used: used,
tokens_remaining,
memories_dropped: dropped,
budget_overflow: overflow,
},
)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub fn recall_with_telemetry(
conn: &Connection,
context: &str,
namespace: Option<&str>,
limit: usize,
tags_filter: Option<&str>,
since: Option<&str>,
until: Option<&str>,
short_extend: i64,
mid_extend: i64,
as_agent: Option<&str>,
budget_tokens: Option<usize>,
include_archived: bool,
source_uri_prefix: Option<&str>,
) -> Result<(
Vec<(Memory, f64)>,
BudgetOutcome,
crate::models::RecallTelemetry,
)> {
let (results, outcome) = recall(
conn,
context,
namespace,
limit,
tags_filter,
since,
until,
short_extend,
mid_extend,
as_agent,
budget_tokens,
include_archived,
source_uri_prefix,
)?;
let telemetry = crate::models::RecallTelemetry {
fts_candidates: results.len(),
hnsw_candidates: 0,
blend_weight_avg: 0.0,
embedding_dim_mismatch: 0,
};
Ok((results, outcome, telemetry))
}
pub fn recall(
conn: &Connection,
context: &str,
namespace: Option<&str>,
limit: usize,
tags_filter: Option<&str>,
since: Option<&str>,
until: Option<&str>,
short_extend: i64,
mid_extend: i64,
as_agent: Option<&str>,
budget_tokens: Option<usize>,
include_archived: bool,
source_uri_prefix: Option<&str>,
) -> Result<(Vec<(Memory, f64)>, BudgetOutcome)> {
let now = Utc::now().to_rfc3339();
let fts_query = sanitize_fts_query(context, true);
let (vis_p, vis_t, vis_u, vis_o) = compute_visibility_prefixes(as_agent);
let (hierarchy_in, hierarchy_active) = hierarchy_in_clause(namespace);
let hierarchy_fragment = hierarchy_in.unwrap_or_default();
let effective_namespace = if hierarchy_active { None } else { namespace };
let archived_fragment = archived_source_clause(include_archived, "m");
let (source_uri_fragment, source_uri_param): (&str, Option<String>) = match source_uri_prefix {
Some(prefix) if !prefix.is_empty() => (
"AND m.source_uri LIKE ?12 ESCAPE '\\'",
Some(format!("{}%", escape_like_pattern(prefix))),
),
_ => ("", None),
};
let sql = format!(
"SELECT m.id, m.tier, m.namespace, m.title, m.content, m.tags, m.priority,
m.confidence, m.source, m.access_count, m.created_at, m.updated_at,
m.last_accessed_at, m.expires_at, m.metadata, m.reflection_depth,
m.memory_kind, m.entity_id, m.persona_version,
m.citations, m.source_uri, m.source_span,
m.confidence_source, m.confidence_signals, m.confidence_decayed_at,
(fts.rank * -1)
+ (m.priority * 0.5)
+ (MIN(m.access_count, 50) * 0.1)
+ (m.confidence * 2.0)
+ (CASE m.tier WHEN 'long' THEN 3.0 WHEN 'mid' THEN 1.0 ELSE 0.0 END)
+ (1.0 / (1.0 + (julianday('now') - julianday(m.updated_at)) * 0.1))
AS score
FROM memories_fts fts
JOIN memories m ON m.rowid = fts.rowid
WHERE memories_fts MATCH ?1
AND (?2 IS NULL OR m.namespace = ?2)
{hierarchy_fragment}
AND (m.expires_at IS NULL OR m.expires_at > ?3)
AND (?4 IS NULL OR EXISTS (SELECT 1 FROM json_each(m.tags) WHERE json_each.value = ?4))
AND (?5 IS NULL OR m.created_at >= ?5)
AND (?6 IS NULL OR m.created_at <= ?6)
{archived_fragment}
{source_uri_fragment}
{vis}
ORDER BY score DESC
LIMIT ?7",
vis = visibility_clause(8, "m"),
);
let mut stmt = conn.prepare(&sql)?;
let row_handler = |row: &rusqlite::Row<'_>| -> rusqlite::Result<(Memory, f64)> {
let mem = row_to_memory(row)?;
let score: f64 = row.get("score")?;
Ok((mem, score))
};
let results: Vec<(Memory, f64)> = if let Some(ref uri_param) = source_uri_param {
let rows = stmt.query_map(
params![
fts_query,
effective_namespace,
now,
tags_filter,
since,
until,
limit,
vis_p,
vis_t,
vis_u,
vis_o,
uri_param,
],
row_handler,
)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
} else {
let rows = stmt.query_map(
params![
fts_query,
effective_namespace,
now,
tags_filter,
since,
until,
limit,
vis_p,
vis_t,
vis_u,
vis_o,
],
row_handler,
)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
let boosted = if let (true, Some(anchor)) = (hierarchy_active, namespace) {
apply_proximity_boost(results, anchor)
} else {
results
};
let (budgeted, outcome) = apply_token_budget(boosted, budget_tokens);
let touch_ids: Vec<&str> = budgeted.iter().map(|(mem, _)| mem.id.as_str()).collect();
if let Err(e) = touch_many(conn, &touch_ids, short_extend, mid_extend) {
tracing::warn!("touch_many failed for recall set: {}", e);
}
Ok((budgeted, outcome))
}
pub fn promote_to_namespace(
conn: &Connection,
source_id: &str,
to_namespace: &str,
) -> Result<String> {
if to_namespace.is_empty() {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: "to_namespace cannot be empty".to_string(),
}));
}
let source = get(conn, source_id)?.ok_or_else(|| {
anyhow::Error::new(StorageError::MemoryNotFound {
id: source_id.to_string(),
role: Some(LinkEnd::Source),
})
})?;
if to_namespace == source.namespace {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: format!(
"to_namespace must be a proper ancestor of the memory's namespace (got self: {})",
source.namespace
),
}));
}
let ancestors = namespace_ancestors(&source.namespace);
if !ancestors.iter().any(|a| a == to_namespace) {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: format!(
"to_namespace '{to_namespace}' is not an ancestor of '{}' (ancestors: {ancestors:?})",
source.namespace
),
}));
}
let now = Utc::now().to_rfc3339();
let clone = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: source.tier.clone(),
namespace: to_namespace.to_string(),
title: source.title.clone(),
content: source.content.clone(),
tags: source.tags.clone(),
priority: source.priority,
confidence: source.confidence,
source: source.source.clone(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: source.expires_at.clone(),
metadata: source.metadata.clone(),
reflection_depth: source.reflection_depth,
memory_kind: source.memory_kind.clone(),
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let actual_id = insert(conn, &clone)?;
create_link(
conn,
&actual_id,
source_id,
crate::models::MemoryLinkRelation::DerivedFrom.as_str(),
)?;
Ok(actual_id)
}
pub fn find_by_title_namespace(
conn: &Connection,
title: &str,
namespace: &str,
) -> Result<Option<String>> {
let id: Option<String> = conn
.query_row(
"SELECT id FROM memories WHERE title = ?1 AND namespace = ?2 LIMIT 1",
params![title, namespace],
|r| r.get(0),
)
.ok();
Ok(id)
}
const MAX_VERSION_SUFFIX: u32 = 1024;
pub fn next_versioned_title(
conn: &Connection,
base_title: &str,
namespace: &str,
) -> Result<String> {
if find_by_title_namespace(conn, base_title, namespace)?.is_none() {
return Ok(base_title.to_string());
}
for n in 2..=MAX_VERSION_SUFFIX {
let candidate = format!("{base_title} ({n})");
if find_by_title_namespace(conn, &candidate, namespace)?.is_none() {
return Ok(candidate);
}
}
Err(anyhow::Error::new(StorageError::UniqueConflict {
reason: format!(
"could not find a free versioned title for '{base_title}' in namespace '{namespace}' \
within {MAX_VERSION_SUFFIX} attempts"
),
}))
}
const CONTRADICTION_TITLE_STOPWORDS: &[&str] = &[
"a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "have", "in", "is",
"it", "its", "of", "on", "or", "that", "the", "this", "to", "was", "were", "will", "with",
];
const CONTRADICTION_TITLE_JACCARD_FLOOR: f32 = 0.30;
fn contradiction_title_tokens(title: &str) -> std::collections::HashSet<String> {
title
.split(|c: char| !c.is_alphanumeric())
.map(str::to_ascii_lowercase)
.filter(|t| !t.is_empty())
.filter(|t| !CONTRADICTION_TITLE_STOPWORDS.contains(&t.as_str()))
.collect()
}
#[allow(clippy::cast_precision_loss)]
fn contradiction_title_jaccard(
a: &std::collections::HashSet<String>,
b: &std::collections::HashSet<String>,
) -> f32 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let inter = a.intersection(b).count() as f32;
let union = a.union(b).count() as f32;
if union > 0.0 { inter / union } else { 0.0 }
}
fn find_similar_title_candidates(
conn: &Connection,
title: &str,
namespace: &str,
limit: usize,
) -> Result<Vec<Memory>> {
let fts_query = sanitize_fts_query(title, true);
let mut stmt = conn.prepare(
"SELECT m.id, m.tier, m.namespace, m.title, m.content, m.tags, m.priority,
m.confidence, m.source, m.access_count, m.created_at, m.updated_at,
m.last_accessed_at, m.expires_at, m.metadata, m.reflection_depth,
m.memory_kind, m.entity_id, m.persona_version,
m.citations, m.source_uri, m.source_span,
m.confidence_source, m.confidence_signals, m.confidence_decayed_at
FROM memories_fts fts
JOIN memories m ON m.rowid = fts.rowid
WHERE memories_fts MATCH ?1 AND m.namespace = ?2
ORDER BY fts.rank
LIMIT ?3",
)?;
let rows = stmt.query_map(
params![fts_query, namespace, i64::try_from(limit).unwrap_or(20)],
row_to_memory,
)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub fn find_contradictions(conn: &Connection, title: &str, namespace: &str) -> Result<Vec<Memory>> {
let candidates = find_similar_title_candidates(conn, title, namespace, 20)?;
let seed_tokens = contradiction_title_tokens(title);
let mut filtered: Vec<Memory> = candidates
.into_iter()
.filter(|cand| {
let cand_tokens = contradiction_title_tokens(&cand.title);
contradiction_title_jaccard(&seed_tokens, &cand_tokens)
>= CONTRADICTION_TITLE_JACCARD_FLOOR
})
.collect();
filtered.truncate(5);
Ok(filtered)
}
pub fn find_synthesis_candidates(
conn: &Connection,
title: &str,
namespace: &str,
) -> Result<Vec<Memory>> {
let mut candidates = find_similar_title_candidates(conn, title, namespace, 20)?;
candidates.truncate(5);
Ok(candidates)
}
pub fn validate_link_pre_create(
conn: &Connection,
source_id: &str,
target_id: &str,
relation: &str,
agent_id: &str,
skip_governance: bool,
) -> Result<()> {
if relation == crate::models::MemoryLinkRelation::ReflectsOn.as_str() {
let link_ns = match get(conn, source_id) {
Ok(Some(m)) => m.namespace,
_ => crate::DEFAULT_NAMESPACE.to_string(),
};
let max_depth = resolve_governance_policy(conn, &link_ns)
.unwrap_or_default()
.effective_max_reflection_depth();
if crate::kg::cycle_check::would_create_reflection_cycle(
conn, source_id, target_id, max_depth,
)?
.would_cycle
{
return Err(anyhow::Error::new(StorageError::LinkReflectionCycle {
source_id: source_id.to_string(),
target_id: target_id.to_string(),
}));
}
}
if !skip_governance {
let link_ns = match get(conn, source_id) {
Ok(Some(m)) => m.namespace,
_ => crate::DEFAULT_NAMESPACE.to_string(),
};
evaluate_link_permission(&link_ns, source_id, target_id, relation, agent_id)
.map_err(anyhow::Error::new)?;
}
Ok(())
}
pub(crate) fn evaluate_link_permission(
link_ns: &str,
source_id: &str,
target_id: &str,
relation: &str,
agent_id: &str,
) -> std::result::Result<(), StorageError> {
use crate::permissions::{Decision, Op, PermissionContext, Permissions};
let ctx = PermissionContext {
op: Op::MemoryLink,
namespace: link_ns.to_string(),
agent_id: agent_id.to_string(),
payload: serde_json::json!({
"source_id": source_id,
"target_id": target_id,
"relation": relation,
}),
};
match Permissions::evaluate(&ctx, &[]) {
Decision::Allow | Decision::Modify(_) => Ok(()),
Decision::Deny(reason) => Err(StorageError::LinkPermissionDenied { reason }),
Decision::Ask(prompt) => Err(StorageError::LinkPermissionDenied {
reason: format!("ask deferred to storage layer ({prompt})"),
}),
}
}
pub fn create_link(
conn: &Connection,
source_id: &str,
target_id: &str,
relation: &str,
) -> Result<()> {
create_link_signed(conn, source_id, target_id, relation, None).map(|_| ())
}
pub fn create_link_signed(
conn: &Connection,
source_id: &str,
target_id: &str,
relation: &str,
keypair: Option<&crate::identity::keypair::AgentKeypair>,
) -> Result<&'static str> {
let agent_id_for_eval = keypair
.as_ref()
.map(|kp| kp.agent_id.as_str())
.unwrap_or("system");
validate_link_pre_create(
conn,
source_id,
target_id,
relation,
agent_id_for_eval,
false,
)?;
let source_exists: bool = conn
.query_row(SQL_MEMORY_EXISTS, params![source_id], |r| r.get(0))
.unwrap_or(false);
if !source_exists {
return Err(anyhow::Error::new(StorageError::MemoryNotFound {
id: source_id.to_string(),
role: Some(LinkEnd::Source),
}));
}
let target_exists: bool = conn
.query_row(SQL_MEMORY_EXISTS, params![target_id], |r| r.get(0))
.unwrap_or(false);
if !target_exists {
return Err(anyhow::Error::new(StorageError::MemoryNotFound {
id: target_id.to_string(),
role: Some(LinkEnd::Target),
}));
}
let now = truncate_to_microseconds(Utc::now()).to_rfc3339();
let (signature, attest_level, observed_by_col): (Option<Vec<u8>>, &'static str, Option<&str>) =
match keypair {
Some(kp) if kp.can_sign() => {
let link = crate::identity::sign::SignableLink {
src_id: source_id,
dst_id: target_id,
relation,
observed_by: Some(kp.agent_id.as_str()),
valid_from: Some(now.as_str()),
valid_until: None,
};
let sig = crate::identity::sign::sign(kp, &link)?;
(
Some(sig),
crate::models::AttestLevel::SelfSigned.as_str(),
Some(kp.agent_id.as_str()),
)
}
_ => (None, crate::models::AttestLevel::Unsigned.as_str(), None),
};
let inserted = conn.execute(
"INSERT OR IGNORE INTO memory_links \
(source_id, target_id, relation, created_at, valid_from, signature, attest_level, observed_by) \
VALUES (?1, ?2, ?3, ?4, ?4, ?5, ?6, ?7)",
params![
source_id,
target_id,
relation,
now,
signature,
attest_level,
observed_by_col
],
)?;
if inserted > 0 {
let agent_for_event = observed_by_col
.map(str::to_string)
.unwrap_or_else(|| "unknown".to_string());
let signable = crate::identity::sign::SignableLink {
src_id: source_id,
dst_id: target_id,
relation,
observed_by: observed_by_col,
valid_from: Some(now.as_str()),
valid_until: None,
};
match crate::identity::sign::canonical_cbor(&signable) {
Ok(cbor) => {
let event = crate::signed_events::SignedEvent {
id: uuid::Uuid::new_v4().to_string(),
agent_id: agent_for_event,
event_type: crate::signed_events::event_types::MEMORY_LINK_CREATED.to_string(),
payload_hash: crate::signed_events::payload_hash(&cbor),
signature: signature.clone(),
attest_level: attest_level.to_string(),
timestamp: Utc::now().to_rfc3339(),
..crate::signed_events::SignedEvent::default()
};
if let Err(e) = crate::signed_events::append_signed_event(conn, &event) {
tracing::warn!(
target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
source_id, target_id, relation,
"failed to append memory_link.created audit row: {e}"
);
}
}
Err(e) => {
tracing::warn!(
target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
source_id, target_id, relation,
"failed to encode canonical CBOR for memory_link.created audit: {e}"
);
}
}
}
Ok(attest_level)
}
pub fn strongest_attest_level_for_source(conn: &Connection, source_id: &str) -> Result<String> {
let mut stmt = conn.prepare(
"SELECT attest_level FROM memory_links \
WHERE source_id = ?1",
)?;
let rows = stmt.query_map(params![source_id], |r| r.get::<_, String>(0))?;
let unsigned = crate::models::AttestLevel::Unsigned.as_str();
let self_signed = crate::models::AttestLevel::SelfSigned.as_str();
let peer_attested = crate::models::AttestLevel::PeerAttested.as_str();
let mut strongest = unsigned;
for row in rows {
let level = row?;
if level == peer_attested {
return Ok(peer_attested.to_string());
}
if level == self_signed && strongest == unsigned {
strongest = self_signed;
}
}
Ok(strongest.to_string())
}
pub fn create_link_inbound(conn: &Connection, link: &MemoryLink, attest_level: &str) -> Result<()> {
let skip_governance = attest_level == crate::models::AttestLevel::PeerAttested.as_str();
let peer_agent_id = link.observed_by.as_deref().unwrap_or("system");
validate_link_pre_create(
conn,
&link.source_id,
&link.target_id,
link.relation.as_str(),
peer_agent_id,
skip_governance,
)?;
let source_exists: bool = conn
.query_row(SQL_MEMORY_EXISTS, params![link.source_id], |r| r.get(0))
.unwrap_or(false);
if !source_exists {
return Err(anyhow::Error::new(StorageError::MemoryNotFound {
id: link.source_id.clone(),
role: Some(LinkEnd::Source),
}));
}
let target_exists: bool = conn
.query_row(SQL_MEMORY_EXISTS, params![link.target_id], |r| r.get(0))
.unwrap_or(false);
if !target_exists {
return Err(anyhow::Error::new(StorageError::MemoryNotFound {
id: link.target_id.clone(),
role: Some(LinkEnd::Target),
}));
}
let now = Utc::now().to_rfc3339();
let valid_from = link.valid_from.clone().unwrap_or_else(|| now.clone());
let created_at = if link.created_at.is_empty() {
now
} else {
link.created_at.clone()
};
let inserted = conn.execute(
"INSERT OR IGNORE INTO memory_links \
(source_id, target_id, relation, created_at, valid_from, valid_until, \
signature, attest_level, observed_by) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
link.source_id,
link.target_id,
link.relation.as_str(),
created_at,
valid_from,
link.valid_until,
link.signature,
attest_level,
link.observed_by,
],
)?;
if inserted > 0 {
let agent_for_event = link
.observed_by
.clone()
.unwrap_or_else(|| "unknown".to_string());
let signable = crate::identity::sign::SignableLink {
src_id: link.source_id.as_str(),
dst_id: link.target_id.as_str(),
relation: link.relation.as_str(),
observed_by: link.observed_by.as_deref(),
valid_from: Some(valid_from.as_str()),
valid_until: link.valid_until.as_deref(),
};
match crate::identity::sign::canonical_cbor(&signable) {
Ok(cbor) => {
let event = crate::signed_events::SignedEvent {
id: uuid::Uuid::new_v4().to_string(),
agent_id: agent_for_event,
event_type: crate::signed_events::event_types::MEMORY_LINK_CREATED.to_string(),
payload_hash: crate::signed_events::payload_hash(&cbor),
signature: link.signature.clone(),
attest_level: attest_level.to_string(),
timestamp: Utc::now().to_rfc3339(),
..crate::signed_events::SignedEvent::default()
};
if let Err(e) = crate::signed_events::append_signed_event(conn, &event) {
tracing::warn!(
target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
source_id = %link.source_id,
target_id = %link.target_id,
relation = %link.relation,
"failed to append memory_link.created audit row (inbound): {e}"
);
}
}
Err(e) => {
tracing::warn!(
target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
source_id = %link.source_id,
target_id = %link.target_id,
relation = %link.relation,
"failed to encode canonical CBOR for inbound memory_link.created audit: {e}"
);
}
}
}
Ok(())
}
pub fn get_links(conn: &Connection, id: &str) -> Result<Vec<MemoryLink>> {
let mut stmt = conn.prepare(
"SELECT source_id, target_id, relation, created_at, \
valid_from, valid_until, observed_by, attest_level \
FROM memory_links \
WHERE source_id = ?1 OR target_id = ?1",
)?;
let rows = stmt.query_map(params![id], |row| {
let relation_str: String = row.get(2)?;
Ok(MemoryLink {
source_id: row.get(0)?,
target_id: row.get(1)?,
relation: crate::models::MemoryLinkRelation::from_str(&relation_str)
.unwrap_or_default(),
created_at: row.get(3)?,
signature: None,
valid_from: row.get::<_, Option<String>>(4)?,
valid_until: row.get::<_, Option<String>>(5)?,
observed_by: row.get::<_, Option<String>>(6)?,
attest_level: row.get::<_, Option<String>>(7)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
#[allow(dead_code)]
pub fn delete_link(conn: &Connection, source_id: &str, target_id: &str) -> Result<bool> {
let changed = conn.execute(
"DELETE FROM memory_links WHERE source_id = ?1 AND target_id = ?2",
params![source_id, target_id],
)?;
Ok(changed > 0)
}
#[derive(Debug, Clone)]
pub struct LinkVerifyRecord {
pub source_id: String,
pub target_id: String,
pub relation: String,
pub signature: Option<Vec<u8>>,
pub observed_by: Option<String>,
pub valid_from: Option<String>,
pub valid_until: Option<String>,
pub attest_level: Option<String>,
}
pub fn get_link_for_verify(
conn: &Connection,
source_id: &str,
target_id: &str,
relation: &str,
) -> Result<Option<LinkVerifyRecord>> {
let mut stmt = conn.prepare(
"SELECT source_id, target_id, relation, signature, observed_by, \
valid_from, valid_until, attest_level \
FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
)?;
let mut rows = stmt.query(params![source_id, target_id, relation])?;
if let Some(row) = rows.next()? {
Ok(Some(LinkVerifyRecord {
source_id: row.get(0)?,
target_id: row.get(1)?,
relation: row.get(2)?,
signature: row.get::<_, Option<Vec<u8>>>(3)?,
observed_by: row.get::<_, Option<String>>(4)?,
valid_from: row.get::<_, Option<String>>(5)?,
valid_until: row.get::<_, Option<String>>(6)?,
attest_level: row.get::<_, Option<String>>(7)?,
}))
} else {
Ok(None)
}
}
pub const CONSOLIDATION_SOURCE: &str = "consolidation";
#[allow(clippy::too_many_arguments)]
pub fn consolidate(
conn: &Connection,
ids: &[String],
title: &str,
summary: &str,
namespace: &str,
tier: &Tier,
source: &str,
consolidator_agent_id: &str,
) -> Result<String> {
let now = Utc::now().to_rfc3339();
let new_id = uuid::Uuid::new_v4().to_string();
conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
let result = (|| -> Result<String> {
let mut max_priority = 5i32;
let mut all_tags: Vec<String> = Vec::new();
let mut total_access = 0i64;
let mut merged_metadata = serde_json::Map::new();
let mut source_agent_ids: Vec<String> = Vec::new();
for id in ids {
match get(conn, id)? {
Some(mem) => {
max_priority = max_priority.max(mem.priority);
all_tags.extend(mem.tags);
total_access = total_access.saturating_add(mem.access_count);
if let serde_json::Value::Object(map) = mem.metadata {
for (k, v) in map {
if k == "agent_id" {
if let serde_json::Value::String(aid) = &v
&& !source_agent_ids.contains(aid)
{
source_agent_ids.push(aid.clone());
}
continue;
}
if let Some(existing) = merged_metadata.get(&k)
&& std::mem::discriminant(existing) != std::mem::discriminant(&v)
{
tracing::warn!(
"consolidate: key '{}' type changed during merge",
k
);
}
merged_metadata.insert(k, v);
}
} else {
tracing::warn!(
"memory {} has non-object metadata during consolidate, skipping",
id
);
}
}
None => {
return Err(anyhow::Error::new(StorageError::MemoryNotFound {
id: id.to_string(),
role: None,
}));
}
}
}
all_tags.sort();
all_tags.dedup();
let tags_json = serde_json::to_string(&all_tags)?;
merged_metadata.insert(
crate::models::MemoryLinkRelation::DerivedFrom
.as_str()
.to_string(),
serde_json::Value::Array(
ids.iter()
.map(|id| serde_json::Value::String(id.clone()))
.collect(),
),
);
merged_metadata.insert(
"agent_id".to_string(),
serde_json::Value::String(consolidator_agent_id.to_string()),
);
if !source_agent_ids.is_empty() {
merged_metadata.insert(
"consolidated_from_agents".to_string(),
serde_json::Value::Array(
source_agent_ids
.into_iter()
.map(serde_json::Value::String)
.collect(),
),
);
}
let merged_metadata_value = serde_json::Value::Object(merged_metadata);
crate::validate::validate_metadata(&merged_metadata_value)
.context("merged metadata exceeds size limit")?;
let metadata_json = serde_json::to_string(&merged_metadata_value)?;
let candidate = Memory {
id: new_id.clone(),
tier: tier.clone(),
namespace: namespace.to_string(),
title: title.to_string(),
content: summary.to_string(),
tags: all_tags.clone(),
priority: max_priority,
confidence: 1.0,
source: source.to_string(),
access_count: total_access,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: merged_metadata_value.clone(),
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CuratorDerived,
confidence_signals: None,
confidence_decayed_at: None,
version: crate::models::default_memory_version(),
};
consult_governance_pre_write(&candidate)?;
conn.execute(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, expires_at, metadata, confidence_source)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1.0, ?8, ?9, ?10, ?10, ?11, ?12, ?13)",
params![new_id, tier.as_str(), namespace, title, summary, tags_json, max_priority, source, total_access, now, candidate.effective_expires_at(), metadata_json, candidate.confidence_source.as_str()],
)?;
for id in ids {
delete(conn, id)?;
}
Ok(new_id.clone())
})();
match result {
Ok(id) => {
conn.execute_batch(connection::SQL_COMMIT)?;
Ok(id)
}
Err(e) => {
if let Err(rb) = conn.execute_batch(connection::SQL_ROLLBACK) {
tracing::error!("ROLLBACK failed in consolidate: {}", rb);
}
Err(e)
}
}
}
fn strip_invisible(s: &str) -> String {
s.chars()
.filter(|c| {
!matches!(c,
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' |
'\u{00AD}' | '\u{034F}' | '\u{061C}' |
'\u{180E}' | '\u{2060}' | '\u{2061}'..='\u{2064}' |
'\u{FE00}'..='\u{FE0F}' | '\u{200E}' | '\u{200F}' |
'\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)
})
.collect()
}
fn sanitize_fts_query(input: &str, use_or: bool) -> String {
let joiner = if use_or { " OR " } else { " " };
let cleaned = strip_invisible(input);
let tokens: Vec<String> = cleaned
.split_whitespace()
.filter(|t| !t.is_empty())
.filter(|t| {
let upper = t.to_uppercase();
upper != "AND" && upper != "OR" && upper != "NOT" && upper != "NEAR"
})
.map(|token| {
let clean: String = token
.chars()
.filter(|c| {
*c != '"'
&& *c != '*'
&& *c != '^'
&& *c != '{'
&& *c != '}'
&& *c != '('
&& *c != ')'
&& *c != ':'
&& *c != '|'
&& *c != '+'
})
.collect();
if clean.is_empty() {
return String::new();
}
format!("\"{clean}\"")
})
.filter(|t| !t.is_empty())
.collect();
if tokens.is_empty() {
return "\"_empty_\"".to_string();
}
tokens.join(joiner)
}
pub fn list_namespaces(conn: &Connection) -> Result<Vec<NamespaceCount>> {
let now = Utc::now().to_rfc3339();
let mut stmt = conn.prepare(
"SELECT namespace, COUNT(*) FROM memories WHERE expires_at IS NULL OR expires_at > ?1 GROUP BY namespace ORDER BY COUNT(*) DESC",
)?;
let rows = stmt.query_map(params![now], |row| {
Ok(NamespaceCount {
namespace: row.get(0)?,
count: row.get(1)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub const TAXONOMY_MAX_LIMIT: usize = 10_000;
pub const TAXONOMY_DEFAULT_LIMIT: usize = 1000;
#[allow(clippy::too_many_lines)]
pub fn get_taxonomy(
conn: &Connection,
namespace_prefix: Option<&str>,
max_depth: usize,
limit: usize,
) -> Result<Taxonomy> {
let now = Utc::now().to_rfc3339();
let effective_limit = limit.min(TAXONOMY_MAX_LIMIT);
let effective_depth = max_depth.min(MAX_NAMESPACE_DEPTH);
let prefix = namespace_prefix.unwrap_or("");
let descendant_pattern = format!(
"{}/%",
prefix
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
);
let total_count: usize = if prefix.is_empty() {
let v: i64 = conn.query_row(
"SELECT COUNT(*) FROM memories WHERE expires_at IS NULL OR expires_at > ?1",
params![now],
|row| row.get(0),
)?;
usize::try_from(v).unwrap_or(0)
} else {
let v: i64 = conn.query_row(
"SELECT COUNT(*) FROM memories
WHERE (expires_at IS NULL OR expires_at > ?1)
AND (namespace = ?2 OR namespace LIKE ?3 ESCAPE '\\')",
params![now, prefix, descendant_pattern],
|row| row.get(0),
)?;
usize::try_from(v).unwrap_or(0)
};
let groups: Vec<(String, usize)> = if prefix.is_empty() {
let mut stmt = conn.prepare(
"SELECT namespace, COUNT(*) FROM memories
WHERE expires_at IS NULL OR expires_at > ?1
GROUP BY namespace
ORDER BY COUNT(*) DESC, namespace ASC
LIMIT ?2",
)?;
let rows = stmt.query_map(
params![now, i64::try_from(effective_limit).unwrap_or(i64::MAX)],
|row| {
let ns: String = row.get(0)?;
let c: i64 = row.get(1)?;
Ok((ns, usize::try_from(c).unwrap_or(0)))
},
)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
} else {
let mut stmt = conn.prepare(
"SELECT namespace, COUNT(*) FROM memories
WHERE (expires_at IS NULL OR expires_at > ?1)
AND (namespace = ?2 OR namespace LIKE ?3 ESCAPE '\\')
GROUP BY namespace
ORDER BY COUNT(*) DESC, namespace ASC
LIMIT ?4",
)?;
let rows = stmt.query_map(
params![
now,
prefix,
descendant_pattern,
i64::try_from(effective_limit).unwrap_or(i64::MAX)
],
|row| {
let ns: String = row.get(0)?;
let c: i64 = row.get(1)?;
Ok((ns, usize::try_from(c).unwrap_or(0)))
},
)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
let walked_count: usize = groups.iter().map(|(_, c)| *c).sum();
let truncated = walked_count < total_count;
let root_name = prefix.rsplit('/').next().unwrap_or("").to_string();
let mut root = TaxonomyNode {
namespace: prefix.to_string(),
name: root_name,
count: 0,
subtree_count: 0,
children: Vec::new(),
};
for (ns, c) in groups {
let suffix: &str = if prefix.is_empty() {
ns.as_str()
} else if ns == prefix {
""
} else if ns.len() > prefix.len() + 1
&& ns.starts_with(prefix)
&& ns.as_bytes()[prefix.len()] == b'/'
{
&ns[prefix.len() + 1..]
} else {
continue;
};
let all_segments: Vec<&str> = if suffix.is_empty() {
Vec::new()
} else {
suffix.split('/').collect()
};
let take = all_segments.len().min(effective_depth);
let used = &all_segments[..take];
let exact_match_in_view = take == all_segments.len();
root.subtree_count += c;
if used.is_empty() {
root.count += c;
continue;
}
let mut path_so_far = prefix.to_string();
let mut node = &mut root;
for (i, seg) in used.iter().enumerate() {
if !path_so_far.is_empty() {
path_so_far.push('/');
}
path_so_far.push_str(seg);
let pos = node.children.iter().position(|ch| ch.name == *seg);
let idx = if let Some(p) = pos {
p
} else {
node.children.push(TaxonomyNode {
namespace: path_so_far.clone(),
name: (*seg).to_string(),
count: 0,
subtree_count: 0,
children: Vec::new(),
});
node.children.len() - 1
};
node = &mut node.children[idx];
node.subtree_count += c;
let is_leaf = i + 1 == used.len();
if is_leaf && exact_match_in_view {
node.count += c;
}
}
}
sort_taxonomy(&mut root);
Ok(Taxonomy {
tree: root,
total_count,
truncated,
})
}
fn sort_taxonomy(node: &mut TaxonomyNode) {
node.children.sort_by(|a, b| a.name.cmp(&b.name));
for child in &mut node.children {
sort_taxonomy(child);
}
}
#[doc(hidden)]
pub fn fold_taxonomy_groups(
prefix: &str,
effective_depth: usize,
total_count: usize,
truncated: bool,
groups: Vec<(String, usize)>,
) -> Taxonomy {
let root_name = prefix.rsplit('/').next().unwrap_or("").to_string();
let mut root = TaxonomyNode {
namespace: prefix.to_string(),
name: root_name,
count: 0,
subtree_count: 0,
children: Vec::new(),
};
for (ns, c) in groups {
let suffix: &str = if prefix.is_empty() {
ns.as_str()
} else if ns == prefix {
""
} else if ns.len() > prefix.len() + 1
&& ns.starts_with(prefix)
&& ns.as_bytes()[prefix.len()] == b'/'
{
&ns[prefix.len() + 1..]
} else {
continue;
};
let all_segments: Vec<&str> = if suffix.is_empty() {
Vec::new()
} else {
suffix.split('/').collect()
};
let take = all_segments.len().min(effective_depth);
let used = &all_segments[..take];
let exact_match_in_view = take == all_segments.len();
root.subtree_count += c;
if used.is_empty() {
root.count += c;
continue;
}
let mut path_so_far = prefix.to_string();
let mut node = &mut root;
for (i, seg) in used.iter().enumerate() {
if !path_so_far.is_empty() {
path_so_far.push('/');
}
path_so_far.push_str(seg);
let pos = node.children.iter().position(|ch| ch.name == *seg);
let idx = if let Some(p) = pos {
p
} else {
node.children.push(TaxonomyNode {
namespace: path_so_far.clone(),
name: (*seg).to_string(),
count: 0,
subtree_count: 0,
children: Vec::new(),
});
node.children.len() - 1
};
node = &mut node.children[idx];
node.subtree_count += c;
let is_leaf = i + 1 == used.len();
if is_leaf && exact_match_in_view {
node.count += c;
}
}
}
sort_taxonomy(&mut root);
Taxonomy {
tree: root,
total_count,
truncated,
}
}
pub const LIST_DEFAULT_CAP: usize = 200;
pub const LIST_MAX_LIMIT: usize = 1000;
pub const LIST_FALLBACK_LIMIT: usize = 100;
pub const ARCHIVE_DEFAULT_PAGE_LIMIT: usize = 50;
pub const PENDING_DEFAULT_PAGE_LIMIT: usize = 100;
pub const DUPLICATE_THRESHOLD_MIN: f32 = 0.5;
pub const DUPLICATE_THRESHOLD_DEFAULT: f32 = 0.85;
pub fn check_duplicate(
conn: &Connection,
query_embedding: &[f32],
namespace: Option<&str>,
threshold: f32,
) -> Result<DuplicateCheck> {
let effective_threshold = threshold.max(DUPLICATE_THRESHOLD_MIN);
let now = Utc::now().to_rfc3339();
let rows: Vec<(String, String, String, Vec<u8>)> = if let Some(ns) = namespace {
let mut stmt = conn.prepare(
"SELECT id, title, namespace, embedding FROM memories
WHERE embedding IS NOT NULL
AND (expires_at IS NULL OR expires_at > ?1)
AND namespace = ?2",
)?;
let mapped = stmt.query_map(params![now, ns], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Vec<u8>>(3)?,
))
})?;
mapped.collect::<rusqlite::Result<Vec<_>>>()?
} else {
let mut stmt = conn.prepare(
"SELECT id, title, namespace, embedding FROM memories
WHERE embedding IS NOT NULL
AND (expires_at IS NULL OR expires_at > ?1)",
)?;
let mapped = stmt.query_map(params![now], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Vec<u8>>(3)?,
))
})?;
mapped.collect::<rusqlite::Result<Vec<_>>>()?
};
let mut best: Option<DuplicateMatch> = None;
let mut scanned: usize = 0;
for (id, title, ns, bytes) in rows {
if bytes.is_empty() {
continue;
}
let candidate = match crate::embeddings::decode_embedding_blob(&bytes) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
memory_id = %id,
blob_len = bytes.len(),
error = %e,
"skipping duplicate-check candidate with malformed embedding"
);
continue;
}
};
if candidate.len() != query_embedding.len() {
tracing::warn!(
memory_id = %id,
expected = query_embedding.len(),
got = candidate.len(),
"skipping duplicate-check candidate with dimension mismatch"
);
continue;
}
let similarity =
crate::embeddings::Embedder::cosine_similarity(query_embedding, &candidate);
scanned += 1;
let is_better = best.as_ref().is_none_or(|m| similarity > m.similarity);
if is_better {
best = Some(DuplicateMatch {
id,
title,
namespace: ns,
similarity,
});
}
}
let is_duplicate = best
.as_ref()
.is_some_and(|m| m.similarity >= effective_threshold);
Ok(DuplicateCheck {
is_duplicate,
threshold: effective_threshold,
nearest: best,
candidates_scanned: scanned,
})
}
#[must_use]
pub fn canonical_content_hash(text: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
hasher.finalize().into()
}
pub const PROACTIVE_CONFLICT_SIM_THRESHOLD: f32 = 0.95;
pub const PROACTIVE_CONFLICT_TOP_K: usize = 5;
pub const PROACTIVE_CONFLICT_SCAN_LIMIT: usize = 1024;
pub const PROACTIVE_CONFLICT_INDEX_K: usize = 32;
pub const PROACTIVE_CONFLICT_CONTENT_JACCARD_FLOOR: f32 = 0.30;
#[derive(Debug, Clone)]
pub struct ProactiveConflict {
pub existing_id: String,
pub existing_title: String,
pub similarity: f32,
pub reason: &'static str,
}
pub fn proactive_conflict_check(
conn: &Connection,
mem: &Memory,
query_embedding: &[f32],
) -> Result<Option<ProactiveConflict>> {
if query_embedding.is_empty() {
return Ok(None);
}
let now = Utc::now().to_rfc3339();
let mut stmt = conn.prepare(
"SELECT id, title, content, embedding FROM memories
WHERE embedding IS NOT NULL
AND (expires_at IS NULL OR expires_at > ?1)
AND namespace = ?2
ORDER BY updated_at DESC
LIMIT ?3",
)?;
let rows: Vec<(String, String, String, Vec<u8>)> = stmt
.query_map(
params![
now,
&mem.namespace,
i64::try_from(PROACTIVE_CONFLICT_SCAN_LIMIT).unwrap_or(i64::MAX)
],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Vec<u8>>(3)?,
))
},
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(proactive_conflict_verdict(mem, query_embedding, rows))
}
pub fn proactive_conflict_check_with_index(
conn: &Connection,
mem: &Memory,
query_embedding: &[f32],
vector_index: Option<&crate::hnsw::VectorIndex>,
) -> Result<Option<ProactiveConflict>> {
if query_embedding.is_empty() {
return Ok(None);
}
if let Some(idx) = vector_index
&& idx.is_fully_searchable()
&& !idx.is_empty()
{
let hits = idx.search(query_embedding, PROACTIVE_CONFLICT_INDEX_K);
let ids: Vec<String> = hits.into_iter().map(|h| h.id).collect();
return proactive_conflict_check_candidates(conn, mem, query_embedding, &ids);
}
tracing::trace!(
target: "proactive_conflict",
namespace = %mem.namespace,
"no fully-searchable (or empty) vector index — bounded recency-scan fallback (#1579 A5)"
);
proactive_conflict_check(conn, mem, query_embedding)
}
pub fn proactive_conflict_check_candidates(
conn: &Connection,
mem: &Memory,
query_embedding: &[f32],
candidate_ids: &[String],
) -> Result<Option<ProactiveConflict>> {
if query_embedding.is_empty() || candidate_ids.is_empty() {
return Ok(None);
}
let now = Utc::now().to_rfc3339();
let placeholders = std::iter::repeat_n("?", candidate_ids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT id, title, content, embedding FROM memories
WHERE id IN ({placeholders})
AND embedding IS NOT NULL
AND (expires_at IS NULL OR expires_at > ?{p_now})
AND namespace = ?{p_ns}",
p_now = candidate_ids.len() + 1,
p_ns = candidate_ids.len() + 2,
);
let mut stmt = conn.prepare(&sql)?;
let bind_iter = candidate_ids
.iter()
.map(String::as_str)
.chain([now.as_str(), mem.namespace.as_str()]);
let rows: Vec<(String, String, String, Vec<u8>)> = stmt
.query_map(rusqlite::params_from_iter(bind_iter), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Vec<u8>>(3)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(proactive_conflict_verdict(mem, query_embedding, rows))
}
fn proactive_conflict_verdict(
mem: &Memory,
query_embedding: &[f32],
rows: Vec<(String, String, String, Vec<u8>)>,
) -> Option<ProactiveConflict> {
let mut scored: Vec<(f32, String, String, String)> = Vec::with_capacity(rows.len());
for (id, title, content, blob) in rows {
if blob.is_empty() {
continue;
}
if id == mem.id {
continue;
}
let candidate = match crate::embeddings::decode_embedding_blob(&blob) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
memory_id = %id,
blob_len = blob.len(),
error = %e,
"proactive_conflict_check: skipping candidate with malformed embedding"
);
continue;
}
};
if candidate.len() != query_embedding.len() {
tracing::warn!(
memory_id = %id,
expected = query_embedding.len(),
got = candidate.len(),
"proactive_conflict_check: skipping candidate with dimension mismatch"
);
continue;
}
let sim = crate::embeddings::Embedder::cosine_similarity(query_embedding, &candidate);
scored.push((sim, id, title, content));
}
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let incoming_tokens = contradiction_title_tokens(&mem.content);
for (sim, id, title, content) in scored.into_iter().take(PROACTIVE_CONFLICT_TOP_K) {
if sim < PROACTIVE_CONFLICT_SIM_THRESHOLD {
break;
}
if content != mem.content
&& contradiction_title_jaccard(&incoming_tokens, &contradiction_title_tokens(&content))
>= PROACTIVE_CONFLICT_CONTENT_JACCARD_FLOOR
{
return Some(ProactiveConflict {
existing_id: id,
existing_title: title,
similarity: sim,
reason: "near_duplicate_with_differing_content",
});
}
}
None
}
pub fn check_duplicate_with_text(
conn: &Connection,
query_embedding: &[f32],
query_text: &str,
namespace: Option<&str>,
threshold: f32,
) -> Result<DuplicateCheck> {
let effective_threshold = threshold.max(DUPLICATE_THRESHOLD_MIN);
let now = Utc::now().to_rfc3339();
let query_hash = canonical_content_hash(query_text);
let rows: Vec<(String, String, String, String)> = if let Some(ns) = namespace {
let mut stmt = conn.prepare(
"SELECT id, title, namespace, content FROM memories
WHERE (expires_at IS NULL OR expires_at > ?1)
AND namespace = ?2",
)?;
let mapped = stmt.query_map(params![now, ns], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?;
mapped.collect::<rusqlite::Result<Vec<_>>>()?
} else {
let mut stmt = conn.prepare(
"SELECT id, title, namespace, content FROM memories
WHERE (expires_at IS NULL OR expires_at > ?1)",
)?;
let mapped = stmt.query_map(params![now], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?;
mapped.collect::<rusqlite::Result<Vec<_>>>()?
};
for (id, title, ns, content) in &rows {
let row_text = crate::embeddings::embedding_document(title, content);
let row_hash = canonical_content_hash(&row_text);
if row_hash == query_hash {
return Ok(DuplicateCheck {
is_duplicate: true,
threshold: effective_threshold,
nearest: Some(DuplicateMatch {
id: id.clone(),
title: title.clone(),
namespace: ns.clone(),
similarity: 1.0,
}),
candidates_scanned: rows.len(),
});
}
}
check_duplicate(conn, query_embedding, namespace, threshold)
}
pub fn entity_register(
conn: &Connection,
canonical_name: &str,
namespace: &str,
aliases: &[String],
extra_metadata: &serde_json::Value,
agent_id: Option<&str>,
) -> Result<crate::models::EntityRegistration> {
use crate::models::{ENTITY_KIND, ENTITY_TAG, EntityRegistration};
let existing_id: Option<String> = match conn.query_row(
"SELECT id FROM memories
WHERE namespace = ?1 AND title = ?2
AND COALESCE(json_extract(metadata, '$.kind'), '') = ?3",
params![namespace, canonical_name, ENTITY_KIND],
|r| r.get::<_, String>(0),
) {
Ok(id) => Some(id),
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(e.into()),
};
let (entity_id, created) = if let Some(id) = existing_id {
(id, false)
} else {
let collision: Option<String> = match conn.query_row(
"SELECT id FROM memories
WHERE namespace = ?1 AND title = ?2
AND COALESCE(json_extract(metadata, '$.kind'), '') != ?3",
params![namespace, canonical_name, ENTITY_KIND],
|r| r.get::<_, String>(0),
) {
Ok(id) => Some(id),
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(e.into()),
};
if collision.is_some() {
return Err(anyhow::Error::new(StorageError::UniqueConflict {
reason: format!(
"entity_register: title '{canonical_name}' in namespace '{namespace}' is already used by a non-entity memory"
),
}));
}
let mut meta_map = match extra_metadata {
serde_json::Value::Object(m) => m.clone(),
_ => serde_json::Map::new(),
};
meta_map.insert(
"kind".to_string(),
serde_json::Value::String(ENTITY_KIND.to_string()),
);
if let Some(a) = agent_id {
meta_map
.entry("agent_id".to_string())
.or_insert(serde_json::Value::String(a.to_string()));
}
let metadata = serde_json::Value::Object(meta_map);
let now = Utc::now().to_rfc3339();
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: namespace.to_string(),
title: canonical_name.to_string(),
content: canonical_name.to_string(),
tags: vec![ENTITY_TAG.to_string()],
priority: 7,
confidence: 1.0,
source: "api".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let id = insert(conn, &mem).context("insert entity memory")?;
(id, true)
};
let now = Utc::now().to_rfc3339();
{
let mut stmt = conn.prepare(
"INSERT OR IGNORE INTO entity_aliases (entity_id, alias, created_at)
VALUES (?1, ?2, ?3)",
)?;
stmt.execute(params![entity_id, canonical_name, now])?;
for alias in aliases {
let trimmed = alias.trim();
if trimmed.is_empty() || trimmed == canonical_name {
continue;
}
stmt.execute(params![entity_id, trimmed, now])?;
}
}
let aliases_out = list_entity_aliases(conn, &entity_id)?;
Ok(EntityRegistration {
entity_id,
canonical_name: canonical_name.to_string(),
namespace: namespace.to_string(),
aliases: aliases_out,
created,
})
}
pub fn entity_get_by_alias(
conn: &Connection,
alias: &str,
namespace: Option<&str>,
) -> Result<Option<crate::models::EntityRecord>> {
use crate::models::{ENTITY_KIND, EntityRecord};
let trimmed = alias.trim();
if trimmed.is_empty() {
return Ok(None);
}
let row: std::result::Result<(String, String, String), rusqlite::Error> =
if let Some(ns) = namespace {
conn.query_row(
"SELECT m.id, m.title, m.namespace
FROM entity_aliases ea
JOIN memories m ON m.id = ea.entity_id
WHERE ea.alias = ?1
AND m.namespace = ?2
AND COALESCE(json_extract(m.metadata, '$.kind'), '') = ?3
ORDER BY m.created_at DESC
LIMIT 1",
params![trimmed, ns, ENTITY_KIND],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
} else {
conn.query_row(
"SELECT m.id, m.title, m.namespace
FROM entity_aliases ea
JOIN memories m ON m.id = ea.entity_id
WHERE ea.alias = ?1
AND COALESCE(json_extract(m.metadata, '$.kind'), '') = ?2
ORDER BY m.created_at DESC
LIMIT 1",
params![trimmed, ENTITY_KIND],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
};
let (entity_id, canonical_name, ns) = match row {
Ok(t) => t,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => return Err(e.into()),
};
let aliases = list_entity_aliases(conn, &entity_id)?;
Ok(Some(EntityRecord {
entity_id,
canonical_name,
namespace: ns,
aliases,
}))
}
pub const KG_TIMELINE_DEFAULT_LIMIT: usize = 200;
pub const KG_TIMELINE_MAX_LIMIT: usize = 1000;
pub fn kg_timeline(
conn: &Connection,
source_id: &str,
since: Option<&str>,
until: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<crate::models::KgTimelineEvent>> {
use crate::models::KgTimelineEvent;
let cap = limit
.unwrap_or(KG_TIMELINE_DEFAULT_LIMIT)
.clamp(1, KG_TIMELINE_MAX_LIMIT);
let mut sql = String::from(
"SELECT ml.target_id, ml.relation, ml.valid_from, ml.valid_until,
ml.observed_by, m.title, m.namespace, ml.created_at
FROM memory_links ml
JOIN memories m ON m.id = ml.target_id
WHERE ml.source_id = ?1
AND ml.valid_from IS NOT NULL",
);
let mut binds: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(source_id.to_string())];
if let Some(s) = since {
sql.push_str(" AND ml.valid_from >= ?");
sql.push_str(&(binds.len() + 1).to_string());
binds.push(Box::new(s.to_string()));
}
if let Some(u) = until {
sql.push_str(" AND ml.valid_from <= ?");
sql.push_str(&(binds.len() + 1).to_string());
binds.push(Box::new(u.to_string()));
}
sql.push_str(" ORDER BY ml.valid_from ASC, ml.created_at ASC LIMIT ?");
sql.push_str(&(binds.len() + 1).to_string());
binds.push(Box::new(i64::try_from(cap).unwrap_or(i64::MAX)));
let mut stmt = conn.prepare(&sql)?;
let bind_refs: Vec<&dyn rusqlite::ToSql> = binds.iter().map(AsRef::as_ref).collect();
let rows = stmt.query_map(rusqlite::params_from_iter(bind_refs), |row| {
Ok(KgTimelineEvent {
target_id: row.get(0)?,
relation: row.get(1)?,
valid_from: row.get(2)?,
valid_until: row.get(3)?,
observed_by: row.get(4)?,
title: row.get(5)?,
target_namespace: row.get(6)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidateResult {
pub valid_until: String,
pub previous_valid_until: Option<String>,
}
pub fn invalidate_link(
conn: &Connection,
source_id: &str,
target_id: &str,
relation: &str,
valid_until: Option<&str>,
) -> Result<Option<InvalidateResult>> {
let stamp = valid_until.map_or_else(|| Utc::now().to_rfc3339(), str::to_string);
conn.execute(connection::SQL_BEGIN_IMMEDIATE, [])?;
let rollback = || {
let _ = conn.execute(connection::SQL_ROLLBACK, []);
};
let prior_row: (
Option<String>,
Option<Vec<u8>>,
Option<String>,
Option<String>,
Option<String>,
) = match conn.query_row(
"SELECT valid_until, signature, attest_level, observed_by, valid_from \
FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
params![source_id, target_id, relation],
|r| {
Ok((
r.get::<_, Option<String>>(0)?,
r.get::<_, Option<Vec<u8>>>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, Option<String>>(3)?,
r.get::<_, Option<String>>(4)?,
))
},
) {
Ok(v) => v,
Err(rusqlite::Error::QueryReturnedNoRows) => {
rollback();
return Ok(None);
}
Err(e) => {
rollback();
return Err(e.into());
}
};
let (prior, prior_signature, _prior_attest, observed_by, valid_from) = prior_row;
let was_signed = prior_signature.is_some();
let update_result = if was_signed {
conn.execute(
"UPDATE memory_links \
SET valid_until = ?4, signature = NULL, attest_level = 'unsigned' \
WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
params![source_id, target_id, relation, &stamp],
)
} else {
conn.execute(
"UPDATE memory_links SET valid_until = ?4 \
WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
params![source_id, target_id, relation, &stamp],
)
};
if let Err(e) = update_result {
rollback();
return Err(e.into());
}
if was_signed {
let signable = crate::identity::sign::SignableLink {
src_id: source_id,
dst_id: target_id,
relation,
observed_by: observed_by.as_deref(),
valid_from: valid_from.as_deref(),
valid_until: Some(stamp.as_str()),
};
match crate::identity::sign::canonical_cbor(&signable) {
Ok(cbor) => {
let event = crate::signed_events::SignedEvent {
id: uuid::Uuid::new_v4().to_string(),
agent_id: observed_by.clone().unwrap_or_else(|| "unknown".to_string()),
event_type: crate::signed_events::event_types::MEMORY_LINK_INVALIDATED
.to_string(),
payload_hash: crate::signed_events::payload_hash(&cbor),
signature: prior_signature,
attest_level: crate::models::AttestLevel::Unsigned.as_str().to_string(),
timestamp: Utc::now().to_rfc3339(),
..crate::signed_events::SignedEvent::default()
};
if let Err(e) = crate::signed_events::append_signed_event_no_tx(conn, &event) {
rollback();
return Err(anyhow::anyhow!(
"failed to append memory_link.invalidated audit row \
(rolled back signature clearing): {e}"
));
}
}
Err(e) => {
rollback();
return Err(anyhow::anyhow!(
"failed to encode canonical CBOR for invalidation audit \
(rolled back signature clearing): {e}"
));
}
}
}
conn.execute(connection::SQL_COMMIT, [])?;
Ok(Some(InvalidateResult {
valid_until: stamp,
previous_valid_until: prior,
}))
}
pub const KG_QUERY_DEFAULT_LIMIT: usize = 200;
pub const KG_QUERY_MAX_LIMIT: usize = 1000;
pub const KG_QUERY_MAX_SUPPORTED_DEPTH: usize = 5;
pub fn kg_query(
conn: &Connection,
source_id: &str,
max_depth: usize,
valid_at: Option<&str>,
allowed_agents: Option<&[String]>,
limit: Option<usize>,
include_invalidated: bool,
) -> Result<Vec<crate::models::KgQueryNode>> {
use crate::models::KgQueryNode;
if max_depth == 0 {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: crate::errors::msg::MAX_DEPTH_MIN.to_string(),
}));
}
if max_depth > KG_QUERY_MAX_SUPPORTED_DEPTH {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: format!(
"max_depth={max_depth} exceeds supported depth={KG_QUERY_MAX_SUPPORTED_DEPTH}"
),
}));
}
if let Some(agents) = allowed_agents
&& agents.is_empty()
{
return Ok(Vec::new());
}
let cap = limit
.unwrap_or(KG_QUERY_DEFAULT_LIMIT)
.clamp(1, KG_QUERY_MAX_LIMIT);
let mut binds: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
let mut hop_filter = String::new();
if let Some(t) = valid_at {
hop_filter.push_str(" AND ml.valid_from IS NOT NULL AND ml.valid_from <= ?");
binds.push(Box::new(t.to_string()));
hop_filter.push_str(&binds.len().to_string());
hop_filter.push_str(" AND (ml.valid_until IS NULL OR ml.valid_until > ?");
binds.push(Box::new(t.to_string()));
hop_filter.push_str(&binds.len().to_string());
hop_filter.push(')');
} else if !include_invalidated {
hop_filter.push_str(
" AND (ml.valid_until IS NULL OR ml.valid_until > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
);
}
if let Some(agents) = allowed_agents {
hop_filter.push_str(" AND ml.observed_by IN (");
for (i, a) in agents.iter().enumerate() {
binds.push(Box::new(a.clone()));
if i > 0 {
hop_filter.push_str(", ");
}
hop_filter.push('?');
hop_filter.push_str(&binds.len().to_string());
}
hop_filter.push(')');
}
binds.push(Box::new(source_id.to_string()));
let source_ph = binds.len();
binds.push(Box::new(i64::try_from(max_depth).unwrap_or(i64::MAX)));
let max_depth_ph = binds.len();
binds.push(Box::new(i64::try_from(cap).unwrap_or(i64::MAX)));
let limit_ph = binds.len();
let sql = format!(
"WITH RECURSIVE traversal(\
target_id, relation, valid_from, valid_until, observed_by, \
link_created_at, depth, path\
) AS (\
SELECT ml.target_id, ml.relation, ml.valid_from, ml.valid_until, \
ml.observed_by, ml.created_at, 1, \
json_array(ml.source_id, ml.target_id) \
FROM memory_links ml \
WHERE ml.source_id = ?{source_ph}{hop_filter} \
UNION ALL \
SELECT ml.target_id, ml.relation, ml.valid_from, ml.valid_until, \
ml.observed_by, ml.created_at, t.depth + 1, \
json_insert(t.path, '$[' || json_array_length(t.path) || ']', ml.target_id) \
FROM memory_links ml \
JOIN traversal t ON ml.source_id = t.target_id \
WHERE t.depth < ?{max_depth_ph} \
AND NOT EXISTS (SELECT 1 FROM json_each(t.path) WHERE value = ml.target_id)\
{hop_filter}\
) \
SELECT t.target_id, t.relation, t.valid_from, t.valid_until, \
t.observed_by, m.title, m.namespace, t.depth, \
(SELECT group_concat(value, '->') FROM json_each(t.path)) \
FROM traversal t \
JOIN memories m ON m.id = t.target_id \
ORDER BY t.depth ASC, COALESCE(t.valid_from, t.link_created_at) ASC, \
t.link_created_at ASC \
LIMIT ?{limit_ph}",
);
let mut stmt = conn.prepare(&sql)?;
let bind_refs: Vec<&dyn rusqlite::ToSql> = binds.iter().map(AsRef::as_ref).collect();
let rows = stmt.query_map(rusqlite::params_from_iter(bind_refs), |row| {
let target_id: String = row.get(0)?;
let depth: i64 = row.get(7)?;
Ok(KgQueryNode {
target_id,
relation: row.get(1)?,
valid_from: row.get(2)?,
valid_until: row.get(3)?,
observed_by: row.get(4)?,
title: row.get(5)?,
target_namespace: row.get(6)?,
depth: usize::try_from(depth).unwrap_or(0),
path: row.get(8)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub const FIND_PATHS_DEFAULT_LIMIT: usize = 10;
pub const FIND_PATHS_MAX_LIMIT: usize = 50;
pub const FIND_PATHS_MAX_DEPTH: usize = 7;
pub const FIND_PATHS_DEFAULT_DEPTH: usize = 4;
pub fn find_paths(
conn: &Connection,
source_id: &str,
target_id: &str,
max_depth: Option<usize>,
max_results: Option<usize>,
include_invalidated: bool,
) -> Result<Vec<Vec<String>>> {
let depth = max_depth.unwrap_or(FIND_PATHS_DEFAULT_DEPTH);
if depth == 0 {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: crate::errors::msg::MAX_DEPTH_MIN.to_string(),
}));
}
if depth > FIND_PATHS_MAX_DEPTH {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: format!(
"max_depth={depth} exceeds supported depth={FIND_PATHS_MAX_DEPTH} (FIND_PATHS_MAX_DEPTH); contact maintainers to raise this bound after benchmarking"
),
}));
}
let cap = max_results
.unwrap_or(FIND_PATHS_DEFAULT_LIMIT)
.clamp(1, FIND_PATHS_MAX_LIMIT);
if source_id == target_id {
return Ok(vec![vec![source_id.to_string()]]);
}
let invalidated_filter = if include_invalidated {
""
} else {
" WHERE (valid_until IS NULL OR valid_until > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
};
let sql = format!(
"WITH RECURSIVE traversal(current_id, depth, path) AS (
SELECT ?1, 0, json_array(?1)
UNION ALL
SELECT next_id, t.depth + 1,
json_insert(t.path, '$[' || json_array_length(t.path) || ']', next_id)
FROM traversal t
JOIN (
SELECT source_id AS from_id, target_id AS next_id
FROM memory_links{invalidated_filter}
UNION
SELECT target_id AS from_id, source_id AS next_id
FROM memory_links{invalidated_filter}
) edges ON edges.from_id = t.current_id
WHERE t.depth < ?3
AND NOT EXISTS (
SELECT 1 FROM json_each(t.path) WHERE value = next_id
)
)
SELECT path
FROM traversal
WHERE current_id = ?2 AND depth >= 1
ORDER BY depth ASC, path ASC
LIMIT ?4"
);
let depth_i64 = i64::try_from(depth).unwrap_or(i64::MAX);
let cap_i64 = i64::try_from(cap).unwrap_or(i64::MAX);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![source_id, target_id, depth_i64, cap_i64], |row| {
let json_path: String = row.get(0)?;
Ok(json_path)
})?;
let mut paths: Vec<Vec<String>> = Vec::new();
for row in rows {
let json = row?;
let parsed: Vec<String> = serde_json::from_str(&json).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
})?;
paths.push(parsed);
}
Ok(paths)
}
fn list_entity_aliases(conn: &Connection, entity_id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT alias FROM entity_aliases
WHERE entity_id = ?1
ORDER BY created_at ASC, alias ASC",
)?;
let aliases: Vec<String> = stmt
.query_map(params![entity_id], |r| r.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(aliases)
}
pub fn register_agent(
conn: &Connection,
agent_id: &str,
agent_type: &str,
capabilities: &[String],
) -> Result<String> {
let title = crate::models::agent_registration_title(agent_id);
let now = Utc::now().to_rfc3339();
let registered_at = conn
.query_row(
"SELECT json_extract(metadata, '$.registered_at') FROM memories
WHERE namespace = ?1 AND title = ?2",
params![AGENTS_NAMESPACE, &title],
|row| row.get::<_, Option<String>>(0),
)
.ok()
.flatten()
.unwrap_or_else(|| now.clone());
let caps_json: Vec<serde_json::Value> = capabilities
.iter()
.map(|c| serde_json::Value::String(c.clone()))
.collect();
let metadata = serde_json::json!({
"agent_id": agent_id,
(field_names::AGENT_TYPE): agent_type,
(field_names::CAPABILITIES): caps_json,
(field_names::REGISTERED_AT): registered_at,
(field_names::LAST_SEEN_AT): now,
"scope": crate::models::MemoryScope::Collective.as_str(),
});
let content = serde_json::to_string(&metadata)
.context("failed to serialize agent registration content")?;
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: AGENTS_NAMESPACE.to_string(),
title,
content,
tags: vec!["agent-registration".to_string()],
priority: 5,
confidence: 1.0,
source: "system".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
insert(conn, &mem)
}
pub fn list_agents(conn: &Connection) -> Result<Vec<AgentRegistration>> {
let now = Utc::now().to_rfc3339();
let mut stmt = conn.prepare(
"SELECT metadata FROM memories
WHERE namespace = ?1
AND (expires_at IS NULL OR expires_at > ?2)
ORDER BY json_extract(metadata, '$.registered_at') ASC",
)?;
let rows = stmt.query_map(params![AGENTS_NAMESPACE, now], |row| {
row.get::<_, String>(0)
})?;
let mut agents = Vec::new();
for r in rows {
let raw = r?;
let meta: serde_json::Value =
serde_json::from_str(&raw).context("failed to parse agent metadata as JSON")?;
let agent_id = meta
.get("agent_id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let agent_type = meta
.get(field_names::AGENT_TYPE)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let capabilities: Vec<String> = meta
.get(field_names::CAPABILITIES)
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let registered_at = meta
.get(field_names::REGISTERED_AT)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let last_seen_at = meta
.get(field_names::LAST_SEEN_AT)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
agents.push(AgentRegistration {
agent_id,
agent_type,
capabilities,
registered_at,
last_seen_at,
});
}
Ok(agents)
}
pub fn bind_agent_pubkey(conn: &Connection, agent_id: &str, pubkey_b64: &str) -> Result<()> {
let title = crate::models::agent_registration_title(agent_id);
let now = Utc::now().to_rfc3339();
let affected = conn.execute(
"UPDATE memories SET
metadata = json_set(metadata, '$.agent_pubkey', ?3, '$.pubkey_bound_at', ?4),
content = json_set(content, '$.agent_pubkey', ?3, '$.pubkey_bound_at', ?4),
updated_at = ?4
WHERE namespace = ?1 AND title = ?2",
params![AGENTS_NAMESPACE, &title, pubkey_b64, &now],
)?;
if affected == 0 {
anyhow::bail!(
"cannot bind pubkey: agent '{agent_id}' is not registered (register it first)"
);
}
Ok(())
}
pub fn agent_pubkey(conn: &Connection, agent_id: &str) -> Result<Option<String>> {
let title = crate::models::agent_registration_title(agent_id);
let pubkey = conn
.query_row(
"SELECT json_extract(metadata, '$.agent_pubkey') FROM memories
WHERE namespace = ?1 AND title = ?2",
params![AGENTS_NAMESPACE, &title],
|row| row.get::<_, Option<String>>(0),
)
.ok()
.flatten();
Ok(pubkey)
}
pub fn revoke_agent_pubkey(conn: &Connection, agent_id: &str) -> Result<()> {
let title = crate::models::agent_registration_title(agent_id);
let now = Utc::now().to_rfc3339();
let affected = conn.execute(
"UPDATE memories SET
metadata = json_set(
json_remove(metadata, '$.agent_pubkey', '$.pubkey_bound_at'),
'$.pubkey_revoked_at', ?3),
content = json_set(
json_remove(content, '$.agent_pubkey', '$.pubkey_bound_at'),
'$.pubkey_revoked_at', ?3),
updated_at = ?3
WHERE namespace = ?1 AND title = ?2",
params![AGENTS_NAMESPACE, &title, &now],
)?;
if affected == 0 {
anyhow::bail!(
"cannot revoke pubkey: agent '{agent_id}' is not registered (register it first)"
);
}
Ok(())
}
pub fn stats(conn: &Connection, db_path: &Path) -> Result<Stats> {
let total: usize = conn.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))?;
let mut stmt =
conn.prepare("SELECT tier, COUNT(*) FROM memories GROUP BY tier ORDER BY COUNT(*) DESC")?;
let by_tier = stmt
.query_map([], |row| {
Ok(TierCount {
tier: row.get(0)?,
count: row.get(1)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut stmt = conn.prepare(
"SELECT namespace, COUNT(*) FROM memories GROUP BY namespace ORDER BY COUNT(*) DESC",
)?;
let by_namespace = stmt
.query_map([], |row| {
Ok(NamespaceCount {
namespace: row.get(0)?,
count: row.get(1)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let now = Utc::now().to_rfc3339();
let one_hour = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let expiring_soon: usize = conn.query_row(
"SELECT COUNT(*) FROM memories WHERE expires_at IS NOT NULL AND expires_at > ?1 AND expires_at <= ?2",
params![now, one_hour], |r| r.get(0),
)?;
let links_count: usize = conn
.query_row("SELECT COUNT(*) FROM memory_links", [], |r| r.get(0))
.unwrap_or(0);
let db_size_bytes = std::fs::metadata(db_path).map_or(0, |m| m.len());
let dim_violations = dim_violations(conn).unwrap_or(0);
let index_evictions_total = crate::hnsw::index_evictions_total();
Ok(Stats {
total,
by_tier,
by_namespace,
expiring_soon,
links_count,
db_size_bytes,
dim_violations,
index_evictions_total,
})
}
pub fn gc_if_needed(conn: &Connection, archive: bool) -> Result<usize> {
let now = Utc::now().to_rfc3339();
let has_expired: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM memories WHERE expires_at IS NOT NULL AND expires_at < ?1)",
params![now],
|r| r.get(0),
)
.unwrap_or(false);
if has_expired {
gc(conn, archive)
} else {
Ok(0)
}
}
pub fn auto_purge_archive(conn: &Connection, max_days: Option<i64>) -> Result<usize> {
match max_days {
Some(days) if days > 0 => purge_archive(conn, Some(days)),
_ => Ok(0),
}
}
const GC_CHUNK_ROWS: usize = 500;
const SQL_GC_EXPIRED_CHUNK_IDS: &str = "SELECT id FROM memories \
WHERE expires_at IS NOT NULL AND expires_at < ?1 \
ORDER BY rowid LIMIT ?2";
pub fn gc(conn: &Connection, archive: bool) -> Result<usize> {
let now = Utc::now().to_rfc3339();
let mut total = 0usize;
loop {
conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
let result = (|| -> Result<usize> {
if archive {
let mut archive_stmt = conn.prepare_cached(&format!(
"INSERT OR REPLACE INTO archived_memories
(id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, archived_at, archive_reason, metadata,
embedding, embedding_dim, original_tier, original_expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version)
SELECT id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, ?1, 'ttl_expired', metadata,
embedding, embedding_dim, tier, expires_at,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version
FROM memories
WHERE id IN ({SQL_GC_EXPIRED_CHUNK_IDS})"
))?;
archive_stmt.execute(params![now, GC_CHUNK_ROWS])?;
}
let mut delete_stmt = conn.prepare_cached(&format!(
"DELETE FROM memories WHERE id IN ({SQL_GC_EXPIRED_CHUNK_IDS})"
))?;
let deleted = delete_stmt.execute(params![now, GC_CHUNK_ROWS])?;
Ok(deleted)
})();
match result {
Ok(n) => {
conn.execute_batch(connection::SQL_COMMIT)?;
total += n;
if n < GC_CHUNK_ROWS {
break;
}
}
Err(e) => {
let _ = conn.execute_batch(connection::SQL_ROLLBACK);
return Err(e);
}
}
}
let _ = conn.execute(
"DELETE FROM namespace_meta WHERE NOT EXISTS \
(SELECT 1 FROM memories WHERE memories.id = namespace_meta.standard_id)",
[],
);
Ok(total)
}
pub fn list_archived(
conn: &Connection,
namespace: Option<&str>,
limit: usize,
offset: usize,
) -> Result<Vec<serde_json::Value>> {
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match namespace {
Some(ns) => (
"SELECT id, tier, namespace, title, content, tags, priority, confidence, \
source, access_count, created_at, updated_at, last_accessed_at, \
expires_at, archived_at, archive_reason, metadata, \
reflection_depth, memory_kind, entity_id, persona_version, \
citations, source_uri, source_span, confidence_source, \
confidence_signals, confidence_decayed_at, version, \
atomised_into, atom_of, mentioned_entity_id \
FROM archived_memories WHERE namespace = ?1 \
ORDER BY archived_at DESC LIMIT ?2 OFFSET ?3"
.to_string(),
vec![Box::new(ns.to_string()), Box::new(limit), Box::new(offset)],
),
None => (
"SELECT id, tier, namespace, title, content, tags, priority, confidence, \
source, access_count, created_at, updated_at, last_accessed_at, \
expires_at, archived_at, archive_reason, metadata, \
reflection_depth, memory_kind, entity_id, persona_version, \
citations, source_uri, source_span, confidence_source, \
confidence_signals, confidence_decayed_at, version, \
atomised_into, atom_of, mentioned_entity_id \
FROM archived_memories \
ORDER BY archived_at DESC LIMIT ?1 OFFSET ?2"
.to_string(),
vec![Box::new(limit), Box::new(offset)],
),
};
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_vec.iter().map(std::convert::AsRef::as_ref).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_refs.as_slice(), |row| {
let metadata_str = row
.get::<_, String>(16)
.unwrap_or_else(|_| "{}".to_string());
let metadata: serde_json::Value =
serde_json::from_str(&metadata_str).unwrap_or_else(|_| serde_json::json!({}));
let tags_str = row.get::<_, String>(5).unwrap_or_else(|_| "[]".to_string());
let tags: serde_json::Value =
serde_json::from_str(&tags_str).unwrap_or_else(|_| serde_json::json!([]));
Ok(serde_json::json!({
"id": row.get::<_, String>(0)?,
"tier": row.get::<_, String>(1)?,
"namespace": row.get::<_, String>(2)?,
"title": row.get::<_, String>(3)?,
"content": row.get::<_, String>(4)?,
"tags": tags,
"priority": row.get::<_, i32>(6)?,
(field_names::CONFIDENCE): row.get::<_, f64>(7)?,
"source": row.get::<_, String>(8)?,
(field_names::ACCESS_COUNT): row.get::<_, i64>(9)?,
(field_names::CREATED_AT): row.get::<_, String>(10)?,
(field_names::UPDATED_AT): row.get::<_, String>(11)?,
(field_names::LAST_ACCESSED_AT): row.get::<_, Option<String>>(12)?,
(field_names::EXPIRES_AT): row.get::<_, Option<String>>(13)?,
(field_names::ARCHIVED_AT): row.get::<_, String>(14)?,
(field_names::ARCHIVE_REASON): row.get::<_, String>(15)?,
"metadata": metadata,
(field_names::REFLECTION_DEPTH): row.get::<_, Option<i64>>(17)?.unwrap_or(0),
(field_names::MEMORY_KIND): row.get::<_, Option<String>>(18)?,
"entity_id": row.get::<_, Option<String>>(19)?,
(field_names::PERSONA_VERSION): row.get::<_, Option<i64>>(20)?,
"citations": row
.get::<_, Option<String>>(21)?
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
.unwrap_or_else(|| serde_json::json!([])),
(field_names::SOURCE_URI): row.get::<_, Option<String>>(22)?,
(field_names::SOURCE_SPAN): row
.get::<_, Option<String>>(23)?
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok()),
(field_names::CONFIDENCE_SOURCE): row.get::<_, Option<String>>(24)?,
(field_names::CONFIDENCE_SIGNALS): row
.get::<_, Option<String>>(25)?
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok()),
(field_names::CONFIDENCE_DECAYED_AT): row.get::<_, Option<String>>(26)?,
"version": row.get::<_, Option<i64>>(27)?.unwrap_or(1),
(field_names::ATOMISED_INTO): row.get::<_, Option<i64>>(28)?,
(field_names::ATOM_OF): row.get::<_, Option<String>>(29)?,
(field_names::MENTIONED_ENTITY_ID): row.get::<_, Option<String>>(30)?,
}))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub fn restore_archived(conn: &Connection, id: &str) -> Result<bool> {
let now = Utc::now().to_rfc3339();
conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
let result = (|| -> Result<bool> {
let exists: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM archived_memories WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap_or(false);
if !exists {
return Ok(false);
}
let active_exists: bool = conn
.query_row(SQL_MEMORY_EXISTS_COUNT, params![id], |r| r.get(0))
.unwrap_or(false);
if active_exists {
return Err(anyhow::Error::new(StorageError::ArchiveRestoreCollision {
id: id.to_string(),
}));
}
let archived_metadata: String = conn
.query_row(
"SELECT metadata FROM archived_memories WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap_or_else(|_| "{}".to_string());
let meta_value: serde_json::Value =
serde_json::from_str(&archived_metadata).unwrap_or_else(|_| serde_json::json!({}));
if let Err(e) = crate::validate::validate_metadata(&meta_value) {
tracing::warn!("archived memory {id} has invalid metadata, resetting to {{}}: {e}");
conn.execute(
"UPDATE archived_memories SET metadata = '{}' WHERE id = ?1",
params![id],
)?;
}
let candidate = load_archived_as_memory(conn, id)?;
consult_governance_pre_write(&candidate)?;
conn.execute(
"INSERT INTO memories
(id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, metadata, embedding, embedding_dim,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version)
SELECT id, COALESCE(original_tier, 'long'), namespace, title, content,
tags, priority, confidence, source, access_count, created_at,
?1, last_accessed_at, original_expires_at, metadata,
embedding, embedding_dim,
COALESCE(reflection_depth, 0),
atomised_into,
atom_of,
COALESCE(memory_kind, 'observation'),
entity_id, persona_version,
COALESCE(citations, '[]'),
source_uri, source_span,
COALESCE(confidence_source, 'caller_provided'),
confidence_signals, confidence_decayed_at,
mentioned_entity_id,
COALESCE(version, 1)
FROM archived_memories WHERE id = ?2",
params![now, id],
)?;
conn.execute("DELETE FROM archived_memories WHERE id = ?1", params![id])?;
Ok(true)
})();
match result {
Ok(v) => {
conn.execute_batch(connection::SQL_COMMIT)?;
Ok(v)
}
Err(e) => {
let _ = conn.execute_batch(connection::SQL_ROLLBACK);
Err(e)
}
}
}
pub fn restore_archived_for_caller(conn: &Connection, id: &str, caller: &str) -> Result<bool> {
let now = Utc::now().to_rfc3339();
conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
let result = (|| -> Result<bool> {
let owned: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM archived_memories \
WHERE id = ?1 \
AND ( \
json_extract(metadata, '$.agent_id') = ?2 OR \
json_extract(metadata, '$.target_agent_id') = ?2 OR \
json_extract(metadata, '$.agent_id') IS NULL OR \
json_extract(metadata, '$.agent_id') = '' \
)",
params![id, caller],
|r| r.get(0),
)
.unwrap_or(false);
if !owned {
return Ok(false);
}
let active_exists: bool = conn
.query_row(SQL_MEMORY_EXISTS_COUNT, params![id], |r| r.get(0))
.unwrap_or(false);
if active_exists {
return Err(anyhow::Error::new(StorageError::ArchiveRestoreCollision {
id: id.to_string(),
}));
}
let archived_metadata: String = conn
.query_row(
"SELECT metadata FROM archived_memories WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap_or_else(|_| "{}".to_string());
let meta_value: serde_json::Value =
serde_json::from_str(&archived_metadata).unwrap_or_else(|_| serde_json::json!({}));
if let Err(e) = crate::validate::validate_metadata(&meta_value) {
tracing::warn!("archived memory {id} has invalid metadata, resetting to {{}}: {e}");
conn.execute(
"UPDATE archived_memories SET metadata = '{}' WHERE id = ?1",
params![id],
)?;
}
let candidate = load_archived_as_memory(conn, id)?;
consult_governance_pre_write(&candidate)?;
conn.execute(
"INSERT INTO memories
(id, tier, namespace, title, content, tags, priority, confidence,
source, access_count, created_at, updated_at, last_accessed_at,
expires_at, metadata, embedding, embedding_dim,
reflection_depth, atomised_into, atom_of, memory_kind,
entity_id, persona_version, citations, source_uri, source_span,
confidence_source, confidence_signals, confidence_decayed_at,
mentioned_entity_id, version)
SELECT id, COALESCE(original_tier, 'long'), namespace, title, content,
tags, priority, confidence, source, access_count, created_at,
?1, last_accessed_at, original_expires_at, metadata,
embedding, embedding_dim,
COALESCE(reflection_depth, 0),
atomised_into,
atom_of,
COALESCE(memory_kind, 'observation'),
entity_id, persona_version,
COALESCE(citations, '[]'),
source_uri, source_span,
COALESCE(confidence_source, 'caller_provided'),
confidence_signals, confidence_decayed_at,
mentioned_entity_id,
COALESCE(version, 1)
FROM archived_memories WHERE id = ?2",
params![now, id],
)?;
conn.execute("DELETE FROM archived_memories WHERE id = ?1", params![id])?;
Ok(true)
})();
match result {
Ok(v) => {
conn.execute_batch(connection::SQL_COMMIT)?;
Ok(v)
}
Err(e) => {
let _ = conn.execute_batch(connection::SQL_ROLLBACK);
Err(e)
}
}
}
fn load_archived_as_memory(conn: &Connection, id: &str) -> Result<Memory> {
let mut stmt = conn.prepare(
"SELECT id, COALESCE(original_tier, tier) AS tier, namespace, title, content,
tags, priority, confidence, source, access_count, created_at,
updated_at, last_accessed_at,
COALESCE(original_expires_at, expires_at) AS expires_at, metadata,
COALESCE(reflection_depth, 0) AS reflection_depth,
COALESCE(memory_kind, 'observation') AS memory_kind,
entity_id, persona_version,
COALESCE(citations, '[]') AS citations,
source_uri, source_span,
COALESCE(confidence_source, 'caller_provided') AS confidence_source,
confidence_signals, confidence_decayed_at,
COALESCE(version, 1) AS version
FROM archived_memories WHERE id = ?1",
)?;
let mem = stmt.query_row(params![id], row_to_memory)?;
Ok(mem)
}
pub fn purge_archive(conn: &Connection, older_than_days: Option<i64>) -> Result<usize> {
match older_than_days {
Some(days) if days < 0 => {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: crate::errors::msg::older_than_days_negative(days),
}));
}
Some(days) => {
let cutoff = (Utc::now() - chrono::Duration::days(days)).to_rfc3339();
let deleted = conn.execute(
"DELETE FROM archived_memories WHERE archived_at < ?1",
params![cutoff],
)?;
Ok(deleted)
}
None => {
let deleted = conn.execute("DELETE FROM archived_memories", [])?;
Ok(deleted)
}
}
}
pub fn purge_archive_for_caller(
conn: &Connection,
caller: &str,
older_than_days: Option<i64>,
) -> Result<usize> {
match older_than_days {
Some(days) if days < 0 => {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: crate::errors::msg::older_than_days_negative(days),
}));
}
Some(days) => {
let cutoff = (Utc::now() - chrono::Duration::days(days)).to_rfc3339();
let deleted = conn.execute(
"DELETE FROM archived_memories \
WHERE archived_at < ?1 \
AND ( \
json_extract(metadata, '$.agent_id') = ?2 OR \
json_extract(metadata, '$.target_agent_id') = ?2 \
)",
params![cutoff, caller],
)?;
Ok(deleted)
}
None => {
let deleted = conn.execute(
"DELETE FROM archived_memories \
WHERE \
json_extract(metadata, '$.agent_id') = ?1 OR \
json_extract(metadata, '$.target_agent_id') = ?1",
params![caller],
)?;
Ok(deleted)
}
}
}
pub fn archive_stats(conn: &Connection) -> Result<serde_json::Value> {
let total: i64 = conn.query_row("SELECT COUNT(*) FROM archived_memories", [], |r| r.get(0))?;
let mut stmt = conn.prepare(
"SELECT namespace, COUNT(*) FROM archived_memories GROUP BY namespace ORDER BY COUNT(*) DESC",
)?;
let by_ns: Vec<serde_json::Value> = stmt
.query_map([], |row| {
Ok(serde_json::json!({
"namespace": row.get::<_, String>(0)?,
"count": row.get::<_, i64>(1)?,
}))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(serde_json::json!({
"archived_total": total,
(field_names::BY_NAMESPACE): by_ns,
}))
}
pub fn export_all(conn: &Connection) -> Result<Vec<Memory>> {
let now = Utc::now().to_rfc3339();
let mut stmt = conn.prepare(
"SELECT * FROM memories WHERE expires_at IS NULL OR expires_at > ?1 ORDER BY created_at ASC",
)?;
let rows = stmt.query_map(params![now], row_to_memory)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub fn export_links(conn: &Connection) -> Result<Vec<MemoryLink>> {
let now = Utc::now().to_rfc3339();
let mut stmt = conn.prepare(
"SELECT ml.source_id, ml.target_id, ml.relation, ml.created_at,
ml.signature, ml.observed_by, ml.valid_from, ml.valid_until
FROM memory_links ml
JOIN memories ms ON ms.id = ml.source_id AND (ms.expires_at IS NULL OR ms.expires_at > ?1)
JOIN memories mt ON mt.id = ml.target_id AND (mt.expires_at IS NULL OR mt.expires_at > ?1)",
)?;
let rows = stmt.query_map(params![now], |row| {
let relation_str: String = row.get(2)?;
Ok(MemoryLink {
source_id: row.get(0)?,
target_id: row.get(1)?,
relation: crate::models::MemoryLinkRelation::from_str(&relation_str)
.unwrap_or_default(),
created_at: row.get(3)?,
signature: row.get::<_, Option<Vec<u8>>>(4)?,
observed_by: row.get::<_, Option<String>>(5)?,
valid_from: row.get::<_, Option<String>>(6)?,
valid_until: row.get::<_, Option<String>>(7)?,
attest_level: None,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub fn insert_if_newer(conn: &Connection, mem: &Memory) -> Result<String> {
consult_governance_pre_write(mem)?;
let tags_json = serde_json::to_string(&mem.tags)?;
let metadata_json = serde_json::to_string(&mem.metadata)?;
let citations_json = serde_json::to_string(&mem.citations)?;
let source_span_json = match mem.source_span {
Some(span) => Some(serde_json::to_string(&span)?),
None => None,
};
let confidence_signals_json = match &mem.confidence_signals {
Some(s) => Some(serde_json::to_string(s)?),
None => None,
};
let mentioned_entity_id = extract_mentioned_entity_id(mem);
let mut newer_wins_stmt = conn.prepare_cached(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, last_accessed_at, expires_at, metadata, reflection_depth, memory_kind, entity_id, persona_version, citations, source_uri, source_span, confidence_source, confidence_signals, confidence_decayed_at, mentioned_entity_id, version)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27)
ON CONFLICT(title, namespace) DO UPDATE SET
content = CASE WHEN excluded.updated_at > memories.updated_at
OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
THEN excluded.content ELSE memories.content END,
tags = CASE WHEN excluded.updated_at > memories.updated_at
OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
THEN excluded.tags ELSE memories.tags END,
priority = MAX(memories.priority, excluded.priority),
confidence = MAX(memories.confidence, excluded.confidence),
source = CASE WHEN excluded.updated_at > memories.updated_at
OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
THEN excluded.source ELSE memories.source END,
tier = CASE WHEN excluded.tier = 'long' THEN 'long'
WHEN memories.tier = 'long' THEN 'long'
WHEN excluded.tier = 'mid' THEN 'mid'
ELSE memories.tier END,
updated_at = MAX(memories.updated_at, excluded.updated_at),
access_count = MAX(memories.access_count, excluded.access_count),
expires_at = CASE WHEN excluded.tier = 'long' OR memories.tier = 'long' THEN NULL
ELSE COALESCE(excluded.expires_at, memories.expires_at) END,
-- Preserve metadata.agent_id across newer-wins merge (NHI provenance immutable).
metadata = CASE
WHEN json_extract(memories.metadata, '$.agent_id') IS NOT NULL
THEN json_set(
CASE WHEN excluded.updated_at > memories.updated_at
OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
THEN excluded.metadata
ELSE memories.metadata END,
'$.agent_id',
json_extract(memories.metadata, '$.agent_id')
)
ELSE CASE WHEN excluded.updated_at > memories.updated_at
OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
THEN excluded.metadata
ELSE memories.metadata END
END,
-- v0.7.0 Task 1/8 — recursion depth takes max so the reflection
-- signal isn't lost on newer-wins federation merges.
reflection_depth = MAX(memories.reflection_depth, excluded.reflection_depth),
-- v0.7.0 L1-1 — kind is sticky across federation merges: a
-- reflection row must not be downgraded to observation by a
-- newer-wins merge from a peer that doesn't know about the kind.
-- v0.7.0 QW-2 — Persona is similarly sticky.
memory_kind = CASE WHEN memories.memory_kind = 'reflection' THEN 'reflection'
WHEN memories.memory_kind = 'persona' THEN 'persona'
ELSE excluded.memory_kind END,
-- v0.7.0 QW-2 — entity_id + persona_version are immutable
-- once set so a federation merge can't drop the persona
-- discriminator off a `memory_kind = 'persona'` row.
entity_id = COALESCE(memories.entity_id, excluded.entity_id),
persona_version = COALESCE(memories.persona_version, excluded.persona_version),
-- v0.7.0 Form 4 — fact-provenance: replace the stored
-- citations array only when the incoming row wins the
-- newer-wins tiebreak; source_uri / source_span follow
-- COALESCE semantics so a federation merge that lacks
-- provenance does not blank out a value the local row
-- already had.
citations = CASE WHEN excluded.updated_at > memories.updated_at
OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
THEN excluded.citations ELSE memories.citations END,
source_uri = COALESCE(excluded.source_uri, memories.source_uri),
source_span = COALESCE(excluded.source_span, memories.source_span),
-- v0.7.0 Form 5 — confidence-provenance follows the newer-
-- wins shape established for the other Form 4 columns.
-- A peer pushing an auto-derived/calibrated value wins on
-- the timestamp tiebreak; otherwise the local row's
-- provenance is preserved so a stale peer cannot blank out
-- a fresher local calibration.
confidence_source = CASE WHEN excluded.updated_at > memories.updated_at
OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
THEN excluded.confidence_source ELSE memories.confidence_source END,
confidence_signals = CASE WHEN excluded.updated_at > memories.updated_at
OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
THEN excluded.confidence_signals ELSE memories.confidence_signals END,
confidence_decayed_at = CASE WHEN excluded.updated_at > memories.updated_at
OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
THEN excluded.confidence_decayed_at ELSE memories.confidence_decayed_at END,
-- v0.7.0 polish PERF-8 (#781) — newer-wins on the mention
-- tag (the winning row's content is the one a future matcher
-- query expects to find); otherwise preserve the local tag
-- so a stale peer that lacks the structured entity_id
-- metadata cannot blank out a value the index serves.
mentioned_entity_id = CASE WHEN excluded.updated_at > memories.updated_at
OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
THEN COALESCE(excluded.mentioned_entity_id, memories.mentioned_entity_id)
ELSE memories.mentioned_entity_id END,
-- #1631 (decide-once, #1029 contract) — `version` IS
-- replicated state on the federation merge path: merge via
-- MAX(local, remote) so an out-of-order peer push can't
-- roll the Gap-1 optimistic-concurrency counter backwards.
-- Matches the pg `apply_remote_memory` GREATEST arm.
version = MAX(memories.version, excluded.version)
RETURNING id",
)?;
let actual_id: String = newer_wins_stmt.query_row(
params![
mem.id,
mem.tier.as_str(),
mem.namespace,
mem.title,
mem.content,
tags_json,
mem.priority,
mem.confidence,
mem.source,
mem.access_count,
mem.created_at,
mem.updated_at,
mem.last_accessed_at,
mem.effective_expires_at(),
metadata_json,
mem.reflection_depth,
mem.memory_kind.as_str(),
mem.entity_id,
mem.persona_version,
citations_json,
mem.source_uri,
source_span_json,
mem.confidence_source.as_str(),
confidence_signals_json,
mem.confidence_decayed_at,
mentioned_entity_id,
mem.version,
],
|r| r.get(0),
)?;
Ok(actual_id)
}
#[derive(Debug)]
pub struct EmbeddingDimMismatch {
pub namespace: String,
pub established: usize,
pub attempted: usize,
}
impl std::fmt::Display for EmbeddingDimMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"embedding dim mismatch in namespace '{}': established {}-dim, refused {}-dim write",
self.namespace, self.established, self.attempted
)
}
}
impl std::error::Error for EmbeddingDimMismatch {}
pub fn namespace_embedding_dim(conn: &Connection, namespace: &str) -> Result<Option<usize>> {
let dim: Option<i64> = conn
.query_row(
"SELECT embedding_dim FROM memories \
WHERE namespace = ?1 AND embedding_dim IS NOT NULL \
LIMIT 1",
params![namespace],
|r| r.get(0),
)
.ok();
Ok(dim.and_then(|d| usize::try_from(d).ok()))
}
pub fn dim_violations(conn: &Connection) -> Result<u64> {
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories \
WHERE embedding IS NOT NULL \
AND length(embedding) >= 4 \
AND ( \
embedding_dim IS NULL \
OR ( \
(length(embedding) % 4 = 0 AND embedding_dim != length(embedding)/4) \
OR (length(embedding) % 4 = 1 AND embedding_dim != (length(embedding)-1)/4) \
OR (length(embedding) % 4 NOT IN (0,1)) \
) \
)",
[],
|r| r.get(0),
)
.unwrap_or(0);
Ok(u64::try_from(n).unwrap_or(0))
}
const SQL_UPDATE_EMBEDDING_WITH_DIM: &str =
"UPDATE memories SET embedding = ?1, embedding_dim = ?2 WHERE id = ?3";
const SQL_UPDATE_EMBEDDING_NULL_DIM: &str =
"UPDATE memories SET embedding = ?1, embedding_dim = NULL WHERE id = ?2";
pub fn set_embedding(conn: &Connection, id: &str, embedding: &[f32]) -> Result<()> {
let namespace: Option<String> = conn
.query_row(
"SELECT namespace FROM memories WHERE id = ?1",
params![id],
|r| r.get(0),
)
.ok();
let attempted = embedding.len();
if attempted == 0 {
let bytes = crate::embeddings::encode_embedding_blob(embedding);
conn.execute(SQL_UPDATE_EMBEDDING_NULL_DIM, params![bytes, id])?;
return Ok(());
}
if let Some(ref ns) = namespace
&& let Some(established) = namespace_embedding_dim(conn, ns)?
&& established != attempted
{
return Err(EmbeddingDimMismatch {
namespace: ns.clone(),
established,
attempted,
}
.into());
}
let bytes = crate::embeddings::encode_embedding_blob(embedding);
let dim_i64 = i64::try_from(attempted).unwrap_or(i64::MAX);
conn.execute(SQL_UPDATE_EMBEDDING_WITH_DIM, params![bytes, dim_i64, id])?;
Ok(())
}
pub fn set_embeddings_batch(
conn: &mut Connection,
entries: &[(String, Vec<f32>)],
) -> Result<usize> {
if entries.is_empty() {
return Ok(0);
}
let mut ns_by_id: HashMap<String, Option<String>> = HashMap::with_capacity(entries.len());
{
let mut stmt = conn.prepare("SELECT namespace FROM memories WHERE id = ?1")?;
for (id, _) in entries {
if ns_by_id.contains_key(id) {
continue;
}
let ns: Option<String> = stmt
.query_row(params![id], |r| r.get::<_, Option<String>>(0))
.ok()
.flatten();
ns_by_id.insert(id.clone(), ns);
}
}
let mut ns_dim_cache: HashMap<String, Option<usize>> = HashMap::new();
let tx = conn.transaction()?;
{
let mut update = tx.prepare(SQL_UPDATE_EMBEDDING_WITH_DIM)?;
let mut update_empty = tx.prepare(SQL_UPDATE_EMBEDDING_NULL_DIM)?;
let mut rows_updated = 0usize;
for (id, embedding) in entries {
let attempted = embedding.len();
if attempted == 0 {
let bytes = crate::embeddings::encode_embedding_blob(embedding);
rows_updated += update_empty.execute(params![bytes, id])?;
continue;
}
if let Some(Some(ns)) = ns_by_id.get(id) {
let established = if let Some(cached) = ns_dim_cache.get(ns) {
*cached
} else {
let resolved = namespace_embedding_dim(&tx, ns)?;
ns_dim_cache.insert(ns.clone(), resolved);
resolved
};
if let Some(established) = established
&& established != attempted
{
return Err(EmbeddingDimMismatch {
namespace: ns.clone(),
established,
attempted,
}
.into());
}
if established.is_none() {
ns_dim_cache.insert(ns.clone(), Some(attempted));
}
}
let bytes = crate::embeddings::encode_embedding_blob(embedding);
let dim_i64 = i64::try_from(attempted).unwrap_or(i64::MAX);
rows_updated += update.execute(params![bytes, dim_i64, id])?;
}
drop(update);
drop(update_empty);
tx.commit()?;
Ok(rows_updated)
}
}
pub fn get_embedding(conn: &Connection, id: &str) -> Result<Option<Vec<f32>>> {
let result: Option<Vec<u8>> = conn
.query_row(
"SELECT embedding FROM memories WHERE id = ?1",
params![id],
|row| row.get(0),
)
.ok();
match result {
Some(bytes) if !bytes.is_empty() => {
let floats = crate::embeddings::decode_embedding_blob(&bytes)?;
Ok(Some(floats))
}
_ => Ok(None),
}
}
pub fn get_unembedded_ids(conn: &Connection) -> Result<Vec<(String, String, String)>> {
let mut stmt =
conn.prepare("SELECT id, title, content FROM memories WHERE embedding IS NULL")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub fn get_unembedded_ids_batch(
conn: &Connection,
limit: usize,
) -> Result<Vec<(String, String, String)>> {
let mut stmt = conn.prepare_cached(
"SELECT id, title, content FROM memories WHERE embedding IS NULL LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub fn get_unembedded_ids_batch_after(
conn: &Connection,
after_id: Option<&str>,
limit: usize,
) -> Result<Vec<(String, String, String)>> {
let map_row = |row: &rusqlite::Row<'_>| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
};
let rows = if let Some(after) = after_id {
let mut stmt = conn.prepare_cached(
"SELECT id, title, content FROM memories \
WHERE embedding IS NULL AND id > ?1 ORDER BY id LIMIT ?2",
)?;
let rows = stmt.query_map(params![after, limit], map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
} else {
let mut stmt = conn.prepare_cached(
"SELECT id, title, content FROM memories \
WHERE embedding IS NULL ORDER BY id LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit], map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
Ok(rows)
}
pub fn get_memory_texts_batch(
conn: &Connection,
namespace: Option<&str>,
after_id: Option<&str>,
limit: usize,
) -> Result<Vec<(String, String, String)>> {
let map_row = |row: &rusqlite::Row<'_>| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
};
let rows = match (namespace, after_id) {
(Some(ns), Some(after)) => {
let mut stmt = conn.prepare_cached(
"SELECT id, title, content FROM memories \
WHERE namespace = ?1 AND id > ?2 ORDER BY id LIMIT ?3",
)?;
let rows = stmt.query_map(params![ns, after, limit], map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
}
(Some(ns), None) => {
let mut stmt = conn.prepare_cached(
"SELECT id, title, content FROM memories \
WHERE namespace = ?1 ORDER BY id LIMIT ?2",
)?;
let rows = stmt.query_map(params![ns, limit], map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
}
(None, Some(after)) => {
let mut stmt = conn.prepare_cached(
"SELECT id, title, content FROM memories \
WHERE id > ?1 ORDER BY id LIMIT ?2",
)?;
let rows = stmt.query_map(params![after, limit], map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
}
(None, None) => {
let mut stmt = conn
.prepare_cached("SELECT id, title, content FROM memories ORDER BY id LIMIT ?1")?;
let rows = stmt.query_map(params![limit], map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
}
};
Ok(rows)
}
pub fn set_embeddings_batch_reembed(
conn: &mut Connection,
entries: &[(String, Vec<f32>)],
) -> Result<usize> {
if entries.is_empty() {
return Ok(0);
}
let tx = conn.transaction()?;
let mut rows_updated = 0usize;
{
let mut update = tx.prepare(SQL_UPDATE_EMBEDDING_WITH_DIM)?;
let mut update_empty = tx.prepare(SQL_UPDATE_EMBEDDING_NULL_DIM)?;
for (id, embedding) in entries {
let bytes = crate::embeddings::encode_embedding_blob(embedding);
if embedding.is_empty() {
rows_updated += update_empty.execute(params![bytes, id])?;
} else {
let dim_i64 = i64::try_from(embedding.len()).unwrap_or(i64::MAX);
rows_updated += update.execute(params![bytes, dim_i64, id])?;
}
}
}
tx.commit()?;
Ok(rows_updated)
}
pub fn embedding_coverage(conn: &Connection, namespace: Option<&str>) -> Result<(u64, u64)> {
let (total, embedded): (i64, i64) = if let Some(ns) = namespace {
conn.query_row(
"SELECT COUNT(*), COUNT(embedding) FROM memories WHERE namespace = ?1",
params![ns],
|r| Ok((r.get(0)?, r.get(1)?)),
)?
} else {
conn.query_row("SELECT COUNT(*), COUNT(embedding) FROM memories", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})?
};
Ok((
u64::try_from(total).unwrap_or(0),
u64::try_from(embedded).unwrap_or(0),
))
}
pub fn distinct_embedding_dims(conn: &Connection, namespace: Option<&str>) -> Result<Vec<usize>> {
const DIM_EXPR: &str = "COALESCE(embedding_dim, \
CASE WHEN length(embedding) % 4 = 1 THEN (length(embedding)-1)/4 \
ELSE length(embedding)/4 END)";
let collect = |stmt: &mut rusqlite::Statement<'_>,
params: &[&dyn rusqlite::ToSql]|
-> Result<Vec<usize>> {
let rows = stmt.query_map(params, |r| r.get::<_, i64>(0))?;
Ok(rows
.collect::<rusqlite::Result<Vec<_>>>()?
.into_iter()
.filter_map(|d| usize::try_from(d).ok())
.collect())
};
if let Some(ns) = namespace {
let mut stmt = conn.prepare(&format!(
"SELECT DISTINCT {DIM_EXPR} AS dim FROM memories \
WHERE embedding IS NOT NULL AND namespace = ?1 ORDER BY dim"
))?;
collect(&mut stmt, &[&ns])
} else {
let mut stmt = conn.prepare(&format!(
"SELECT DISTINCT {DIM_EXPR} AS dim FROM memories \
WHERE embedding IS NOT NULL ORDER BY dim"
))?;
collect(&mut stmt, &[])
}
}
pub fn count_embedded_memories(conn: &Connection) -> Result<i64> {
conn.query_row(
"SELECT COUNT(*) FROM memories WHERE embedding IS NOT NULL",
[],
|row| row.get(0),
)
.map_err(Into::into)
}
pub fn get_all_embeddings(conn: &Connection) -> Result<Vec<(String, Vec<f32>)>> {
let mut stmt =
conn.prepare("SELECT id, embedding FROM memories WHERE embedding IS NOT NULL")?;
let rows = stmt.query_map([], |row| {
let id: String = row.get(0)?;
let bytes: Vec<u8> = row.get(1)?;
Ok((id, bytes))
})?;
let mut entries = Vec::new();
for row in rows {
let (id, bytes) = row?;
if bytes.is_empty() {
continue;
}
match crate::embeddings::decode_embedding_blob(&bytes) {
Ok(floats) => entries.push((id, floats)),
Err(e) => {
tracing::warn!(
memory_id = %id,
error = %e,
"skipping memory with malformed embedding BLOB during HNSW build"
);
}
}
}
Ok(entries)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub fn recall_hybrid(
conn: &Connection,
context: &str,
query_embedding: &[f32],
namespace: Option<&str>,
limit: usize,
tags_filter: Option<&str>,
since: Option<&str>,
until: Option<&str>,
vector_index: Option<&crate::hnsw::VectorIndex>,
short_extend: i64,
mid_extend: i64,
as_agent: Option<&str>,
budget_tokens: Option<usize>,
scoring: &crate::config::ResolvedScoring,
include_archived: bool,
source_uri_prefix: Option<&str>,
) -> Result<(Vec<(Memory, f64)>, BudgetOutcome)> {
let (results, outcome, _telemetry) = recall_hybrid_with_telemetry(
conn,
context,
query_embedding,
namespace,
limit,
tags_filter,
since,
until,
vector_index,
short_extend,
mid_extend,
as_agent,
budget_tokens,
scoring,
include_archived,
source_uri_prefix,
)?;
Ok((results, outcome))
}
#[allow(clippy::too_many_arguments)]
pub fn recall_hybrid_precomputed_hnsw(
conn: &Connection,
context: &str,
query_embedding: &[f32],
namespace: Option<&str>,
limit: usize,
tags_filter: Option<&str>,
since: Option<&str>,
until: Option<&str>,
precomputed_hnsw_hits: &[crate::hnsw::VectorHit],
short_extend: i64,
mid_extend: i64,
as_agent: Option<&str>,
budget_tokens: Option<usize>,
scoring: &crate::config::ResolvedScoring,
include_archived: bool,
source_uri_prefix: Option<&str>,
) -> Result<(Vec<(Memory, f64)>, BudgetOutcome)> {
let (results, outcome, _telemetry) = recall_hybrid_with_telemetry_precomputed_hnsw(
conn,
context,
query_embedding,
namespace,
limit,
tags_filter,
since,
until,
precomputed_hnsw_hits,
short_extend,
mid_extend,
as_agent,
budget_tokens,
scoring,
include_archived,
source_uri_prefix,
)?;
Ok((results, outcome))
}
struct HybridPrep<'a> {
fts_query: String,
now: String,
prefixes: VisibilityPrefixes,
fts_hierarchy_fragment: String,
sem_hierarchy_fragment: String,
effective_namespace: Option<&'a str>,
hierarchy_active: bool,
fts_archived_fragment: &'static str,
sem_archived_fragment: &'static str,
fts_source_uri_fragment: &'static str,
sem_source_uri_fragment: &'static str,
source_uri_like_param: Option<String>,
}
fn prepare_hybrid_query<'a>(
context: &str,
namespace: Option<&'a str>,
as_agent: Option<&str>,
include_archived: bool,
source_uri_prefix: Option<&str>,
) -> HybridPrep<'a> {
let now = Utc::now().to_rfc3339();
let fts_query = sanitize_fts_query(context, true);
let prefixes = compute_visibility_prefixes(as_agent);
let (fts_hierarchy_in, hierarchy_active) = hierarchy_in_clause(namespace);
let fts_hierarchy_fragment = fts_hierarchy_in.unwrap_or_default();
let sem_hierarchy_fragment = if hierarchy_active {
if let Some(ns) = namespace {
let ancestors = crate::models::namespace_ancestors(ns);
let quoted: Vec<String> = ancestors
.iter()
.map(|a| format!("'{}'", a.replace('\'', "''")))
.collect();
format!("AND memories.namespace IN ({})", quoted.join(","))
} else {
String::new()
}
} else {
String::new()
};
let effective_namespace = if hierarchy_active { None } else { namespace };
let fts_archived_fragment = archived_source_clause(include_archived, "m");
let sem_archived_fragment = archived_source_clause(include_archived, "memories");
let source_uri_like_param: Option<String> = match source_uri_prefix {
Some(prefix) if !prefix.is_empty() => Some(format!("{}%", escape_like_pattern(prefix))),
_ => None,
};
let fts_source_uri_fragment = if source_uri_like_param.is_some() {
"AND m.source_uri LIKE ?12 ESCAPE '\\'"
} else {
""
};
let sem_source_uri_fragment = if source_uri_like_param.is_some() {
"AND memories.source_uri LIKE ?10 ESCAPE '\\'"
} else {
""
};
HybridPrep {
fts_query,
now,
prefixes,
fts_hierarchy_fragment,
sem_hierarchy_fragment,
effective_namespace,
hierarchy_active,
fts_archived_fragment,
sem_archived_fragment,
fts_source_uri_fragment,
sem_source_uri_fragment,
source_uri_like_param,
}
}
fn fts_keyword_phase(
conn: &Connection,
prep: &HybridPrep<'_>,
tags_filter: Option<&str>,
since: Option<&str>,
until: Option<&str>,
limit: usize,
) -> Result<Vec<(Memory, f64, Option<Vec<u8>>)>> {
let fts_limit = (limit * 3).max(30);
let fts_sql = format!(
"SELECT m.id, m.tier, m.namespace, m.title, m.content, m.tags, m.priority,
m.confidence, m.source, m.access_count, m.created_at, m.updated_at,
m.last_accessed_at, m.expires_at, m.metadata, m.reflection_depth,
m.memory_kind, m.entity_id, m.persona_version,
m.citations, m.source_uri, m.source_span,
m.confidence_source, m.confidence_signals, m.confidence_decayed_at, m.embedding,
(fts.rank * -1) + (m.priority * 0.5) + (MIN(m.access_count, 50) * 0.1)
+ (m.confidence * 2.0)
+ (CASE m.tier WHEN 'long' THEN 3.0 WHEN 'mid' THEN 1.0 ELSE 0.0 END)
+ (1.0 / (1.0 + (julianday('now') - julianday(m.updated_at)) * 0.1))
AS fts_score
FROM memories_fts fts
JOIN memories m ON m.rowid = fts.rowid
WHERE memories_fts MATCH ?1
AND (?2 IS NULL OR m.namespace = ?2)
{fts_hierarchy_fragment}
AND (m.expires_at IS NULL OR m.expires_at > ?3)
AND (?4 IS NULL OR EXISTS (SELECT 1 FROM json_each(m.tags) WHERE json_each.value = ?4))
AND (?5 IS NULL OR m.created_at >= ?5)
AND (?6 IS NULL OR m.created_at <= ?6)
{fts_archived_fragment}
{fts_source_uri_fragment}
{vis}
ORDER BY fts_score DESC
LIMIT ?7",
fts_hierarchy_fragment = prep.fts_hierarchy_fragment,
fts_archived_fragment = prep.fts_archived_fragment,
fts_source_uri_fragment = prep.fts_source_uri_fragment,
vis = visibility_clause(8, "m"),
);
let mut fts_stmt = conn.prepare_cached(&fts_sql)?;
let fts_row_handler =
|row: &rusqlite::Row<'_>| -> rusqlite::Result<(Memory, f64, Option<Vec<u8>>)> {
let mem = row_to_memory(row)?;
let fts_score: f64 = row.get("fts_score")?;
let embedding_bytes: Option<Vec<u8>> = row.get(25)?;
Ok((mem, fts_score, embedding_bytes))
};
let (vis_p, vis_t, vis_u, vis_o) = prep.prefixes.clone();
let rows: Vec<(Memory, f64, Option<Vec<u8>>)> =
if let Some(ref uri_param) = prep.source_uri_like_param {
fts_stmt
.query_map(
params![
prep.fts_query,
prep.effective_namespace,
prep.now,
tags_filter,
since,
until,
fts_limit,
vis_p,
vis_t,
vis_u,
vis_o,
uri_param,
],
fts_row_handler,
)?
.collect::<rusqlite::Result<Vec<_>>>()?
} else {
fts_stmt
.query_map(
params![
prep.fts_query,
prep.effective_namespace,
prep.now,
tags_filter,
since,
until,
fts_limit,
vis_p,
vis_t,
vis_u,
vis_o,
],
fts_row_handler,
)?
.collect::<rusqlite::Result<Vec<_>>>()?
};
Ok(rows)
}
#[allow(clippy::too_many_arguments)]
fn semantic_phase(
conn: &Connection,
prep: &HybridPrep<'_>,
query_embedding: &[f32],
vector_index: Option<&crate::hnsw::VectorIndex>,
precomputed_hnsw_hits: Option<&[crate::hnsw::VectorHit]>,
namespace: Option<&str>,
tags_filter: Option<&str>,
since: Option<&str>,
until: Option<&str>,
limit: usize,
include_archived: bool,
source_uri_prefix: Option<&str>,
scored: &mut HashMap<String, (Memory, f64, f64)>,
dim_mismatch_count: &mut usize,
) -> Result<usize> {
let mut hnsw_candidates_count: usize = 0;
let now = prep.now.as_str();
if precomputed_hnsw_hits.is_some() || vector_index.is_some() {
let owned_hits;
let hits: &[crate::hnsw::VectorHit] = if let Some(pre) = precomputed_hnsw_hits {
pre
} else {
let ann_limit = (limit * 5).max(50);
owned_hits = vector_index
.expect("vector_index set in legacy branch")
.search(query_embedding, ann_limit);
owned_hits.as_slice()
};
let mut needed_ids: Vec<String> = Vec::with_capacity(hits.len());
let mut hit_meta: Vec<(String, f64)> = Vec::with_capacity(hits.len());
for hit in hits {
if scored.contains_key(&hit.id) {
continue;
}
let cosine = f64::from(1.0 - hit.distance);
if cosine > crate::RECALL_COSINE_GATE {
needed_ids.push(hit.id.clone());
hit_meta.push((hit.id.clone(), cosine));
}
}
let fetched = get_many(conn, &needed_ids)?;
for (id, cosine) in hit_meta {
let Some(mem) = fetched.get(&id) else {
continue;
};
if let Some(ns) = namespace {
if prep.hierarchy_active {
let ancestors = crate::models::namespace_ancestors(ns);
if !ancestors.iter().any(|a| a == &mem.namespace) {
continue;
}
} else if mem.namespace != ns {
continue;
}
}
if let Some(exp) = &mem.expires_at
&& exp.as_str() <= now
{
continue;
}
if let Some(tf) = tags_filter
&& !mem.tags.iter().any(|t| t == tf)
{
continue;
}
if let Some(s) = since
&& mem.created_at.as_str() < s
{
continue;
}
if let Some(u) = until
&& mem.created_at.as_str() > u
{
continue;
}
if !is_visible(mem, &prep.prefixes) {
continue;
}
if !include_archived && is_archived_source(mem) {
continue;
}
if let Some(prefix) = source_uri_prefix
&& !prefix.is_empty()
&& !mem
.source_uri
.as_deref()
.is_some_and(|u| u.starts_with(prefix))
{
continue;
}
scored.insert(mem.id.clone(), (mem.clone(), 0.0, cosine));
hnsw_candidates_count += 1;
}
return Ok(hnsw_candidates_count);
}
let sem_sql = format!(
"SELECT id, tier, namespace, title, content, tags, priority,
confidence, source, access_count, created_at, updated_at,
last_accessed_at, expires_at, metadata, reflection_depth, memory_kind, embedding
FROM memories
WHERE embedding IS NOT NULL
AND (?1 IS NULL OR namespace = ?1)
{sem_hierarchy_fragment}
AND (expires_at IS NULL OR expires_at > ?2)
AND (?3 IS NULL OR EXISTS (SELECT 1 FROM json_each(memories.tags) WHERE json_each.value = ?3))
AND (?4 IS NULL OR created_at >= ?4)
AND (?5 IS NULL OR created_at <= ?5)
{sem_archived_fragment}
{sem_source_uri_fragment}
{vis}",
sem_hierarchy_fragment = prep.sem_hierarchy_fragment,
sem_archived_fragment = prep.sem_archived_fragment,
sem_source_uri_fragment = prep.sem_source_uri_fragment,
vis = visibility_clause(6, "memories"),
);
let mut sem_stmt = conn.prepare_cached(&sem_sql)?;
let sem_row_handler = |row: &rusqlite::Row<'_>| -> rusqlite::Result<(Memory, Option<Vec<u8>>)> {
let mem = row_to_memory(row)?;
let emb_bytes: Option<Vec<u8>> = row.get(17)?;
Ok((mem, emb_bytes))
};
let (vis_p, vis_t, vis_u, vis_o) = prep.prefixes.clone();
let sem_results: Vec<(Memory, Option<Vec<u8>>)> =
if let Some(ref uri_param) = prep.source_uri_like_param {
sem_stmt
.query_map(
params![
prep.effective_namespace,
prep.now,
tags_filter,
since,
until,
vis_p,
vis_t,
vis_u,
vis_o,
uri_param,
],
sem_row_handler,
)?
.collect::<rusqlite::Result<Vec<_>>>()?
} else {
sem_stmt
.query_map(
params![
prep.effective_namespace,
prep.now,
tags_filter,
since,
until,
vis_p,
vis_t,
vis_u,
vis_o,
],
sem_row_handler,
)?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (mem, emb_bytes) in sem_results {
if scored.contains_key(&mem.id) {
continue;
}
if let Some(bytes) = emb_bytes
&& !bytes.is_empty()
{
let Ok(emb) = crate::embeddings::decode_embedding_blob(&bytes) else {
tracing::warn!(
memory_id = %mem.id,
"skipping malformed embedding BLOB during semantic recall"
);
continue;
};
let cosine =
match crate::embeddings::Embedder::cosine_similarity_checked(query_embedding, &emb)
{
crate::embeddings::CosineComparison::Comparable(c) => f64::from(c),
crate::embeddings::CosineComparison::DimensionMismatch { .. } => {
*dim_mismatch_count += 1;
continue;
}
};
if cosine > crate::RECALL_COSINE_GATE {
scored.insert(mem.id.clone(), (mem, 0.0, cosine));
hnsw_candidates_count += 1;
}
}
}
Ok(hnsw_candidates_count)
}
fn blend_and_rank(
scored: HashMap<String, (Memory, f64, f64)>,
max_fts_score: f64,
scoring: &crate::config::ResolvedScoring,
limit: usize,
) -> (Vec<(Memory, f64)>, Vec<f64>) {
let now_utc = Utc::now();
let mut weights: Vec<f64> = Vec::new();
let mut results: Vec<(Memory, f64)> = scored
.into_values()
.map(|(mem, fts_score, cosine)| {
let norm_fts = if max_fts_score > 0.0 {
fts_score / max_fts_score
} else {
0.0
};
let content_len = f64::from(i32::try_from(mem.content.len()).unwrap_or(i32::MAX));
let semantic_weight = if content_len <= 500.0 {
0.50
} else if content_len >= 5000.0 {
0.15
} else {
0.50 - 0.35 * ((content_len - 500.0) / 4500.0)
};
weights.push(semantic_weight);
let blended = semantic_weight * cosine + (1.0 - semantic_weight) * norm_fts;
let age_days = chrono::DateTime::parse_from_rfc3339(&mem.created_at)
.ok()
.map_or(0.0, |ts| {
let secs = (now_utc - ts.with_timezone(&Utc)).num_seconds();
#[allow(clippy::cast_precision_loss)]
{
secs as f64 / crate::SECS_PER_DAY as f64
}
});
let decay = scoring.decay_multiplier(&mem.tier, age_days);
(mem, blended * decay)
})
.collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(limit);
(results, weights)
}
fn apply_recall_post_ops(
conn: &Connection,
results: Vec<(Memory, f64)>,
hierarchy_active: bool,
namespace: Option<&str>,
budget_tokens: Option<usize>,
short_extend: i64,
mid_extend: i64,
) -> (Vec<(Memory, f64)>, BudgetOutcome) {
let boosted = if let (true, Some(anchor)) = (hierarchy_active, namespace) {
apply_proximity_boost(results, anchor)
} else {
results
};
let (budgeted, outcome) = apply_token_budget(boosted, budget_tokens);
let touch_ids: Vec<&str> = budgeted.iter().map(|(mem, _)| mem.id.as_str()).collect();
if let Err(e) = touch_many(conn, &touch_ids, short_extend, mid_extend) {
tracing::warn!("touch_many failed for hybrid recall set: {}", e);
}
(budgeted, outcome)
}
fn assemble_recall_telemetry(
fts_candidates: usize,
hnsw_candidates: usize,
blend_weights: &[f64],
embedding_dim_mismatch: usize,
) -> crate::models::RecallTelemetry {
let blend_weight_avg = if blend_weights.is_empty() {
0.0
} else {
#[allow(clippy::cast_precision_loss)]
let n = blend_weights.len() as f64;
blend_weights.iter().sum::<f64>() / n
};
crate::models::RecallTelemetry {
fts_candidates,
hnsw_candidates,
blend_weight_avg,
embedding_dim_mismatch,
}
}
#[allow(clippy::too_many_arguments)]
pub fn recall_hybrid_with_telemetry(
conn: &Connection,
context: &str,
query_embedding: &[f32],
namespace: Option<&str>,
limit: usize,
tags_filter: Option<&str>,
since: Option<&str>,
until: Option<&str>,
vector_index: Option<&crate::hnsw::VectorIndex>,
short_extend: i64,
mid_extend: i64,
as_agent: Option<&str>,
budget_tokens: Option<usize>,
scoring: &crate::config::ResolvedScoring,
include_archived: bool,
source_uri_prefix: Option<&str>,
) -> Result<(
Vec<(Memory, f64)>,
BudgetOutcome,
crate::models::RecallTelemetry,
)> {
recall_hybrid_with_telemetry_inner(
conn,
context,
query_embedding,
namespace,
limit,
tags_filter,
since,
until,
vector_index,
None,
short_extend,
mid_extend,
as_agent,
budget_tokens,
scoring,
include_archived,
source_uri_prefix,
)
}
#[allow(clippy::too_many_arguments)]
pub fn recall_hybrid_with_telemetry_precomputed_hnsw(
conn: &Connection,
context: &str,
query_embedding: &[f32],
namespace: Option<&str>,
limit: usize,
tags_filter: Option<&str>,
since: Option<&str>,
until: Option<&str>,
precomputed_hnsw_hits: &[crate::hnsw::VectorHit],
short_extend: i64,
mid_extend: i64,
as_agent: Option<&str>,
budget_tokens: Option<usize>,
scoring: &crate::config::ResolvedScoring,
include_archived: bool,
source_uri_prefix: Option<&str>,
) -> Result<(
Vec<(Memory, f64)>,
BudgetOutcome,
crate::models::RecallTelemetry,
)> {
recall_hybrid_with_telemetry_inner(
conn,
context,
query_embedding,
namespace,
limit,
tags_filter,
since,
until,
None,
Some(precomputed_hnsw_hits),
short_extend,
mid_extend,
as_agent,
budget_tokens,
scoring,
include_archived,
source_uri_prefix,
)
}
#[allow(clippy::too_many_arguments)]
fn recall_hybrid_with_telemetry_inner(
conn: &Connection,
context: &str,
query_embedding: &[f32],
namespace: Option<&str>,
limit: usize,
tags_filter: Option<&str>,
since: Option<&str>,
until: Option<&str>,
vector_index: Option<&crate::hnsw::VectorIndex>,
precomputed_hnsw_hits: Option<&[crate::hnsw::VectorHit]>,
short_extend: i64,
mid_extend: i64,
as_agent: Option<&str>,
budget_tokens: Option<usize>,
scoring: &crate::config::ResolvedScoring,
include_archived: bool,
source_uri_prefix: Option<&str>,
) -> Result<(
Vec<(Memory, f64)>,
BudgetOutcome,
crate::models::RecallTelemetry,
)> {
let prep = prepare_hybrid_query(
context,
namespace,
as_agent,
include_archived,
source_uri_prefix,
);
let fts_results = fts_keyword_phase(conn, &prep, tags_filter, since, until, limit)?;
let scored_cap = fts_results
.len()
.saturating_add(limit.saturating_mul(5).max(50));
let mut scored: HashMap<String, (Memory, f64, f64)> = HashMap::with_capacity(scored_cap);
let mut max_fts_score: f64 = 1.0;
let mut fts_candidates_count: usize = 0;
let mut dim_mismatch_count: usize = 0;
for (mem, fts_score, embedding_bytes) in fts_results {
if fts_score > max_fts_score {
max_fts_score = fts_score;
}
let cosine = match embedding_bytes {
Some(bytes) if !bytes.is_empty() => {
match crate::embeddings::decode_embedding_blob(&bytes) {
Ok(emb) => match crate::embeddings::Embedder::cosine_similarity_checked(
query_embedding,
&emb,
) {
crate::embeddings::CosineComparison::Comparable(c) => f64::from(c),
crate::embeddings::CosineComparison::DimensionMismatch { .. } => {
dim_mismatch_count += 1;
0.0
}
},
Err(_) => {
tracing::warn!(
memory_id = %mem.id,
"skipping malformed embedding BLOB during hybrid recall (FTS branch)"
);
0.0
}
}
}
_ => 0.0,
};
scored.insert(mem.id.clone(), (mem, fts_score, cosine));
fts_candidates_count += 1;
}
let hnsw_candidates_count = semantic_phase(
conn,
&prep,
query_embedding,
vector_index,
precomputed_hnsw_hits,
namespace,
tags_filter,
since,
until,
limit,
include_archived,
source_uri_prefix,
&mut scored,
&mut dim_mismatch_count,
)?;
if dim_mismatch_count > 0 {
tracing::warn!(
dim_mismatch_count,
active_query_dim = query_embedding.len(),
"recall skipped {dim_mismatch_count} stored embedding(s) with mismatched \
dimensionality — the embedder model appears to have changed; re-embed the \
affected memories to restore their semantic recall signal"
);
}
let (results, blend_weights) = blend_and_rank(scored, max_fts_score, scoring, limit);
let (budgeted, outcome) = apply_recall_post_ops(
conn,
results,
prep.hierarchy_active,
namespace,
budget_tokens,
short_extend,
mid_extend,
);
let telemetry = assemble_recall_telemetry(
fts_candidates_count,
hnsw_candidates_count,
&blend_weights,
dim_mismatch_count,
);
Ok((budgeted, outcome, telemetry))
}
pub fn checkpoint(conn: &Connection) -> Result<()> {
conn.pragma_update(None, "wal_checkpoint", "TRUNCATE")?;
Ok(())
}
pub fn sync_state_observe(
conn: &Connection,
agent_id: &str,
peer_id: &str,
seen_at: &str,
) -> Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sync_state (agent_id, peer_id, last_seen_at, last_pulled_at) \
VALUES (?1, ?2, ?3, ?4) \
ON CONFLICT(agent_id, peer_id) DO UPDATE SET \
last_seen_at = CASE WHEN excluded.last_seen_at > last_seen_at \
THEN excluded.last_seen_at \
ELSE last_seen_at END, \
last_pulled_at = excluded.last_pulled_at",
params![agent_id, peer_id, seen_at, now],
)?;
Ok(())
}
pub fn sync_state_load(conn: &Connection, agent_id: &str) -> Result<crate::models::VectorClock> {
let mut stmt =
conn.prepare("SELECT peer_id, last_seen_at FROM sync_state WHERE agent_id = ?1")?;
let rows = stmt.query_map(params![agent_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut clock = crate::models::VectorClock::default();
for row in rows {
let (peer, at) = row?;
clock.entries.insert(peer, at);
}
Ok(clock)
}
#[must_use]
#[allow(dead_code)] pub fn sync_state_last_pushed(conn: &Connection, agent_id: &str, peer_id: &str) -> Option<String> {
conn.query_row(
"SELECT last_pushed_at FROM sync_state WHERE agent_id = ?1 AND peer_id = ?2",
params![agent_id, peer_id],
|r| r.get::<_, Option<String>>(0),
)
.ok()
.flatten()
}
#[allow(dead_code)] pub fn sync_state_record_push(
conn: &Connection,
agent_id: &str,
peer_id: &str,
pushed_at: &str,
) -> Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sync_state (agent_id, peer_id, last_seen_at, last_pulled_at, last_pushed_at) \
VALUES (?1, ?2, ?3, ?3, ?4) \
ON CONFLICT(agent_id, peer_id) DO UPDATE SET \
last_pushed_at = CASE \
WHEN excluded.last_pushed_at IS NULL THEN last_pushed_at \
WHEN last_pushed_at IS NULL THEN excluded.last_pushed_at \
WHEN excluded.last_pushed_at > last_pushed_at THEN excluded.last_pushed_at \
ELSE last_pushed_at END",
params![agent_id, peer_id, now, pushed_at],
)?;
Ok(())
}
pub fn memories_updated_since(
conn: &Connection,
since: Option<&str>,
limit: usize,
) -> Result<Vec<Memory>> {
const COLS: &str = "SELECT id, tier, namespace, title, content, tags, priority, confidence, \
source, access_count, created_at, updated_at, last_accessed_at, \
expires_at, metadata \
FROM memories ";
let rows = match since {
None => {
let mut stmt = conn.prepare(&format!("{COLS} ORDER BY updated_at ASC LIMIT ?1"))?;
stmt.query_map(params![limit], row_to_memory)?
.collect::<rusqlite::Result<Vec<_>>>()
}
Some(s) => {
let mut stmt = conn.prepare(&format!(
"{COLS} WHERE updated_at > ?1 ORDER BY updated_at ASC LIMIT ?2"
))?;
stmt.query_map(params![s, limit], row_to_memory)?
.collect::<rusqlite::Result<Vec<_>>>()
}
};
rows.map_err(Into::into)
}
pub fn health_check(conn: &Connection) -> Result<bool> {
let _: i64 = conn.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))?;
conn.execute(
"INSERT INTO memories_fts(memories_fts) VALUES('integrity-check')",
[],
)?;
Ok(true)
}
pub fn set_namespace_standard(
conn: &Connection,
namespace: &str,
standard_id: &str,
parent: Option<&str>,
) -> Result<()> {
let _mem = get(conn, standard_id)?.ok_or_else(|| {
anyhow::Error::new(StorageError::MemoryNotFound {
id: standard_id.to_string(),
role: None,
})
})?;
let resolved_parent = match parent {
Some(p) => {
if p == namespace {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: "namespace cannot be its own parent".to_string(),
}));
}
Some(p.to_string())
}
None => auto_detect_parent(conn, namespace),
};
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO namespace_meta (namespace, standard_id, updated_at, parent_namespace)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(namespace) DO UPDATE SET standard_id = ?2, updated_at = ?3, parent_namespace = ?4",
params![namespace, standard_id, now, resolved_parent],
)?;
Ok(())
}
fn auto_detect_parent(conn: &Connection, namespace: &str) -> Option<String> {
let mut candidate = namespace.to_string();
while let Some(pos) = candidate.rfind('-') {
candidate.truncate(pos);
if candidate.is_empty() {
break;
}
if get_namespace_standard(conn, &candidate)
.ok()
.flatten()
.is_some()
{
return Some(candidate);
}
}
None
}
#[allow(clippy::unnecessary_wraps)]
pub fn get_namespace_standard(conn: &Connection, namespace: &str) -> Result<Option<String>> {
let result = conn
.query_row(
"SELECT standard_id FROM namespace_meta WHERE namespace = ?1",
params![namespace],
|r| r.get(0),
)
.ok();
Ok(result)
}
pub fn get_namespace_parent(conn: &Connection, namespace: &str) -> Option<String> {
conn.query_row(
"SELECT parent_namespace FROM namespace_meta WHERE namespace = ?1 AND parent_namespace IS NOT NULL",
params![namespace],
|r| r.get(0),
)
.ok()
}
#[allow(clippy::unnecessary_wraps)]
pub fn get_namespace_meta_entry(
conn: &Connection,
namespace: &str,
) -> Result<Option<crate::models::NamespaceMetaEntry>> {
let row = conn
.query_row(
"SELECT namespace, standard_id, parent_namespace, updated_at
FROM namespace_meta WHERE namespace = ?1",
params![namespace],
|r| {
Ok(crate::models::NamespaceMetaEntry {
namespace: r.get(0)?,
standard_id: r.get(1)?,
parent_namespace: r.get(2)?,
updated_at: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
})
},
)
.ok();
Ok(row)
}
pub fn clear_namespace_standard(conn: &Connection, namespace: &str) -> Result<bool> {
let changed = conn.execute(
"DELETE FROM namespace_meta WHERE namespace = ?1",
params![namespace],
)?;
Ok(changed > 0)
}
#[must_use]
pub fn build_namespace_chain(conn: &Connection, namespace: &str) -> Vec<String> {
const MAX_EXPLICIT_DEPTH: usize = 8;
let mut chain: Vec<String> = Vec::new();
if namespace == "*" {
chain.push("*".to_string());
return chain;
}
chain.push("*".to_string());
let mut hierarchy_chain: Vec<String> = crate::models::namespace_ancestors(namespace)
.into_iter()
.rev()
.collect();
if let Some(root) = hierarchy_chain.first().cloned() {
let mut explicit_above: Vec<String> = Vec::new();
let mut current = root;
for _ in 0..MAX_EXPLICIT_DEPTH {
match get_namespace_parent(conn, ¤t) {
Some(p)
if p != "*"
&& !explicit_above.contains(&p)
&& !hierarchy_chain.contains(&p) =>
{
explicit_above.push(p.clone());
current = p;
}
_ => break,
}
}
for p in explicit_above.into_iter().rev() {
chain.push(p);
}
}
for entry in hierarchy_chain.drain(..) {
if !chain.contains(&entry) {
chain.push(entry);
}
}
chain
}
fn read_namespace_policy(conn: &Connection, namespace: &str) -> Option<GovernancePolicy> {
let standard_id = get_namespace_standard(conn, namespace).ok()??;
let mem = get(conn, &standard_id).ok()??;
match GovernancePolicy::from_metadata(&mem.metadata) {
Some(Ok(p)) => Some(p),
Some(Err(parse_err)) => {
tracing::warn!(
target: "ai_memory::governance::policy_read",
namespace = %namespace,
standard_id = %standard_id,
error = %parse_err,
"stored metadata.governance failed typed deserialise — \
inheritance walk will continue past this namespace as \
if no policy were set. Likely cause: direct SQL update, \
older binary, or corrupted migration. Operator should \
re-run `memory_namespace_set_standard` to restore the \
typed shape."
);
None
}
None => None,
}
}
pub fn resolve_governance_policy(conn: &Connection, namespace: &str) -> Option<GovernancePolicy> {
let chain = build_namespace_chain(conn, namespace);
for level in chain.into_iter().rev() {
if let Some(policy) = read_namespace_policy(conn, &level) {
return Some(policy);
}
}
None
}
pub fn resolve_require_approval_above_depth(conn: &Connection, namespace: &str) -> Option<u32> {
let chain = build_namespace_chain(conn, namespace);
for level in chain.into_iter().rev() {
let standard_id = match get_namespace_standard(conn, &level) {
Ok(Some(id)) => id,
_ => continue,
};
let mem = match get(conn, &standard_id) {
Ok(Some(m)) => m,
_ => continue,
};
let gov = match mem.metadata.get(crate::META_KEY_GOVERNANCE) {
Some(g) if !g.is_null() => g,
_ => continue,
};
if let Some(threshold) = gov.get("require_approval_above_depth") {
if let Some(n) = threshold.as_u64() {
return Some(u32::try_from(n).unwrap_or(0));
}
}
if GovernancePolicy::from_metadata(&mem.metadata).is_some() {
return None;
}
}
None
}
pub fn resolve_skill_promotion_min_depth(conn: &Connection, namespace: &str) -> Option<u32> {
let chain = build_namespace_chain(conn, namespace);
for level in chain.into_iter().rev() {
let standard_id = match get_namespace_standard(conn, &level) {
Ok(Some(id)) => id,
_ => continue,
};
let mem = match get(conn, &standard_id) {
Ok(Some(m)) => m,
_ => continue,
};
let gov = match mem.metadata.get(crate::META_KEY_GOVERNANCE) {
Some(g) if !g.is_null() => g,
_ => continue,
};
if let Some(threshold) = gov.get("skill_promotion_min_depth") {
if let Some(n) = threshold.as_u64() {
return Some(u32::try_from(n).unwrap_or(u32::MAX));
}
}
if GovernancePolicy::from_metadata(&mem.metadata).is_some() {
return None;
}
}
None
}
pub fn is_registered_agent(conn: &Connection, agent_id: &str) -> bool {
let title = crate::models::agent_registration_title(agent_id);
conn.query_row(
"SELECT 1 FROM memories WHERE namespace = ?1 AND title = ?2",
params![AGENTS_NAMESPACE, &title],
|r| r.get::<_, i64>(0),
)
.is_ok()
}
fn evaluate_level(
conn: &Connection,
action: GovernedAction,
namespace: &str,
level: &GovernanceLevel,
agent_id: &str,
memory_owner: Option<&str>,
namespace_owner: Option<&str>,
) -> GovernanceDecision {
use crate::governance::GovernanceRefusal;
match level {
GovernanceLevel::Any => GovernanceDecision::Allow,
GovernanceLevel::Registered => {
if is_registered_agent(conn, agent_id) {
GovernanceDecision::Allow
} else {
GovernanceDecision::Deny(
GovernanceRefusal::new(
action,
GovernanceLevel::Registered,
agent_id,
format!("caller '{agent_id}' is not a registered agent"),
)
.with_namespace(namespace),
)
}
}
GovernanceLevel::Owner => {
let owner = memory_owner.or(namespace_owner);
match owner {
Some(o) if o == agent_id => GovernanceDecision::Allow,
Some(o) => GovernanceDecision::Deny(
GovernanceRefusal::new(
action,
GovernanceLevel::Owner,
agent_id,
format!("caller '{agent_id}' is not the owner ('{o}')"),
)
.with_namespace(namespace)
.with_owner(o),
),
None => GovernanceDecision::Deny(
GovernanceRefusal::new(
action,
GovernanceLevel::Owner,
agent_id,
"owner-level action has no resolvable owner",
)
.with_namespace(namespace),
),
}
}
GovernanceLevel::Approve => {
GovernanceDecision::Pending(String::new())
}
}
}
fn namespace_owner(conn: &Connection, namespace: &str) -> Option<String> {
let chain = build_namespace_chain(conn, namespace);
for level in chain.into_iter().rev() {
let Some(standard_id) = get_namespace_standard(conn, &level).ok().flatten() else {
continue;
};
let Some(mem) = get(conn, &standard_id).ok().flatten() else {
continue;
};
if let Some(owner) = mem
.metadata
.get("agent_id")
.and_then(|v| v.as_str())
.map(str::to_string)
{
return Some(owner);
}
}
None
}
pub fn enforce_governance(
conn: &Connection,
action: GovernedAction,
namespace: &str,
agent_id: &str,
memory_id: Option<&str>,
memory_owner: Option<&str>,
payload: &serde_json::Value,
) -> Result<GovernanceDecision> {
use crate::config::{PermissionsMode, active_permissions_mode, record_permissions_decision};
let mode = active_permissions_mode();
record_permissions_decision(mode);
if mode == PermissionsMode::Off {
return Ok(GovernanceDecision::Allow);
}
let Some(policy) = resolve_governance_policy(conn, namespace) else {
return Ok(GovernanceDecision::Allow);
};
let level = match action {
GovernedAction::Store => &policy.core.write,
GovernedAction::Delete => &policy.core.delete,
GovernedAction::Promote => &policy.core.promote,
GovernedAction::Reflect => &policy.core.write,
};
let ns_owner = if matches!(action, GovernedAction::Store) {
namespace_owner(conn, namespace)
} else {
None
};
let decision = evaluate_level(
conn,
action,
namespace,
level,
agent_id,
memory_owner,
ns_owner.as_deref(),
);
if mode == PermissionsMode::Advisory {
match &decision {
GovernanceDecision::Allow => {}
GovernanceDecision::Deny(refusal) => {
tracing::warn!(
target: "ai_memory::governance",
namespace = %namespace,
agent_id = %agent_id,
action = ?action,
reason = %refusal.reason,
denied_level = %refusal.denied_level.as_str(),
"permissions.mode=advisory: would-deny suppressed (allowing)"
);
}
GovernanceDecision::Pending(_) => {
tracing::warn!(
target: "ai_memory::governance",
namespace = %namespace,
agent_id = %agent_id,
action = ?action,
"permissions.mode=advisory: would-queue-approval suppressed (allowing)"
);
}
}
return Ok(GovernanceDecision::Allow);
}
if let GovernanceDecision::Pending(_) = decision {
let pending_id =
queue_pending_action(conn, action, namespace, memory_id, agent_id, payload)?;
return Ok(GovernanceDecision::Pending(pending_id));
}
Ok(decision)
}
pub fn queue_pending_action(
conn: &Connection,
action: GovernedAction,
namespace: &str,
memory_id: Option<&str>,
requested_by: &str,
payload: &serde_json::Value,
) -> Result<String> {
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let payload_json = serde_json::to_string(payload)?;
conn.execute(
"INSERT INTO pending_actions (id, action_type, memory_id, namespace, payload, requested_by, requested_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending')",
params![
id,
action.as_str(),
memory_id,
namespace,
payload_json,
requested_by,
now,
],
)?;
Ok(id)
}
pub fn upsert_pending_action(conn: &Connection, pa: &PendingAction) -> Result<()> {
let payload_json = serde_json::to_string(&pa.payload)?;
let approvals_json = serde_json::to_string(&pa.approvals)?;
conn.execute(
"INSERT INTO pending_actions
(id, action_type, memory_id, namespace, payload, requested_by,
requested_at, status, decided_by, decided_at, approvals)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(id) DO UPDATE SET
action_type = excluded.action_type,
memory_id = excluded.memory_id,
namespace = excluded.namespace,
payload = excluded.payload,
requested_by = excluded.requested_by,
requested_at = excluded.requested_at,
status = excluded.status,
decided_by = excluded.decided_by,
decided_at = excluded.decided_at,
approvals = excluded.approvals",
params![
pa.id,
pa.action_type,
pa.memory_id,
pa.namespace,
payload_json,
pa.requested_by,
pa.requested_at,
pa.status,
pa.decided_by,
pa.decided_at,
approvals_json,
],
)?;
Ok(())
}
pub fn list_pending_actions(
conn: &Connection,
status: Option<&str>,
limit: usize,
) -> Result<Vec<PendingAction>> {
let mut stmt = conn.prepare(
"SELECT id, action_type, memory_id, namespace, payload, requested_by,
requested_at, status, decided_by, decided_at, approvals
FROM pending_actions
WHERE (?1 IS NULL OR status = ?1)
ORDER BY requested_at DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![status, limit], |row| {
let payload_str: String = row.get(4)?;
let payload: serde_json::Value =
serde_json::from_str(&payload_str).unwrap_or(serde_json::Value::Null);
let approvals_str: String = row.get(10)?;
let approvals: Vec<Approval> = serde_json::from_str(&approvals_str).unwrap_or_default();
Ok(PendingAction {
id: row.get(0)?,
action_type: row.get(1)?,
memory_id: row.get(2)?,
namespace: row.get(3)?,
payload,
requested_by: row.get(5)?,
requested_at: row.get(6)?,
status: row.get(7)?,
decided_by: row.get(8)?,
decided_at: row.get(9)?,
approvals,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub fn get_pending_action(conn: &Connection, id: &str) -> Result<Option<PendingAction>> {
let row = conn.query_row(
"SELECT id, action_type, memory_id, namespace, payload, requested_by,
requested_at, status, decided_by, decided_at, approvals
FROM pending_actions WHERE id = ?1",
params![id],
|row| {
let payload_str: String = row.get(4)?;
let payload: serde_json::Value =
serde_json::from_str(&payload_str).unwrap_or(serde_json::Value::Null);
let approvals_str: String = row.get(10)?;
let approvals: Vec<Approval> = serde_json::from_str(&approvals_str).unwrap_or_default();
Ok(PendingAction {
id: row.get(0)?,
action_type: row.get(1)?,
memory_id: row.get(2)?,
namespace: row.get(3)?,
payload,
requested_by: row.get(5)?,
requested_at: row.get(6)?,
status: row.get(7)?,
decided_by: row.get(8)?,
decided_at: row.get(9)?,
approvals,
})
},
);
match row {
Ok(p) => Ok(Some(p)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn decide_pending_action(
conn: &Connection,
id: &str,
approve: bool,
decided_by: &str,
) -> Result<bool> {
let new_status = if approve { "approved" } else { "rejected" };
let now = Utc::now().to_rfc3339();
let updated = conn.execute(
"UPDATE pending_actions SET status = ?1, decided_by = ?2, decided_at = ?3
WHERE id = ?4 AND status = 'pending'",
params![new_status, decided_by, now, id],
)?;
if updated > 0 && !approve {
if let Ok(Some(pa)) = get_pending_action(conn, id) {
emit_pending_action_event(conn, &pa, "pending_action.denied", Some(decided_by));
}
}
Ok(updated > 0)
}
fn emit_pending_action_event(
conn: &Connection,
pa: &PendingAction,
event_type: &str,
decided_by_override: Option<&str>,
) {
use std::collections::BTreeMap;
let decided_by = decided_by_override
.map(str::to_string)
.or_else(|| pa.decided_by.clone())
.unwrap_or_default();
let timestamp = Utc::now().to_rfc3339();
let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
map.insert(
field_names::PENDING_ID,
ciborium::Value::Text(pa.id.clone()),
);
map.insert(
field_names::ACTION_TYPE,
ciborium::Value::Text(pa.action_type.clone()),
);
map.insert("namespace", ciborium::Value::Text(pa.namespace.clone()));
map.insert(
field_names::REQUESTED_BY,
ciborium::Value::Text(pa.requested_by.clone()),
);
map.insert(
field_names::DECIDED_BY,
ciborium::Value::Text(decided_by.clone()),
);
map.insert("status", ciborium::Value::Text(pa.status.clone()));
map.insert("timestamp", ciborium::Value::Text(timestamp.clone()));
let entries: Vec<(ciborium::Value, ciborium::Value)> = map
.into_iter()
.map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
.collect();
let value = ciborium::Value::Map(entries);
let mut cbor: Vec<u8> = Vec::with_capacity(128);
if let Err(e) = ciborium::ser::into_writer(&value, &mut cbor) {
tracing::warn!(
target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
pending_id = %pa.id,
event_type,
"failed to encode canonical CBOR for pending_action event: {e}"
);
return;
}
let agent_id = if event_type == "pending_action.timed_out" {
pa.requested_by.clone()
} else {
decided_by
};
let event = crate::signed_events::SignedEvent::with_daemon_signature(
crate::signed_events::payload_hash(&cbor),
agent_id,
event_type.to_string(),
timestamp,
);
if let Err(e) = crate::signed_events::append_signed_event(conn, &event) {
tracing::warn!(
target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
pending_id = %pa.id,
event_type,
"failed to append pending_action audit row: {e}"
);
}
}
fn verify_payload_agent_id(pa: &PendingAction) -> Result<()> {
let payload_agent_id = pa
.payload
.get("agent_id")
.and_then(serde_json::Value::as_str)
.or_else(|| {
pa.payload
.get("metadata")
.and_then(|m| m.get("agent_id"))
.and_then(serde_json::Value::as_str)
});
if let Some(claimed) = payload_agent_id
&& claimed != pa.requested_by
{
return Err(anyhow::Error::new(StorageError::ApproverLaundering {
pending_id: pa.id.clone(),
claimed: claimed.to_string(),
requester: pa.requested_by.clone(),
}));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApproveOutcome {
NotFound,
Rejected(String),
Pending { votes: usize, quorum: u32 },
Approved,
}
pub fn approve_with_approver_type(
conn: &Connection,
pending_id: &str,
approver_agent_id: &str,
) -> Result<ApproveOutcome> {
let Some(pa) = get_pending_action(conn, pending_id)? else {
return Ok(ApproveOutcome::NotFound);
};
if pa.status != "pending" {
return Ok(ApproveOutcome::Rejected(format!(
"already decided: status={}",
pa.status
)));
}
let approver = resolve_governance_policy(conn, &pa.namespace)
.map_or(ApproverType::Human, |p| p.core.approver);
match approver {
ApproverType::Human => {
let ok = decide_pending_action(conn, pending_id, true, approver_agent_id)?;
if ok {
Ok(ApproveOutcome::Approved)
} else {
Ok(ApproveOutcome::Rejected(
crate::errors::msg::DECISION_WRITE_FAILED.into(),
))
}
}
ApproverType::Agent(required) => {
if approver_agent_id != required {
return Ok(ApproveOutcome::Rejected(format!(
"designated approver is '{required}'; got '{approver_agent_id}'"
)));
}
let ok = decide_pending_action(conn, pending_id, true, approver_agent_id)?;
if ok {
Ok(ApproveOutcome::Approved)
} else {
Ok(ApproveOutcome::Rejected(
crate::errors::msg::DECISION_WRITE_FAILED.into(),
))
}
}
ApproverType::Consensus(quorum) => {
if !is_registered_agent(conn, approver_agent_id) {
return Ok(ApproveOutcome::Rejected(format!(
"consensus voter '{approver_agent_id}' is not a registered agent"
)));
}
let canonical_id = approver_agent_id.to_ascii_lowercase();
let mut approvals = pa.approvals.clone();
if approvals
.iter()
.any(|a| a.agent_id.eq_ignore_ascii_case(&canonical_id))
{
return Ok(ApproveOutcome::Pending {
votes: approvals.len(),
quorum,
});
}
approvals.push(Approval {
agent_id: canonical_id.clone(),
approved_at: Utc::now().to_rfc3339(),
});
let approvals_json = serde_json::to_string(&approvals)?;
conn.execute(
"UPDATE pending_actions SET approvals = ?1 WHERE id = ?2 AND status = 'pending'",
params![approvals_json, pending_id],
)?;
let votes = approvals.len();
if u32::try_from(votes).unwrap_or(u32::MAX) >= quorum {
let ok = decide_pending_action(conn, pending_id, true, &canonical_id)?;
if ok {
return Ok(ApproveOutcome::Approved);
}
return Ok(ApproveOutcome::Rejected(
"decision write failed at consensus threshold".into(),
));
}
Ok(ApproveOutcome::Pending { votes, quorum })
}
}
}
pub fn execute_pending_action(conn: &Connection, pending_id: &str) -> Result<Option<String>> {
let Some(pa) = get_pending_action(conn, pending_id)? else {
return Err(anyhow::Error::new(StorageError::PendingActionNotFound {
pending_id: pending_id.to_string(),
}));
};
if pa.status != "approved" {
return Err(anyhow::Error::new(
StorageError::PendingActionStateInvalid {
pending_id: pending_id.to_string(),
status: pa.status.clone(),
},
));
}
if let Err(e) = verify_payload_agent_id(&pa) {
emit_pending_action_event(conn, &pa, "pending_action.refused_agent_id_mismatch", None);
return Err(e);
}
let memory_id = match pa.action_type.as_str() {
"store" => {
let mut mem: Memory = serde_json::from_value(pa.payload.clone()).map_err(|e| {
anyhow::Error::new(StorageError::InvalidArgument {
reason: format!("invalid store payload: {e}"),
})
})?;
mem.id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
mem.created_at.clone_from(&now);
mem.updated_at = now;
mem.access_count = 0;
let actual_id = insert(conn, &mem)?;
Some(actual_id)
}
"delete" => {
if let Some(mid) = pa.memory_id.clone() {
delete(conn, &mid)?;
Some(mid)
} else {
None
}
}
"promote" => {
if let Some(mid) = pa.memory_id.clone() {
if let Some(to_ns) = pa
.payload
.get(field_names::TO_NAMESPACE)
.and_then(|v| v.as_str())
{
let clone_id = promote_to_namespace(conn, &mid, to_ns)?;
Some(clone_id)
} else {
let (_found, _changed) = update(
conn,
&mid,
None,
None,
Some(&Tier::Long),
None,
None,
None,
None,
Some(""),
None,
)?;
Some(mid)
}
} else {
None
}
}
"reflect" => execute_reflect_from_payload(conn, &pa)?,
other => {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: format!("unknown action_type: {other}"),
}));
}
};
emit_pending_action_event(
conn,
&pa,
"pending_action.approved",
pa.decided_by.as_deref(),
);
Ok(memory_id)
}
fn execute_reflect_from_payload(conn: &Connection, pa: &PendingAction) -> Result<Option<String>> {
let payload = &pa.payload;
let source_ids: Vec<String> = payload
.get(field_names::SOURCE_IDS)
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
if source_ids.is_empty() {
return Err(anyhow::Error::new(StorageError::InvalidArgument {
reason: "invalid reflect payload: source_ids missing or empty".to_string(),
}));
}
let title = payload
.get("title")
.and_then(|v| v.as_str())
.ok_or_else(|| {
anyhow::Error::new(StorageError::InvalidArgument {
reason: "invalid reflect payload: title missing".to_string(),
})
})?
.to_string();
let content = payload
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| {
anyhow::Error::new(StorageError::InvalidArgument {
reason: "invalid reflect payload: content missing".to_string(),
})
})?
.to_string();
let namespace = payload
.get("namespace")
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| Some(pa.namespace.clone()));
let tier = payload
.get("tier")
.and_then(|v| v.as_str())
.and_then(Tier::from_str)
.unwrap_or(Tier::Mid);
let tags: Vec<String> = payload
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let priority = i32::try_from(
payload
.get("priority")
.and_then(|v| v.as_i64())
.unwrap_or(5),
)
.unwrap_or(5);
let confidence = payload
.get(field_names::CONFIDENCE)
.and_then(|v| v.as_f64())
.unwrap_or(1.0);
let agent_id = payload
.get("agent_id")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| pa.requested_by.clone());
let metadata = payload
.get("metadata")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let input = crate::storage::reflect::ReflectInput {
source_ids,
title,
content,
namespace,
tier,
tags,
priority,
confidence,
source: crate::validate::DEFAULT_NHI_SOURCE.to_string(),
agent_id,
metadata,
};
let outcome = crate::storage::reflect::reflect(conn, &input)
.map_err(|e| anyhow::anyhow!("reflect execute failed: {e}"))?;
Ok(Some(outcome.id))
}
pub fn is_namespace_standard(conn: &Connection, id: &str) -> bool {
conn.query_row(
"SELECT COUNT(*) FROM namespace_meta WHERE standard_id = ?1",
params![id],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0)
> 0
}
pub fn count_active_governance_rules(conn: &Connection) -> Result<usize> {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories m
INNER JOIN namespace_meta nm ON nm.standard_id = m.id
WHERE json_extract(m.metadata, '$.governance') IS NOT NULL",
[],
|r| r.get(0),
)
.unwrap_or(0);
Ok(usize::try_from(count.max(0)).unwrap_or(0))
}
pub fn list_active_governance_policies(
conn: &Connection,
) -> Result<Vec<(String, GovernancePolicy)>> {
let mut stmt = conn.prepare(
"SELECT nm.namespace, m.metadata
FROM namespace_meta nm
INNER JOIN memories m ON m.id = nm.standard_id
WHERE json_extract(m.metadata, '$.governance') IS NOT NULL
ORDER BY nm.namespace ASC",
)?;
let rows = stmt.query_map([], |r| {
let ns: String = r.get(0)?;
let meta_str: String = r.get(1)?;
Ok((ns, meta_str))
})?;
let mut out = Vec::new();
for row in rows.flatten() {
let (ns, meta_str) = row;
let Ok(meta) = serde_json::from_str::<serde_json::Value>(&meta_str) else {
continue;
};
match GovernancePolicy::from_metadata(&meta) {
Some(Ok(policy)) => out.push((ns, policy)),
_ => continue,
}
}
Ok(out)
}
pub fn count_subscriptions(conn: &Connection) -> Result<usize> {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM subscriptions", [], |r| r.get(0))
.unwrap_or(0);
Ok(usize::try_from(count.max(0)).unwrap_or(0))
}
pub fn count_pending_actions_by_status(conn: &Connection, status: &str) -> Result<usize> {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pending_actions WHERE status = ?1",
params![status],
|r| r.get(0),
)
.unwrap_or(0);
Ok(usize::try_from(count.max(0)).unwrap_or(0))
}
pub fn sweep_pending_action_timeouts(
conn: &Connection,
global_default_secs: i64,
) -> Result<Vec<(String, String)>> {
if global_default_secs <= 0 {
return Ok(Vec::new());
}
let mut stmt = conn.prepare(
"SELECT id, namespace FROM pending_actions
WHERE status = 'pending'
AND (julianday('now') - julianday(requested_at)) * 86400.0
> COALESCE(default_timeout_seconds, ?1)",
)?;
let rows: Vec<(String, String)> = stmt
.query_map(params![global_default_secs], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
if rows.is_empty() {
return Ok(Vec::new());
}
let now = Utc::now().to_rfc3339();
let tx_savepoint = conn.unchecked_transaction()?;
{
let mut update = tx_savepoint.prepare(
"UPDATE pending_actions
SET status = 'expired', expired_at = ?1
WHERE id = ?2 AND status = 'pending'",
)?;
for (id, _) in &rows {
update.execute(params![now, id])?;
}
}
tx_savepoint.commit()?;
for (id, _) in &rows {
if let Ok(Some(pa)) = get_pending_action(conn, id) {
emit_pending_action_event(conn, &pa, "pending_action.timed_out", None);
}
}
Ok(rows)
}
pub fn doctor_dim_violations(conn: &Connection) -> Result<Option<usize>> {
let has_dim = conn
.prepare("SELECT embedding_dim FROM memories LIMIT 0")
.is_ok();
if !has_dim {
return Ok(None);
}
let n: i64 = conn
.query_row(
"WITH per_ns_modes AS (
SELECT namespace, embedding_dim, COUNT(*) AS c
FROM memories
WHERE embedding IS NOT NULL AND embedding_dim IS NOT NULL
GROUP BY namespace, embedding_dim
),
ranked AS (
SELECT namespace, embedding_dim,
ROW_NUMBER() OVER (PARTITION BY namespace ORDER BY c DESC) AS rn
FROM per_ns_modes
),
modes AS (
SELECT namespace, embedding_dim AS modal_dim
FROM ranked WHERE rn = 1
)
SELECT COUNT(*)
FROM memories m
LEFT JOIN modes mo ON mo.namespace = m.namespace
WHERE m.embedding IS NOT NULL
AND (m.embedding_dim IS NULL
OR (mo.modal_dim IS NOT NULL AND m.embedding_dim != mo.modal_dim))",
[],
|r| r.get(0),
)
.unwrap_or(0);
Ok(Some(usize::try_from(n.max(0)).unwrap_or(0)))
}
pub fn doctor_oldest_pending_age_secs(conn: &Connection) -> Result<Option<i64>> {
let row: Option<String> = conn
.query_row(
"SELECT requested_at FROM pending_actions WHERE status = 'pending'
ORDER BY requested_at ASC LIMIT 1",
[],
|r| r.get(0),
)
.ok();
let Some(ts) = row else {
return Ok(None);
};
let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&ts) else {
return Ok(None);
};
let raw_age = (Utc::now() - parsed.with_timezone(&Utc)).num_seconds();
let age = if raw_age < 0 {
tracing::warn!(
requested_at = %ts,
raw_age_seconds = raw_age,
"pending_actions row has future timestamp; clamping age to 0"
);
0
} else {
raw_age
};
Ok(Some(age))
}
pub fn doctor_governance_coverage(conn: &Connection) -> Result<(usize, usize)> {
let with_policy: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories m
INNER JOIN namespace_meta nm ON nm.standard_id = m.id
WHERE json_extract(m.metadata, '$.governance') IS NOT NULL",
[],
|r| r.get(0),
)
.unwrap_or(0);
let total_meta: i64 = conn
.query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
.unwrap_or(0);
let with = usize::try_from(with_policy.max(0)).unwrap_or(0);
let total = usize::try_from(total_meta.max(0)).unwrap_or(0);
Ok((with, total.saturating_sub(with)))
}
pub fn doctor_governance_depth_distribution(conn: &Connection) -> Result<Vec<usize>> {
const MAX_DEPTH: usize = 16;
let mut stmt = conn.prepare("SELECT namespace, parent_namespace FROM namespace_meta")?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
})?;
let parent_map: HashMap<String, Option<String>> = rows
.filter_map(rusqlite::Result::ok)
.collect::<HashMap<_, _>>();
let mut hist = vec![0_usize; MAX_DEPTH + 1];
for ns in parent_map.keys() {
let mut depth = 0_usize;
let mut cur = parent_map.get(ns).cloned().flatten();
while let Some(p) = cur {
depth += 1;
if depth >= MAX_DEPTH {
break;
}
cur = parent_map.get(&p).cloned().flatten();
}
let bucket = depth.min(MAX_DEPTH);
hist[bucket] += 1;
}
Ok(hist)
}
pub fn doctor_webhook_delivery_totals(conn: &Connection) -> Result<(u64, u64)> {
let dispatched: i64 = conn
.query_row(
"SELECT COALESCE(SUM(dispatch_count), 0) FROM subscriptions",
[],
|r| r.get(0),
)
.unwrap_or(0);
let failed: i64 = conn
.query_row(
"SELECT COALESCE(SUM(failure_count), 0) FROM subscriptions",
[],
|r| r.get(0),
)
.unwrap_or(0);
Ok((
u64::try_from(dispatched.max(0)).unwrap_or(0),
u64::try_from(failed.max(0)).unwrap_or(0),
))
}
#[derive(Debug, Clone)]
pub struct CapabilityExpansionRow {
pub id: String,
pub agent_id: Option<String>,
pub event_type: String,
pub requested_family: Option<String>,
pub granted: bool,
pub attestation_tier: Option<String>,
pub timestamp: String,
}
pub fn record_capability_expansion(
conn: &Connection,
agent_id: Option<&str>,
family: &str,
granted: bool,
attestation_tier: Option<&str>,
) {
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let result = conn.execute(
"INSERT INTO audit_log (id, agent_id, event_type, requested_family, \
granted, attestation_tier, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
id,
agent_id,
"capability_expansion",
family,
i32::from(granted),
attestation_tier,
now,
],
);
if let Err(e) = result {
tracing::warn!(
"audit_log insert failed (capability_expansion / agent={:?} / family={}): {e}",
agent_id,
family,
);
}
}
pub fn list_capability_expansions(
conn: &Connection,
limit: usize,
agent_filter: Option<&str>,
) -> Result<Vec<CapabilityExpansionRow>> {
let n = (limit.min(10_000)) as i64;
let map_row = |r: &rusqlite::Row<'_>| -> rusqlite::Result<CapabilityExpansionRow> {
Ok(CapabilityExpansionRow {
id: r.get(0)?,
agent_id: r.get(1)?,
event_type: r.get(2)?,
requested_family: r.get(3)?,
granted: r.get::<_, i64>(4)? != 0,
attestation_tier: r.get(5)?,
timestamp: r.get(6)?,
})
};
if let Some(a) = agent_filter {
let mut stmt = conn.prepare(
"SELECT id, agent_id, event_type, requested_family, granted, \
attestation_tier, timestamp FROM audit_log \
WHERE event_type = 'capability_expansion' AND agent_id = ?1 \
ORDER BY timestamp DESC LIMIT ?2",
)?;
let rows = stmt.query_map(rusqlite::params![a, n], map_row)?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
} else {
let mut stmt = conn.prepare(
"SELECT id, agent_id, event_type, requested_family, granted, \
attestation_tier, timestamp FROM audit_log \
WHERE event_type = 'capability_expansion' \
ORDER BY timestamp DESC LIMIT ?1",
)?;
let rows = stmt.query_map(rusqlite::params![n], map_row)?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
}
pub fn doctor_max_sync_skew_secs(conn: &Connection) -> Result<Option<i64>> {
let mut stmt = match conn.prepare(
"SELECT last_seen_at, last_pulled_at FROM sync_state WHERE last_pulled_at IS NOT NULL",
) {
Ok(s) => s,
Err(_) => return Ok(None),
};
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
let mut max_skew: Option<i64> = None;
for row in rows {
let Ok((seen, pulled)) = row else { continue };
let Ok(s) = chrono::DateTime::parse_from_rfc3339(&seen) else {
continue;
};
let Ok(p) = chrono::DateTime::parse_from_rfc3339(&pulled) else {
continue;
};
let skew = (s.with_timezone(&Utc) - p.with_timezone(&Utc))
.num_seconds()
.abs();
max_skew = Some(max_skew.map_or(skew, |m| m.max(skew)));
}
Ok(max_skew)
}
pub struct ReflectionDepthRow {
pub namespace: String,
pub depth0: i64,
pub depth1: i64,
pub depth2: i64,
pub depth3_plus: i64,
pub avg_depth: f64,
pub max_depth: i64,
pub total: i64,
}
pub fn doctor_reflection_depth_distribution(conn: &Connection) -> Result<Vec<ReflectionDepthRow>> {
let mut stmt = conn.prepare(
"SELECT
namespace,
SUM(CASE WHEN reflection_depth = 0 THEN 1 ELSE 0 END),
SUM(CASE WHEN reflection_depth = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN reflection_depth = 2 THEN 1 ELSE 0 END),
SUM(CASE WHEN reflection_depth >= 3 THEN 1 ELSE 0 END),
AVG(CAST(reflection_depth AS REAL)),
MAX(reflection_depth),
COUNT(*)
FROM memories
GROUP BY namespace
HAVING MAX(reflection_depth) > 0
ORDER BY namespace",
)?;
let rows = stmt.query_map([], |r| {
Ok(ReflectionDepthRow {
namespace: r.get(0)?,
depth0: r.get(1)?,
depth1: r.get(2)?,
depth2: r.get(3)?,
depth3_plus: r.get(4)?,
avg_depth: r.get(5)?,
max_depth: r.get(6)?,
total: r.get(7)?,
})
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
pub fn doctor_reflection_depth_exceeded_count(
conn: &Connection,
since_rfc3339: &str,
) -> Result<i64> {
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM signed_events
WHERE event_type = 'reflection.depth_exceeded'
AND timestamp >= ?1",
params![since_rfc3339],
|r| r.get(0),
)
.unwrap_or(0);
Ok(n)
}
pub fn doctor_reflection_totals_by_namespace(
conn: &Connection,
) -> Result<Vec<(String, i64, i64, i64)>> {
let now = Utc::now();
let last_day_cutoff = (now - chrono::Duration::hours(24)).to_rfc3339();
let cutoff_7d = (now - chrono::Duration::days(7)).to_rfc3339();
let mut stmt = conn.prepare(
"SELECT
namespace,
SUM(CASE WHEN created_at >= ?1 THEN 1 ELSE 0 END),
SUM(CASE WHEN created_at >= ?2 THEN 1 ELSE 0 END),
COUNT(*)
FROM memories
WHERE reflection_depth > 0
GROUP BY namespace
ORDER BY namespace",
)?;
let rows = stmt.query_map(params![last_day_cutoff, cutoff_7d], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
))
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{MID_TTL_EXTEND_SECS, Memory, SHORT_TTL_EXTEND_SECS, Tier};
fn test_db() -> Connection {
open(std::path::Path::new(":memory:")).unwrap()
}
fn insert_memory_at(conn: &Connection, id: &str, updated_at: &str) {
conn.execute(
"INSERT INTO memories (id, tier, namespace, title, content, created_at, updated_at) \
VALUES (?1, 'mid', 'ns', ?1, 'content body', ?2, ?2)",
params![id, updated_at],
)
.expect("insert memory row");
}
#[test]
fn memories_updated_since_sargable_split_none_and_some_paths() {
let conn = test_db();
let t1 = "2026-01-01T00:00:00+00:00";
let t2 = "2026-01-02T00:00:00+00:00";
let t3 = "2026-01-03T00:00:00+00:00";
insert_memory_at(&conn, "b", t2);
insert_memory_at(&conn, "c", t3);
insert_memory_at(&conn, "a", t1);
let all = memories_updated_since(&conn, None, 100).expect("none path");
let ids: Vec<&str> = all.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec!["a", "b", "c"],
"None path: all rows ASC by updated_at"
);
let after_t1 = memories_updated_since(&conn, Some(t1), 100).expect("some path");
let ids: Vec<&str> = after_t1.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec!["b", "c"],
"Some(t1): strict > excludes the boundary row"
);
let after_t3 = memories_updated_since(&conn, Some(t3), 100).expect("some path empty");
assert!(
after_t3.is_empty(),
"Some(t3): nothing strictly newer than the max"
);
let one = memories_updated_since(&conn, Some(t1), 1).expect("some path limited");
let ids: Vec<&str> = one.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec!["b"],
"Some(t1) LIMIT 1: oldest row strictly after t1"
);
}
#[test]
fn memories_updated_since_uses_updated_at_index() {
let conn = test_db();
let mut stmt = conn
.prepare(
"EXPLAIN QUERY PLAN \
SELECT id FROM memories WHERE updated_at > ?1 \
ORDER BY updated_at ASC LIMIT ?2",
)
.expect("prepare explain");
let plan: String = stmt
.query_map(params!["2026-01-01T00:00:00+00:00", 10_i64], |r| {
r.get::<_, String>(3)
})
.expect("explain rows")
.map(|r| r.expect("explain detail"))
.collect::<Vec<_>>()
.join(" | ");
assert!(
plan.contains("idx_memories_updated_at"),
"sargable catchup query must use idx_memories_updated_at; plan was: {plan}"
);
}
#[test]
fn perf_8_hierarchy_in_clause_cache_hits_on_repeat() {
hierarchy_cache_clear_for_tests();
let ns = Some("alphaone/team/alice");
let (a, active_a) = hierarchy_in_clause(ns);
let (b, active_b) = hierarchy_in_clause(ns);
assert!(active_a && active_b);
assert_eq!(
a, b,
"PERF-8: cached hierarchy_in_clause result drift on second lookup",
);
assert!(
a.expect("non-None fragment")
.contains("AND m.namespace IN ("),
"PERF-8: fragment shape regressed",
);
}
#[test]
fn perf_8_hierarchy_cache_handles_non_hierarchical_ns() {
hierarchy_cache_clear_for_tests();
let (frag, active) = hierarchy_in_clause(Some("global"));
assert_eq!(frag, None);
assert!(!active);
}
#[test]
fn perf_8_hierarchy_cache_bounded_under_pressure() {
hierarchy_cache_clear_for_tests();
for i in 0..(HIERARCHY_CACHE_MAX * 2) {
let ns = format!("tenant{i}/sub");
let _ = hierarchy_in_clause(Some(&ns));
}
let cache_len = hierarchy_cache().lock().unwrap().len();
assert!(
cache_len <= HIERARCHY_CACHE_MAX,
"PERF-8: hierarchy cache grew unbounded: {cache_len} > {HIERARCHY_CACHE_MAX}",
);
}
#[test]
fn get_many_batches_and_handles_empty_missing_and_chunked_inputs_981() {
let conn = test_db();
let m1 = make_memory("alpha", "ns/a", Tier::Long, 5);
let m2 = make_memory("beta", "ns/b", Tier::Long, 5);
let m3 = make_memory("gamma", "ns/c", Tier::Long, 5);
insert(&conn, &m1).unwrap();
insert(&conn, &m2).unwrap();
insert(&conn, &m3).unwrap();
assert!(get_many(&conn, &[]).unwrap().is_empty());
let ids = vec![m1.id.clone(), m2.id.clone()];
let got = get_many(&conn, &ids).unwrap();
assert_eq!(got.len(), 2);
assert!(got.contains_key(&m1.id));
assert!(got.contains_key(&m2.id));
assert!(!got.contains_key(&m3.id));
let mixed = vec![m1.id.clone(), "nope-not-a-real-id".to_string()];
let got = get_many(&conn, &mixed).unwrap();
assert_eq!(got.len(), 1);
assert!(got.contains_key(&m1.id));
let reversed = vec![m3.id.clone(), m2.id.clone(), m1.id.clone()];
let got = get_many(&conn, &reversed).unwrap();
assert_eq!(got.len(), 3);
for id in &reversed {
assert!(got.contains_key(id), "id {id} missing from set-fetch");
}
let mut bulk: Vec<Memory> = Vec::with_capacity(750);
let mut bulk_ids: Vec<String> = Vec::with_capacity(750);
for i in 0..750 {
let m = make_memory(&format!("bulk-{i}"), "ns/bulk", Tier::Long, 1);
insert(&conn, &m).unwrap();
bulk_ids.push(m.id.clone());
bulk.push(m);
}
let got = get_many(&conn, &bulk_ids).unwrap();
assert_eq!(
got.len(),
750,
"chunked fetch >500 must still return every row",
);
}
fn make_memory(title: &str, ns: &str, tier: Tier, priority: i32) -> Memory {
let now = chrono::Utc::now().to_rfc3339();
Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: tier.clone(),
namespace: ns.to_string(),
title: title.to_string(),
content: format!("Content for {title}"),
tags: vec![],
priority,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: tier
.default_ttl_secs()
.map(|s| (chrono::Utc::now() + chrono::Duration::seconds(s)).to_rfc3339()),
metadata: serde_json::json!({}),
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
}
}
fn mem_with_scope(ns: &str, scope: Option<&str>) -> Memory {
let mut m = make_memory("scoped", ns, Tier::Long, 5);
if let Some(s) = scope {
let mut map = serde_json::Map::new();
map.insert(
crate::META_KEY_SCOPE.to_string(),
serde_json::Value::String(s.to_string()),
);
m.metadata = serde_json::Value::Object(map);
}
m
}
#[test]
fn is_visible_scope_matrix_covers_every_arm() {
let unfiltered = (None, None, None, None);
assert!(super::is_visible(
&mem_with_scope("acme/eng/web", Some("private")),
&unfiltered
));
let prefixes = super::compute_visibility_prefixes(Some("acme/eng/web/team"));
assert_eq!(
prefixes,
(
Some("acme/eng/web/team".to_string()),
Some("acme/eng/web".to_string()),
Some("acme/eng".to_string()),
Some("acme".to_string()),
)
);
assert!(super::is_visible(
&mem_with_scope("zzz/other", Some("collective")),
&prefixes
));
assert!(super::is_visible(
&mem_with_scope("acme/eng/web/team", Some("private")),
&prefixes
));
assert!(!super::is_visible(
&mem_with_scope("acme/eng/web", Some("private")),
&prefixes
));
assert!(super::is_visible(
&mem_with_scope("acme/eng/web/team", None),
&prefixes
));
assert!(!super::is_visible(
&mem_with_scope("acme/other", None),
&prefixes
));
assert!(super::is_visible(
&mem_with_scope("acme/eng/web", Some("team")),
&prefixes
));
assert!(super::is_visible(
&mem_with_scope("acme/eng/web/team/v2", Some("team")),
&prefixes
));
assert!(!super::is_visible(
&mem_with_scope("acme/eng/api", Some("team")),
&prefixes
));
assert!(super::is_visible(
&mem_with_scope("acme/eng", Some("unit")),
&prefixes
));
assert!(!super::is_visible(
&mem_with_scope("acme/sales", Some("unit")),
&prefixes
));
assert!(super::is_visible(
&mem_with_scope("acme", Some("org")),
&prefixes
));
assert!(!super::is_visible(
&mem_with_scope("globex", Some("org")),
&prefixes
));
let shallow = super::compute_visibility_prefixes(Some("acme"));
assert_eq!(shallow.3, None);
assert!(!super::is_visible(
&mem_with_scope("acme", Some("org")),
&shallow
));
assert!(!super::is_visible(
&mem_with_scope("acme/eng/web/team", Some("definitely-not-a-scope")),
&prefixes
));
assert_eq!(
super::compute_visibility_prefixes(None),
(None, None, None, None)
);
}
#[test]
fn open_creates_schema() {
let conn = test_db();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn insert_and_get() {
let conn = test_db();
let mem = make_memory("Test insert", "test", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.title, "Test insert");
assert_eq!(got.namespace, "test");
assert_eq!(got.priority, 5);
}
#[test]
fn get_nonexistent() {
let conn = test_db();
let got = get(&conn, "nonexistent-id").unwrap();
assert!(got.is_none());
}
fn ttl_gap_secs(created_at: &str, expires_at: &str) -> i64 {
let base = chrono::DateTime::parse_from_rfc3339(created_at).unwrap();
let exp = chrono::DateTime::parse_from_rfc3339(expires_at).unwrap();
(exp - base).num_seconds()
}
#[test]
fn insert_backfills_mid_expiry_when_none() {
let conn = test_db();
let mut mem = make_memory("mid none", "test", Tier::Mid, 5);
mem.expires_at = None;
let id = insert(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
let exp = got.expires_at.expect("mid must not land immortal");
assert_eq!(ttl_gap_secs(&got.created_at, &exp), crate::SECS_PER_WEEK);
}
#[test]
fn insert_backfills_short_expiry_when_none() {
let conn = test_db();
let mut mem = make_memory("short none", "test", Tier::Short, 5);
mem.expires_at = None;
let id = insert(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
let exp = got.expires_at.expect("short must not land immortal");
assert_eq!(
ttl_gap_secs(&got.created_at, &exp),
6 * crate::SECS_PER_HOUR
);
}
#[test]
fn insert_leaves_long_expiry_none() {
let conn = test_db();
let mut mem = make_memory("long none", "test", Tier::Long, 5);
mem.expires_at = None;
let id = insert(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert!(got.expires_at.is_none(), "long has no TTL — must stay NULL");
}
#[test]
fn insert_preserves_explicit_expiry() {
let conn = test_db();
let explicit = "2027-06-15T12:00:00+00:00".to_string();
let mut mem = make_memory("mid explicit", "test", Tier::Mid, 5);
mem.expires_at = Some(explicit.clone());
let id = insert(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.expires_at, Some(explicit));
}
#[test]
fn insert_with_conflict_backfills_mid_expiry_when_none() {
let conn = test_db();
let mut mem = make_memory("conflict mid", "test", Tier::Mid, 5);
mem.expires_at = None;
let id = insert_with_conflict(&conn, &mem, ConflictMode::Merge).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
let exp = got.expires_at.expect("mid must not land immortal");
assert_eq!(ttl_gap_secs(&got.created_at, &exp), crate::SECS_PER_WEEK);
}
#[test]
fn insert_if_newer_backfills_mid_expiry_when_none() {
let conn = test_db();
let mut mem = make_memory("newer mid", "test", Tier::Mid, 5);
mem.expires_at = None;
let id = insert_if_newer(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
let exp = got.expires_at.expect("mid must not land immortal");
assert_eq!(ttl_gap_secs(&got.created_at, &exp), crate::SECS_PER_WEEK);
}
#[test]
fn consolidate_backfills_mid_expiry() {
let conn = test_db();
let a = make_memory("src a", "test", Tier::Mid, 5);
let b = make_memory("src b", "test", Tier::Mid, 5);
let id_a = insert(&conn, &a).unwrap();
let id_b = insert(&conn, &b).unwrap();
let new_id = consolidate(
&conn,
&[id_a, id_b],
"merged",
"summary body",
"test",
&Tier::Mid,
"test",
"agent-x",
)
.unwrap();
let got = get(&conn, &new_id).unwrap().unwrap();
let exp = got
.expires_at
.expect("consolidated mid must not land immortal");
assert_eq!(ttl_gap_secs(&got.created_at, &exp), crate::SECS_PER_WEEK);
}
#[test]
fn update_partial_fields() {
let conn = test_db();
let mem = make_memory("Original", "test", Tier::Mid, 5);
let id = insert(&conn, &mem).unwrap();
let (found, content_changed) = update(
&conn,
&id,
Some("Updated Title"),
None,
None,
None,
None,
Some(9),
None,
None,
None,
)
.unwrap();
assert!(found);
assert!(content_changed);
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.title, "Updated Title");
assert_eq!(got.priority, 9);
assert_eq!(got.content, mem.content); }
#[test]
fn update_content_changed_flag() {
let conn = test_db();
let mem = make_memory("Stable", "test", Tier::Mid, 5);
let id = insert(&conn, &mem).unwrap();
let (found, content_changed) = update(
&conn,
&id,
None,
None,
None,
None,
None,
Some(8),
None,
None,
None,
)
.unwrap();
assert!(found);
assert!(!content_changed);
let (found, content_changed) = update(
&conn,
&id,
None,
Some("New content"),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(found);
assert!(content_changed);
}
#[test]
fn update_nonexistent_returns_false() {
let conn = test_db();
let (found, _) = update(
&conn,
"bad-id",
Some("New"),
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(!found);
}
#[test]
fn update_tier_downgrade_protection() {
let conn = test_db();
let mem = make_memory("Permanent", "test", Tier::Long, 9);
let id = insert(&conn, &mem).unwrap();
let (found, _) = update(
&conn,
&id,
None,
None,
Some(&Tier::Short),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(found);
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.tier, Tier::Long);
let mem2 = make_memory("Working", "test", Tier::Mid, 5);
let id2 = insert(&conn, &mem2).unwrap();
let (found, _) = update(
&conn,
&id2,
None,
None,
Some(&Tier::Short),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(found);
let got2 = get(&conn, &id2).unwrap().unwrap();
assert_eq!(got2.tier, Tier::Mid);
let (found, _) = update(
&conn,
&id2,
None,
None,
Some(&Tier::Long),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(found);
let got3 = get(&conn, &id2).unwrap().unwrap();
assert_eq!(got3.tier, Tier::Long); }
#[test]
fn update_title_collision_returns_error() {
let conn = test_db();
let mem_a = make_memory("Alpha", "test", Tier::Mid, 5);
let mem_b = make_memory("Beta", "test", Tier::Mid, 5);
let id_a = insert(&conn, &mem_a).unwrap();
let _id_b = insert(&conn, &mem_b).unwrap();
let result = update(
&conn,
&id_a,
Some("Beta"),
None,
None,
None,
None,
None,
None,
None,
None,
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("already exists in namespace"));
}
#[test]
fn delete_existing() {
let conn = test_db();
let mem = make_memory("To delete", "test", Tier::Short, 3);
let id = insert(&conn, &mem).unwrap();
assert!(delete(&conn, &id).unwrap());
assert!(get(&conn, &id).unwrap().is_none());
}
#[test]
fn delete_nonexistent() {
let conn = test_db();
assert!(!delete(&conn, "bad-id").unwrap());
}
#[test]
fn list_with_namespace_filter() {
let conn = test_db();
insert(&conn, &make_memory("A", "ns1", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("B", "ns2", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("C", "ns1", Tier::Long, 5)).unwrap();
let results = list(
&conn,
Some("ns1"),
None,
100,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn list_with_tier_filter() {
let conn = test_db();
insert(&conn, &make_memory("Long", "test", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("Mid", "test", Tier::Mid, 5)).unwrap();
let results = list(
&conn,
None,
Some(&Tier::Long),
100,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Long");
}
#[test]
fn list_with_limit() {
let conn = test_db();
for i in 0..5 {
insert(
&conn,
&make_memory(&format!("Mem {i}"), "test", Tier::Long, 5),
)
.unwrap();
}
let results = list(&conn, None, None, 3, 0, None, None, None, None, None).unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn search_keyword_match() {
let conn = test_db();
insert(
&conn,
&make_memory("PostgreSQL config", "test", Tier::Long, 5),
)
.unwrap();
insert(&conn, &make_memory("Redis cache", "test", Tier::Long, 5)).unwrap();
let results = search(
&conn,
"PostgreSQL",
None,
None,
10,
None,
None,
None,
None,
None,
None,
false,
)
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].title.contains("PostgreSQL"));
}
#[test]
fn search_no_match() {
let conn = test_db();
insert(&conn, &make_memory("PostgreSQL", "test", Tier::Long, 5)).unwrap();
let results = search(
&conn,
"nonexistent_term_xyz",
None,
None,
10,
None,
None,
None,
None,
None,
None,
false,
)
.unwrap();
assert_eq!(results.len(), 0);
}
#[test]
fn recall_returns_scored() {
let conn = test_db();
insert(
&conn,
&make_memory("Rust programming language", "test", Tier::Long, 8),
)
.unwrap();
insert(
&conn,
&make_memory("Python scripting", "test", Tier::Long, 5),
)
.unwrap();
let (results, _tokens) = recall(
&conn,
"Rust programming",
None,
10,
None,
None,
None,
SHORT_TTL_EXTEND_SECS,
MID_TTL_EXTEND_SECS,
None,
None,
false,
None,
)
.unwrap();
assert!(!results.is_empty());
let (mem, score) = &results[0];
assert!(mem.title.contains("Rust"));
assert!(*score > 0.0);
}
#[test]
fn recall_empty_context() {
let conn = test_db();
insert(&conn, &make_memory("Test", "test", Tier::Long, 5)).unwrap();
let results = recall(
&conn,
"",
None,
10,
None,
None,
None,
SHORT_TTL_EXTEND_SECS,
MID_TTL_EXTEND_SECS,
None,
None,
false,
None,
);
assert!(results.is_ok() || results.is_err());
}
#[test]
fn touch_increments_access_count() {
let conn = test_db();
let mem = make_memory("Touchable", "test", Tier::Mid, 5);
let id = insert(&conn, &mem).unwrap();
assert_eq!(get(&conn, &id).unwrap().unwrap().access_count, 0);
touch(&conn, &id, SHORT_TTL_EXTEND_SECS, MID_TTL_EXTEND_SECS).unwrap();
assert_eq!(get(&conn, &id).unwrap().unwrap().access_count, 1);
touch(&conn, &id, SHORT_TTL_EXTEND_SECS, MID_TTL_EXTEND_SECS).unwrap();
assert_eq!(get(&conn, &id).unwrap().unwrap().access_count, 2);
}
#[test]
fn find_contradictions_similar_titles() {
let conn = test_db();
insert(
&conn,
&make_memory("Database is PostgreSQL", "infra", Tier::Long, 8),
)
.unwrap();
insert(
&conn,
&make_memory("Database is MySQL", "infra", Tier::Long, 5),
)
.unwrap();
let contradictions = find_contradictions(&conn, "Database is PostgreSQL", "infra").unwrap();
assert!(!contradictions.is_empty());
}
#[test]
fn find_contradictions_disjoint_topics_no_false_positives_1320() {
let conn = test_db();
insert(
&conn,
&make_memory("Tomatoes are red fruit", "v1-p5-disjoint", Tier::Long, 5),
)
.unwrap();
insert(
&conn,
&make_memory(
"Moon landing happened in 1969",
"v1-p5-disjoint",
Tier::Long,
5,
),
)
.unwrap();
insert(
&conn,
&make_memory(
"Retrieval-augmented generation works by combining recall with synthesis",
"v1-p5-disjoint",
Tier::Long,
5,
),
)
.unwrap();
let hits = find_contradictions(&conn, "Tomatoes are red fruit", "v1-p5-disjoint").unwrap();
assert!(
hits.iter().all(|m| m.title == "Tomatoes are red fruit"),
"tomato seed leaked false positives: {:?}",
hits.iter().map(|m| m.title.as_str()).collect::<Vec<_>>(),
);
let hits =
find_contradictions(&conn, "Moon landing happened in 1969", "v1-p5-disjoint").unwrap();
assert!(
hits.iter()
.all(|m| m.title == "Moon landing happened in 1969"),
"moon-landing seed leaked false positives: {:?}",
hits.iter().map(|m| m.title.as_str()).collect::<Vec<_>>(),
);
let hits = find_contradictions(
&conn,
"Retrieval-augmented generation works by combining recall with synthesis",
"v1-p5-disjoint",
)
.unwrap();
assert!(
hits.iter().all(|m| m.title.starts_with("Retrieval")),
"retrieval seed leaked false positives: {:?}",
hits.iter().map(|m| m.title.as_str()).collect::<Vec<_>>(),
);
}
#[test]
fn find_contradictions_pure_stopword_seed_returns_empty_1320() {
let conn = test_db();
insert(
&conn,
&make_memory(
"The thing is the other thing",
"v1-p5-stopword",
Tier::Long,
5,
),
)
.unwrap();
let hits = find_contradictions(&conn, "the is a", "v1-p5-stopword").unwrap();
assert!(
hits.is_empty(),
"pure-stopword seed pulled candidates: {:?}",
hits.iter().map(|m| m.title.as_str()).collect::<Vec<_>>(),
);
}
#[test]
fn find_contradictions_similar_titles_still_caught_1320() {
let conn = test_db();
insert(
&conn,
&make_memory("Database is PostgreSQL", "v1-p5-positive", Tier::Long, 8),
)
.unwrap();
insert(
&conn,
&make_memory("Database is MySQL", "v1-p5-positive", Tier::Long, 5),
)
.unwrap();
let hits = find_contradictions(&conn, "Database is PostgreSQL", "v1-p5-positive").unwrap();
let titles: Vec<&str> = hits.iter().map(|m| m.title.as_str()).collect();
assert!(
titles.contains(&"Database is MySQL"),
"similar-title detection regressed: {titles:?}",
);
}
#[test]
fn contradiction_title_jaccard_floor_pinned_1320() {
assert!(
(CONTRADICTION_TITLE_JACCARD_FLOOR - 0.30).abs() < f32::EPSILON,
"floor drifted: {CONTRADICTION_TITLE_JACCARD_FLOOR}",
);
}
#[test]
fn contradiction_title_tokens_strips_stopwords_and_lowercases_1320() {
let toks = contradiction_title_tokens("The Database Is PostgreSQL");
assert!(toks.contains("database"));
assert!(toks.contains("postgresql"));
assert!(!toks.contains("the"));
assert!(!toks.contains("is"));
}
#[test]
fn create_and_get_links() {
let conn = test_db();
let id1 = insert(&conn, &make_memory("Memory A", "test", Tier::Long, 5)).unwrap();
let id2 = insert(&conn, &make_memory("Memory B", "test", Tier::Long, 5)).unwrap();
create_link(&conn, &id1, &id2, "related_to").unwrap();
let links = get_links(&conn, &id1).unwrap();
assert_eq!(links.len(), 1);
assert_eq!(
links[0].relation,
crate::models::MemoryLinkRelation::RelatedTo
);
}
#[test]
fn consolidate_merges_memories() {
let conn = test_db();
let id1 = insert(&conn, &make_memory("Part 1", "test", Tier::Mid, 5)).unwrap();
let id2 = insert(&conn, &make_memory("Part 2", "test", Tier::Mid, 5)).unwrap();
let new_id = consolidate(
&conn,
&[id1.clone(), id2.clone()],
"Combined",
"Part 1 + Part 2",
"test",
&Tier::Long,
"test",
"test-consolidator",
)
.unwrap();
assert!(get(&conn, &id1).unwrap().is_none());
assert!(get(&conn, &id2).unwrap().is_none());
let combined = get(&conn, &new_id).unwrap().unwrap();
assert_eq!(combined.title, "Combined");
assert_eq!(combined.tier, Tier::Long);
}
#[test]
fn stats_counts() {
let conn = test_db();
let path = std::path::Path::new(":memory:");
insert(&conn, &make_memory("A", "ns1", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("B", "ns1", Tier::Mid, 5)).unwrap();
insert(&conn, &make_memory("C", "ns2", Tier::Short, 5)).unwrap();
let s = stats(&conn, path).unwrap();
assert_eq!(s.total, 3);
}
#[test]
fn gc_removes_expired() {
let conn = test_db();
let mut mem = make_memory("Expired", "test", Tier::Short, 5);
mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string()); insert(&conn, &mem).unwrap();
let removed = gc(&conn, false).unwrap();
assert_eq!(removed, 1);
}
#[test]
fn gc_preserves_long_term() {
let conn = test_db();
insert(&conn, &make_memory("Permanent", "test", Tier::Long, 5)).unwrap();
let removed = gc(&conn, false).unwrap();
assert_eq!(removed, 0);
}
#[test]
fn gc_archives_before_delete() {
let conn = test_db();
let mut mem = make_memory("Archivable", "test", Tier::Short, 5);
mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
insert(&conn, &mem).unwrap();
let removed = gc(&conn, true).unwrap();
assert_eq!(removed, 1);
let archived = list_archived(&conn, None, 10, 0).unwrap();
assert_eq!(archived.len(), 1);
assert_eq!(archived[0]["title"], "Archivable");
assert_eq!(archived[0]["archive_reason"], "ttl_expired");
}
#[test]
fn restore_archived_memory() {
let conn = test_db();
let mut mem = make_memory("Restorable", "test", Tier::Short, 5);
let original_expiry = "2020-01-01T00:00:00+00:00".to_string();
mem.expires_at = Some(original_expiry.clone());
let id = insert(&conn, &mem).unwrap();
gc(&conn, true).unwrap();
assert!(get(&conn, &id).unwrap().is_none());
let restored = restore_archived(&conn, &id).unwrap();
assert!(restored);
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.title, "Restorable");
assert_eq!(
got.tier.as_str(),
Tier::Short.as_str(),
"G5: restore must preserve the original tier"
);
assert_eq!(
got.expires_at,
Some(original_expiry),
"G5: restore must preserve the original expires_at"
);
}
#[test]
fn purge_archive_removes_all() {
let conn = test_db();
let mut mem = make_memory("Purgeable", "test", Tier::Short, 5);
mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
insert(&conn, &mem).unwrap();
gc(&conn, true).unwrap();
let purged = purge_archive(&conn, None).unwrap();
assert_eq!(purged, 1);
assert_eq!(list_archived(&conn, None, 10, 0).unwrap().len(), 0);
}
#[test]
fn purge_archive_rejects_negative_days() {
let conn = test_db();
let result = purge_archive(&conn, Some(-1));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("non-negative"));
}
#[test]
fn restore_rejects_active_id_collision() {
let conn = test_db();
let mut mem = make_memory("Collision Test", "test", Tier::Short, 5);
mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
let id = insert(&conn, &mem).unwrap();
gc(&conn, true).unwrap();
assert!(get(&conn, &id).unwrap().is_none());
conn.execute(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at)
VALUES (?1, 'long', 'test', 'Blocker Title', 'blocks restore', '[]', 5, 1.0, 'test', 0, datetime('now'), datetime('now'))",
rusqlite::params![id],
).unwrap();
let result = restore_archived(&conn, &id);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("already exists in active table")
);
}
#[test]
fn archive_stats_counts() {
let conn = test_db();
let mut m1 = make_memory("Stats A", "ns1", Tier::Short, 5);
m1.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
let mut m2 = make_memory("Stats B", "ns1", Tier::Short, 5);
m2.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
insert(&conn, &m1).unwrap();
insert(&conn, &m2).unwrap();
gc(&conn, true).unwrap();
let stats = archive_stats(&conn).unwrap();
assert_eq!(stats["archived_total"], 2);
}
#[test]
fn archive_memory_moves_live_row_to_archive() {
let conn = test_db();
let mem = make_memory("Archive me", "s29", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
let moved = archive_memory(&conn, &id, Some("explicit")).unwrap();
assert!(moved, "live row must be archived on first call");
assert!(
get(&conn, &id).unwrap().is_none(),
"row must be removed from active table"
);
let archived = list_archived(&conn, None, 10, 0).unwrap();
assert_eq!(archived.len(), 1);
assert_eq!(archived[0]["id"], id);
assert_eq!(archived[0]["archive_reason"], "explicit");
let second = archive_memory(&conn, &id, Some("explicit")).unwrap();
assert!(
!second,
"second archive call must report no-op (no live row)"
);
}
#[test]
fn archive_memory_missing_id_returns_false() {
let conn = test_db();
let moved = archive_memory(&conn, "nonexistent-id", None).unwrap();
assert!(!moved);
}
#[test]
fn archive_memory_default_reason_is_archive() {
let conn = test_db();
let mem = make_memory("Default reason", "s29", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
assert!(archive_memory(&conn, &id, None).unwrap());
let archived = list_archived(&conn, None, 10, 0).unwrap();
assert_eq!(archived[0]["archive_reason"], "archive");
}
#[test]
fn export_all_and_links() {
let conn = test_db();
let id1 = insert(&conn, &make_memory("Export A", "test", Tier::Long, 5)).unwrap();
let id2 = insert(&conn, &make_memory("Export B", "test", Tier::Long, 5)).unwrap();
create_link(&conn, &id1, &id2, "supersedes").unwrap();
let mems = export_all(&conn).unwrap();
assert_eq!(mems.len(), 2);
let links = export_links(&conn).unwrap();
assert_eq!(links.len(), 1);
}
#[test]
fn list_namespaces_counts() {
let conn = test_db();
insert(&conn, &make_memory("A", "alpha", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("B", "alpha", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("C", "beta", Tier::Long, 5)).unwrap();
let ns = list_namespaces(&conn).unwrap();
assert_eq!(ns.len(), 2);
}
#[test]
fn taxonomy_flat_namespaces_only() {
let conn = test_db();
insert(&conn, &make_memory("A", "alpha", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("B", "alpha", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("C", "beta", Tier::Long, 5)).unwrap();
let tax = get_taxonomy(&conn, None, 8, 1000).unwrap();
assert_eq!(tax.total_count, 3);
assert!(!tax.truncated);
assert_eq!(tax.tree.namespace, "");
assert_eq!(tax.tree.subtree_count, 3);
assert_eq!(tax.tree.count, 0); assert_eq!(tax.tree.children.len(), 2);
let alpha = tax
.tree
.children
.iter()
.find(|c| c.name == "alpha")
.unwrap();
assert_eq!(alpha.count, 2);
assert_eq!(alpha.subtree_count, 2);
assert!(alpha.children.is_empty());
let beta = tax.tree.children.iter().find(|c| c.name == "beta").unwrap();
assert_eq!(beta.count, 1);
}
#[test]
fn taxonomy_hierarchical_tree() {
let conn = test_db();
insert(&conn, &make_memory("a", "alphaone", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("b", "alphaone/eng", Tier::Long, 5)).unwrap();
insert(
&conn,
&make_memory("c", "alphaone/eng/platform", Tier::Long, 5),
)
.unwrap();
insert(
&conn,
&make_memory("d", "alphaone/eng/platform", Tier::Long, 5),
)
.unwrap();
insert(&conn, &make_memory("e", "alphaone/sales", Tier::Long, 5)).unwrap();
let tax = get_taxonomy(&conn, None, 8, 1000).unwrap();
assert_eq!(tax.total_count, 5);
assert_eq!(tax.tree.subtree_count, 5);
assert_eq!(tax.tree.children.len(), 1);
let alphaone = &tax.tree.children[0];
assert_eq!(alphaone.name, "alphaone");
assert_eq!(alphaone.namespace, "alphaone");
assert_eq!(alphaone.count, 1); assert_eq!(alphaone.subtree_count, 5);
assert_eq!(alphaone.children.len(), 2);
let eng = alphaone.children.iter().find(|c| c.name == "eng").unwrap();
assert_eq!(eng.namespace, "alphaone/eng");
assert_eq!(eng.count, 1);
assert_eq!(eng.subtree_count, 3);
let platform = &eng.children[0];
assert_eq!(platform.name, "platform");
assert_eq!(platform.namespace, "alphaone/eng/platform");
assert_eq!(platform.count, 2);
assert_eq!(platform.subtree_count, 2);
assert!(platform.children.is_empty());
}
#[test]
fn taxonomy_prefix_scopes_subtree() {
let conn = test_db();
insert(&conn, &make_memory("a", "alphaone/eng", Tier::Long, 5)).unwrap();
insert(
&conn,
&make_memory("b", "alphaone/eng/platform", Tier::Long, 5),
)
.unwrap();
insert(&conn, &make_memory("c", "alphaone/sales", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("d", "alphaone-sibling", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("e", "other", Tier::Long, 5)).unwrap();
let tax = get_taxonomy(&conn, Some("alphaone/eng"), 8, 1000).unwrap();
assert_eq!(tax.total_count, 2);
assert_eq!(tax.tree.namespace, "alphaone/eng");
assert_eq!(tax.tree.name, "eng");
assert_eq!(tax.tree.count, 1);
assert_eq!(tax.tree.subtree_count, 2);
assert_eq!(tax.tree.children.len(), 1);
assert_eq!(tax.tree.children[0].name, "platform");
assert_eq!(tax.tree.children[0].count, 1);
}
#[test]
fn taxonomy_prefix_like_metacharacters_do_not_widen_match_l5() {
let conn = test_db();
insert(&conn, &make_memory("a", "a%/child", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("b", "ax/child", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("c", "a_/child", Tier::Long, 5)).unwrap();
let tax = get_taxonomy(&conn, Some("a%"), 8, 1000).unwrap();
assert_eq!(
tax.total_count, 1,
"prefix 'a%' must not aggregate 'ax/...' or 'a_/...' subtrees"
);
let tax = get_taxonomy(&conn, Some("a_"), 8, 1000).unwrap();
assert_eq!(
tax.total_count, 1,
"prefix 'a_' must not aggregate single-char-wildcard siblings"
);
let tax = get_taxonomy(&conn, Some("ax"), 8, 1000).unwrap();
assert_eq!(tax.total_count, 1);
}
#[test]
fn taxonomy_depth_clamps_but_preserves_subtree_counts() {
let conn = test_db();
insert(
&conn,
&make_memory("a", "alphaone/eng/platform/db", Tier::Long, 5),
)
.unwrap();
insert(
&conn,
&make_memory("b", "alphaone/eng/platform/api", Tier::Long, 5),
)
.unwrap();
let tax = get_taxonomy(&conn, None, 2, 1000).unwrap();
assert_eq!(tax.total_count, 2);
let alphaone = &tax.tree.children[0];
let eng = &alphaone.children[0];
assert!(eng.children.is_empty());
assert_eq!(eng.subtree_count, 2);
assert_eq!(eng.count, 0); }
#[test]
fn taxonomy_excludes_expired_memories() {
let conn = test_db();
let mut alive = make_memory("alive", "alpha", Tier::Long, 5);
let mut dead = make_memory("dead", "alpha", Tier::Short, 5);
dead.expires_at = Some("2000-01-01T00:00:00Z".to_string());
alive.expires_at = None;
insert(&conn, &alive).unwrap();
insert(&conn, &dead).unwrap();
let tax = get_taxonomy(&conn, None, 8, 1000).unwrap();
assert_eq!(tax.total_count, 1);
assert_eq!(tax.tree.children.len(), 1);
assert_eq!(tax.tree.children[0].count, 1);
}
#[test]
fn taxonomy_truncates_at_limit_but_total_stays_honest() {
let conn = test_db();
for ns in ["aa", "bb", "cc", "dd", "ee"] {
insert(&conn, &make_memory("m", ns, Tier::Long, 5)).unwrap();
}
let tax = get_taxonomy(&conn, None, 8, 2).unwrap();
assert_eq!(tax.total_count, 5);
assert!(tax.truncated);
assert_eq!(tax.tree.children.len(), 2);
}
#[test]
fn forget_by_namespace() {
let conn = test_db();
insert(&conn, &make_memory("A", "delete-me", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("B", "delete-me", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("C", "keep", Tier::Long, 5)).unwrap();
let deleted = forget(&conn, Some("delete-me"), None, None, false).unwrap();
assert_eq!(deleted, 2);
let remaining = list(&conn, None, None, 100, 0, None, None, None, None, None).unwrap();
assert_eq!(remaining.len(), 1);
}
#[test]
fn set_and_get_embedding() {
let conn = test_db();
let mem = make_memory("Embed test", "test", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
let emb = vec![0.1f32, 0.2, 0.3, 0.4];
set_embedding(&conn, &id, &emb).unwrap();
let got = get_embedding(&conn, &id).unwrap().unwrap();
assert_eq!(got.len(), 4);
assert!((got[0] - 0.1).abs() < 1e-6);
}
#[test]
fn unembedded_batch_after_cursor_paginates_1595() {
let conn = test_db();
let mut ids: Vec<String> = (0..5)
.map(|i| {
insert(
&conn,
&make_memory(&format!("row-{i}"), "bf-1595", Tier::Long, 5),
)
.unwrap()
})
.collect();
ids.sort();
let first = get_unembedded_ids_batch_after(&conn, None, 2).unwrap();
assert_eq!(first.len(), 2);
assert_eq!(first[0].0, ids[0], "scan starts at the smallest id");
let cursor = first.last().unwrap().0.clone();
let rest = get_unembedded_ids_batch_after(&conn, Some(&cursor), 10).unwrap();
assert_eq!(rest.len(), 3);
assert!(
rest.iter().all(|(id, _, _)| id.as_str() > cursor.as_str()),
"every row must sort strictly after the cursor"
);
set_embedding(&conn, &ids[0], &[0.1, 0.2]).unwrap();
let after = get_unembedded_ids_batch_after(&conn, None, 10).unwrap();
assert_eq!(after.len(), 4);
assert!(after.iter().all(|(id, _, _)| id != &ids[0]));
}
#[test]
fn memory_texts_batch_namespace_and_cursor_1598() {
let conn = test_db();
let mut ns_a_ids: Vec<String> = (0..3)
.map(|i| {
insert(
&conn,
&make_memory(&format!("a-{i}"), "reembed-a", Tier::Long, 5),
)
.unwrap()
})
.collect();
ns_a_ids.sort();
for i in 0..2 {
insert(
&conn,
&make_memory(&format!("b-{i}"), "reembed-b", Tier::Long, 5),
)
.unwrap();
}
set_embedding(&conn, &ns_a_ids[0], &[0.5, 0.5]).unwrap();
let all = get_memory_texts_batch(&conn, None, None, 100).unwrap();
assert_eq!(all.len(), 5, "unfiltered scan sees every live row");
let ns_a = get_memory_texts_batch(&conn, Some("reembed-a"), None, 100).unwrap();
assert_eq!(ns_a.len(), 3);
assert_eq!(ns_a[0].0, ns_a_ids[0], "embedded row still scanned");
let first = get_memory_texts_batch(&conn, Some("reembed-a"), None, 1).unwrap();
let cursor = first[0].0.clone();
let rest = get_memory_texts_batch(&conn, Some("reembed-a"), Some(&cursor), 100).unwrap();
assert_eq!(rest.len(), 2);
assert!(rest.iter().all(|(id, _, _)| id.as_str() > cursor.as_str()));
}
#[test]
fn set_embeddings_batch_reembed_bypasses_dim_invariant_1598() {
let mut conn = test_db();
let id1 = insert(&conn, &make_memory("dim-est", "reembed-dim", Tier::Long, 5)).unwrap();
let id2 = insert(&conn, &make_memory("dim-mig", "reembed-dim", Tier::Long, 5)).unwrap();
set_embedding(&conn, &id1, &[0.1, 0.2, 0.3, 0.4]).unwrap();
let refused =
set_embeddings_batch(&mut conn, &[(id2.clone(), vec![0.1_f32; 8])]).unwrap_err();
assert!(
refused.downcast_ref::<EmbeddingDimMismatch>().is_some(),
"checked writer must refuse the dim change: {refused}"
);
let entries = vec![
(id1.clone(), vec![0.9_f32; 8]),
(id2.clone(), vec![0.8_f32; 8]),
];
let written = set_embeddings_batch_reembed(&mut conn, &entries).unwrap();
assert_eq!(written, 2);
assert_eq!(get_embedding(&conn, &id1).unwrap().unwrap().len(), 8);
assert_eq!(get_embedding(&conn, &id2).unwrap().unwrap().len(), 8);
assert_eq!(
namespace_embedding_dim(&conn, "reembed-dim").unwrap(),
Some(8),
"namespace converges to the target dim"
);
let n = set_embeddings_batch_reembed(
&mut conn,
&[("no-such-id".to_string(), vec![0.1_f32; 8])],
)
.unwrap();
assert_eq!(n, 0);
assert_eq!(set_embeddings_batch_reembed(&mut conn, &[]).unwrap(), 0);
}
#[test]
fn embedding_coverage_counts_1598() {
let conn = test_db();
let id_a = insert(&conn, &make_memory("c-a", "cov-a", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("c-b", "cov-a", Tier::Long, 5)).unwrap();
insert(&conn, &make_memory("c-c", "cov-b", Tier::Long, 5)).unwrap();
set_embedding(&conn, &id_a, &[0.1, 0.2]).unwrap();
assert_eq!(embedding_coverage(&conn, None).unwrap(), (3, 1));
assert_eq!(embedding_coverage(&conn, Some("cov-a")).unwrap(), (2, 1));
assert_eq!(embedding_coverage(&conn, Some("cov-b")).unwrap(), (1, 0));
assert_eq!(embedding_coverage(&conn, Some("cov-none")).unwrap(), (0, 0));
}
#[test]
fn distinct_embedding_dims_lists_mixed_1598() {
let mut conn = test_db();
let id_a = insert(&conn, &make_memory("d-a", "dims-a", Tier::Long, 5)).unwrap();
let id_b = insert(&conn, &make_memory("d-b", "dims-b", Tier::Long, 5)).unwrap();
let id_c = insert(&conn, &make_memory("d-c", "dims-b", Tier::Long, 5)).unwrap();
set_embedding(&conn, &id_a, &[0.1, 0.2]).unwrap();
set_embedding(&conn, &id_b, &[0.1; 8]).unwrap();
set_embeddings_batch_reembed(&mut conn, &[(id_c, vec![0.2_f32; 4])]).unwrap();
assert_eq!(distinct_embedding_dims(&conn, None).unwrap(), vec![2, 4, 8]);
assert_eq!(
distinct_embedding_dims(&conn, Some("dims-b")).unwrap(),
vec![4, 8]
);
assert!(
distinct_embedding_dims(&conn, Some("dims-none"))
.unwrap()
.is_empty()
);
}
fn insert_with_embedding(
conn: &Connection,
title: &str,
ns: &str,
embedding: &[f32],
) -> String {
let mem = make_memory(title, ns, Tier::Long, 5);
let id = insert(conn, &mem).unwrap();
set_embedding(conn, &id, embedding).unwrap();
id
}
#[test]
fn check_duplicate_empty_db_returns_no_match() {
let conn = test_db();
let q = vec![1.0_f32, 0.0, 0.0];
let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
assert!(!r.is_duplicate);
assert!(r.nearest.is_none());
assert_eq!(r.candidates_scanned, 0);
}
#[test]
fn check_duplicate_finds_highest_cosine_match() {
let conn = test_db();
let id_a = insert_with_embedding(&conn, "alpha", "ns", &[1.0, 0.0, 0.0]);
let _id_b = insert_with_embedding(&conn, "beta", "ns", &[0.7, 0.7, 0.0]);
let _id_c = insert_with_embedding(&conn, "gamma", "ns", &[0.0, 1.0, 0.0]);
let q = vec![1.0_f32, 0.0, 0.0];
let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
let nearest = r.nearest.expect("expected a nearest match");
assert_eq!(nearest.id, id_a);
assert!(nearest.similarity > 0.99);
assert_eq!(r.candidates_scanned, 3);
assert!(r.is_duplicate);
assert!((r.threshold - 0.85).abs() < 1e-6);
}
#[test]
fn check_duplicate_below_threshold_not_flagged_but_returns_nearest() {
let conn = test_db();
let id_b = insert_with_embedding(&conn, "beta", "ns", &[0.7, 0.7, 0.0]);
let q = vec![1.0_f32, 0.0, 0.0];
let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
let nearest = r
.nearest
.expect("nearest must surface even when below threshold");
assert_eq!(nearest.id, id_b);
assert!(!r.is_duplicate);
}
#[test]
fn check_duplicate_threshold_clamped_to_floor() {
let conn = test_db();
let _ = insert_with_embedding(&conn, "x", "ns", &[1.0, 0.0, 0.0]);
let q = vec![0.0_f32, 1.0, 0.0]; let r = check_duplicate(&conn, &q, None, 0.0).unwrap();
assert!((r.threshold - DUPLICATE_THRESHOLD_MIN).abs() < 1e-6);
assert!(!r.is_duplicate);
}
#[test]
fn check_duplicate_namespace_filter_isolates_scan() {
let conn = test_db();
let _hit_in_other_ns = insert_with_embedding(&conn, "x", "other", &[1.0, 0.0, 0.0]);
let id_target = insert_with_embedding(&conn, "y", "ns", &[0.6, 0.8, 0.0]);
let q = vec![1.0_f32, 0.0, 0.0];
let r = check_duplicate(&conn, &q, Some("ns"), 0.85).unwrap();
assert_eq!(r.candidates_scanned, 1);
assert_eq!(r.nearest.expect("namespace filter ignored").id, id_target);
}
#[test]
fn check_duplicate_skips_expired_rows() {
let conn = test_db();
let mut mem = make_memory("expired", "ns", Tier::Short, 5);
mem.expires_at = Some((chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339());
let id = insert(&conn, &mem).unwrap();
set_embedding(&conn, &id, &[1.0, 0.0, 0.0]).unwrap();
let q = vec![1.0_f32, 0.0, 0.0];
let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
assert_eq!(r.candidates_scanned, 0);
assert!(r.nearest.is_none());
}
#[test]
fn check_duplicate_skips_unembedded_rows() {
let conn = test_db();
let id_embedded = insert_with_embedding(&conn, "with-emb", "ns", &[1.0, 0.0, 0.0]);
let mem = make_memory("no-emb", "ns", Tier::Long, 5);
let _ = insert(&conn, &mem).unwrap();
let q = vec![1.0_f32, 0.0, 0.0];
let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
assert_eq!(r.candidates_scanned, 1);
assert_eq!(r.nearest.expect("embedded match").id, id_embedded);
}
#[test]
fn check_duplicate_skips_blob_with_non_multiple_of_4_length() {
let conn = test_db();
let mem = make_memory("malformed-blob", "ns", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
conn.execute(
"UPDATE memories SET embedding = ?1 WHERE id = ?2",
params![&[0u8; 7][..], &id],
)
.unwrap();
let q = vec![1.0_f32, 0.0];
let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
assert_eq!(
r.candidates_scanned, 0,
"malformed blob must be skipped, not silently truncated"
);
assert!(r.nearest.is_none());
}
#[test]
fn check_duplicate_skips_blob_with_dimension_mismatch() {
let conn = test_db();
let _id = insert_with_embedding(&conn, "different-dim", "ns", &[1.0, 0.0, 0.0]);
let q = vec![1.0_f32, 0.0, 0.0, 0.0];
let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
assert_eq!(
r.candidates_scanned, 0,
"dimension-mismatched candidate must be skipped"
);
assert!(r.nearest.is_none());
}
#[test]
fn get_unembedded_returns_memoryless() {
let conn = test_db();
let mem = make_memory("No embed", "test", Tier::Long, 5);
insert(&conn, &mem).unwrap();
let unembedded = get_unembedded_ids(&conn).unwrap();
assert_eq!(unembedded.len(), 1);
}
#[test]
fn health_check_passes() {
let conn = test_db();
assert!(health_check(&conn).unwrap());
}
#[test]
fn sanitize_fts_strips_operators_and_quotes() {
let sanitized = sanitize_fts_query("test* \"injection\" (drop)", true);
assert!(!sanitized.contains('*'));
assert!(!sanitized.contains('('));
assert!(!sanitized.contains(')'));
let sanitized2 = sanitize_fts_query("hello AND world OR NOT NEAR test", true);
assert!(sanitized2.contains("hello"));
assert!(sanitized2.contains("world"));
assert!(sanitized2.contains("test"));
let sanitized3 = sanitize_fts_query("", true);
assert_eq!(sanitized3, "\"_empty_\"");
let sanitized4 = sanitize_fts_query("-secret +required", true);
assert!(!sanitized4.contains('+'));
assert!(sanitized4.contains("secret"));
assert!(sanitized4.contains("required"));
let sanitized5 = sanitize_fts_query("well-known", true);
assert!(sanitized5.contains("well-known"));
}
#[test]
fn get_by_prefix_8char() {
let conn = test_db();
let mem = make_memory("Prefix test", "test", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
let prefix = &id[..8];
let got = get_by_prefix(&conn, prefix).unwrap().unwrap();
assert_eq!(got.id, id);
assert_eq!(got.title, "Prefix test");
}
#[test]
fn get_by_prefix_full_uuid() {
let conn = test_db();
let mem = make_memory("Full UUID prefix", "test", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
let got = get_by_prefix(&conn, &id).unwrap().unwrap();
assert_eq!(got.id, id);
}
#[test]
fn get_by_prefix_nonexistent() {
let conn = test_db();
let got = get_by_prefix(&conn, "ffffffff").unwrap();
assert!(got.is_none());
}
#[test]
fn get_by_prefix_ambiguous() {
let conn = test_db();
let mut mem1 = make_memory("Ambig A", "test", Tier::Long, 5);
mem1.id = "aaaa1111-0000-0000-0000-000000000001".to_string();
insert(&conn, &mem1).unwrap();
let mut mem2 = make_memory("Ambig B", "test2", Tier::Long, 5);
mem2.id = "aaaa2222-0000-0000-0000-000000000002".to_string();
insert(&conn, &mem2).unwrap();
let result = get_by_prefix(&conn, "aaaa");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("ambiguous"));
assert!(err_msg.contains("2 matches"));
assert!(
err_msg.contains("aaaa1111-0000-0000-0000-000000000001"),
"error should list matching IDs, got: {err_msg}"
);
assert!(err_msg.contains("aaaa2222-0000-0000-0000-000000000002"));
}
#[test]
fn resolve_id_exact_then_prefix() {
let conn = test_db();
let mem = make_memory("Resolve test", "test", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
let got = resolve_id(&conn, &id).unwrap().unwrap();
assert_eq!(got.id, id);
let got2 = resolve_id(&conn, &id[..8]).unwrap().unwrap();
assert_eq!(got2.id, id);
let got3 = resolve_id(&conn, "zzzzzzzz").unwrap();
assert!(got3.is_none());
}
#[test]
fn insert_if_newer_updates() {
let conn = test_db();
let mut mem = make_memory("Sync test", "test", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
mem.id = id.clone();
mem.content = "Updated via sync".to_string();
mem.updated_at = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let result_id = insert_if_newer(&conn, &mem).unwrap();
assert_eq!(result_id, id);
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.content, "Updated via sync");
}
#[test]
fn metadata_default_empty_object() {
let conn = test_db();
let mem = make_memory("Default metadata", "test", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata, serde_json::json!({}));
}
#[test]
fn metadata_store_and_retrieve() {
let conn = test_db();
let mut mem = make_memory("With metadata", "test", Tier::Long, 5);
mem.metadata = serde_json::json!({"agent_id": "claude-1", "session": 42});
let id = insert(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata["agent_id"], "claude-1");
assert_eq!(got.metadata["session"], 42);
}
#[test]
fn metadata_roundtrip_nested_json() {
let conn = test_db();
let mut mem = make_memory("Nested metadata", "test", Tier::Long, 5);
mem.metadata = serde_json::json!({
"agent": {"type": "ai:claude", "version": "4.6"},
"tags_extra": ["experimental"],
"score": 0.95
});
let id = insert(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata["agent"]["type"], "ai:claude");
assert_eq!(got.metadata["tags_extra"][0], "experimental");
assert!((got.metadata["score"].as_f64().unwrap() - 0.95).abs() < f64::EPSILON);
}
#[test]
fn metadata_preserved_on_update() {
let conn = test_db();
let mut mem = make_memory("Update metadata", "test", Tier::Long, 5);
mem.metadata = serde_json::json!({"key": "original"});
let id = insert(&conn, &mem).unwrap();
let (found, _) = update(
&conn,
&id,
None,
Some("new content"),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(found);
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata["key"], "original");
assert_eq!(got.content, "new content");
let new_meta = serde_json::json!({"key": "updated", "extra": true});
let (found, _) = update(
&conn,
&id,
None,
None,
None,
None,
None,
None,
None,
None,
Some(&new_meta),
)
.unwrap();
assert!(found);
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata["key"], "updated");
assert_eq!(got.metadata["extra"], true);
}
#[test]
fn metadata_preserved_on_upsert() {
let conn = test_db();
let mut mem = make_memory("Upsert meta", "test", Tier::Long, 5);
mem.metadata = serde_json::json!({"version": 1});
insert(&conn, &mem).unwrap();
let mut mem2 = make_memory("Upsert meta", "test", Tier::Long, 5);
mem2.metadata = serde_json::json!({"version": 2});
let id = insert(&conn, &mem2).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata["version"], 2);
}
#[test]
fn metadata_in_list_and_search() {
let conn = test_db();
let mut mem = make_memory("Searchable metadata", "test", Tier::Long, 8);
mem.metadata = serde_json::json!({"source_model": "opus"});
insert(&conn, &mem).unwrap();
let results = list(
&conn,
Some("test"),
None,
10,
0,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].metadata["source_model"], "opus");
let results = search(
&conn,
"Searchable",
Some("test"),
None,
10,
None,
None,
None,
None,
None,
None,
false,
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].metadata["source_model"], "opus");
}
#[test]
fn metadata_in_recall() {
let conn = test_db();
let mut mem = make_memory("Recallable metadata", "test", Tier::Long, 8);
mem.metadata = serde_json::json!({"context": "test-recall"});
insert(&conn, &mem).unwrap();
let (results, _tokens) = recall(
&conn,
"Recallable",
Some("test"),
10,
None,
None,
None,
crate::SECS_PER_HOUR,
crate::SECS_PER_DAY,
None,
None,
false,
None,
)
.unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].0.metadata["context"], "test-recall");
}
#[test]
fn metadata_in_export_import() {
let conn = test_db();
let mut mem = make_memory("Export metadata", "test", Tier::Long, 5);
mem.metadata = serde_json::json!({"exported": true});
insert(&conn, &mem).unwrap();
let exported = export_all(&conn).unwrap();
assert_eq!(exported.len(), 1);
assert_eq!(exported[0].metadata["exported"], true);
let conn2 = test_db();
insert(&conn2, &exported[0]).unwrap();
let got = get(&conn2, &exported[0].id).unwrap().unwrap();
assert_eq!(got.metadata["exported"], true);
}
#[test]
fn metadata_schema_migration() {
let conn = test_db();
let mem = make_memory("Migration test", "test", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
let metadata_str: String = conn
.query_row(
"SELECT metadata FROM memories WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(metadata_str, "{}");
}
#[test]
fn metadata_survives_archive_restore_cycle() {
let conn = test_db();
let mut mem = make_memory("Archivable", "test", Tier::Short, 5);
mem.metadata = serde_json::json!({"origin": "archive-test"});
mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
let id = insert(&conn, &mem).unwrap();
let deleted = gc(&conn, true).unwrap();
assert_eq!(deleted, 1);
let archived = list_archived(&conn, None, 10, 0).unwrap();
assert_eq!(archived.len(), 1);
assert_eq!(archived[0]["metadata"]["origin"], "archive-test");
let restored = restore_archived(&conn, &id).unwrap();
assert!(restored);
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata["origin"], "archive-test");
}
#[test]
fn metadata_in_insert_if_newer() {
let conn = test_db();
let mut mem = make_memory("Sync metadata", "test", Tier::Long, 5);
mem.metadata = serde_json::json!({"version": 1});
let id = insert(&conn, &mem).unwrap();
mem.id = id.clone();
mem.metadata = serde_json::json!({"version": 2, "synced": true});
mem.updated_at = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
insert_if_newer(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata["version"], 2);
assert_eq!(got.metadata["synced"], true);
mem.metadata = serde_json::json!({"version": 0, "stale": true});
mem.updated_at = "2020-01-01T00:00:00+00:00".to_string();
insert_if_newer(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata["version"], 2); assert!(got.metadata.get("stale").is_none());
}
#[test]
fn metadata_merged_in_consolidate() {
let conn = test_db();
let mut mem_a = make_memory("Consolidate A", "test", Tier::Long, 5);
mem_a.metadata = serde_json::json!({"agent": "claude", "shared": "from_a"});
let id_a = insert(&conn, &mem_a).unwrap();
let mut mem_b = make_memory("Consolidate B", "test", Tier::Long, 7);
mem_b.metadata = serde_json::json!({"model": "opus", "shared": "from_b"});
let id_b = insert(&conn, &mem_b).unwrap();
let new_id = consolidate(
&conn,
&[id_a, id_b],
"Merged",
"Combined content",
"test",
&Tier::Long,
"consolidation",
"test-consolidator",
)
.unwrap();
let got = get(&conn, &new_id).unwrap().unwrap();
assert_eq!(got.metadata["agent"], "claude");
assert_eq!(got.metadata["model"], "opus");
assert_eq!(got.metadata["shared"], "from_b");
}
#[test]
fn metadata_consolidate_rejects_oversized_merge() {
let conn = test_db();
let mut mem_a = make_memory("Big meta A", "test", Tier::Long, 5);
let big_val_a: serde_json::Map<String, serde_json::Value> = (0..500)
.map(|i| {
(
format!("key_a_{i}"),
serde_json::Value::String("x".repeat(60)),
)
})
.collect();
mem_a.metadata = serde_json::Value::Object(big_val_a);
let id_a = insert(&conn, &mem_a).unwrap();
let mut mem_b = make_memory("Big meta B", "test", Tier::Long, 5);
let big_val_b: serde_json::Map<String, serde_json::Value> = (0..500)
.map(|i| {
(
format!("key_b_{i}"),
serde_json::Value::String("x".repeat(60)),
)
})
.collect();
mem_b.metadata = serde_json::Value::Object(big_val_b);
let id_b = insert(&conn, &mem_b).unwrap();
let result = consolidate(
&conn,
&[id_a, id_b],
"Oversized merge",
"Should fail",
"test",
&Tier::Long,
"consolidation",
"test-consolidator",
);
let err = result.expect_err("consolidate should fail for oversized merged metadata");
let msg = err.to_string();
assert!(
msg.contains("merged metadata exceeds size limit"),
"expected metadata size error, got: {msg}"
);
}
#[test]
fn metadata_special_characters_roundtrip() {
let conn = test_db();
let mut mem = make_memory("Special chars metadata", "test", Tier::Long, 5);
mem.metadata = serde_json::json!({
"pipe": "a|b|c",
"newline": "line1\nline2",
"tab": "col1\tcol2",
"backslash": "path\\to\\file",
"unicode": "\u{1F600}\u{1F4A9}",
"cjk": "\u{4e16}\u{754c}",
"empty": "",
"nested_special": {"inner|key": "val\nue"}
});
let id = insert(&conn, &mem).unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata["pipe"], "a|b|c");
assert_eq!(got.metadata["newline"], "line1\nline2");
assert_eq!(got.metadata["unicode"], "\u{1F600}\u{1F4A9}");
assert_eq!(got.metadata["cjk"], "\u{4e16}\u{754c}");
assert_eq!(got.metadata["nested_special"]["inner|key"], "val\nue");
}
#[test]
fn metadata_corrupt_column_falls_back_to_empty() {
let conn = test_db();
let mem = make_memory("Corrupt test", "test", Tier::Long, 5);
let id = insert(&conn, &mem).unwrap();
conn.execute(
"UPDATE memories SET metadata = 'NOT VALID JSON {{{{' WHERE id = ?1",
params![id],
)
.unwrap();
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata, serde_json::json!({}));
}
#[test]
fn metadata_restore_resets_corrupt_archived_metadata() {
let conn = test_db();
let mut mem = make_memory("Corrupt archive", "test", Tier::Short, 5);
mem.metadata = serde_json::json!({"valid": true});
mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
let id = insert(&conn, &mem).unwrap();
gc(&conn, true).unwrap();
conn.execute(
"UPDATE archived_memories SET metadata = 'CORRUPT JSON' WHERE id = ?1",
params![id],
)
.unwrap();
let restored = restore_archived(&conn, &id).unwrap();
assert!(restored);
let got = get(&conn, &id).unwrap().unwrap();
assert_eq!(got.metadata, serde_json::json!({}));
}
#[test]
fn scope_index_exists_after_migration() {
let conn = test_db();
let has_col: bool = conn
.prepare("SELECT scope_idx FROM memories LIMIT 0")
.is_ok();
assert!(has_col, "scope_idx generated column missing");
let idx_exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_scope_idx'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(idx_exists, 1, "idx_memories_scope_idx missing");
}
#[test]
fn scope_index_used_for_direct_scope_filter() {
let conn = test_db();
for i in 0..200 {
let scope = if i % 3 == 0 { "collective" } else { "private" };
let mut mem = make_memory(&format!("row-{i}"), "test", Tier::Long, 5);
mem.metadata = serde_json::json!({"scope": scope});
insert(&conn, &mem).unwrap();
}
conn.execute("ANALYZE", []).unwrap();
let plan: Vec<String> = conn
.prepare("EXPLAIN QUERY PLAN SELECT id FROM memories WHERE scope_idx = ?1")
.unwrap()
.query_map(params!["collective"], |row| row.get::<_, String>(3))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
let joined = plan.join("\n");
assert!(
joined.contains("idx_memories_scope_idx"),
"direct scope filter must use idx_memories_scope_idx; got:\n{joined}"
);
}
#[test]
fn scope_idx_reflects_metadata_on_insert_and_update() {
let conn = test_db();
let mut mem = make_memory("scope-tracking", "test", Tier::Long, 5);
mem.metadata = serde_json::json!({"scope": "team"});
let id = insert(&conn, &mem).unwrap();
let scope: String = conn
.query_row(
"SELECT scope_idx FROM memories WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(scope, "team");
let new_meta = serde_json::json!({"scope": "unit"});
update(
&conn,
&id,
None,
None,
None,
None,
None,
None,
None,
None,
Some(&new_meta),
)
.unwrap();
let scope2: String = conn
.query_row(
"SELECT scope_idx FROM memories WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(scope2, "unit");
let mut bare = make_memory("no-scope-key", "test", Tier::Long, 5);
bare.metadata = serde_json::json!({});
let id2 = insert(&conn, &bare).unwrap();
let scope3: String = conn
.query_row(
"SELECT scope_idx FROM memories WHERE id = ?1",
params![id2],
|r| r.get(0),
)
.unwrap();
assert_eq!(scope3, "private");
}
#[test]
fn auto_purge_archive_respects_max_days() {
let conn = test_db();
let mut mem = make_memory("Purge test", "test", Tier::Short, 5);
mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
insert(&conn, &mem).unwrap();
gc(&conn, true).unwrap();
let archived = list_archived(&conn, None, 10, 0).unwrap();
assert_eq!(archived.len(), 1);
conn.execute(
"UPDATE archived_memories SET archived_at = ?1",
params![(chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339()],
)
.unwrap();
let purged = auto_purge_archive(&conn, None).unwrap();
assert_eq!(purged, 0);
assert_eq!(list_archived(&conn, None, 10, 0).unwrap().len(), 1);
let purged = auto_purge_archive(&conn, Some(0)).unwrap();
assert_eq!(purged, 0);
let purged = auto_purge_archive(&conn, Some(90)).unwrap();
assert_eq!(purged, 0);
let purged = auto_purge_archive(&conn, Some(7)).unwrap();
assert_eq!(purged, 1);
assert!(list_archived(&conn, None, 10, 0).unwrap().is_empty());
}
fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap();
let cols: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.filter_map(Result::ok)
.collect();
cols.iter().any(|c| c == column)
}
fn index_exists(conn: &Connection, name: &str) -> bool {
conn.query_row(
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?1",
params![name],
|r| r.get::<_, i64>(0),
)
.is_ok()
}
#[test]
fn schema_v15_memory_links_has_temporal_columns() {
let conn = test_db();
assert!(column_exists(&conn, "memory_links", "valid_from"));
assert!(column_exists(&conn, "memory_links", "valid_until"));
assert!(column_exists(&conn, "memory_links", "observed_by"));
assert!(column_exists(&conn, "memory_links", "signature"));
}
#[test]
fn schema_v15_memory_links_temporal_indexes_exist() {
let conn = test_db();
assert!(index_exists(&conn, "idx_links_temporal_src"));
assert!(index_exists(&conn, "idx_links_temporal_tgt"));
assert!(index_exists(&conn, "idx_links_relation"));
}
#[test]
fn schema_v15_entity_aliases_table_exists() {
let conn = test_db();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM entity_aliases", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
assert!(index_exists(&conn, "idx_entity_aliases_alias"));
}
#[test]
fn schema_v15_entity_aliases_primary_key_unique() {
let conn = test_db();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO entity_aliases (entity_id, alias, created_at) VALUES (?1, ?2, ?3)",
params!["e1", "Alpha", &now],
)
.unwrap();
let dup = conn.execute(
"INSERT INTO entity_aliases (entity_id, alias, created_at) VALUES (?1, ?2, ?3)",
params!["e1", "Alpha", &now],
);
assert!(dup.is_err(), "expected PK uniqueness violation");
}
#[test]
fn entity_register_creates_new_entity_with_aliases() {
let conn = test_db();
let aliases = vec!["pa".to_string(), "Project A".to_string()];
let reg = entity_register(
&conn,
"Project Alpha",
"projects/alpha",
&aliases,
&serde_json::json!({}),
Some("test-agent"),
)
.unwrap();
assert!(reg.created, "first registration must be created=true");
assert_eq!(reg.canonical_name, "Project Alpha");
assert_eq!(reg.namespace, "projects/alpha");
assert_eq!(
reg.aliases,
vec![
"Project A".to_string(),
"Project Alpha".to_string(),
"pa".to_string()
]
);
let m = get(&conn, ®.entity_id).unwrap().unwrap();
assert_eq!(m.title, "Project Alpha");
assert_eq!(m.tier.rank(), Tier::Long.rank());
assert!(m.tags.contains(&"entity".to_string()));
assert_eq!(m.metadata["kind"], "entity");
assert_eq!(m.metadata["agent_id"], "test-agent");
}
#[test]
fn entity_register_reuses_existing_and_merges_aliases() {
let conn = test_db();
let first = entity_register(
&conn,
"Project Alpha",
"projects/alpha",
&["pa".to_string()],
&serde_json::json!({}),
Some("a1"),
)
.unwrap();
let second = entity_register(
&conn,
"Project Alpha",
"projects/alpha",
&["pa".to_string(), "alpha".to_string()],
&serde_json::json!({}),
Some("a2"),
)
.unwrap();
assert!(first.created);
assert!(!second.created, "second call must reuse the entity");
assert_eq!(first.entity_id, second.entity_id);
assert_eq!(
second.aliases,
vec![
"Project Alpha".to_string(),
"pa".to_string(),
"alpha".to_string()
]
);
}
#[test]
fn entity_register_errors_on_collision_with_non_entity_memory() {
let conn = test_db();
let mem = make_memory("Conflict", "projects/alpha", Tier::Long, 5);
insert(&conn, &mem).unwrap();
let err = entity_register(
&conn,
"Conflict",
"projects/alpha",
&[],
&serde_json::json!({}),
None,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("non-entity memory"),
"expected collision error, got: {msg}"
);
}
#[test]
fn entity_register_skips_blank_aliases() {
let conn = test_db();
let reg = entity_register(
&conn,
"Trim Test",
"test",
&[String::new(), " ".to_string(), "ok".to_string()],
&serde_json::json!({}),
None,
)
.unwrap();
assert_eq!(reg.aliases, vec!["Trim Test".to_string(), "ok".to_string()]);
}
#[test]
fn entity_register_preserves_caller_metadata_keys() {
let conn = test_db();
let extra = serde_json::json!({"team": "platform", "kind": "ignored"});
let reg = entity_register(&conn, "Service X", "svc", &[], &extra, None).unwrap();
let m = get(&conn, ®.entity_id).unwrap().unwrap();
assert_eq!(m.metadata["team"], "platform");
assert_eq!(m.metadata["kind"], "entity");
}
#[test]
fn entity_get_by_alias_returns_record_with_full_alias_set() {
let conn = test_db();
let reg = entity_register(
&conn,
"Project Alpha",
"projects/alpha",
&["pa".to_string(), "alpha".to_string()],
&serde_json::json!({}),
None,
)
.unwrap();
let got = entity_get_by_alias(&conn, "pa", None).unwrap().unwrap();
assert_eq!(got.entity_id, reg.entity_id);
assert_eq!(got.canonical_name, "Project Alpha");
assert_eq!(got.namespace, "projects/alpha");
assert_eq!(
got.aliases,
vec![
"Project Alpha".to_string(),
"alpha".to_string(),
"pa".to_string()
]
);
}
#[test]
fn entity_register_canonical_name_resolves_via_get_by_alias() {
let conn = test_db();
let reg = entity_register(
&conn,
"OnlyCanonical",
"test",
&[],
&serde_json::json!({}),
None,
)
.unwrap();
assert!(reg.created);
assert_eq!(
reg.aliases,
vec!["OnlyCanonical".to_string()],
"canonical_name must be auto-inserted as an alias"
);
let got = entity_get_by_alias(&conn, "OnlyCanonical", Some("test"))
.unwrap()
.expect("canonical_name must resolve via entity_get_by_alias");
assert_eq!(got.entity_id, reg.entity_id);
assert_eq!(got.canonical_name, "OnlyCanonical");
}
#[test]
fn entity_get_by_alias_returns_none_for_unknown_alias() {
let conn = test_db();
let got = entity_get_by_alias(&conn, "missing", None).unwrap();
assert!(got.is_none());
}
#[test]
fn entity_get_by_alias_filters_by_namespace() {
let conn = test_db();
entity_register(
&conn,
"Acme",
"ns_a",
&["a".to_string()],
&serde_json::json!({}),
None,
)
.unwrap();
entity_register(
&conn,
"Acme Corp",
"ns_b",
&["a".to_string()],
&serde_json::json!({}),
None,
)
.unwrap();
let in_a = entity_get_by_alias(&conn, "a", Some("ns_a"))
.unwrap()
.unwrap();
assert_eq!(in_a.namespace, "ns_a");
assert_eq!(in_a.canonical_name, "Acme");
let in_b = entity_get_by_alias(&conn, "a", Some("ns_b"))
.unwrap()
.unwrap();
assert_eq!(in_b.namespace, "ns_b");
assert_eq!(in_b.canonical_name, "Acme Corp");
}
#[test]
fn entity_get_by_alias_without_namespace_picks_most_recent() {
let conn = test_db();
entity_register(
&conn,
"Older",
"ns_old",
&["dup".to_string()],
&serde_json::json!({}),
None,
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
entity_register(
&conn,
"Newer",
"ns_new",
&["dup".to_string()],
&serde_json::json!({}),
None,
)
.unwrap();
let got = entity_get_by_alias(&conn, "dup", None).unwrap().unwrap();
assert_eq!(got.canonical_name, "Newer");
assert_eq!(got.namespace, "ns_new");
}
#[test]
fn entity_get_by_alias_ignores_non_entity_memory_with_matching_alias() {
let conn = test_db();
let mut mem = make_memory("Decoy", "test", Tier::Long, 5);
mem.metadata = serde_json::json!({});
let mid = insert(&conn, &mem).unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO entity_aliases (entity_id, alias, created_at) VALUES (?1, ?2, ?3)",
params![&mid, "decoy", &now],
)
.unwrap();
let got = entity_get_by_alias(&conn, "decoy", None).unwrap();
assert!(got.is_none(), "non-entity memories must not resolve");
}
#[test]
fn entity_register_idempotent_aliases_are_deduped() {
let conn = test_db();
let reg = entity_register(
&conn,
"Dedup",
"test",
&["x".to_string(), "x".to_string(), "y".to_string()],
&serde_json::json!({}),
None,
)
.unwrap();
assert_eq!(reg.aliases.len(), 3);
assert!(reg.aliases.contains(&"Dedup".to_string()));
assert!(reg.aliases.contains(&"x".to_string()));
assert!(reg.aliases.contains(&"y".to_string()));
}
fn insert_link_at(
conn: &Connection,
source_id: &str,
target_id: &str,
relation: &str,
valid_from: &str,
) {
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params![source_id, target_id, relation, now, valid_from],
)
.unwrap();
}
#[test]
fn create_link_populates_valid_from_for_new_rows() {
let conn = test_db();
let src = make_memory("kg-src", "test", Tier::Long, 5);
let tgt = make_memory("kg-tgt", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
let valid_from: Option<String> = conn
.query_row(
"SELECT valid_from FROM memory_links WHERE source_id = ?1",
params![&src.id],
|r| r.get(0),
)
.unwrap();
assert!(
valid_from.is_some(),
"create_link must populate valid_from so kg_timeline can see new links"
);
}
#[test]
fn schema_v23_memory_links_has_attest_level_column() {
let conn = test_db();
assert!(
column_exists(&conn, "memory_links", "attest_level"),
"v23 must add attest_level column to memory_links"
);
}
#[test]
fn create_link_signed_without_keypair_is_unsigned() {
let conn = test_db();
let src = make_memory("h2-src-unsigned", "test", Tier::Long, 5);
let tgt = make_memory("h2-tgt-unsigned", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
let level = create_link_signed(&conn, &src.id, &tgt.id, "related_to", None).unwrap();
assert_eq!(level, "unsigned");
let (sig, attest): (Option<Vec<u8>>, Option<String>) = conn
.query_row(
"SELECT signature, attest_level FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2",
params![&src.id, &tgt.id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert!(sig.is_none(), "no keypair → signature must be NULL");
assert_eq!(attest.as_deref(), Some("unsigned"));
}
#[test]
fn create_link_signed_with_keypair_persists_valid_signature() {
use crate::identity::{keypair, sign as link_sign};
use ed25519_dalek::Verifier;
let conn = test_db();
let src = make_memory("h2-src-signed", "test", Tier::Long, 5);
let tgt = make_memory("h2-tgt-signed", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
let kp = keypair::generate("alice").unwrap();
let level = create_link_signed(&conn, &src.id, &tgt.id, "supersedes", Some(&kp)).unwrap();
assert_eq!(level, "self_signed");
let (sig, attest, valid_from): (Option<Vec<u8>>, Option<String>, Option<String>) = conn
.query_row(
"SELECT signature, attest_level, valid_from FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2",
params![&src.id, &tgt.id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
let sig_bytes = sig.expect("signature must be present when keypair is provided");
assert_eq!(sig_bytes.len(), 64, "Ed25519 signature is 64 bytes");
assert_eq!(attest.as_deref(), Some("self_signed"));
let valid_from = valid_from.expect("valid_from must be set on the insert path");
let signable = link_sign::SignableLink {
src_id: &src.id,
dst_id: &tgt.id,
relation: "supersedes",
observed_by: Some(kp.agent_id.as_str()),
valid_from: Some(valid_from.as_str()),
valid_until: None,
};
let payload = link_sign::canonical_cbor(&signable).unwrap();
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig_obj = ed25519_dalek::Signature::from_bytes(&sig_arr);
kp.public
.verify(&payload, &sig_obj)
.expect("persisted signature must verify against the writer's public key");
}
#[test]
fn h6_create_link_signed_truncates_valid_from_to_microseconds() {
use crate::identity::{keypair, sign as link_sign};
use ed25519_dalek::Verifier;
let conn = test_db();
let src = make_memory("h6-src", "test", Tier::Long, 5);
let tgt = make_memory("h6-tgt", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
let kp = keypair::generate("alice").unwrap();
let level = create_link_signed(&conn, &src.id, &tgt.id, "related_to", Some(&kp)).unwrap();
assert_eq!(level, "self_signed");
let (sig, valid_from): (Option<Vec<u8>>, Option<String>) = conn
.query_row(
"SELECT signature, valid_from FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2",
params![&src.id, &tgt.id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
let valid_from = valid_from.expect("valid_from set on signed insert path");
if let Some(dot) = valid_from.find('.') {
let after = &valid_from[dot + 1..];
let frac_len = after.chars().take_while(|c| c.is_ascii_digit()).count();
assert!(
frac_len <= 6,
"H6 regression: valid_from has {frac_len}-digit fractional second; expected ≤ 6 (microseconds). Value: {valid_from}"
);
}
let sig_bytes = sig.expect("signature persisted");
let signable = link_sign::SignableLink {
src_id: &src.id,
dst_id: &tgt.id,
relation: "related_to",
observed_by: Some(kp.agent_id.as_str()),
valid_from: Some(valid_from.as_str()),
valid_until: None,
};
let payload = link_sign::canonical_cbor(&signable).unwrap();
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let sig_obj = ed25519_dalek::Signature::from_bytes(&sig_arr);
kp.public.verify(&payload, &sig_obj).expect(
"H6 regression: signature must verify against canonical CBOR \
derived from the stored (microsecond-truncated) valid_from",
);
}
#[test]
fn a3_validate_link_pre_create_refuses_reflection_cycle() {
use crate::config::{
PermissionsMode, lock_permissions_mode_for_test,
override_active_permissions_mode_for_test,
};
let _gate = lock_permissions_mode_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Off);
let conn = test_db();
let a = make_memory("a3-a", "ns", Tier::Long, 5);
let b = make_memory("a3-b", "ns", Tier::Long, 5);
let c = make_memory("a3-c", "ns", Tier::Long, 5);
insert(&conn, &a).unwrap();
insert(&conn, &b).unwrap();
insert(&conn, &c).unwrap();
create_link(&conn, &a.id, &b.id, "reflects_on").unwrap();
create_link(&conn, &b.id, &c.id, "reflects_on").unwrap();
let err = create_link(&conn, &c.id, &a.id, "reflects_on")
.expect_err("cycle-closing reflects_on must be refused");
let msg = err.to_string();
assert!(
msg.starts_with(LINK_CYCLE_ERR_PREFIX),
"expected {LINK_CYCLE_ERR_PREFIX} prefix, got: {msg}"
);
create_link(&conn, &c.id, &a.id, "related_to")
.expect("related_to is not gated by the cycle check");
}
#[test]
fn a3_validate_link_pre_create_respects_governance_deny() {
use crate::config::{
PermissionsMode, lock_permissions_mode_for_test,
override_active_permissions_mode_for_test,
};
use crate::permissions::{
PermissionRule, RuleDecision, clear_active_permission_rules_for_test,
set_active_permission_rules,
};
let _gate = lock_permissions_mode_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Enforce);
clear_active_permission_rules_for_test();
set_active_permission_rules(vec![PermissionRule {
namespace_pattern: "a3-deny/**".to_string(),
op: "memory_link".to_string(),
agent_pattern: "*".to_string(),
decision: RuleDecision::Deny,
reason: Some("test: link denied by a3 rule".to_string()),
}]);
let conn = test_db();
let s = make_memory("a3-src", "a3-deny/scope", Tier::Long, 5);
let t = make_memory("a3-tgt", "a3-deny/scope", Tier::Long, 5);
insert(&conn, &s).unwrap();
insert(&conn, &t).unwrap();
let err = create_link(&conn, &s.id, &t.id, "related_to")
.expect_err("a Deny rule must refuse the link write");
let msg = err.to_string();
assert!(
msg.starts_with(LINK_PERMISSION_DENIED_ERR_PREFIX),
"expected {LINK_PERMISSION_DENIED_ERR_PREFIX} prefix, got: {msg}"
);
clear_active_permission_rules_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Advisory);
}
#[test]
fn a3_create_link_inbound_peer_attested_bypasses_governance() {
use crate::config::{
PermissionsMode, lock_permissions_mode_for_test,
override_active_permissions_mode_for_test,
};
use crate::permissions::{
PermissionRule, RuleDecision, clear_active_permission_rules_for_test,
set_active_permission_rules,
};
let _gate = lock_permissions_mode_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Enforce);
clear_active_permission_rules_for_test();
set_active_permission_rules(vec![PermissionRule {
namespace_pattern: "**".to_string(),
op: "memory_link".to_string(),
agent_pattern: "*".to_string(),
decision: RuleDecision::Deny,
reason: Some("test: every link denied".to_string()),
}]);
let conn = test_db();
let s = make_memory("inbound-src", "a3-fed", Tier::Long, 5);
let t = make_memory("inbound-tgt", "a3-fed", Tier::Long, 5);
insert(&conn, &s).unwrap();
insert(&conn, &t).unwrap();
let link = MemoryLink {
source_id: s.id.clone(),
target_id: t.id.clone(),
relation: crate::models::MemoryLinkRelation::RelatedTo,
created_at: chrono::Utc::now().to_rfc3339(),
valid_from: None,
valid_until: None,
observed_by: Some("peer:remote".to_string()),
signature: Some(vec![0xAB_u8; 64]),
attest_level: None,
};
create_link_inbound(&conn, &link, "peer_attested")
.expect("peer_attested must bypass K9 governance");
let link2 = MemoryLink {
source_id: t.id.clone(),
target_id: s.id.clone(),
relation: crate::models::MemoryLinkRelation::RelatedTo,
created_at: chrono::Utc::now().to_rfc3339(),
valid_from: None,
valid_until: None,
observed_by: Some("peer:remote".to_string()),
signature: None,
attest_level: None,
};
let err = create_link_inbound(&conn, &link2, "unsigned")
.expect_err("unsigned inbound must NOT bypass governance");
assert!(
err.to_string()
.starts_with(LINK_PERMISSION_DENIED_ERR_PREFIX)
);
clear_active_permission_rules_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Advisory);
}
#[test]
fn a3_create_link_inbound_peer_attested_still_refuses_cycle() {
use crate::config::{
PermissionsMode, lock_permissions_mode_for_test,
override_active_permissions_mode_for_test,
};
let _gate = lock_permissions_mode_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Off);
let conn = test_db();
let a = make_memory("inbound-cycle-a", "ns", Tier::Long, 5);
let b = make_memory("inbound-cycle-b", "ns", Tier::Long, 5);
insert(&conn, &a).unwrap();
insert(&conn, &b).unwrap();
create_link(&conn, &a.id, &b.id, "reflects_on").unwrap();
let cycle_link = MemoryLink {
source_id: b.id.clone(),
target_id: a.id.clone(),
relation: crate::models::MemoryLinkRelation::ReflectsOn,
created_at: chrono::Utc::now().to_rfc3339(),
valid_from: None,
valid_until: None,
observed_by: Some("peer:remote".to_string()),
signature: None,
attest_level: None,
};
let err = create_link_inbound(&conn, &cycle_link, "peer_attested")
.expect_err("cycle check must run even on peer_attested inbound");
assert!(err.to_string().starts_with(LINK_CYCLE_ERR_PREFIX));
}
#[test]
fn h6_truncate_to_microseconds_drops_nanos() {
use chrono::{TimeZone, Timelike};
let ns = Utc.with_ymd_and_hms(2026, 5, 10, 12, 34, 56).unwrap();
let ns = ns.with_nanosecond(123_456_789).unwrap();
let truncated = truncate_to_microseconds(ns);
assert_eq!(truncated.nanosecond(), 123_456_000);
let s = truncated.to_rfc3339();
let dot = s.find('.').expect("fractional second present");
let frac = &s[dot + 1..];
let frac_len = frac.chars().take_while(|c| c.is_ascii_digit()).count();
assert_eq!(frac_len, 6, "expected exactly 6-digit fractional; got: {s}");
}
#[test]
fn kg_timeline_returns_events_ordered_by_valid_from_ascending() {
let conn = test_db();
let src = make_memory("alpha", "kg/projects/alpha", Tier::Long, 5);
let s1 = make_memory("kickoff", "kg/projects/alpha", Tier::Long, 5);
let s2 = make_memory("design phase", "kg/projects/alpha", Tier::Long, 5);
let s3 = make_memory("implementation", "kg/projects/alpha", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &s1).unwrap();
insert(&conn, &s2).unwrap();
insert(&conn, &s3).unwrap();
insert_link_at(
&conn,
&src.id,
&s2.id,
"supersedes",
"2026-02-03T00:00:00+00:00",
);
insert_link_at(
&conn,
&src.id,
&s1.id,
"related_to",
"2026-01-15T00:00:00+00:00",
);
insert_link_at(
&conn,
&src.id,
&s3.id,
"supersedes",
"2026-03-22T00:00:00+00:00",
);
let events = kg_timeline(&conn, &src.id, None, None, None).unwrap();
assert_eq!(events.len(), 3);
assert_eq!(events[0].target_id, s1.id);
assert_eq!(events[1].target_id, s2.id);
assert_eq!(events[2].target_id, s3.id);
assert_eq!(events[0].title, "kickoff");
assert_eq!(events[1].relation, "supersedes");
assert_eq!(events[0].target_namespace, "kg/projects/alpha");
}
#[test]
fn kg_timeline_filters_by_since_inclusive() {
let conn = test_db();
let src = make_memory("e", "ns", Tier::Long, 5);
let t1 = make_memory("e1", "ns", Tier::Long, 5);
let t2 = make_memory("e2", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &t1).unwrap();
insert(&conn, &t2).unwrap();
insert_link_at(
&conn,
&src.id,
&t1.id,
"related_to",
"2026-01-01T00:00:00+00:00",
);
insert_link_at(
&conn,
&src.id,
&t2.id,
"related_to",
"2026-03-01T00:00:00+00:00",
);
let events = kg_timeline(
&conn,
&src.id,
Some("2026-02-01T00:00:00+00:00"),
None,
None,
)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].target_id, t2.id);
let on_boundary = kg_timeline(
&conn,
&src.id,
Some("2026-03-01T00:00:00+00:00"),
None,
None,
)
.unwrap();
assert_eq!(on_boundary.len(), 1);
}
#[test]
fn kg_timeline_filters_by_until_inclusive() {
let conn = test_db();
let src = make_memory("e", "ns", Tier::Long, 5);
let t1 = make_memory("e1", "ns", Tier::Long, 5);
let t2 = make_memory("e2", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &t1).unwrap();
insert(&conn, &t2).unwrap();
insert_link_at(
&conn,
&src.id,
&t1.id,
"related_to",
"2026-01-01T00:00:00+00:00",
);
insert_link_at(
&conn,
&src.id,
&t2.id,
"related_to",
"2026-03-01T00:00:00+00:00",
);
let events = kg_timeline(
&conn,
&src.id,
None,
Some("2026-02-01T00:00:00+00:00"),
None,
)
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].target_id, t1.id);
}
#[test]
fn kg_timeline_skips_links_with_null_valid_from() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
let t1 = make_memory("t1", "ns", Tier::Long, 5);
let t2 = make_memory("t2", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &t1).unwrap();
insert(&conn, &t2).unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from) \
VALUES (?1, ?2, 'related_to', ?3, NULL)",
params![&src.id, &t1.id, &now],
)
.unwrap();
insert_link_at(
&conn,
&src.id,
&t2.id,
"supersedes",
"2026-01-01T00:00:00+00:00",
);
let events = kg_timeline(&conn, &src.id, None, None, None).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].target_id, t2.id);
}
#[test]
fn kg_timeline_excludes_links_where_source_is_target() {
let conn = test_db();
let entity = make_memory("entity", "ns", Tier::Long, 5);
let other = make_memory("other", "ns", Tier::Long, 5);
insert(&conn, &entity).unwrap();
insert(&conn, &other).unwrap();
insert_link_at(
&conn,
&other.id,
&entity.id,
"related_to",
"2026-01-01T00:00:00+00:00",
);
let events = kg_timeline(&conn, &entity.id, None, None, None).unwrap();
assert!(events.is_empty());
}
#[test]
fn kg_timeline_limit_clamped_to_max() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
for i in 0..5 {
let t = make_memory(&format!("t{i}"), "ns", Tier::Long, 5);
insert(&conn, &t).unwrap();
insert_link_at(
&conn,
&src.id,
&t.id,
"related_to",
&format!("2026-01-0{}T00:00:00+00:00", i + 1),
);
}
let events = kg_timeline(&conn, &src.id, None, None, Some(usize::MAX)).unwrap();
assert_eq!(events.len(), 5);
let one = kg_timeline(&conn, &src.id, None, None, Some(0)).unwrap();
assert_eq!(one.len(), 1);
}
#[test]
fn kg_timeline_carries_observed_by_and_valid_until() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
let t = make_memory("t", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &t).unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from, valid_until, observed_by) \
VALUES (?1, ?2, 'supersedes', ?3, '2026-01-01T00:00:00+00:00', '2026-12-31T23:59:59+00:00', 'agent-pm-1')",
params![&src.id, &t.id, &now],
)
.unwrap();
let events = kg_timeline(&conn, &src.id, None, None, None).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].observed_by.as_deref(), Some("agent-pm-1"));
assert_eq!(
events[0].valid_until.as_deref(),
Some("2026-12-31T23:59:59+00:00")
);
}
#[test]
fn kg_timeline_empty_for_unknown_source() {
let conn = test_db();
let events = kg_timeline(&conn, "nonexistent-id", None, None, None).unwrap();
assert!(events.is_empty());
}
#[test]
fn invalidate_link_sets_valid_until_to_provided_timestamp() {
let conn = test_db();
let src = make_memory("inv-s", "test", Tier::Long, 5);
let tgt = make_memory("inv-t", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
let stamp = "2026-12-31T23:59:59+00:00";
let res = invalidate_link(&conn, &src.id, &tgt.id, "related_to", Some(stamp))
.unwrap()
.expect("link must exist");
assert_eq!(res.valid_until, stamp);
assert!(res.previous_valid_until.is_none());
let stored: Option<String> = conn
.query_row(
"SELECT valid_until FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
params![&src.id, &tgt.id, "related_to"],
|r| r.get(0),
)
.unwrap();
assert_eq!(stored.as_deref(), Some(stamp));
}
#[test]
fn invalidate_link_defaults_to_now_when_no_timestamp_provided() {
let conn = test_db();
let src = make_memory("inv-s", "test", Tier::Long, 5);
let tgt = make_memory("inv-t", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
let res = invalidate_link(&conn, &src.id, &tgt.id, "related_to", None)
.unwrap()
.expect("link must exist");
let parsed = chrono::DateTime::parse_from_rfc3339(&res.valid_until)
.expect("default valid_until must be RFC3339");
let now = chrono::Utc::now();
let drift = now.signed_duration_since(parsed.with_timezone(&chrono::Utc));
assert!(
drift.num_seconds().abs() < 60,
"default valid_until {} should be near now {now}",
res.valid_until
);
}
#[test]
fn invalidate_link_returns_none_for_unknown_triple() {
let conn = test_db();
let res = invalidate_link(&conn, "missing-src", "missing-tgt", "related_to", None).unwrap();
assert!(res.is_none());
}
#[test]
fn invalidate_link_returns_none_when_relation_does_not_match() {
let conn = test_db();
let src = make_memory("inv-s", "test", Tier::Long, 5);
let tgt = make_memory("inv-t", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
let res = invalidate_link(&conn, &src.id, &tgt.id, "supersedes", None).unwrap();
assert!(res.is_none(), "must not match across relation values");
}
#[test]
fn invalidate_link_overwrites_existing_valid_until_and_reports_prior() {
let conn = test_db();
let src = make_memory("inv-s", "test", Tier::Long, 5);
let tgt = make_memory("inv-t", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
let first = "2026-06-01T00:00:00+00:00";
let second = "2026-12-01T00:00:00+00:00";
let r1 = invalidate_link(&conn, &src.id, &tgt.id, "related_to", Some(first))
.unwrap()
.unwrap();
assert!(r1.previous_valid_until.is_none());
let r2 = invalidate_link(&conn, &src.id, &tgt.id, "related_to", Some(second))
.unwrap()
.unwrap();
assert_eq!(r2.previous_valid_until.as_deref(), Some(first));
assert_eq!(r2.valid_until, second);
}
#[test]
fn invalidate_link_distinguishes_relation_when_multiple_links_share_endpoints() {
let conn = test_db();
let src = make_memory("inv-s", "test", Tier::Long, 5);
let tgt = make_memory("inv-t", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
create_link(&conn, &src.id, &tgt.id, "supersedes").unwrap();
let stamp = "2026-07-15T12:00:00+00:00";
invalidate_link(&conn, &src.id, &tgt.id, "related_to", Some(stamp))
.unwrap()
.unwrap();
let related: Option<String> = conn
.query_row(
"SELECT valid_until FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2 AND relation = 'related_to'",
params![&src.id, &tgt.id],
|r| r.get(0),
)
.unwrap();
let supers: Option<String> = conn
.query_row(
"SELECT valid_until FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2 AND relation = 'supersedes'",
params![&src.id, &tgt.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(related.as_deref(), Some(stamp));
assert!(
supers.is_none(),
"the sibling 'supersedes' link must remain valid"
);
}
#[test]
fn invalidate_link_preserves_other_columns() {
let conn = test_db();
let src = make_memory("inv-s", "test", Tier::Long, 5);
let tgt = make_memory("inv-t", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO memory_links \
(source_id, target_id, relation, created_at, valid_from, observed_by) \
VALUES (?1, ?2, 'related_to', ?3, '2026-01-01T00:00:00+00:00', 'agent-x')",
params![&src.id, &tgt.id, &now],
)
.unwrap();
invalidate_link(
&conn,
&src.id,
&tgt.id,
"related_to",
Some("2026-12-31T23:59:59+00:00"),
)
.unwrap()
.unwrap();
let (vf, ob, ca): (Option<String>, Option<String>, String) = conn
.query_row(
"SELECT valid_from, observed_by, created_at FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2 AND relation = 'related_to'",
params![&src.id, &tgt.id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(vf.as_deref(), Some("2026-01-01T00:00:00+00:00"));
assert_eq!(ob.as_deref(), Some("agent-x"));
assert_eq!(ca, now);
}
#[test]
fn kg_query_default_excludes_invalidated_edges() {
let conn = test_db();
let src = make_memory("inv-src", "ns", Tier::Long, 5);
let live = make_memory("inv-live", "ns", Tier::Long, 5);
let dead = make_memory("inv-dead", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &live).unwrap();
insert(&conn, &dead).unwrap();
insert_link_full(&conn, &src.id, &live.id, "related_to", None, None, None);
insert_link_full(
&conn,
&src.id,
&dead.id,
"supersedes",
None,
Some("2020-01-01T00:00:00+00:00"),
None,
);
let current = kg_query(&conn, &src.id, 1, None, None, None, false).unwrap();
assert_eq!(current.len(), 1);
assert_eq!(current[0].target_id, live.id);
let full = kg_query(&conn, &src.id, 1, None, None, None, true).unwrap();
assert_eq!(full.len(), 2);
}
#[test]
fn default_for_managed_namespace_helper_yields_write_owner() {
let policy = crate::models::GovernancePolicy::default_for_managed_namespace();
assert_eq!(policy.core.write, crate::models::GovernanceLevel::Owner);
assert_eq!(policy.core.promote, crate::models::GovernanceLevel::Any);
assert_eq!(policy.core.delete, crate::models::GovernanceLevel::Owner);
assert!(policy.core.inherit);
}
#[test]
fn namespace_set_standard_with_explicit_owner_policy_enforces_lock() {
let conn = test_db();
let mut standard = make_memory("std", "ns/locked", Tier::Long, 8);
let policy =
serde_json::to_value(crate::models::GovernancePolicy::default_for_managed_namespace())
.unwrap();
standard.metadata = serde_json::json!({"governance": policy});
let standard_id = insert(&conn, &standard).unwrap();
set_namespace_standard(&conn, "ns/locked", &standard_id, None).unwrap();
let resolved = resolve_governance_policy(&conn, "ns/locked")
.expect("policy must resolve when explicitly set");
assert_eq!(resolved.core.write, crate::models::GovernanceLevel::Owner);
}
#[test]
fn enforce_governance_inherits_owner_for_deep_child_owner_write() {
use crate::config::{
PermissionsMode, lock_permissions_mode_for_test,
override_active_permissions_mode_for_test,
};
use crate::models::{
ApproverType, CorePolicy, GovernanceDecision, GovernanceLevel, GovernancePolicy,
GovernedAction, default_metadata,
};
let _gate = lock_permissions_mode_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Enforce);
let conn = test_db();
let parent_ns = "f1/parent";
let owner = "ai:alice";
let policy = GovernancePolicy {
core: CorePolicy {
write: GovernanceLevel::Owner,
promote: GovernanceLevel::Any,
delete: GovernanceLevel::Owner,
approver: ApproverType::Human,
inherit: true,
max_reflection_depth: None,
},
..Default::default()
};
let now = chrono::Utc::now().to_rfc3339();
let mut metadata = default_metadata();
if let Some(obj) = metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String(owner.to_string()),
);
obj.insert(
"governance".to_string(),
serde_json::to_value(&policy).unwrap(),
);
}
let standard = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: format!("_standards-{parent_ns}"),
title: "f1-standard".to_string(),
content: "f1 policy".to_string(),
tags: vec![],
priority: 9,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let standard_id = insert(&conn, &standard).unwrap();
set_namespace_standard(&conn, parent_ns, &standard_id, None).unwrap();
let child_ns = "f1/parent/a/b/c";
let payload = serde_json::json!({"title": "deep-child"});
let allow = enforce_governance(
&conn,
GovernedAction::Store,
child_ns,
owner,
None,
None,
&payload,
)
.expect("enforce_governance must not error on inherited owner policy");
assert!(
matches!(allow, GovernanceDecision::Allow),
"owner write at deep child must Allow when chain walk finds the parent's owner: got {allow:?}"
);
let deny = enforce_governance(
&conn,
GovernedAction::Store,
child_ns,
"ai:eve",
None,
None,
&payload,
)
.expect("enforce_governance must not error");
match deny {
GovernanceDecision::Deny(refusal) => {
assert!(
refusal.reason.contains("not the owner"),
"non-owner deny should cite ownership mismatch, got: {refusal:?}"
);
assert_eq!(
refusal.denied_level,
GovernanceLevel::Owner,
"owner-level refusal must carry GovernanceLevel::Owner; got {refusal:?}",
);
}
other => panic!("expected Deny for non-owner, got {other:?}"),
}
}
#[test]
fn enforce_governance_deep_child_with_inherit_false_still_resolves_via_walk() {
use crate::config::{
PermissionsMode, lock_permissions_mode_for_test,
override_active_permissions_mode_for_test,
};
use crate::models::{
ApproverType, CorePolicy, GovernanceDecision, GovernanceLevel, GovernancePolicy,
GovernedAction, default_metadata,
};
let _gate = lock_permissions_mode_for_test();
override_active_permissions_mode_for_test(PermissionsMode::Enforce);
let conn = test_db();
let parent_ns = "f1nb/parent";
let owner = "ai:alice";
let policy = GovernancePolicy {
core: CorePolicy {
write: GovernanceLevel::Owner,
promote: GovernanceLevel::Any,
delete: GovernanceLevel::Owner,
approver: ApproverType::Human,
inherit: false,
max_reflection_depth: None,
},
..Default::default()
};
let now = chrono::Utc::now().to_rfc3339();
let mut metadata = default_metadata();
if let Some(obj) = metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String(owner.to_string()),
);
obj.insert(
"governance".to_string(),
serde_json::to_value(&policy).unwrap(),
);
}
let standard = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: format!("_standards-{parent_ns}"),
title: "f1nb-standard".to_string(),
content: "policy".to_string(),
tags: vec![],
priority: 9,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata,
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let standard_id = insert(&conn, &standard).unwrap();
set_namespace_standard(&conn, parent_ns, &standard_id, None).unwrap();
let decision = enforce_governance(
&conn,
GovernedAction::Store,
"f1nb/parent/x/y",
owner,
None,
None,
&serde_json::json!({}),
)
.unwrap();
assert!(
matches!(decision, GovernanceDecision::Allow),
"owner write at deep child resolves via leaf-first walk: got {decision:?}"
);
}
#[test]
fn find_paths_default_excludes_invalidated_edges() {
let conn = test_db();
let a = make_memory("fp-a", "ns", Tier::Long, 5);
let b = make_memory("fp-b", "ns", Tier::Long, 5);
let c = make_memory("fp-c", "ns", Tier::Long, 5);
insert(&conn, &a).unwrap();
insert(&conn, &b).unwrap();
insert(&conn, &c).unwrap();
insert_link_full(&conn, &a.id, &c.id, "related_to", None, None, None);
insert_link_full(
&conn,
&a.id,
&b.id,
"supersedes",
None,
Some("2020-01-01T00:00:00+00:00"),
None,
);
insert_link_full(&conn, &b.id, &c.id, "related_to", None, None, None);
let current = find_paths(&conn, &a.id, &c.id, Some(3), None, false).unwrap();
assert_eq!(current.len(), 1);
assert_eq!(current[0], vec![a.id.clone(), c.id.clone()]);
let full = find_paths(&conn, &a.id, &c.id, Some(3), None, true).unwrap();
assert_eq!(full.len(), 2);
}
fn insert_link_full(
conn: &Connection,
source_id: &str,
target_id: &str,
relation: &str,
valid_from: Option<&str>,
valid_until: Option<&str>,
observed_by: Option<&str>,
) {
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO memory_links \
(source_id, target_id, relation, created_at, valid_from, valid_until, observed_by) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
source_id,
target_id,
relation,
now,
valid_from,
valid_until,
observed_by
],
)
.unwrap();
}
#[test]
fn kg_query_returns_outbound_neighbors_at_depth_1() {
let conn = test_db();
let src = make_memory("alpha", "kg/projects/alpha", Tier::Long, 5);
let n1 = make_memory("kickoff", "kg/projects/alpha", Tier::Long, 5);
let n2 = make_memory("design", "kg/projects/alpha", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &n1).unwrap();
insert(&conn, &n2).unwrap();
insert_link_full(
&conn,
&src.id,
&n1.id,
"related_to",
Some("2026-01-15T00:00:00+00:00"),
None,
Some("agent-1"),
);
insert_link_full(
&conn,
&src.id,
&n2.id,
"supersedes",
Some("2026-02-03T00:00:00+00:00"),
None,
Some("agent-2"),
);
let nodes = kg_query(&conn, &src.id, 1, None, None, None, false).unwrap();
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].target_id, n1.id);
assert_eq!(nodes[1].target_id, n2.id);
assert_eq!(nodes[0].title, "kickoff");
assert_eq!(nodes[0].relation, "related_to");
assert_eq!(nodes[0].observed_by.as_deref(), Some("agent-1"));
assert_eq!(nodes[0].depth, 1);
assert_eq!(nodes[0].path, format!("{}->{}", src.id, n1.id));
assert_eq!(nodes[0].target_namespace, "kg/projects/alpha");
}
#[test]
fn kg_query_filters_by_valid_at_window() {
let conn = test_db();
let src = make_memory("e", "ns", Tier::Long, 5);
let t1 = make_memory("e1", "ns", Tier::Long, 5);
let t2 = make_memory("e2", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &t1).unwrap();
insert(&conn, &t2).unwrap();
insert_link_full(
&conn,
&src.id,
&t1.id,
"related_to",
Some("2026-01-01T00:00:00+00:00"),
Some("2026-02-01T00:00:00+00:00"),
None,
);
insert_link_full(
&conn,
&src.id,
&t2.id,
"related_to",
Some("2026-03-01T00:00:00+00:00"),
None,
None,
);
let n_jan = kg_query(
&conn,
&src.id,
1,
Some("2026-01-15T00:00:00+00:00"),
None,
None,
false,
)
.unwrap();
assert_eq!(n_jan.len(), 1);
assert_eq!(n_jan[0].target_id, t1.id);
let n_feb = kg_query(
&conn,
&src.id,
1,
Some("2026-02-15T00:00:00+00:00"),
None,
None,
false,
)
.unwrap();
assert!(n_feb.is_empty());
let n_apr = kg_query(
&conn,
&src.id,
1,
Some("2026-04-01T00:00:00+00:00"),
None,
None,
false,
)
.unwrap();
assert_eq!(n_apr.len(), 1);
assert_eq!(n_apr[0].target_id, t2.id);
}
#[test]
fn kg_query_skips_null_valid_from_when_valid_at_filter_active() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
let t = make_memory("t", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &t).unwrap();
insert_link_full(&conn, &src.id, &t.id, "related_to", None, None, None);
let with_filter = kg_query(
&conn,
&src.id,
1,
Some("2026-01-15T00:00:00+00:00"),
None,
None,
false,
)
.unwrap();
assert!(with_filter.is_empty());
let without = kg_query(&conn, &src.id, 1, None, None, None, false).unwrap();
assert_eq!(without.len(), 1);
assert_eq!(without[0].target_id, t.id);
}
#[test]
fn kg_query_filters_by_allowed_agents() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
let t1 = make_memory("t1", "ns", Tier::Long, 5);
let t2 = make_memory("t2", "ns", Tier::Long, 5);
let t3 = make_memory("t3", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &t1).unwrap();
insert(&conn, &t2).unwrap();
insert(&conn, &t3).unwrap();
insert_link_full(
&conn,
&src.id,
&t1.id,
"related_to",
Some("2026-01-01T00:00:00+00:00"),
None,
Some("agent-a"),
);
insert_link_full(
&conn,
&src.id,
&t2.id,
"related_to",
Some("2026-01-02T00:00:00+00:00"),
None,
Some("agent-b"),
);
insert_link_full(
&conn,
&src.id,
&t3.id,
"related_to",
Some("2026-01-03T00:00:00+00:00"),
None,
None,
);
let allow_a = vec!["agent-a".to_string()];
let only_a = kg_query(&conn, &src.id, 1, None, Some(&allow_a), None, false).unwrap();
assert_eq!(only_a.len(), 1);
assert_eq!(only_a[0].target_id, t1.id);
let allow_both = vec!["agent-a".to_string(), "agent-b".to_string()];
let both = kg_query(&conn, &src.id, 1, None, Some(&allow_both), None, false).unwrap();
assert_eq!(both.len(), 2);
}
#[test]
fn kg_query_empty_allowed_agents_returns_zero_rows() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
let t = make_memory("t", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &t).unwrap();
insert_link_full(
&conn,
&src.id,
&t.id,
"related_to",
Some("2026-01-01T00:00:00+00:00"),
None,
Some("agent-a"),
);
let unfiltered = kg_query(&conn, &src.id, 1, None, None, None, false).unwrap();
assert_eq!(unfiltered.len(), 1);
let empty: Vec<String> = Vec::new();
let none = kg_query(&conn, &src.id, 1, None, Some(&empty), None, false).unwrap();
assert!(none.is_empty());
}
#[test]
fn kg_query_rejects_max_depth_zero() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
let err = kg_query(&conn, &src.id, 0, None, None, None, false).unwrap_err();
assert!(err.to_string().contains("max_depth"));
}
#[test]
fn kg_query_rejects_unsupported_max_depth() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
let err = kg_query(
&conn,
&src.id,
KG_QUERY_MAX_SUPPORTED_DEPTH + 1,
None,
None,
None,
false,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains(&format!("max_depth={}", KG_QUERY_MAX_SUPPORTED_DEPTH + 1)));
assert!(msg.contains(&format!("supported depth={KG_QUERY_MAX_SUPPORTED_DEPTH}")));
}
#[test]
fn kg_query_traverses_multiple_hops() {
let conn = test_db();
let src = make_memory("src", "ns", Tier::Long, 5);
let mid = make_memory("mid", "ns", Tier::Long, 5);
let leaf = make_memory("leaf", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &mid).unwrap();
insert(&conn, &leaf).unwrap();
insert_link_full(
&conn,
&src.id,
&mid.id,
"related_to",
Some("2026-01-01T00:00:00+00:00"),
None,
Some("agent-x"),
);
insert_link_full(
&conn,
&mid.id,
&leaf.id,
"supersedes",
Some("2026-01-02T00:00:00+00:00"),
None,
Some("agent-x"),
);
let d1 = kg_query(&conn, &src.id, 1, None, None, None, false).unwrap();
assert_eq!(d1.len(), 1);
assert_eq!(d1[0].target_id, mid.id);
assert_eq!(d1[0].depth, 1);
let d2 = kg_query(&conn, &src.id, 2, None, None, None, false).unwrap();
assert_eq!(d2.len(), 2);
assert_eq!(d2[0].target_id, mid.id);
assert_eq!(d2[0].depth, 1);
assert_eq!(d2[0].path, format!("{}->{}", src.id, mid.id));
assert_eq!(d2[1].target_id, leaf.id);
assert_eq!(d2[1].depth, 2);
assert_eq!(d2[1].relation, "supersedes");
assert_eq!(d2[1].path, format!("{}->{}->{}", src.id, mid.id, leaf.id));
}
#[test]
fn kg_query_multi_hop_respects_valid_at_per_hop() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
let mid = make_memory("m", "ns", Tier::Long, 5);
let leaf = make_memory("l", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &mid).unwrap();
insert(&conn, &leaf).unwrap();
insert_link_full(
&conn,
&src.id,
&mid.id,
"related_to",
Some("2026-01-01T00:00:00+00:00"),
Some("2026-02-01T00:00:00+00:00"),
None,
);
insert_link_full(
&conn,
&mid.id,
&leaf.id,
"related_to",
Some("2026-04-01T00:00:00+00:00"),
None,
None,
);
let mid_only = kg_query(
&conn,
&src.id,
3,
Some("2026-01-15T00:00:00+00:00"),
None,
None,
false,
)
.unwrap();
assert_eq!(mid_only.len(), 1);
assert_eq!(mid_only[0].target_id, mid.id);
let neither = kg_query(
&conn,
&src.id,
3,
Some("2026-04-15T00:00:00+00:00"),
None,
None,
false,
)
.unwrap();
assert!(neither.is_empty());
}
#[test]
fn kg_query_detects_cycles() {
let conn = test_db();
let a = make_memory("a", "ns", Tier::Long, 5);
let b = make_memory("b", "ns", Tier::Long, 5);
let c = make_memory("c", "ns", Tier::Long, 5);
insert(&conn, &a).unwrap();
insert(&conn, &b).unwrap();
insert(&conn, &c).unwrap();
insert_link_full(
&conn,
&a.id,
&b.id,
"related_to",
Some("2026-01-01T00:00:00+00:00"),
None,
None,
);
insert_link_full(
&conn,
&b.id,
&c.id,
"related_to",
Some("2026-01-02T00:00:00+00:00"),
None,
None,
);
insert_link_full(
&conn,
&c.id,
&a.id,
"related_to",
Some("2026-01-03T00:00:00+00:00"),
None,
None,
);
let nodes = kg_query(&conn, &a.id, 5, None, None, None, false).unwrap();
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].target_id, b.id);
assert_eq!(nodes[0].depth, 1);
assert_eq!(nodes[1].target_id, c.id);
assert_eq!(nodes[1].depth, 2);
}
#[test]
fn kg_query_multi_hop_filters_by_allowed_agents_per_hop() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
let mid = make_memory("m", "ns", Tier::Long, 5);
let leaf = make_memory("l", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &mid).unwrap();
insert(&conn, &leaf).unwrap();
insert_link_full(
&conn,
&src.id,
&mid.id,
"related_to",
Some("2026-01-01T00:00:00+00:00"),
None,
Some("agent-a"),
);
insert_link_full(
&conn,
&mid.id,
&leaf.id,
"related_to",
Some("2026-01-02T00:00:00+00:00"),
None,
Some("agent-b"),
);
let allow_a = vec!["agent-a".to_string()];
let only_first = kg_query(&conn, &src.id, 3, None, Some(&allow_a), None, false).unwrap();
assert_eq!(only_first.len(), 1);
assert_eq!(only_first[0].target_id, mid.id);
let allow_both = vec!["agent-a".to_string(), "agent-b".to_string()];
let both = kg_query(&conn, &src.id, 3, None, Some(&allow_both), None, false).unwrap();
assert_eq!(both.len(), 2);
assert_eq!(both[1].target_id, leaf.id);
assert_eq!(both[1].depth, 2);
}
#[test]
fn kg_query_limit_clamped_to_max() {
let conn = test_db();
let src = make_memory("s", "ns", Tier::Long, 5);
insert(&conn, &src).unwrap();
for i in 0..3 {
let t = make_memory(&format!("t{i}"), "ns", Tier::Long, 5);
insert(&conn, &t).unwrap();
insert_link_full(
&conn,
&src.id,
&t.id,
"related_to",
Some(&format!("2026-01-{:02}T00:00:00+00:00", i + 1)),
None,
None,
);
}
let all = kg_query(&conn, &src.id, 1, None, None, Some(usize::MAX), false).unwrap();
assert_eq!(all.len(), 3);
let one = kg_query(&conn, &src.id, 1, None, None, Some(0), false).unwrap();
assert_eq!(one.len(), 1);
}
#[test]
fn kg_query_empty_for_unknown_source() {
let conn = test_db();
let nodes = kg_query(&conn, "no-such-id", 1, None, None, None, false).unwrap();
assert!(nodes.is_empty());
}
#[test]
fn schema_v15_existing_links_get_valid_from_backfilled() {
let path = std::env::temp_dir().join(format!(
"ai_memory_v15_backfill_{}.db",
uuid::Uuid::new_v4()
));
{
let conn = open(&path).unwrap();
let src = make_memory("src", "test", Tier::Long, 5);
let tgt = make_memory("tgt", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
conn.execute(
"INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from) \
VALUES (?1, ?2, 'related_to', ?3, NULL)",
params![&src.id, &tgt.id, &chrono::Utc::now().to_rfc3339()],
)
.unwrap();
conn.execute("DELETE FROM schema_version", []).unwrap();
conn.execute("INSERT INTO schema_version (version) VALUES (14)", [])
.unwrap();
}
let conn2 = open(&path).unwrap();
let backfilled: Option<String> = conn2
.query_row("SELECT valid_from FROM memory_links LIMIT 1", [], |r| {
r.get(0)
})
.unwrap();
assert!(
backfilled.is_some(),
"expected valid_from to be backfilled, got NULL"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn namespace_prefix_query_index_available() {
let conn = test_db();
let result: Option<String> = conn
.query_row(
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_memories_namespace'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
result,
Some("idx_memories_namespace".to_string()),
"idx_memories_namespace index should exist"
);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories WHERE namespace LIKE 'test/%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn doctor_dim_violations_post_p2_returns_zero_on_fresh_db() {
let conn = test_db();
let result = doctor_dim_violations(&conn).unwrap();
assert_eq!(result, Some(0));
}
#[test]
fn doctor_oldest_pending_age_secs_empty_queue() {
let conn = test_db();
let age = doctor_oldest_pending_age_secs(&conn).unwrap();
assert_eq!(age, None);
}
#[test]
fn doctor_oldest_pending_age_secs_reports_age() {
let conn = test_db();
let one_hour_ago = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
conn.execute(
"INSERT INTO pending_actions (id, action_type, namespace, payload, requested_by, requested_at, status)
VALUES ('p1', 'store', 'ns', '{}', 'agent', ?1, 'pending')",
params![one_hour_ago],
)
.unwrap();
let age = doctor_oldest_pending_age_secs(&conn).unwrap().unwrap();
assert!((3500..=3700).contains(&age), "expected ~3600s, got {age}");
}
#[test]
fn doctor_governance_coverage_with_namespace_meta() {
let conn = test_db();
let (with, without) = doctor_governance_coverage(&conn).unwrap();
assert_eq!((with, without), (0, 0));
}
#[test]
fn doctor_governance_depth_distribution_chains() {
let conn = test_db();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO namespace_meta (namespace, parent_namespace, updated_at) VALUES ('root', NULL, ?1)",
params![now],
).unwrap();
conn.execute(
"INSERT INTO namespace_meta (namespace, parent_namespace, updated_at) VALUES ('a', 'root', ?1)",
params![now],
).unwrap();
conn.execute(
"INSERT INTO namespace_meta (namespace, parent_namespace, updated_at) VALUES ('a/b', 'a', ?1)",
params![now],
).unwrap();
conn.execute(
"INSERT INTO namespace_meta (namespace, parent_namespace, updated_at) VALUES ('a/b/c', 'a/b', ?1)",
params![now],
).unwrap();
let dist = doctor_governance_depth_distribution(&conn).unwrap();
assert_eq!(dist[0], 1, "root has depth 0");
assert_eq!(dist[1], 1, "a has depth 1");
assert_eq!(dist[2], 1, "a/b has depth 2");
assert_eq!(dist[3], 1, "a/b/c has depth 3");
}
#[test]
fn doctor_webhook_delivery_totals_empty() {
let conn = test_db();
let (dispatched, failed) = doctor_webhook_delivery_totals(&conn).unwrap();
assert_eq!((dispatched, failed), (0, 0));
}
#[test]
fn doctor_max_sync_skew_secs_empty() {
let conn = test_db();
let skew = doctor_max_sync_skew_secs(&conn).unwrap();
assert_eq!(skew, None);
}
#[test]
fn audit_log_record_and_list_grant_and_deny() {
let conn = test_db();
record_capability_expansion(&conn, Some("alice"), "graph", true, None);
record_capability_expansion(&conn, Some("bob"), "power", false, None);
let rows = list_capability_expansions(&conn, 50, None).unwrap();
assert_eq!(rows.len(), 2);
assert!(rows[0].timestamp >= rows[1].timestamp);
let grant_row = rows
.iter()
.find(|r| r.agent_id.as_deref() == Some("alice"))
.unwrap();
assert!(grant_row.granted);
assert_eq!(grant_row.requested_family.as_deref(), Some("graph"));
let deny_row = rows
.iter()
.find(|r| r.agent_id.as_deref() == Some("bob"))
.unwrap();
assert!(!deny_row.granted);
assert_eq!(deny_row.requested_family.as_deref(), Some("power"));
}
#[test]
fn audit_log_filter_by_agent() {
let conn = test_db();
record_capability_expansion(&conn, Some("alice"), "graph", true, None);
record_capability_expansion(&conn, Some("bob"), "power", false, None);
let alice = list_capability_expansions(&conn, 50, Some("alice")).unwrap();
assert_eq!(alice.len(), 1);
assert_eq!(alice[0].agent_id.as_deref(), Some("alice"));
let none_match = list_capability_expansions(&conn, 50, Some("nobody")).unwrap();
assert!(none_match.is_empty());
}
#[test]
fn audit_log_anonymous_caller() {
let conn = test_db();
record_capability_expansion(&conn, None, "core", true, None);
let rows = list_capability_expansions(&conn, 50, None).unwrap();
assert_eq!(rows.len(), 1);
assert!(rows[0].agent_id.is_none());
}
#[test]
fn audit_log_migration_idempotent_on_re_open() {
let p = tempfile::NamedTempFile::new().unwrap();
let p = p.path().to_path_buf();
let _ = open(&p).unwrap();
let conn = open(&p).unwrap();
let cnt: i64 = conn
.query_row(
"SELECT count(*) FROM sqlite_master WHERE name LIKE 'idx_audit_log_%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
cnt, 3,
"expected 3 audit_log indexes (agent_id, ts, event_type)"
);
}
fn insert_stale_pending(
conn: &Connection,
id: &str,
namespace: &str,
age_secs: i64,
per_row_timeout: Option<i64>,
) {
let requested_at = (chrono::Utc::now() - chrono::Duration::seconds(age_secs)).to_rfc3339();
conn.execute(
"INSERT INTO pending_actions
(id, action_type, namespace, payload, requested_by, requested_at,
status, default_timeout_seconds)
VALUES (?1, 'store', ?2, '{}', 'tester', ?3, 'pending', ?4)",
params![id, namespace, requested_at, per_row_timeout],
)
.unwrap();
}
#[test]
fn sweep_marks_stale_pending_row_expired() {
let conn = test_db();
insert_stale_pending(&conn, "stale-1", "ns/a", 7_200, None);
let expired = sweep_pending_action_timeouts(&conn, crate::SECS_PER_HOUR).unwrap();
assert_eq!(expired.len(), 1, "expected exactly one expiry");
assert_eq!(expired[0], ("stale-1".to_string(), "ns/a".to_string()));
let (status, expired_at): (String, Option<String>) = conn
.query_row(
"SELECT status, expired_at FROM pending_actions WHERE id = ?1",
params!["stale-1"],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(status, "expired");
assert!(
expired_at.is_some(),
"expired_at must be stamped by the sweeper"
);
}
#[test]
fn sweep_leaves_fresh_pending_alone() {
let conn = test_db();
insert_stale_pending(&conn, "fresh-1", "ns/a", 30, None);
let expired = sweep_pending_action_timeouts(&conn, crate::SECS_PER_HOUR).unwrap();
assert!(expired.is_empty());
let status: String = conn
.query_row(
"SELECT status FROM pending_actions WHERE id = ?1",
params!["fresh-1"],
|r| r.get(0),
)
.unwrap();
assert_eq!(status, "pending");
}
#[test]
fn sweep_per_row_timeout_overrides_global_default() {
let conn = test_db();
insert_stale_pending(&conn, "short-ttl", "ns/a", 300, Some(60));
insert_stale_pending(&conn, "no-override", "ns/a", 300, None);
let expired = sweep_pending_action_timeouts(&conn, crate::SECS_PER_HOUR).unwrap();
let ids: Vec<&String> = expired.iter().map(|(id, _)| id).collect();
assert_eq!(ids, vec![&"short-ttl".to_string()]);
}
#[test]
fn sweep_skips_already_decided_rows() {
let conn = test_db();
let approved_at = (chrono::Utc::now() - chrono::Duration::seconds(7_200)).to_rfc3339();
conn.execute(
"INSERT INTO pending_actions
(id, action_type, namespace, payload, requested_by, requested_at,
status, decided_by, decided_at)
VALUES ('approved-old', 'store', 'ns/a', '{}', 'alice', ?1,
'approved', 'bob', ?1)",
params![approved_at],
)
.unwrap();
let expired = sweep_pending_action_timeouts(&conn, 60).unwrap();
assert!(expired.is_empty(), "non-pending rows must be ignored");
let status: String = conn
.query_row(
"SELECT status FROM pending_actions WHERE id = 'approved-old'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(status, "approved", "decided row status preserved");
}
#[test]
fn sweep_disabled_when_global_default_non_positive() {
let conn = test_db();
insert_stale_pending(&conn, "stale-2", "ns/a", 7_200, None);
let expired = sweep_pending_action_timeouts(&conn, 0).unwrap();
assert!(expired.is_empty());
let expired_neg = sweep_pending_action_timeouts(&conn, -1).unwrap();
assert!(expired_neg.is_empty());
}
#[test]
fn sweep_empty_queue_is_silent_noop() {
let conn = test_db();
let expired = sweep_pending_action_timeouts(&conn, 60).unwrap();
assert!(expired.is_empty());
}
#[test]
fn test_memories_tier_check_rejects_invalid() {
let conn = test_db();
let now = chrono::Utc::now().to_rfc3339();
let err = conn.execute(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, metadata) \
VALUES (?1, 'long-term', 'ns-ck', 'bad-tier', 'x', '[]', 5, 1.0, 'test', 0, ?2, ?2, '{}')",
params!["m-bad-tier", now],
).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("memories.tier must be one of"),
"expected R1-M2 tier check, got: {msg}"
);
}
#[test]
fn test_memories_priority_check_rejects_oob() {
let conn = test_db();
let now = chrono::Utc::now().to_rfc3339();
let err = conn.execute(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, metadata) \
VALUES (?1, 'mid', 'ns-ck', 'bad-prio', 'x', '[]', 11, 1.0, 'test', 0, ?2, ?2, '{}')",
params!["m-bad-prio", now],
).unwrap_err();
assert!(
err.to_string()
.contains("memories.priority must be between 1 and 10"),
"expected R1-M2 priority check, got: {err}"
);
let err_low = conn.execute(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, metadata) \
VALUES (?1, 'mid', 'ns-ck', 'bad-prio-low', 'x', '[]', 0, 1.0, 'test', 0, ?2, ?2, '{}')",
params!["m-bad-prio-low", now],
).unwrap_err();
assert!(err_low.to_string().contains("priority"));
}
#[test]
fn test_memories_confidence_check_rejects_oob() {
let conn = test_db();
let now = chrono::Utc::now().to_rfc3339();
let err = conn.execute(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, metadata) \
VALUES (?1, 'mid', 'ns-ck', 'bad-conf', 'x', '[]', 5, 1.5, 'test', 0, ?2, ?2, '{}')",
params!["m-bad-conf", now],
).unwrap_err();
assert!(
err.to_string().contains("memories.confidence"),
"expected R1-M2 confidence check, got: {err}"
);
}
#[test]
fn test_memory_links_relation_check_rejects_unknown() {
let conn = test_db();
let src = insert(&conn, &make_memory("rel-src", "ns-ck", Tier::Mid, 5)).unwrap();
let tgt = insert(&conn, &make_memory("rel-tgt", "ns-ck", Tier::Mid, 5)).unwrap();
let now = chrono::Utc::now().to_rfc3339();
let err = conn
.execute(
"INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from) \
VALUES (?1, ?2, 'follows', ?3, ?3)",
params![src, tgt, now],
)
.unwrap_err();
assert!(
err.to_string()
.contains("memory_links.relation must be one of"),
"expected R1-M2 relation check, got: {err}"
);
}
#[test]
fn test_memory_links_attest_level_check_rejects_unknown() {
let conn = test_db();
let src = insert(&conn, &make_memory("att-src", "ns-ck", Tier::Mid, 5)).unwrap();
let tgt = insert(&conn, &make_memory("att-tgt", "ns-ck", Tier::Mid, 5)).unwrap();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from, attest_level) \
VALUES (?1, ?2, 'related_to', ?3, ?3, NULL)",
params![src, tgt, now],
)
.expect("NULL attest_level must remain accepted");
let err = conn.execute(
"INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from, attest_level) \
VALUES (?1, ?2, 'supersedes', ?3, ?3, 'totally-fake')",
params![src, tgt, now],
).unwrap_err();
assert!(err.to_string().contains("memory_links.attest_level"));
}
#[test]
fn test_insert_with_conflict_error_mode_refuses_duplicate() {
let conn = test_db();
let m1 = make_memory("dup-title", "ns-conflict", Tier::Mid, 5);
let _id = insert_with_conflict(&conn, &m1, ConflictMode::Error).unwrap();
let mut m2 = make_memory("dup-title", "ns-conflict", Tier::Mid, 7);
m2.content = "second writer should be refused".to_string();
let err = insert_with_conflict(&conn, &m2, ConflictMode::Error).unwrap_err();
let conflict = err.downcast_ref::<ConflictError>();
assert!(
conflict.is_some(),
"expected typed ConflictError, got: {err}"
);
let row = find_by_title_namespace(&conn, "dup-title", "ns-conflict")
.unwrap()
.expect("first row still present");
let fetched = get(&conn, &row).unwrap().unwrap();
assert_ne!(
fetched.content, "second writer should be refused",
"Error mode must not mutate the existing row"
);
}
#[test]
fn test_insert_with_conflict_merge_mode_updates() {
let conn = test_db();
let m1 = make_memory("merge-title", "ns-merge", Tier::Mid, 5);
let id_a = insert_with_conflict(&conn, &m1, ConflictMode::Merge).unwrap();
let mut m2 = make_memory("merge-title", "ns-merge", Tier::Mid, 7);
m2.content = "merged-content".to_string();
let id_b = insert_with_conflict(&conn, &m2, ConflictMode::Merge).unwrap();
assert_eq!(id_a, id_b, "merge mode returns the existing row id");
let fetched = get(&conn, &id_a).unwrap().unwrap();
assert_eq!(fetched.content, "merged-content");
}
#[test]
fn test_insert_with_conflict_version_keeps_both() {
let conn = test_db();
let m1 = make_memory("versioned", "ns-v", Tier::Mid, 5);
let id_a = insert_with_conflict(&conn, &m1, ConflictMode::Version).unwrap();
let mut m2 = make_memory("versioned", "ns-v", Tier::Mid, 5);
m2.content = "second version content".to_string();
let id_b = insert_with_conflict(&conn, &m2, ConflictMode::Version).unwrap();
assert_ne!(id_a, id_b, "version mode produces a distinct row");
let original_id = find_by_title_namespace(&conn, "versioned", "ns-v")
.unwrap()
.expect("original row");
let versioned_id = find_by_title_namespace(&conn, "versioned (2)", "ns-v")
.unwrap()
.expect("versioned row");
assert_eq!(original_id, id_a);
assert_eq!(versioned_id, id_b);
}
#[test]
fn test_memory_link_relation_round_trips() {
let conn = test_db();
let src = insert(&conn, &make_memory("rt-src", "ns-rt", Tier::Mid, 5)).unwrap();
let tgt = insert(&conn, &make_memory("rt-tgt", "ns-rt", Tier::Mid, 5)).unwrap();
create_link(&conn, &src, &tgt, "supersedes").unwrap();
let links = get_links(&conn, &src).unwrap();
assert_eq!(links.len(), 1);
assert_eq!(
links[0].relation,
crate::models::MemoryLinkRelation::Supersedes,
"relation must round-trip as the typed Supersedes variant"
);
let wire = serde_json::to_string(&links[0]).unwrap();
assert!(
wire.contains("\"relation\":\"supersedes\""),
"serde wire form must be the canonical lowercase snake_case \
string; got {wire}"
);
}
fn count_signed_events(conn: &Connection, event_type: &str) -> usize {
crate::signed_events::list_signed_events(conn, None, 1000, 0)
.unwrap_or_default()
.into_iter()
.filter(|e| e.event_type == event_type)
.count()
}
#[test]
fn test_execute_reflect_arm_succeeds_round_trip() {
let conn = test_db();
let src1 = make_memory("src-1", "ns/reflect", Tier::Mid, 5);
let src2 = make_memory("src-2", "ns/reflect", Tier::Mid, 5);
let src1_id = insert(&conn, &src1).unwrap();
let src2_id = insert(&conn, &src2).unwrap();
let payload = serde_json::json!({
"source_ids": [src1_id, src2_id],
"title": "reflective synthesis",
"content": "deep observation across sources",
"namespace": "ns/reflect",
"tier": Tier::Mid.as_str(),
"tags": ["reflective"],
"priority": 6,
"confidence": 0.9,
"agent_id": "alice",
"proposed_depth": 1,
});
let pending_id = queue_pending_action(
&conn,
crate::models::GovernedAction::Reflect,
"ns/reflect",
None,
"alice",
&payload,
)
.unwrap();
assert!(decide_pending_action(&conn, &pending_id, true, "approver").unwrap());
let result = execute_pending_action(&conn, &pending_id).expect("reflect execute ok");
let new_id = result.expect("reflect must return the new reflection id");
let mem = get(&conn, &new_id)
.unwrap()
.expect("reflection memory landed");
assert_eq!(mem.title, "reflective synthesis");
assert_eq!(mem.namespace, "ns/reflect");
assert_eq!(mem.reflection_depth, 1, "depth = max(source depths) + 1");
assert_eq!(mem.metadata["agent_id"], "alice");
}
#[test]
fn test_execute_refuses_payload_agent_id_mismatch() {
let conn = test_db();
let mut mem = make_memory("laundered store", "ns/launder", Tier::Mid, 5);
mem.metadata = serde_json::json!({"agent_id": "bob"});
let payload = serde_json::to_value(&mem).unwrap();
let pending_id = queue_pending_action(
&conn,
crate::models::GovernedAction::Store,
"ns/launder",
None,
"alice",
&payload,
)
.unwrap();
assert!(decide_pending_action(&conn, &pending_id, true, "approver").unwrap());
let err = execute_pending_action(&conn, &pending_id)
.expect_err("execute MUST refuse laundered agent_id");
let msg = format!("{err}");
assert!(
msg.contains("approver-on-behalf laundering refused"),
"expected laundering-refusal message, got: {msg}"
);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memories WHERE namespace = 'ns/launder'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 0, "refused execute must not insert a memory");
assert_eq!(
count_signed_events(&conn, "pending_action.refused_agent_id_mismatch"),
1,
"refusal must append a signed_events row"
);
assert_eq!(count_signed_events(&conn, "pending_action.approved"), 0);
}
#[test]
fn test_approve_emits_signed_event() {
let conn = test_db();
let mem = make_memory("approved store", "ns/approve", Tier::Mid, 5);
let payload = serde_json::to_value(&mem).unwrap();
let pending_id = queue_pending_action(
&conn,
crate::models::GovernedAction::Store,
"ns/approve",
None,
mem.metadata["agent_id"].as_str().unwrap_or("alice"),
&payload,
)
.unwrap();
assert!(decide_pending_action(&conn, &pending_id, true, "approver").unwrap());
let _ = execute_pending_action(&conn, &pending_id).expect("execute ok");
assert_eq!(
count_signed_events(&conn, "pending_action.approved"),
1,
"approve+execute must append one audit row"
);
assert_eq!(count_signed_events(&conn, "pending_action.denied"), 0);
assert_eq!(count_signed_events(&conn, "pending_action.timed_out"), 0);
}
#[test]
fn test_deny_emits_signed_event() {
let conn = test_db();
let payload = serde_json::json!({"title": "to-deny", "content": "x"});
let pending_id = queue_pending_action(
&conn,
crate::models::GovernedAction::Store,
"ns/deny",
None,
"alice",
&payload,
)
.unwrap();
let transitioned = decide_pending_action(&conn, &pending_id, false, "approver").unwrap();
assert!(transitioned, "deny transition must succeed on pending row");
assert_eq!(
count_signed_events(&conn, "pending_action.denied"),
1,
"deny must append one audit row"
);
assert_eq!(count_signed_events(&conn, "pending_action.approved"), 0);
assert_eq!(count_signed_events(&conn, "pending_action.timed_out"), 0);
}
#[test]
fn test_timeout_sweeper_emits_signed_event() {
let conn = test_db();
insert_stale_pending(&conn, "stale-a", "ns/x", 7_200, None);
insert_stale_pending(&conn, "stale-b", "ns/y", 7_200, None);
insert_stale_pending(&conn, "fresh-c", "ns/z", 30, None);
let expired = sweep_pending_action_timeouts(&conn, crate::SECS_PER_HOUR).unwrap();
assert_eq!(expired.len(), 2, "two stale rows must expire");
assert_eq!(
count_signed_events(&conn, "pending_action.timed_out"),
2,
"one audit row per expired pending row"
);
let fresh_status: String = conn
.query_row(
"SELECT status FROM pending_actions WHERE id = 'fresh-c'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(fresh_status, "pending");
}
fn count_signed_events_of_type(conn: &Connection, event_type: &str) -> i64 {
conn.query_row(
"SELECT COUNT(*) FROM signed_events WHERE event_type = ?1",
params![event_type],
|r| r.get(0),
)
.unwrap()
}
#[test]
fn test_memory_link_created_emits_signed_event_unsigned_path() {
let conn = test_db();
let src = make_memory("s4info2-src-u", "test", Tier::Long, 5);
let tgt = make_memory("s4info2-tgt-u", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
let before = count_signed_events_of_type(&conn, "memory_link.created");
create_link_signed(&conn, &src.id, &tgt.id, "related_to", None).unwrap();
let after = count_signed_events_of_type(&conn, "memory_link.created");
assert_eq!(after, before + 1, "unsigned create must emit one audit row");
let (attest, sig): (String, Option<Vec<u8>>) = conn
.query_row(
"SELECT attest_level, signature FROM signed_events \
WHERE event_type = 'memory_link.created' \
ORDER BY timestamp DESC LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(attest, "unsigned");
assert!(sig.is_none(), "unsigned create must emit NULL signature");
}
#[test]
fn test_memory_link_created_emits_signed_event_signed_path() {
use crate::identity::{keypair, sign as link_sign};
let conn = test_db();
let src = make_memory("s4info2-src-s", "test", Tier::Long, 5);
let tgt = make_memory("s4info2-tgt-s", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
let kp = keypair::generate("alice").unwrap();
create_link_signed(&conn, &src.id, &tgt.id, "supersedes", Some(&kp)).unwrap();
let (link_sig, valid_from): (Vec<u8>, String) = conn
.query_row(
"SELECT signature, valid_from FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2",
params![&src.id, &tgt.id],
|r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?)),
)
.unwrap();
let signable = link_sign::SignableLink {
src_id: &src.id,
dst_id: &tgt.id,
relation: "supersedes",
observed_by: Some(kp.agent_id.as_str()),
valid_from: Some(valid_from.as_str()),
valid_until: None,
};
let expected_hash = crate::signed_events::payload_hash(
&link_sign::canonical_cbor(&signable).expect("cbor"),
);
let (agent, attest, sig, payload): (String, String, Option<Vec<u8>>, Vec<u8>) = conn
.query_row(
"SELECT agent_id, attest_level, signature, payload_hash \
FROM signed_events \
WHERE event_type = 'memory_link.created' \
ORDER BY timestamp DESC LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(agent, "alice");
assert_eq!(attest, "self_signed");
assert_eq!(
sig.as_deref(),
Some(link_sig.as_slice()),
"audit row signature must mirror memory_links.signature byte-for-byte"
);
assert_eq!(
payload, expected_hash,
"audit row payload_hash must SHA-256 the canonical CBOR H2 signed over"
);
}
#[test]
fn test_memory_link_created_emit_is_idempotent_on_replay() {
let conn = test_db();
let src = make_memory("s4info2-src-d", "test", Tier::Long, 5);
let tgt = make_memory("s4info2-tgt-d", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
create_link_signed(&conn, &src.id, &tgt.id, "related_to", None).unwrap();
let after_first = count_signed_events_of_type(&conn, "memory_link.created");
create_link_signed(&conn, &src.id, &tgt.id, "related_to", None).unwrap();
let after_second = count_signed_events_of_type(&conn, "memory_link.created");
assert_eq!(
after_second, after_first,
"duplicate (src,dst,relation) replay must not emit a second audit row"
);
}
#[test]
fn test_create_link_inbound_emits_signed_event() {
let conn = test_db();
let src = make_memory("s4info2-in-src", "test", Tier::Long, 5);
let tgt = make_memory("s4info2-in-tgt", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
let now = chrono::Utc::now().to_rfc3339();
let link = MemoryLink {
source_id: src.id.clone(),
target_id: tgt.id.clone(),
relation: crate::models::MemoryLinkRelation::RelatedTo,
created_at: now.clone(),
signature: None,
observed_by: Some("peer-bob".to_string()),
valid_from: Some(now.clone()),
valid_until: None,
attest_level: None,
};
let before = count_signed_events_of_type(&conn, "memory_link.created");
create_link_inbound(&conn, &link, "unsigned").unwrap();
let after = count_signed_events_of_type(&conn, "memory_link.created");
assert_eq!(after, before + 1);
let agent: String = conn
.query_row(
"SELECT agent_id FROM signed_events \
WHERE event_type = 'memory_link.created' \
ORDER BY timestamp DESC LIMIT 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
agent, "peer-bob",
"inbound emit must record the peer's claimed observed_by"
);
}
#[test]
fn test_create_link_signed_emit_failure_does_not_roll_back() {
let conn = test_db();
let src = make_memory("s4info2-fail-src", "test", Tier::Long, 5);
let tgt = make_memory("s4info2-fail-tgt", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &tgt).unwrap();
conn.execute("DROP TABLE signed_events", []).unwrap();
let result = create_link_signed(&conn, &src.id, &tgt.id, "related_to", None);
assert!(
result.is_ok(),
"audit emit failure must not crater the link create: {result:?}"
);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memory_links \
WHERE source_id = ?1 AND target_id = ?2",
params![&src.id, &tgt.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(
count, 1,
"link row must have committed despite audit failure"
);
}
#[test]
fn l1_1_migration_backfill_sets_reflection_kind() {
let conn = test_db();
let now = chrono::Utc::now().to_rfc3339();
let id = uuid::Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, \
confidence, source, access_count, created_at, updated_at, metadata, \
reflection_depth, memory_kind) \
VALUES (?1,'mid','ns','backfill-test','content','[]',5,1.0,'test',0,?2,?2,?3,0,'observation')",
rusqlite::params![id, now, r#"{"type":"reflection"}"#],
)
.unwrap();
let before: String = conn
.query_row(
"SELECT memory_kind FROM memories WHERE id = ?1",
[&id],
|r| r.get(0),
)
.unwrap();
assert_eq!(before, "observation");
conn.execute(
"UPDATE memories SET memory_kind = 'reflection' \
WHERE memory_kind = 'observation' \
AND json_valid(metadata) \
AND json_extract(metadata, '$.type') = 'reflection'",
[],
)
.unwrap();
let after: String = conn
.query_row(
"SELECT memory_kind FROM memories WHERE id = ?1",
[&id],
|r| r.get(0),
)
.unwrap();
assert_eq!(
after, "reflection",
"backfill must upgrade metadata.type=reflection rows to memory_kind=reflection"
);
}
#[test]
fn l1_1_migration_backfill_leaves_non_reflection_rows_alone() {
let conn = test_db();
let now = chrono::Utc::now().to_rfc3339();
let id = uuid::Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO memories (id, tier, namespace, title, content, tags, priority, \
confidence, source, access_count, created_at, updated_at, metadata, \
reflection_depth, memory_kind) \
VALUES (?1,'mid','ns','obs-test','content','[]',5,1.0,'test',0,?2,?2,'{}',0,'observation')",
rusqlite::params![id, now],
)
.unwrap();
conn.execute(
"UPDATE memories SET memory_kind = 'reflection' \
WHERE memory_kind = 'observation' \
AND json_valid(metadata) \
AND json_extract(metadata, '$.type') = 'reflection'",
[],
)
.unwrap();
let after: String = conn
.query_row(
"SELECT memory_kind FROM memories WHERE id = ?1",
[&id],
|r| r.get(0),
)
.unwrap();
assert_eq!(
after, "observation",
"backfill must not change rows without metadata.type=reflection"
);
}
#[test]
fn l1_1_memories_by_kind_returns_correct_subset() {
let conn = test_db();
let obs = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "kind-ns".to_string(),
title: "obs-memory".to_string(),
content: "observation content".to_string(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let ref_mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "kind-ns".to_string(),
title: "ref-memory".to_string(),
content: "reflection content".to_string(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
reflection_depth: 1,
memory_kind: crate::models::MemoryKind::Reflection,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
insert(&conn, &obs).unwrap();
insert(&conn, &ref_mem).unwrap();
let obs_rows = memories_by_kind(&conn, &crate::models::MemoryKind::Observation).unwrap();
let ref_rows = memories_by_kind(&conn, &crate::models::MemoryKind::Reflection).unwrap();
assert!(
obs_rows
.iter()
.all(|m| m.memory_kind == crate::models::MemoryKind::Observation),
"memories_by_kind(Observation) must return only Observation memories"
);
assert!(
ref_rows
.iter()
.all(|m| m.memory_kind == crate::models::MemoryKind::Reflection),
"memories_by_kind(Reflection) must return only Reflection memories"
);
assert!(
obs_rows.iter().any(|m| m.title == "obs-memory"),
"obs-memory must be in Observation results"
);
assert!(
ref_rows.iter().any(|m| m.title == "ref-memory"),
"ref-memory must be in Reflection results"
);
assert!(
!ref_rows.iter().any(|m| m.title == "obs-memory"),
"obs-memory must not appear in Reflection results"
);
}
#[test]
fn l1_1_memory_kind_roundtrips_through_insert_get() {
let conn = test_db();
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Long,
namespace: "roundtrip-ns".to_string(),
title: "kind-roundtrip".to_string(),
content: "roundtrip content".to_string(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
reflection_depth: 1,
memory_kind: crate::models::MemoryKind::Reflection,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
let id = insert(&conn, &mem).unwrap();
let got = get(&conn, &id)
.unwrap()
.expect("inserted memory must be found");
assert_eq!(
got.memory_kind,
crate::models::MemoryKind::Reflection,
"memory_kind=Reflection must roundtrip through insert→get"
);
}
#[test]
fn l1_1_upsert_preserves_reflection_kind() {
let conn = test_db();
let now = chrono::Utc::now().to_rfc3339();
let id = uuid::Uuid::new_v4().to_string();
let mem_reflection = Memory {
id: id.clone(),
tier: Tier::Long,
namespace: "sticky-ns".to_string(),
title: "sticky-title".to_string(),
content: "original content".to_string(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now.clone(),
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
reflection_depth: 1,
memory_kind: crate::models::MemoryKind::Reflection,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
insert(&conn, &mem_reflection).unwrap();
let mem_obs = Memory {
id: uuid::Uuid::new_v4().to_string(), tier: Tier::Long,
namespace: "sticky-ns".to_string(),
title: "sticky-title".to_string(),
content: "updated content".to_string(),
tags: vec![],
priority: 6,
confidence: 1.0,
source: "test".to_string(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({}),
reflection_depth: 0,
memory_kind: crate::models::MemoryKind::Observation,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
insert(&conn, &mem_obs).unwrap();
let got = get(&conn, &id)
.unwrap()
.expect("original memory must still exist");
assert_eq!(
got.memory_kind,
crate::models::MemoryKind::Reflection,
"upsert with Observation must not overwrite an existing Reflection kind"
);
}
#[test]
fn strongest_attest_returns_unsigned_for_isolate_source() {
let conn = test_db();
let lonely = make_memory("lonely", "test", Tier::Long, 5);
insert(&conn, &lonely).unwrap();
let got = strongest_attest_level_for_source(&conn, &lonely.id).unwrap();
assert_eq!(got, "unsigned");
}
#[test]
fn strongest_attest_picks_self_signed_over_unsigned() {
use crate::identity::keypair;
let _gate = crate::config::lock_permissions_mode_for_test();
let conn = test_db();
let src = make_memory("attest-src", "test", Tier::Long, 5);
let a = make_memory("attest-a", "test", Tier::Long, 5);
let b = make_memory("attest-b", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &a).unwrap();
insert(&conn, &b).unwrap();
create_link_signed(&conn, &src.id, &a.id, "related_to", None).unwrap();
let kp = keypair::generate("alice").unwrap();
create_link_signed(&conn, &src.id, &b.id, "supersedes", Some(&kp)).unwrap();
let got = strongest_attest_level_for_source(&conn, &src.id).unwrap();
assert_eq!(got, "self_signed", "self_signed beats unsigned");
}
#[test]
fn strongest_attest_picks_peer_attested_over_self_signed() {
let conn = test_db();
let src = make_memory("attest-pa-src", "test", Tier::Long, 5);
let a = make_memory("attest-pa-a", "test", Tier::Long, 5);
let b = make_memory("attest-pa-b", "test", Tier::Long, 5);
insert(&conn, &src).unwrap();
insert(&conn, &a).unwrap();
insert(&conn, &b).unwrap();
let kp = crate::identity::keypair::generate("alice").unwrap();
create_link_signed(&conn, &src.id, &a.id, "related_to", Some(&kp)).unwrap();
let now = chrono::Utc::now().to_rfc3339();
let sig = vec![0xAB_u8; 64];
conn.execute(
"INSERT INTO memory_links \
(source_id, target_id, relation, created_at, valid_from, signature, attest_level, observed_by) \
VALUES (?1, ?2, 'related_to', ?3, ?3, ?4, 'peer_attested', 'peer-bob')",
params![&src.id, &b.id, &now, &sig],
)
.unwrap();
let got = strongest_attest_level_for_source(&conn, &src.id).unwrap();
assert_eq!(got, "peer_attested", "peer_attested beats self_signed");
}
#[test]
fn ck_trigger_refuses_self_signed_insert_without_signature() {
let conn = test_db();
let s = make_memory("ck-src", "test", Tier::Long, 5);
let t = make_memory("ck-tgt", "test", Tier::Long, 5);
insert(&conn, &s).unwrap();
insert(&conn, &t).unwrap();
let now = chrono::Utc::now().to_rfc3339();
let res = conn.execute(
"INSERT INTO memory_links \
(source_id, target_id, relation, created_at, valid_from, signature, attest_level) \
VALUES (?1, ?2, 'related_to', ?3, ?3, NULL, 'self_signed')",
params![&s.id, &t.id, &now],
);
let err = res.expect_err("CHECK trigger must reject self_signed + NULL signature");
let msg = format!("{err}");
assert!(
msg.contains("CHECK constraint failed")
|| msg.contains("attest_level")
|| msg.contains("64-byte signature"),
"trigger error must name the failure mode, got: {msg}"
);
}
#[test]
fn ck_trigger_refuses_self_signed_insert_with_wrong_length_signature() {
let conn = test_db();
let s = make_memory("ck-src-wlen", "test", Tier::Long, 5);
let t = make_memory("ck-tgt-wlen", "test", Tier::Long, 5);
insert(&conn, &s).unwrap();
insert(&conn, &t).unwrap();
let now = chrono::Utc::now().to_rfc3339();
let res = conn.execute(
"INSERT INTO memory_links \
(source_id, target_id, relation, created_at, valid_from, signature, attest_level) \
VALUES (?1, ?2, 'related_to', ?3, ?3, ?4, 'self_signed')",
params![&s.id, &t.id, &now, &[0u8; 8][..]],
);
assert!(
res.is_err(),
"CHECK trigger must reject wrong-length signature"
);
}
#[test]
fn ck_trigger_refuses_update_to_self_signed_without_signature() {
let conn = test_db();
let s = make_memory("ck-upd-src", "test", Tier::Long, 5);
let t = make_memory("ck-upd-tgt", "test", Tier::Long, 5);
insert(&conn, &s).unwrap();
insert(&conn, &t).unwrap();
create_link_signed(&conn, &s.id, &t.id, "related_to", None).unwrap();
let res = conn.execute(
"UPDATE memory_links SET attest_level = 'self_signed' \
WHERE source_id = ?1 AND target_id = ?2",
params![&s.id, &t.id],
);
assert!(
res.is_err(),
"CHECK trigger must reject UPDATE to self_signed with NULL signature"
);
}
#[test]
fn ck_trigger_admits_unsigned_with_null_signature() {
let conn = test_db();
let s = make_memory("ck-unsigned-src", "test", Tier::Long, 5);
let t = make_memory("ck-unsigned-tgt", "test", Tier::Long, 5);
insert(&conn, &s).unwrap();
insert(&conn, &t).unwrap();
create_link_signed(&conn, &s.id, &t.id, "related_to", None)
.expect("unsigned create must still succeed under the new CHECK trigger");
}
#[test]
fn agent_pubkey_none_before_bind_and_some_after() {
let conn = test_db();
register_agent(&conn, "ai:curator", "ai:generic", &[]).expect("register");
assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), None);
let kp = crate::identity::keypair::generate("ai:curator").expect("generate");
let b64 = kp.public_base64();
bind_agent_pubkey(&conn, "ai:curator", &b64).expect("bind");
assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), Some(b64));
}
#[test]
fn agent_pubkey_none_for_unregistered_agent() {
let conn = test_db();
assert_eq!(agent_pubkey(&conn, "ai:ghost").unwrap(), None);
}
#[test]
fn bind_agent_pubkey_rejects_unregistered_agent() {
let conn = test_db();
let err = bind_agent_pubkey(&conn, "ai:ghost", "AAAA").unwrap_err();
assert!(
err.to_string().contains("not registered"),
"binding to an unregistered agent must be rejected; got: {err}",
);
}
#[test]
fn bind_agent_pubkey_rotates_key_in_place() {
let conn = test_db();
register_agent(&conn, "ai:curator", "ai:generic", &[]).expect("register");
let k1 = crate::identity::keypair::generate("ai:curator")
.unwrap()
.public_base64();
let k2 = crate::identity::keypair::generate("ai:curator")
.unwrap()
.public_base64();
assert_ne!(k1, k2, "two fresh keys differ");
bind_agent_pubkey(&conn, "ai:curator", &k1).expect("bind k1");
assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), Some(k1));
bind_agent_pubkey(&conn, "ai:curator", &k2).expect("rotate to k2");
assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), Some(k2));
}
#[test]
fn bind_agent_pubkey_preserves_registration_fields() {
let conn = test_db();
register_agent(
&conn,
"ai:curator",
"ai:claude-opus",
&["recall".to_string(), "write".to_string()],
)
.expect("register");
let before = list_agents(&conn).expect("list before");
let kp = crate::identity::keypair::generate("ai:curator").unwrap();
bind_agent_pubkey(&conn, "ai:curator", &kp.public_base64()).expect("bind");
let after = list_agents(&conn).expect("list after");
let a_before = before
.iter()
.find(|a| a.agent_id == "ai:curator")
.expect("present before");
let a_after = after
.iter()
.find(|a| a.agent_id == "ai:curator")
.expect("present after");
assert_eq!(a_after.agent_type, a_before.agent_type);
assert_eq!(a_after.capabilities, a_before.capabilities);
assert_eq!(a_after.registered_at, a_before.registered_at);
}
#[test]
fn revoke_agent_pubkey_clears_bound_key() {
let conn = test_db();
register_agent(&conn, "ai:curator", "ai:generic", &[]).expect("register");
let kp = crate::identity::keypair::generate("ai:curator").unwrap();
bind_agent_pubkey(&conn, "ai:curator", &kp.public_base64()).expect("bind");
assert!(agent_pubkey(&conn, "ai:curator").unwrap().is_some());
revoke_agent_pubkey(&conn, "ai:curator").expect("revoke");
assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), None);
}
#[test]
fn revoke_agent_pubkey_is_idempotent_without_bound_key() {
let conn = test_db();
register_agent(&conn, "ai:curator", "ai:generic", &[]).expect("register");
revoke_agent_pubkey(&conn, "ai:curator").expect("revoke unbound");
assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), None);
}
#[test]
fn revoke_agent_pubkey_rejects_unregistered_agent() {
let conn = test_db();
let err = revoke_agent_pubkey(&conn, "ai:ghost").unwrap_err();
assert!(
err.to_string().contains("not registered"),
"revoking an unregistered agent must be rejected; got: {err}",
);
}
#[test]
fn revoke_agent_pubkey_preserves_registration_fields() {
let conn = test_db();
register_agent(
&conn,
"ai:curator",
"ai:claude-opus",
&["recall".to_string(), "write".to_string()],
)
.expect("register");
let kp = crate::identity::keypair::generate("ai:curator").unwrap();
bind_agent_pubkey(&conn, "ai:curator", &kp.public_base64()).expect("bind");
revoke_agent_pubkey(&conn, "ai:curator").expect("revoke");
let after = list_agents(&conn).expect("list after");
let a = after
.iter()
.find(|a| a.agent_id == "ai:curator")
.expect("present after revoke");
assert_eq!(a.agent_type, "ai:claude-opus");
assert_eq!(
a.capabilities,
vec!["recall".to_string(), "write".to_string()]
);
}
#[test]
fn revoke_then_rebind_restores_attestable_key() {
let conn = test_db();
register_agent(&conn, "ai:curator", "ai:generic", &[]).expect("register");
let k1 = crate::identity::keypair::generate("ai:curator")
.unwrap()
.public_base64();
bind_agent_pubkey(&conn, "ai:curator", &k1).expect("bind k1");
revoke_agent_pubkey(&conn, "ai:curator").expect("revoke");
assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), None);
let k2 = crate::identity::keypair::generate("ai:curator")
.unwrap()
.public_base64();
bind_agent_pubkey(&conn, "ai:curator", &k2).expect("rebind k2");
assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), Some(k2));
}
}