1#![allow(clippy::too_many_lines)]
10
11use crate::models::field_names;
12use anyhow::{Context, Result};
13use chrono::{DateTime, Utc};
14use rusqlite::{Connection, params};
15use std::collections::HashMap;
16use std::path::Path;
17
18const SQL_DELETE_MEMORY_BY_ID: &str = "DELETE FROM memories WHERE id = ?1";
20const SQL_DELETE_NAMESPACE_META_BY_STANDARD_ID: &str =
21 "DELETE FROM namespace_meta WHERE standard_id = ?1";
22const SQL_MEMORY_EXISTS_COUNT: &str = "SELECT COUNT(*) > 0 FROM memories WHERE id = ?1";
23const SQL_MEMORY_EXISTS: &str = "SELECT EXISTS(SELECT 1 FROM memories WHERE id = ?1)";
24const SQL_SELECT_MEMORY_ROW_BY_ID: &str = "SELECT * FROM memories WHERE id = ?1";
25const SQL_LIST_BASE: &str = "SELECT * FROM memories WHERE (expires_at IS NULL OR expires_at > ?)";
32const SQL_LIST_ORDER_LIMIT: &str = " ORDER BY priority DESC, updated_at DESC LIMIT ? OFFSET ?";
33
34#[must_use]
46pub fn truncate_to_microseconds(t: DateTime<Utc>) -> DateTime<Utc> {
47 use chrono::Timelike;
48 let micros = t.nanosecond() / 1_000;
49 t.with_nanosecond(micros * 1_000).unwrap_or(t)
50}
51
52use crate::models::{
53 AGENTS_NAMESPACE, AgentRegistration, Approval, ApproverType, ConfidenceSource, DuplicateCheck,
54 DuplicateMatch, GovernanceDecision, GovernanceLevel, GovernancePolicy, GovernedAction,
55 MAX_NAMESPACE_DEPTH, Memory, MemoryKind, MemoryLink, NamespaceCount, PROMOTION_THRESHOLD,
56 PendingAction, SourceSpan, Stats, Taxonomy, TaxonomyNode, Tier, TierCount, namespace_ancestors,
57};
58
59mod error;
66pub use error::{LINK_CYCLE_ERR_PREFIX, LINK_PERMISSION_DENIED_ERR_PREFIX, LinkEnd, StorageError};
67
68pub static GOVERNANCE_PRE_WRITE: std::sync::OnceLock<
115 Box<dyn Fn(&Memory) -> std::result::Result<(), String> + Send + Sync>,
116> = std::sync::OnceLock::new();
117
118#[derive(Debug, Clone)]
128pub struct GovernanceRefusal {
129 pub reason: String,
130}
131
132impl std::fmt::Display for GovernanceRefusal {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 write!(f, "governance-refused: {}", self.reason)
135 }
136}
137
138impl std::error::Error for GovernanceRefusal {}
139
140#[inline]
157pub(crate) fn consult_governance_pre_write(mem: &Memory) -> Result<()> {
158 if let Some(hook) = GOVERNANCE_PRE_WRITE.get() {
159 if let Err(reason) = hook(mem) {
160 return Err(anyhow::Error::new(GovernanceRefusal { reason }));
161 }
162 }
163 Ok(())
164}
165
166type VisibilityPrefixes = (
170 Option<String>,
171 Option<String>,
172 Option<String>,
173 Option<String>,
174);
175
176fn compute_visibility_prefixes(as_agent: Option<&str>) -> VisibilityPrefixes {
177 let Some(ns) = as_agent else {
178 return (None, None, None, None);
179 };
180 let ancestors = namespace_ancestors(ns);
181 let p = ancestors.first().cloned();
182 let t = ancestors.get(1).cloned();
183 let u = ancestors.get(2).cloned();
184 let o = ancestors.get(3).cloned();
185 (p, t, u, o)
186}
187
188fn is_visible(mem: &Memory, prefixes: &VisibilityPrefixes) -> bool {
193 use crate::models::namespace::MemoryScope;
201 let (p, t, u, o) = prefixes;
202 if p.is_none() {
203 return true;
204 }
205 let Some(scope) = mem
206 .metadata
207 .get(crate::META_KEY_SCOPE)
208 .and_then(|v| v.as_str())
209 .map_or(Some(MemoryScope::default()), MemoryScope::from_str)
210 else {
211 return false;
212 };
213 match scope {
214 MemoryScope::Collective => true,
215 MemoryScope::Private => p.as_ref().is_some_and(|ns| &mem.namespace == ns),
216 MemoryScope::Team => matches_subtree(&mem.namespace, t.as_deref()),
217 MemoryScope::Unit => matches_subtree(&mem.namespace, u.as_deref()),
218 MemoryScope::Org => matches_subtree(&mem.namespace, o.as_deref()),
219 }
220}
221
222fn matches_subtree(namespace: &str, prefix: Option<&str>) -> bool {
223 match prefix {
224 None => false,
225 Some(p) => namespace == p || namespace.starts_with(&format!("{p}/")),
226 }
227}
228
229fn archived_source_clause(include_archived: bool, table_alias: &str) -> &'static str {
275 if include_archived {
276 ""
277 } else {
278 match table_alias {
288 "m" => {
289 "AND NOT (\
290 m.atomised_into IS NOT NULL AND m.atomised_into > 0 \
291 AND json_extract(m.metadata, '$.atomisation_archived_at') IS NOT NULL\
292 )"
293 }
294 "memories" => {
295 "AND NOT (\
296 memories.atomised_into IS NOT NULL AND memories.atomised_into > 0 \
297 AND json_extract(memories.metadata, '$.atomisation_archived_at') IS NOT NULL\
298 )"
299 }
300 _ => "",
301 }
302 }
303}
304
305fn is_archived_source(mem: &Memory) -> bool {
325 mem.metadata
326 .get(field_names::ATOMISATION_ARCHIVED_AT)
327 .is_some_and(|v| !v.is_null())
328}
329
330fn visibility_clause(start: usize, table_alias: &str) -> String {
331 let private_ph = start;
332 let team_ph = start + 1;
333 let unit_ph = start + 2;
334 let org_ph = start + 3;
335 let ta = table_alias;
336 format!(
337 "AND (\
338 ?{private_ph} IS NULL \
339 OR {ta}.scope_idx = 'collective' \
340 OR ({ta}.scope_idx = 'private' AND {ta}.namespace = ?{private_ph}) \
341 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 '\\')) \
342 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 '\\')) \
343 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 '\\'))\
344 )"
345 )
346}
347
348fn escape_like_pattern(s: &str) -> String {
355 let mut out = String::with_capacity(s.len());
356 for ch in s.chars() {
357 match ch {
358 '\\' | '%' | '_' => {
359 out.push('\\');
360 out.push(ch);
361 }
362 _ => out.push(ch),
363 }
364 }
365 out
366}
367
368pub(crate) mod connection;
374pub mod migration_meta;
380pub mod migrations;
381pub(crate) mod reflect;
382
383pub use connection::open;
387pub use connection::{DEFAULT_DB_MMAP_SIZE_BYTES, set_db_mmap_size};
392pub use migrations::current_schema_version_for_tests;
398pub use migrations::pre_migration_backup_infix_for_tests;
401pub use reflect::{
402 ReflectError, ReflectHookDecision, ReflectHooks, ReflectInput, ReflectOutcome,
403 canonical_cbor_reflection_depth_exceeded, reflect, reflect_with_hooks,
404};
405#[allow(unused_imports)]
413pub(crate) use reflect::emit_reflection_depth_exceeded_audit;
414
415pub(crate) fn row_to_memory(row: &rusqlite::Row) -> rusqlite::Result<Memory> {
416 let row_id: String = row.get("id")?;
417 let tags_json: String = row.get("tags")?;
418 let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
419 let tier_str: String = row.get("tier")?;
420 let tier = Tier::from_str(&tier_str).unwrap_or(Tier::Mid);
421 let metadata_str: String = row
422 .get::<_, String>("metadata")
423 .unwrap_or_else(|_| "{}".to_string());
424 let metadata: serde_json::Value = serde_json::from_str(&metadata_str).unwrap_or_else(|e| {
425 tracing::warn!(
426 row_id = %row_id,
427 column = "metadata",
428 error = %e,
429 "corrupt metadata in DB row, defaulting to {{}}"
430 );
431 crate::metrics::record_corrupt_provenance("metadata");
432 serde_json::json!({})
433 });
434 let citations = match row.get::<_, String>("citations").ok() {
441 Some(s) => match serde_json::from_str::<Vec<crate::models::Citation>>(&s) {
442 Ok(v) => v,
443 Err(e) => {
444 tracing::warn!(
445 row_id = %row_id,
446 column = "citations",
447 error = %e,
448 "corrupt citations JSON in DB row, defaulting to []"
449 );
450 crate::metrics::record_corrupt_provenance("citations");
451 Vec::new()
452 }
453 },
454 None => Vec::new(),
455 };
456 let source_span: Option<SourceSpan> = row
457 .get::<_, Option<String>>(field_names::SOURCE_SPAN)
458 .unwrap_or(None)
459 .and_then(|s| match serde_json::from_str::<SourceSpan>(&s) {
460 Ok(span) => Some(span),
461 Err(e) => {
462 tracing::warn!(
463 row_id = %row_id,
464 column = field_names::SOURCE_SPAN,
465 error = %e,
466 "corrupt source_span JSON in DB row, defaulting to None"
467 );
468 crate::metrics::record_corrupt_provenance(field_names::SOURCE_SPAN);
469 None
470 }
471 });
472 let confidence_signals = row
473 .get::<_, Option<String>>(field_names::CONFIDENCE_SIGNALS)
474 .unwrap_or(None)
475 .and_then(
476 |s| match serde_json::from_str::<crate::models::ConfidenceSignals>(&s) {
477 Ok(v) => Some(v),
478 Err(e) => {
479 tracing::warn!(
480 row_id = %row_id,
481 column = field_names::CONFIDENCE_SIGNALS,
482 error = %e,
483 "corrupt confidence_signals JSON in DB row, defaulting to None"
484 );
485 crate::metrics::record_corrupt_provenance(field_names::CONFIDENCE_SIGNALS);
486 None
487 }
488 },
489 );
490 Ok(Memory {
491 id: row_id,
492 tier,
493 namespace: row.get("namespace")?,
494 title: row.get("title")?,
495 content: row.get("content")?,
496 tags,
497 priority: row.get("priority")?,
498 confidence: row.get(field_names::CONFIDENCE).unwrap_or(1.0),
499 source: row.get("source").unwrap_or_else(|_| "api".to_string()),
500 access_count: row.get(field_names::ACCESS_COUNT)?,
501 created_at: row.get(field_names::CREATED_AT)?,
502 updated_at: row.get(field_names::UPDATED_AT)?,
503 last_accessed_at: row.get(field_names::LAST_ACCESSED_AT)?,
504 expires_at: row.get(field_names::EXPIRES_AT)?,
505 metadata,
506 reflection_depth: row.get(field_names::REFLECTION_DEPTH).unwrap_or(0_i32),
511 memory_kind: row
515 .get::<_, String>(field_names::MEMORY_KIND)
516 .ok()
517 .and_then(|s| crate::models::MemoryKind::from_str(&s))
518 .unwrap_or_default(),
519 entity_id: row.get::<_, Option<String>>("entity_id").unwrap_or(None),
524 persona_version: row
525 .get::<_, Option<i32>>(field_names::PERSONA_VERSION)
526 .unwrap_or(None),
527 citations,
533 source_uri: row
534 .get::<_, Option<String>>(field_names::SOURCE_URI)
535 .unwrap_or(None),
536 source_span,
537 confidence_source: row
543 .get::<_, String>(field_names::CONFIDENCE_SOURCE)
544 .ok()
545 .and_then(|s| crate::models::ConfidenceSource::from_str(&s))
546 .unwrap_or_default(),
547 confidence_signals,
548 confidence_decayed_at: row
549 .get::<_, Option<String>>(field_names::CONFIDENCE_DECAYED_AT)
550 .unwrap_or(None),
551 version: row.get::<_, i64>("version").unwrap_or(1),
557 })
558}
559
560pub(crate) fn extract_mentioned_entity_id(mem: &Memory) -> Option<String> {
589 if mem.memory_kind != MemoryKind::Reflection {
590 return None;
591 }
592 if let Some(eid) = mem
594 .metadata
595 .get("entity_id")
596 .and_then(|v| v.as_str())
597 .map(str::trim)
598 .filter(|s| !s.is_empty())
599 {
600 return Some(eid.to_string());
601 }
602 if let Some(start) = mem.title.find("[entity:") {
606 let rest = &mem.title[start + "[entity:".len()..];
607 if let Some(end) = rest.find(']') {
608 let extracted = rest[..end].trim();
609 if !extracted.is_empty() {
610 return Some(extracted.to_string());
611 }
612 }
613 }
614 None
615}
616
617pub fn insert(conn: &Connection, mem: &Memory) -> Result<String> {
625 consult_governance_pre_write(mem)?;
630
631 let tags_json = serde_json::to_string(&mem.tags)?;
632 let metadata_json = serde_json::to_string(&mem.metadata)?;
633 let citations_json = serde_json::to_string(&mem.citations)?;
638 let source_span_json = match mem.source_span {
639 Some(span) => Some(serde_json::to_string(&span)?),
640 None => None,
641 };
642 let confidence_signals_json = match &mem.confidence_signals {
648 Some(s) => Some(serde_json::to_string(s)?),
649 None => None,
650 };
651 let mentioned_entity_id = extract_mentioned_entity_id(mem);
656 let mut insert_stmt = conn.prepare_cached(
661 "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)
662 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)
663 ON CONFLICT(title, namespace) DO UPDATE SET
664 content = excluded.content,
665 tags = excluded.tags,
666 priority = MAX(memories.priority, excluded.priority),
667 confidence = MAX(memories.confidence, excluded.confidence),
668 source = excluded.source,
669 tier = CASE WHEN excluded.tier = 'long' THEN 'long'
670 WHEN memories.tier = 'long' THEN 'long'
671 WHEN excluded.tier = 'mid' THEN 'mid'
672 ELSE memories.tier END,
673 updated_at = excluded.updated_at,
674 expires_at = CASE WHEN excluded.tier = 'long' OR memories.tier = 'long' THEN NULL
675 ELSE COALESCE(excluded.expires_at, memories.expires_at) END,
676 -- Preserve metadata.agent_id across upsert (NHI provenance is immutable).
677 metadata = CASE
678 WHEN json_extract(memories.metadata, '$.agent_id') IS NOT NULL
679 THEN json_set(
680 excluded.metadata,
681 '$.agent_id',
682 json_extract(memories.metadata, '$.agent_id')
683 )
684 ELSE excluded.metadata
685 END,
686 -- v0.7.0 Task 1/8 — recursion depth takes the max across upsert
687 -- so a subsequent reflection at higher depth doesn't lose its
688 -- provenance signal when re-stored at the same (title, namespace).
689 reflection_depth = MAX(memories.reflection_depth, excluded.reflection_depth),
690 -- v0.7.0 L1-1 — kind is sticky: once Reflection, always Reflection.
691 -- An upsert of an observation onto an existing reflection row must
692 -- not downgrade the kind (reflect is not reversible by re-store).
693 -- v0.7.0 QW-2 — Persona is also sticky once set; the engine
694 -- writes new versions via fresh rows under a unique
695 -- `__persona_<entity>_v<n>` title rather than upsert.
696 memory_kind = CASE WHEN memories.memory_kind = 'reflection' THEN 'reflection'
697 WHEN memories.memory_kind = 'persona' THEN 'persona'
698 ELSE excluded.memory_kind END,
699 -- v0.7.0 QW-2 — entity_id + persona_version stay attached to
700 -- the row they were minted with (Persona-kind upserts use
701 -- versioned titles so the conflict path is exercised only
702 -- on accidental same-title collisions).
703 entity_id = COALESCE(memories.entity_id, excluded.entity_id),
704 persona_version = COALESCE(memories.persona_version, excluded.persona_version),
705 -- v0.7.0 Form 4 — fact-provenance: when the incoming row
706 -- carries a non-empty citations array, replace the stored
707 -- value (caller re-asserted provenance); otherwise keep
708 -- the existing value (silent merge would lose freshly-cited
709 -- evidence). source_uri / source_span follow COALESCE
710 -- semantics so a new write that omits them does not blank
711 -- out existing provenance pointers.
712 citations = CASE WHEN excluded.citations = '[]'
713 THEN memories.citations
714 ELSE excluded.citations END,
715 source_uri = COALESCE(excluded.source_uri, memories.source_uri),
716 source_span = COALESCE(excluded.source_span, memories.source_span),
717 -- v0.7.0 Form 5 — confidence-provenance follows the same
718 -- shape as Form 4 columns: explicit non-default replaces;
719 -- caller_provided + NULL signals keep the existing
720 -- provenance signal so a re-store doesn't blank out an
721 -- auto-derived or calibrated value.
722 confidence_source = CASE WHEN excluded.confidence_source != 'caller_provided'
723 THEN excluded.confidence_source
724 ELSE memories.confidence_source END,
725 confidence_signals = COALESCE(excluded.confidence_signals, memories.confidence_signals),
726 confidence_decayed_at = COALESCE(excluded.confidence_decayed_at, memories.confidence_decayed_at),
727 -- v0.7.0 polish PERF-8 (#781) — denormalised mention tag.
728 -- COALESCE keeps any pre-existing tag (re-write that
729 -- omits the structured entity_id metadata should NOT
730 -- blank out the indexed column) while letting a fresh
731 -- extraction populate previously-NULL rows.
732 mentioned_entity_id = COALESCE(excluded.mentioned_entity_id, memories.mentioned_entity_id),
733 -- #1632 — upsert-merge IS a mutation (content/tags/priority
734 -- can change), so the Gap-1 optimistic-concurrency counter
735 -- bumps here exactly like db::update. Pre-#1632 a re-store
736 -- rewrote content while version stood still, so a stale
737 -- If-Match could overwrite the merge invisibly. The decay
738 -- sweep remains the only documented non-bumping mutator
739 -- (tests/non_version_bumping_sites_1036.rs).
740 version = memories.version + 1
741 RETURNING id",
742 )?;
743 let actual_id: String = insert_stmt.query_row(
744 params![
745 mem.id,
746 mem.tier.as_str(),
747 mem.namespace,
748 mem.title,
749 mem.content,
750 tags_json,
751 mem.priority,
752 mem.confidence,
753 mem.source,
754 mem.access_count,
755 mem.created_at,
756 mem.updated_at,
757 mem.last_accessed_at,
758 mem.effective_expires_at(),
759 metadata_json,
760 mem.reflection_depth,
761 mem.memory_kind.as_str(),
762 mem.entity_id,
763 mem.persona_version,
764 citations_json,
765 mem.source_uri,
766 source_span_json,
767 mem.confidence_source.as_str(),
768 confidence_signals_json,
769 mem.confidence_decayed_at,
770 mentioned_entity_id,
771 ],
772 |r| r.get(0),
773 )?;
774 Ok(actual_id)
775}
776
777#[derive(Debug, Clone, Copy, PartialEq, Eq)]
807pub enum ConflictMode {
808 Error,
811 Merge,
816 Version,
819}
820
821#[derive(Debug)]
826pub struct ConflictError {
827 pub existing_id: String,
828 pub title: String,
829 pub namespace: String,
830}
831
832impl std::fmt::Display for ConflictError {
833 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
834 write!(
835 f,
836 "CONFLICT: memory with title '{}' already exists in namespace '{}' \
837 (existing id: {})",
838 self.title, self.namespace, self.existing_id
839 )
840 }
841}
842
843impl std::error::Error for ConflictError {}
844
845pub fn capture_turn_idempotent(
866 conn: &Connection,
867 write: &crate::models::CaptureTurnWrite,
868) -> std::result::Result<crate::models::CaptureTurnResult, String> {
869 use rusqlite::OptionalExtension;
870
871 let existing: Option<String> = conn
874 .prepare_cached(
875 "SELECT memory_id FROM transcript_line_dedup \
876 WHERE host_session_id IS NOT NULL \
877 AND host_session_id = ?1 \
878 AND host_turn_index = ?2",
879 )
880 .and_then(|mut stmt| {
881 stmt.query_row(
882 params![&write.host_session_id, write.host_turn_index],
883 |row| row.get(0),
884 )
885 .optional()
886 })
887 .map_err(|e| format!("DEDUP_QUERY_FAILED: {e}"))?;
888
889 if let Some(memory_id) = existing {
890 return Ok(crate::models::CaptureTurnResult {
891 memory_id,
892 dedup_hit: true,
893 });
894 }
895
896 conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)
897 .map_err(|e| format!("TX_BEGIN_FAILED: {e}"))?;
898
899 let tx_result = (|| -> std::result::Result<String, String> {
900 let inserted_id =
901 insert(conn, &write.memory).map_err(|e| format!("MEMORY_INSERT_FAILED: {e}"))?;
902
903 conn.prepare_cached(
904 "INSERT INTO transcript_line_dedup \
905 (sha256, memory_id, host_kind, transcript_path, \
906 host_session_id, host_turn_index, recovered_at) \
907 VALUES (?1, ?2, ?3, NULL, ?4, ?5, ?6)",
908 )
909 .and_then(|mut stmt| {
910 stmt.execute(params![
911 write.sha256,
912 inserted_id,
913 write.host_kind,
914 write.host_session_id,
915 write.host_turn_index,
916 write.recovered_at_ms,
917 ])
918 })
919 .map_err(|e| format!("DEDUP_INSERT_FAILED: {e}"))?;
920
921 crate::signed_events::append_signed_event_no_tx(conn, &write.signed_event)
922 .map_err(|e| format!("SIGNED_EVENTS_APPEND_FAILED: {e}"))?;
923
924 Ok(inserted_id)
925 })();
926
927 match tx_result {
928 Ok(memory_id) => {
929 conn.execute_batch(connection::SQL_COMMIT)
930 .map_err(|e| format!("TX_COMMIT_FAILED: {e}"))?;
931 Ok(crate::models::CaptureTurnResult {
932 memory_id,
933 dedup_hit: false,
934 })
935 }
936 Err(e) => {
937 let _ = conn.execute_batch(connection::SQL_ROLLBACK);
938 Err(e)
939 }
940 }
941}
942
943pub fn insert_with_conflict(conn: &Connection, mem: &Memory, mode: ConflictMode) -> Result<String> {
963 match mode {
964 ConflictMode::Merge => insert(conn, mem),
965 ConflictMode::Error => {
966 consult_governance_pre_write(mem)?;
974 if let Some(existing_id) = find_by_title_namespace(conn, &mem.title, &mem.namespace)? {
990 return Err(ConflictError {
991 existing_id,
992 title: mem.title.clone(),
993 namespace: mem.namespace.clone(),
994 }
995 .into());
996 }
997 let tags_json = serde_json::to_string(&mem.tags)?;
998 let metadata_json = serde_json::to_string(&mem.metadata)?;
999 let citations_json = serde_json::to_string(&mem.citations)?;
1005 let source_span_json = match mem.source_span {
1006 Some(span) => Some(serde_json::to_string(&span)?),
1007 None => None,
1008 };
1009 let confidence_signals_json = match &mem.confidence_signals {
1013 Some(s) => Some(serde_json::to_string(s)?),
1014 None => None,
1015 };
1016 let mentioned_entity_id = extract_mentioned_entity_id(mem);
1022 let actual_id: String = conn.query_row(
1032 "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)
1033 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)
1034 RETURNING id",
1035 params![
1036 mem.id, mem.tier.as_str(), mem.namespace, mem.title, mem.content,
1037 tags_json, mem.priority, mem.confidence, mem.source, mem.access_count,
1038 mem.created_at, mem.updated_at, mem.last_accessed_at, mem.effective_expires_at(),
1039 metadata_json, mem.reflection_depth, mem.memory_kind.as_str(),
1040 mem.entity_id, mem.persona_version,
1041 citations_json, mem.source_uri, source_span_json,
1042 mem.confidence_source.as_str(), confidence_signals_json, mem.confidence_decayed_at,
1043 mentioned_entity_id,
1044 ],
1045 |r| r.get(0),
1046 ).map_err(|e| {
1047 let msg = e.to_string();
1052 if msg.contains("UNIQUE constraint failed") {
1053 anyhow::Error::new(ConflictError {
1054 existing_id: String::new(),
1055 title: mem.title.clone(),
1056 namespace: mem.namespace.clone(),
1057 })
1058 } else {
1059 e.into()
1060 }
1061 })?;
1062 Ok(actual_id)
1063 }
1064 ConflictMode::Version => {
1065 let resolved_title = next_versioned_title(conn, &mem.title, &mem.namespace)?;
1066 let mut versioned = mem.clone();
1067 versioned.title = resolved_title;
1068 insert(conn, &versioned)
1072 }
1073 }
1074}
1075
1076pub fn get(conn: &Connection, id: &str) -> Result<Option<Memory>> {
1077 let mut stmt = conn.prepare_cached(SQL_SELECT_MEMORY_ROW_BY_ID)?;
1078 let mut rows = stmt.query_map(params![id], row_to_memory)?;
1079 match rows.next() {
1080 Some(Ok(m)) => Ok(Some(m)),
1081 Some(Err(e)) => Err(e.into()),
1082 None => Ok(None),
1083 }
1084}
1085
1086pub fn get_many(conn: &Connection, ids: &[String]) -> Result<HashMap<String, Memory>> {
1109 let mut out: HashMap<String, Memory> = HashMap::with_capacity(ids.len());
1110 if ids.is_empty() {
1111 return Ok(out);
1112 }
1113 const CHUNK: usize = 500;
1114 for chunk in ids.chunks(CHUNK) {
1115 let placeholders = std::iter::repeat("?")
1116 .take(chunk.len())
1117 .collect::<Vec<_>>()
1118 .join(",");
1119 let sql = format!("SELECT * FROM memories WHERE id IN ({placeholders})");
1120 let mut stmt = conn.prepare(&sql)?;
1121 let rows = stmt.query_map(rusqlite::params_from_iter(chunk.iter()), row_to_memory)?;
1122 for r in rows {
1123 let mem = r?;
1124 out.insert(mem.id.clone(), mem);
1125 }
1126 }
1127 Ok(out)
1128}
1129
1130pub fn get_by_prefix(conn: &Connection, prefix: &str) -> Result<Option<Memory>> {
1133 let escaped = prefix.replace('%', "\\%").replace('_', "\\_");
1135 let pattern = format!("{escaped}%");
1136 let mut stmt = conn.prepare("SELECT * FROM memories WHERE id LIKE ?1 ESCAPE '\\'")?;
1137 let rows: Vec<Memory> = stmt
1138 .query_map(params![pattern], row_to_memory)?
1139 .filter_map(Result::ok)
1140 .collect();
1141 match rows.len() {
1142 0 => Ok(None),
1143 1 => Ok(Some(rows.into_iter().next().expect("len checked"))),
1144 _ => {
1145 let ids: Vec<String> = rows.iter().map(|m| m.id.clone()).collect();
1146 Err(anyhow::Error::new(StorageError::AmbiguousIdPrefix {
1152 prefix: prefix.to_string(),
1153 candidates: ids,
1154 }))
1155 }
1156 }
1157}
1158
1159pub fn resolve_id(conn: &Connection, id: &str) -> Result<Option<Memory>> {
1161 if let Some(mem) = get(conn, id)? {
1162 return Ok(Some(mem));
1163 }
1164 get_by_prefix(conn, id)
1165}
1166
1167pub fn touch(conn: &Connection, id: &str, short_extend: i64, mid_extend: i64) -> Result<()> {
1169 let now = Utc::now();
1170 let now_str = now.to_rfc3339();
1171 let short_expires = (now + chrono::Duration::seconds(short_extend)).to_rfc3339();
1172 let mid_expires = (now + chrono::Duration::seconds(mid_extend)).to_rfc3339();
1173
1174 conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
1175
1176 let result = (|| -> Result<()> {
1177 conn.execute(
1186 "UPDATE memories SET
1187 access_count = MIN(access_count + 1, 1000000),
1188 last_accessed_at = ?1,
1189 expires_at = CASE
1190 WHEN tier = 'long' THEN expires_at
1191 WHEN tier = 'short' AND expires_at IS NOT NULL THEN MAX(expires_at, ?2)
1192 WHEN tier = 'mid' AND expires_at IS NOT NULL THEN MAX(expires_at, ?3)
1193 ELSE expires_at
1194 END
1195 WHERE id = ?4",
1196 params![now_str, short_expires, mid_expires, id],
1197 )?;
1198
1199 conn.execute(
1200 "UPDATE memories SET tier = 'long', expires_at = NULL, updated_at = ?1
1201 WHERE id = ?2 AND tier = 'mid' AND access_count >= ?3",
1202 params![now_str, id, PROMOTION_THRESHOLD],
1203 )?;
1204
1205 conn.execute(
1206 "UPDATE memories SET priority = MIN(priority + 1, 10)
1207 WHERE id = ?1 AND access_count > 0 AND access_count % 10 = 0 AND priority < 10",
1208 params![id],
1209 )?;
1210
1211 Ok(())
1212 })();
1213
1214 match result {
1215 Ok(()) => {
1216 conn.execute_batch(connection::SQL_COMMIT)?;
1217 Ok(())
1218 }
1219 Err(e) => {
1220 if let Err(rb) = conn.execute_batch(connection::SQL_ROLLBACK) {
1221 tracing::error!("ROLLBACK failed in touch: {}", rb);
1222 }
1223 Err(e)
1224 }
1225 }
1226}
1227
1228pub fn touch_many(
1247 conn: &Connection,
1248 ids: &[&str],
1249 short_extend: i64,
1250 mid_extend: i64,
1251) -> Result<usize> {
1252 if ids.is_empty() {
1253 return Ok(0);
1254 }
1255 let now = Utc::now();
1256 let now_str = now.to_rfc3339();
1257 let short_expires = (now + chrono::Duration::seconds(short_extend)).to_rfc3339();
1258 let mid_expires = (now + chrono::Duration::seconds(mid_extend)).to_rfc3339();
1259
1260 conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
1261
1262 let result = (|| -> Result<()> {
1263 let mut bump_stmt = conn.prepare_cached(
1271 "UPDATE memories SET
1272 access_count = MIN(access_count + 1, 1000000),
1273 last_accessed_at = ?1,
1274 expires_at = CASE
1275 WHEN tier = 'long' THEN expires_at
1276 WHEN tier = 'short' AND expires_at IS NOT NULL THEN MAX(expires_at, ?2)
1277 WHEN tier = 'mid' AND expires_at IS NOT NULL THEN MAX(expires_at, ?3)
1278 ELSE expires_at
1279 END
1280 WHERE id = ?4",
1281 )?;
1282 let mut promote_stmt = conn.prepare_cached(
1283 "UPDATE memories SET tier = 'long', expires_at = NULL, updated_at = ?1
1284 WHERE id = ?2 AND tier = 'mid' AND access_count >= ?3",
1285 )?;
1286 let mut priority_stmt = conn.prepare_cached(
1287 "UPDATE memories SET priority = MIN(priority + 1, 10)
1288 WHERE id = ?1 AND access_count > 0 AND access_count % 10 = 0 AND priority < 10",
1289 )?;
1290 for id in ids {
1291 bump_stmt.execute(params![now_str, short_expires, mid_expires, id])?;
1292 promote_stmt.execute(params![now_str, id, PROMOTION_THRESHOLD])?;
1293 priority_stmt.execute(params![id])?;
1294 }
1295 Ok(())
1296 })();
1297
1298 match result {
1299 Ok(()) => {
1300 conn.execute_batch(connection::SQL_COMMIT)?;
1301 Ok(ids.len())
1302 }
1303 Err(e) => {
1304 if let Err(rb) = conn.execute_batch(connection::SQL_ROLLBACK) {
1305 tracing::error!("ROLLBACK failed in touch_many: {}", rb);
1306 }
1307 Err(e)
1308 }
1309 }
1310}
1311
1312#[allow(clippy::too_many_arguments)]
1313#[derive(Debug, Clone)]
1322pub struct VersionConflict {
1323 pub id: String,
1324 pub expected: i64,
1325 pub current: i64,
1326}
1327
1328impl std::fmt::Display for VersionConflict {
1329 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1330 write!(
1331 f,
1332 "CONFLICT: memory {} expected_version={} but stored version={}",
1333 self.id, self.expected, self.current
1334 )
1335 }
1336}
1337
1338impl std::error::Error for VersionConflict {}
1339
1340#[allow(clippy::too_many_arguments)]
1341pub fn update(
1342 conn: &Connection,
1343 id: &str,
1344 title: Option<&str>,
1345 content: Option<&str>,
1346 tier: Option<&Tier>,
1347 namespace: Option<&str>,
1348 tags: Option<&Vec<String>>,
1349 priority: Option<i32>,
1350 confidence: Option<f64>,
1351 expires_at: Option<&str>,
1352 metadata: Option<&serde_json::Value>,
1353) -> Result<(bool, bool)> {
1354 update_with_expected_version(
1355 conn, id, title, content, tier, namespace, tags, priority, confidence, expires_at,
1356 metadata, None, None,
1357 )
1358}
1359
1360#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1375pub fn update_with_expected_version(
1376 conn: &Connection,
1377 id: &str,
1378 title: Option<&str>,
1379 content: Option<&str>,
1380 tier: Option<&Tier>,
1381 namespace: Option<&str>,
1382 tags: Option<&Vec<String>>,
1383 priority: Option<i32>,
1384 confidence: Option<f64>,
1385 expires_at: Option<&str>,
1386 metadata: Option<&serde_json::Value>,
1387 source_uri: Option<&str>,
1388 expected_version: Option<i64>,
1389) -> Result<(bool, bool)> {
1390 let mut stmt = conn.prepare_cached(SQL_SELECT_MEMORY_ROW_BY_ID)?;
1391 let mut rows = stmt.query_map(params![id], row_to_memory)?;
1392 let Some(Ok(existing)) = rows.next() else {
1393 return Ok((false, false));
1394 };
1395 drop(rows);
1396 drop(stmt);
1397
1398 if let Some(expected) = expected_version
1403 && existing.version != expected
1404 {
1405 return Err(VersionConflict {
1406 id: existing.id.clone(),
1407 expected,
1408 current: existing.version,
1409 }
1410 .into());
1411 }
1412
1413 let new_title = title.unwrap_or(&existing.title);
1414 let new_content = content.unwrap_or(&existing.content);
1415 let content_changed = new_title != existing.title || new_content != existing.content;
1416
1417 let effective_tier = match (tier, &existing.tier) {
1419 (Some(requested), existing_tier) => match (existing_tier, requested) {
1420 (Tier::Long, _) => &Tier::Long, (Tier::Mid, Tier::Short) => &Tier::Mid, (_, requested) => requested, },
1424 (None, existing_tier) => existing_tier,
1425 };
1426
1427 let namespace = namespace.unwrap_or(&existing.namespace);
1428 let tags = tags.unwrap_or(&existing.tags);
1429 let priority = priority.unwrap_or(existing.priority);
1430 let confidence = confidence.unwrap_or(existing.confidence);
1431 let expires_at = match expires_at {
1433 Some("" | "null") => None,
1434 Some(v) => Some(v),
1435 None => existing.expires_at.as_deref(),
1436 };
1437 let metadata = metadata.unwrap_or(&existing.metadata);
1438
1439 let governed = Memory {
1447 tier: effective_tier.clone(),
1448 namespace: namespace.to_string(),
1449 title: new_title.to_string(),
1450 content: new_content.to_string(),
1451 tags: tags.clone(),
1452 priority,
1453 confidence,
1454 expires_at: expires_at.map(str::to_string),
1455 metadata: metadata.clone(),
1456 source_uri: source_uri
1457 .map(str::to_string)
1458 .or_else(|| existing.source_uri.clone()),
1459 ..existing.clone()
1460 };
1461 consult_governance_pre_write(&governed)?;
1462
1463 let tags_json = serde_json::to_string(tags)?;
1464 let metadata_json = serde_json::to_string(metadata)?;
1465 let now = Utc::now().to_rfc3339();
1466
1467 let update_res = conn.execute(
1489 "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
1490 WHERE id=?12 AND (?13 IS NULL OR version = ?13)",
1491 params![effective_tier.as_str(), namespace, new_title, new_content, tags_json, priority, confidence, now, expires_at, metadata_json, source_uri, id, expected_version],
1492 );
1493 match update_res {
1494 Ok(0) => {
1495 if let Some(expected) = expected_version {
1500 let current_version: Option<i64> = conn
1501 .query_row(
1502 "SELECT version FROM memories WHERE id = ?1",
1503 params![id],
1504 |r| r.get(0),
1505 )
1506 .ok();
1507 if let Some(current) = current_version {
1508 return Err(VersionConflict {
1509 id: id.to_string(),
1510 expected,
1511 current,
1512 }
1513 .into());
1514 }
1515 }
1516 Ok((false, false))
1517 }
1518 Ok(_) => Ok((true, content_changed)),
1519 Err(rusqlite::Error::SqliteFailure(err, _))
1520 if err.code == rusqlite::ErrorCode::ConstraintViolation =>
1521 {
1522 let other: Option<String> = conn
1523 .query_row(
1524 "SELECT id FROM memories WHERE title = ?1 AND namespace = ?2 AND id != ?3",
1525 params![new_title, namespace, id],
1526 |r| r.get(0),
1527 )
1528 .ok();
1529 if let Some(other_id) = other {
1530 return Err(anyhow::Error::new(StorageError::UniqueConflict {
1533 reason: format!(
1534 "title '{new_title}' already exists in namespace '{namespace}' (memory {other_id})"
1535 ),
1536 }));
1537 }
1538 Err(anyhow::anyhow!("update failed with constraint violation"))
1539 }
1540 Err(e) => Err(e.into()),
1541 }
1542}
1543
1544#[derive(Debug, Clone)]
1559pub struct SupersedeResult {
1560 pub archived_id: String,
1561 pub new_id: String,
1562}
1563
1564#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1595pub fn update_with_archive_on_supersede(
1596 conn: &Connection,
1597 id: &str,
1598 title: Option<&str>,
1599 content: Option<&str>,
1600 tier: Option<&Tier>,
1601 namespace: Option<&str>,
1602 tags: Option<&Vec<String>>,
1603 priority: Option<i32>,
1604 confidence: Option<f64>,
1605 expires_at: Option<&str>,
1606 metadata: Option<&serde_json::Value>,
1607 source_uri: Option<&str>,
1608 expected_version: Option<i64>,
1609 edit_source: crate::models::EditSource,
1610) -> Result<SupersedeResult> {
1611 let mut stmt = conn.prepare_cached(SQL_SELECT_MEMORY_ROW_BY_ID)?;
1613 let mut rows = stmt.query_map(params![id], row_to_memory)?;
1614 let Some(Ok(existing)) = rows.next() else {
1615 return Err(anyhow::Error::new(StorageError::MemoryNotFound {
1617 id: id.to_string(),
1618 role: None,
1619 }));
1620 };
1621 drop(rows);
1622 drop(stmt);
1623
1624 if let Some(expected) = expected_version
1626 && existing.version != expected
1627 {
1628 return Err(VersionConflict {
1629 id: existing.id.clone(),
1630 expected,
1631 current: existing.version,
1632 }
1633 .into());
1634 }
1635
1636 let new_id = uuid::Uuid::new_v4().to_string();
1640 let now = Utc::now().to_rfc3339();
1641 let new_title = title.unwrap_or(&existing.title).to_string();
1642 let new_content = content.unwrap_or(&existing.content).to_string();
1643 let new_tier = match (tier, &existing.tier) {
1645 (Some(requested), existing_tier) => match (existing_tier, requested) {
1646 (Tier::Long, _) => Tier::Long,
1647 (Tier::Mid, Tier::Short) => Tier::Mid,
1648 (_, r) => r.clone(),
1649 },
1650 (None, existing_tier) => existing_tier.clone(),
1651 };
1652 let new_namespace = namespace.unwrap_or(&existing.namespace).to_string();
1653 let new_tags = tags.cloned().unwrap_or_else(|| existing.tags.clone());
1654 let new_priority = priority.unwrap_or(existing.priority);
1655 let new_confidence = confidence.unwrap_or(existing.confidence);
1656 let new_expires = match expires_at {
1657 Some("" | "null") => None,
1658 Some(v) => Some(v.to_string()),
1659 None => existing.expires_at.clone(),
1660 };
1661 let new_source_uri = match source_uri {
1665 Some(uri) => Some(uri.to_string()),
1666 None => existing.source_uri.clone(),
1667 };
1668 let mut new_metadata = metadata
1672 .cloned()
1673 .unwrap_or_else(|| existing.metadata.clone());
1674 if let serde_json::Value::Object(ref mut m) = new_metadata {
1675 m.insert(
1676 "edit_source".to_string(),
1677 serde_json::Value::String(edit_source.as_str().to_string()),
1678 );
1679 m.insert(
1680 field_names::SUPERSEDED_ID.to_string(),
1681 serde_json::Value::String(existing.id.clone()),
1682 );
1683 }
1684
1685 let archived_id = existing.id.clone();
1694
1695 let mut new_mem = existing.clone();
1706 new_mem.id = new_id.clone();
1707 new_mem.title = new_title;
1708 new_mem.content = new_content;
1709 new_mem.tier = new_tier;
1710 new_mem.namespace = new_namespace;
1711 new_mem.tags = new_tags;
1712 new_mem.priority = new_priority;
1713 new_mem.confidence = new_confidence;
1714 new_mem.expires_at = new_expires;
1715 new_mem.metadata = new_metadata;
1716 new_mem.source_uri = new_source_uri;
1717 new_mem.created_at = now.clone();
1718 new_mem.updated_at = now.clone();
1719 new_mem.access_count = 0;
1720 new_mem.last_accessed_at = None;
1721 new_mem.version = crate::models::default_memory_version();
1725
1726 consult_governance_pre_write(&new_mem)?;
1730
1731 conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
1733 let tx_result = (|| -> Result<()> {
1734 let moved = archive_memory_no_tx(conn, &archived_id, Some("superseded"))?;
1736 if !moved {
1737 return Err(anyhow::Error::new(StorageError::ArchiveSupersedeFailed {
1741 archived_id: archived_id.clone(),
1742 }));
1743 }
1744 insert(conn, &new_mem)?;
1746 Ok(())
1747 })();
1748 match tx_result {
1749 Ok(()) => conn.execute_batch(connection::SQL_COMMIT)?,
1750 Err(e) => {
1751 let _ = conn.execute_batch(connection::SQL_ROLLBACK);
1752 return Err(e);
1753 }
1754 }
1755
1756 Ok(SupersedeResult {
1764 archived_id,
1765 new_id,
1766 })
1767}
1768
1769pub fn delete(conn: &Connection, id: &str) -> Result<bool> {
1770 conn.execute(SQL_DELETE_NAMESPACE_META_BY_STANDARD_ID, params![id])?;
1772 let changed = conn.execute(SQL_DELETE_MEMORY_BY_ID, params![id])?;
1773 Ok(changed > 0)
1774}
1775
1776pub fn archive_memory(conn: &Connection, id: &str, reason: Option<&str>) -> Result<bool> {
1792 conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
1793 let result = archive_memory_no_tx(conn, id, reason);
1794 match result {
1795 Ok(moved) => {
1796 conn.execute_batch(connection::SQL_COMMIT)?;
1797 Ok(moved)
1798 }
1799 Err(e) => {
1800 let _ = conn.execute_batch(connection::SQL_ROLLBACK);
1801 Err(e)
1802 }
1803 }
1804}
1805
1806pub(crate) fn archive_memory_no_tx(
1812 conn: &Connection,
1813 id: &str,
1814 reason: Option<&str>,
1815) -> Result<bool> {
1816 let now = Utc::now().to_rfc3339();
1817 let reason = reason.unwrap_or("archive");
1818 let result = (|| -> Result<bool> {
1819 let exists: bool = conn
1820 .query_row(SQL_MEMORY_EXISTS_COUNT, params![id], |r| r.get(0))
1821 .unwrap_or(false);
1822 if !exists {
1823 return Ok(false);
1824 }
1825 conn.execute(
1829 "INSERT OR REPLACE INTO archived_memories
1830 (id, tier, namespace, title, content, tags, priority, confidence,
1831 source, access_count, created_at, updated_at, last_accessed_at,
1832 expires_at, archived_at, archive_reason, metadata,
1833 embedding, embedding_dim, original_tier, original_expires_at,
1834 reflection_depth, atomised_into, atom_of, memory_kind,
1835 entity_id, persona_version, citations, source_uri, source_span,
1836 confidence_source, confidence_signals, confidence_decayed_at,
1837 mentioned_entity_id, version)
1838 SELECT id, tier, namespace, title, content, tags, priority, confidence,
1839 source, access_count, created_at, updated_at, last_accessed_at,
1840 expires_at, ?1, ?2, metadata,
1841 embedding, embedding_dim, tier, expires_at,
1842 reflection_depth, atomised_into, atom_of, memory_kind,
1843 entity_id, persona_version, citations, source_uri, source_span,
1844 confidence_source, confidence_signals, confidence_decayed_at,
1845 mentioned_entity_id, version
1846 FROM memories WHERE id = ?3",
1847 params![now, reason, id],
1848 )?;
1849 conn.execute(SQL_DELETE_NAMESPACE_META_BY_STANDARD_ID, params![id])?;
1852 let removed = conn.execute(SQL_DELETE_MEMORY_BY_ID, params![id])?;
1853 Ok(removed > 0)
1854 })();
1855 result
1856}
1857
1858pub fn archive_memory_for_caller(
1880 conn: &Connection,
1881 id: &str,
1882 reason: Option<&str>,
1883 caller: &str,
1884) -> Result<bool> {
1885 let now = Utc::now().to_rfc3339();
1886 let reason = reason.unwrap_or("archive");
1887 conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
1888 let result = (|| -> Result<bool> {
1889 let owned: bool = conn
1892 .query_row(
1893 "SELECT COUNT(*) > 0 FROM memories \
1894 WHERE id = ?1 \
1895 AND ( \
1896 json_extract(metadata, '$.agent_id') = ?2 OR \
1897 json_extract(metadata, '$.target_agent_id') = ?2 OR \
1898 json_extract(metadata, '$.agent_id') IS NULL OR \
1899 json_extract(metadata, '$.agent_id') = '' \
1900 )",
1901 params![id, caller],
1902 |r| r.get(0),
1903 )
1904 .unwrap_or(false);
1905 if !owned {
1906 return Ok(false);
1907 }
1908 conn.execute(
1909 "INSERT OR REPLACE INTO archived_memories
1910 (id, tier, namespace, title, content, tags, priority, confidence,
1911 source, access_count, created_at, updated_at, last_accessed_at,
1912 expires_at, archived_at, archive_reason, metadata,
1913 embedding, embedding_dim, original_tier, original_expires_at,
1914 reflection_depth, atomised_into, atom_of, memory_kind,
1915 entity_id, persona_version, citations, source_uri, source_span,
1916 confidence_source, confidence_signals, confidence_decayed_at,
1917 mentioned_entity_id, version)
1918 SELECT id, tier, namespace, title, content, tags, priority, confidence,
1919 source, access_count, created_at, updated_at, last_accessed_at,
1920 expires_at, ?1, ?2, metadata,
1921 embedding, embedding_dim, tier, expires_at,
1922 reflection_depth, atomised_into, atom_of, memory_kind,
1923 entity_id, persona_version, citations, source_uri, source_span,
1924 confidence_source, confidence_signals, confidence_decayed_at,
1925 mentioned_entity_id, version
1926 FROM memories WHERE id = ?3",
1927 params![now, reason, id],
1928 )?;
1929 conn.execute(SQL_DELETE_NAMESPACE_META_BY_STANDARD_ID, params![id])?;
1932 let removed = conn.execute(SQL_DELETE_MEMORY_BY_ID, params![id])?;
1933 Ok(removed > 0)
1934 })();
1935 match result {
1936 Ok(moved) => {
1937 conn.execute_batch(connection::SQL_COMMIT)?;
1938 Ok(moved)
1939 }
1940 Err(e) => {
1941 let _ = conn.execute_batch(connection::SQL_ROLLBACK);
1942 Err(e)
1943 }
1944 }
1945}
1946
1947fn forget_fts_query(pat: &str) -> String {
1962 sanitize_fts_query(pat, false)
1963}
1964
1965pub fn forget_count(
1967 conn: &Connection,
1968 namespace: Option<&str>,
1969 pattern: Option<&str>,
1970 tier: Option<&Tier>,
1971) -> Result<usize> {
1972 if pattern.is_none() && namespace.is_none() && tier.is_none() {
1973 return Err(anyhow::Error::new(StorageError::InvalidArgument {
1975 reason: crate::errors::msg::FORGET_FILTER_REQUIRED.to_string(),
1976 }));
1977 }
1978 if let Some(pat) = pattern {
1979 let fts_query = forget_fts_query(pat);
1980 let tier_str = tier.map(|t| t.as_str().to_string());
1981 let count: i64 = conn.query_row(
1982 "SELECT COUNT(*) FROM memories WHERE rowid IN (
1983 SELECT m.rowid FROM memories_fts fts
1984 JOIN memories m ON m.rowid = fts.rowid
1985 WHERE memories_fts MATCH ?1
1986 AND (?2 IS NULL OR m.namespace = ?2)
1987 AND (?3 IS NULL OR m.tier = ?3)
1988 )",
1989 params![fts_query, namespace, tier_str],
1990 |r| r.get(0),
1991 )?;
1992 return Ok(usize::try_from(count).unwrap_or(0));
1993 }
1994 let tier_str = tier.map(|t| t.as_str().to_string());
1995 let count: i64 = conn.query_row(
1996 "SELECT COUNT(*) FROM memories WHERE (?1 IS NULL OR namespace = ?1) AND (?2 IS NULL OR tier = ?2)",
1997 params![namespace, tier_str],
1998 |r| r.get(0),
1999 )?;
2000 Ok(usize::try_from(count).unwrap_or(0))
2001}
2002
2003pub fn forget(
2006 conn: &Connection,
2007 namespace: Option<&str>,
2008 pattern: Option<&str>,
2009 tier: Option<&Tier>,
2010 archive: bool,
2011) -> Result<usize> {
2012 if pattern.is_none() && namespace.is_none() && tier.is_none() {
2013 return Err(anyhow::Error::new(StorageError::InvalidArgument {
2015 reason: crate::errors::msg::FORGET_FILTER_REQUIRED.to_string(),
2016 }));
2017 }
2018
2019 if archive {
2020 let now = Utc::now().to_rfc3339();
2022 if let Some(pat) = pattern {
2023 let fts_query = forget_fts_query(pat);
2024 let tier_str = tier.map(|t| t.as_str().to_string());
2025 conn.execute(
2035 "INSERT OR REPLACE INTO archived_memories
2036 (id, tier, namespace, title, content, tags, priority, confidence,
2037 source, access_count, created_at, updated_at, last_accessed_at,
2038 expires_at, archived_at, archive_reason, metadata,
2039 embedding, embedding_dim, original_tier, original_expires_at,
2040 reflection_depth, atomised_into, atom_of, memory_kind,
2041 entity_id, persona_version, citations, source_uri, source_span,
2042 confidence_source, confidence_signals, confidence_decayed_at,
2043 mentioned_entity_id, version)
2044 SELECT id, tier, namespace, title, content, tags, priority, confidence,
2045 source, access_count, created_at, updated_at, last_accessed_at,
2046 expires_at, ?4, 'forget', metadata,
2047 embedding, embedding_dim, tier, expires_at,
2048 reflection_depth, atomised_into, atom_of, memory_kind,
2049 entity_id, persona_version, citations, source_uri, source_span,
2050 confidence_source, confidence_signals, confidence_decayed_at,
2051 mentioned_entity_id, version
2052 FROM memories WHERE rowid IN (
2053 SELECT m.rowid FROM memories_fts fts
2054 JOIN memories m ON m.rowid = fts.rowid
2055 WHERE memories_fts MATCH ?1
2056 AND (?2 IS NULL OR m.namespace = ?2)
2057 AND (?3 IS NULL OR m.tier = ?3)
2058 )",
2059 params![fts_query, namespace, tier_str, now],
2060 )?;
2061 } else {
2062 let tier_str = tier.map(|t| t.as_str().to_string());
2063 conn.execute(
2067 "INSERT OR REPLACE INTO archived_memories
2068 (id, tier, namespace, title, content, tags, priority, confidence,
2069 source, access_count, created_at, updated_at, last_accessed_at,
2070 expires_at, archived_at, archive_reason, metadata,
2071 embedding, embedding_dim, original_tier, original_expires_at,
2072 reflection_depth, atomised_into, atom_of, memory_kind,
2073 entity_id, persona_version, citations, source_uri, source_span,
2074 confidence_source, confidence_signals, confidence_decayed_at,
2075 mentioned_entity_id, version)
2076 SELECT id, tier, namespace, title, content, tags, priority, confidence,
2077 source, access_count, created_at, updated_at, last_accessed_at,
2078 expires_at, ?3, 'forget', metadata,
2079 embedding, embedding_dim, tier, expires_at,
2080 reflection_depth, atomised_into, atom_of, memory_kind,
2081 entity_id, persona_version, citations, source_uri, source_span,
2082 confidence_source, confidence_signals, confidence_decayed_at,
2083 mentioned_entity_id, version
2084 FROM memories WHERE (?1 IS NULL OR namespace = ?1) AND (?2 IS NULL OR tier = ?2)",
2085 params![namespace, tier_str, now],
2086 )?;
2087 }
2088 }
2089
2090 if let Some(pat) = pattern {
2092 let fts_query = forget_fts_query(pat);
2093 let tier_str = tier.map(|t| t.as_str().to_string());
2094 let deleted = conn.execute(
2095 "DELETE FROM memories WHERE rowid IN (
2096 SELECT m.rowid FROM memories_fts fts
2097 JOIN memories m ON m.rowid = fts.rowid
2098 WHERE memories_fts MATCH ?1
2099 AND (?2 IS NULL OR m.namespace = ?2)
2100 AND (?3 IS NULL OR m.tier = ?3)
2101 )",
2102 params![fts_query, namespace, tier_str],
2103 )?;
2104 return Ok(deleted);
2105 }
2106
2107 let tier_str = tier.map(|t| t.as_str().to_string());
2108 let deleted = conn.execute(
2109 "DELETE FROM memories WHERE (?1 IS NULL OR namespace = ?1) AND (?2 IS NULL OR tier = ?2)",
2110 params![namespace, tier_str],
2111 )?;
2112 Ok(deleted)
2113}
2114
2115#[derive(Debug, Clone, serde::Serialize)]
2117pub struct ForgetMatch {
2118 pub id: String,
2119 pub title: String,
2120 pub namespace: String,
2121 pub tier: String,
2122}
2123
2124pub fn forget_matches(
2137 conn: &Connection,
2138 namespace: Option<&str>,
2139 pattern: Option<&str>,
2140 tier: Option<&Tier>,
2141 limit: usize,
2142) -> Result<Vec<ForgetMatch>> {
2143 if pattern.is_none() && namespace.is_none() && tier.is_none() {
2144 return Err(anyhow::Error::new(StorageError::InvalidArgument {
2146 reason: crate::errors::msg::FORGET_FILTER_REQUIRED.to_string(),
2147 }));
2148 }
2149 let tier_str = tier.map(|t| t.as_str().to_string());
2150 let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
2151 let row_to_match = |row: &rusqlite::Row<'_>| -> rusqlite::Result<ForgetMatch> {
2152 Ok(ForgetMatch {
2153 id: row.get(0)?,
2154 title: row.get(1)?,
2155 namespace: row.get(2)?,
2156 tier: row.get(3)?,
2157 })
2158 };
2159 if let Some(pat) = pattern {
2160 let fts_query = forget_fts_query(pat);
2161 let mut stmt = conn.prepare(
2162 "SELECT m.id, m.title, m.namespace, m.tier
2163 FROM memories_fts fts
2164 JOIN memories m ON m.rowid = fts.rowid
2165 WHERE memories_fts MATCH ?1
2166 AND (?2 IS NULL OR m.namespace = ?2)
2167 AND (?3 IS NULL OR m.tier = ?3)
2168 ORDER BY m.rowid
2169 LIMIT ?4",
2170 )?;
2171 let rows = stmt
2172 .query_map(
2173 params![fts_query, namespace, tier_str, limit_i64],
2174 row_to_match,
2175 )?
2176 .collect::<rusqlite::Result<Vec<_>>>()?;
2177 return Ok(rows);
2178 }
2179 let mut stmt = conn.prepare(
2180 "SELECT id, title, namespace, tier FROM memories
2181 WHERE (?1 IS NULL OR namespace = ?1) AND (?2 IS NULL OR tier = ?2)
2182 ORDER BY rowid
2183 LIMIT ?3",
2184 )?;
2185 let rows = stmt
2186 .query_map(params![namespace, tier_str, limit_i64], row_to_match)?
2187 .collect::<rusqlite::Result<Vec<_>>>()?;
2188 Ok(rows)
2189}
2190
2191#[allow(clippy::too_many_arguments)]
2216#[must_use]
2217pub fn build_list_query(
2218 namespace: Option<&str>,
2219 tier: Option<&Tier>,
2220 min_priority: Option<i32>,
2221 now: &str,
2222 since: Option<&str>,
2223 until: Option<&str>,
2224 tags_filter: Option<&str>,
2225 agent_id: Option<&str>,
2226 limit: usize,
2227 offset: usize,
2228) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
2229 let mut sql = String::from(SQL_LIST_BASE);
2230 let mut params_vec: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(now.to_string())];
2231 if let Some(ns) = namespace {
2232 sql.push_str(" AND namespace = ?");
2233 params_vec.push(Box::new(ns.to_string()));
2234 }
2235 if let Some(t) = tier {
2236 sql.push_str(" AND tier = ?");
2237 params_vec.push(Box::new(t.as_str().to_string()));
2238 }
2239 if let Some(p) = min_priority {
2240 sql.push_str(" AND priority >= ?");
2241 params_vec.push(Box::new(p));
2242 }
2243 if let Some(s) = since {
2244 sql.push_str(" AND created_at >= ?");
2245 params_vec.push(Box::new(s.to_string()));
2246 }
2247 if let Some(u) = until {
2248 sql.push_str(" AND created_at <= ?");
2249 params_vec.push(Box::new(u.to_string()));
2250 }
2251 if let Some(tag) = tags_filter {
2252 sql.push_str(
2253 " AND EXISTS (SELECT 1 FROM json_each(memories.tags) WHERE json_each.value = ?)",
2254 );
2255 params_vec.push(Box::new(tag.to_string()));
2256 }
2257 if let Some(a) = agent_id {
2258 sql.push_str(" AND agent_id_idx = ?");
2259 params_vec.push(Box::new(a.to_string()));
2260 }
2261 sql.push_str(SQL_LIST_ORDER_LIMIT);
2262 params_vec.push(Box::new(limit));
2263 params_vec.push(Box::new(offset));
2264 (sql, params_vec)
2265}
2266
2267#[allow(clippy::too_many_arguments)]
2268pub fn list(
2269 conn: &Connection,
2270 namespace: Option<&str>,
2271 tier: Option<&Tier>,
2272 limit: usize,
2273 offset: usize,
2274 min_priority: Option<i32>,
2275 since: Option<&str>,
2276 until: Option<&str>,
2277 tags_filter: Option<&str>,
2278 agent_id: Option<&str>,
2279) -> Result<Vec<Memory>> {
2280 let now = Utc::now().to_rfc3339();
2281 let (sql, params_vec) = build_list_query(
2282 namespace,
2283 tier,
2284 min_priority,
2285 &now,
2286 since,
2287 until,
2288 tags_filter,
2289 agent_id,
2290 limit,
2291 offset,
2292 );
2293 let params_refs: Vec<&dyn rusqlite::types::ToSql> =
2294 params_vec.iter().map(std::convert::AsRef::as_ref).collect();
2295 let mut stmt = conn.prepare_cached(&sql)?;
2296 let rows = stmt.query_map(params_refs.as_slice(), row_to_memory)?;
2297 rows.collect::<rusqlite::Result<Vec<_>>>()
2298 .map_err(Into::into)
2299}
2300
2301#[allow(dead_code)] pub(crate) fn memories_by_kind(
2311 conn: &Connection,
2312 kind: &crate::models::MemoryKind,
2313) -> Result<Vec<Memory>> {
2314 let now = Utc::now().to_rfc3339();
2315 let mut stmt = conn.prepare(
2316 "SELECT * FROM memories
2317 WHERE memory_kind = ?1
2318 AND (expires_at IS NULL OR expires_at > ?2)
2319 ORDER BY priority DESC, updated_at DESC",
2320 )?;
2321 let rows = stmt.query_map(params![kind.as_str(), now], row_to_memory)?;
2322 rows.collect::<rusqlite::Result<Vec<_>>>()
2323 .map_err(Into::into)
2324}
2325
2326#[allow(clippy::too_many_arguments)]
2327pub fn search(
2328 conn: &Connection,
2329 query: &str,
2330 namespace: Option<&str>,
2331 tier: Option<&Tier>,
2332 limit: usize,
2333 min_priority: Option<i32>,
2334 since: Option<&str>,
2335 until: Option<&str>,
2336 tags_filter: Option<&str>,
2337 agent_id: Option<&str>,
2338 as_agent: Option<&str>,
2339 include_archived: bool,
2343) -> Result<Vec<Memory>> {
2344 search_with_source_uri(
2345 conn,
2346 query,
2347 namespace,
2348 tier,
2349 limit,
2350 min_priority,
2351 since,
2352 until,
2353 tags_filter,
2354 agent_id,
2355 as_agent,
2356 include_archived,
2357 None,
2358 )
2359}
2360
2361#[allow(clippy::too_many_arguments)]
2371pub fn search_with_source_uri(
2372 conn: &Connection,
2373 query: &str,
2374 namespace: Option<&str>,
2375 tier: Option<&Tier>,
2376 limit: usize,
2377 min_priority: Option<i32>,
2378 since: Option<&str>,
2379 until: Option<&str>,
2380 tags_filter: Option<&str>,
2381 agent_id: Option<&str>,
2382 as_agent: Option<&str>,
2383 include_archived: bool,
2384 source_uri: Option<&str>,
2385) -> Result<Vec<Memory>> {
2386 let now = Utc::now().to_rfc3339();
2387 let tier_str = tier.map(|t| t.as_str().to_string());
2388 let fts_query = sanitize_fts_query(query, false);
2389 let (vis_p, vis_t, vis_u, vis_o) = compute_visibility_prefixes(as_agent);
2390 let archived_fragment = archived_source_clause(include_archived, "m");
2391 let source_uri_fragment = if source_uri.is_some() {
2392 "AND m.source_uri = ?15"
2393 } else {
2394 ""
2395 };
2396
2397 let sql = format!(
2398 "SELECT m.id, m.tier, m.namespace, m.title, m.content, m.tags, m.priority,
2399 m.confidence, m.source, m.access_count, m.created_at, m.updated_at,
2400 m.last_accessed_at, m.expires_at, m.metadata, m.reflection_depth,
2401 m.memory_kind, m.entity_id, m.persona_version,
2402 m.citations, m.source_uri, m.source_span,
2403 m.confidence_source, m.confidence_signals, m.confidence_decayed_at
2404 FROM memories_fts fts
2405 JOIN memories m ON m.rowid = fts.rowid
2406 WHERE memories_fts MATCH ?1
2407 AND (?2 IS NULL OR m.namespace = ?2)
2408 AND (?3 IS NULL OR m.tier = ?3)
2409 AND (?4 IS NULL OR m.priority >= ?4)
2410 AND (m.expires_at IS NULL OR m.expires_at > ?5)
2411 AND (?6 IS NULL OR m.created_at >= ?6)
2412 AND (?7 IS NULL OR m.created_at <= ?7)
2413 AND (?8 IS NULL OR EXISTS (SELECT 1 FROM json_each(m.tags) WHERE json_each.value = ?8))
2414 AND (?10 IS NULL OR m.agent_id_idx = ?10)
2415 {archived_fragment}
2416 {source_uri_fragment}
2417 {vis}
2418 ORDER BY (fts.rank * -1)
2419 + (m.priority * 0.5)
2420 + (MIN(m.access_count, 50) * 0.1)
2421 + (m.confidence * 2.0)
2422 + (1.0 / (1.0 + (julianday('now') - julianday(m.updated_at)) * 0.1))
2423 DESC
2424 LIMIT ?9",
2425 vis = visibility_clause(11, "m"),
2426 );
2427 let mut stmt = conn.prepare(&sql)?;
2428 let rows = if let Some(uri) = source_uri {
2429 stmt.query_map(
2430 params![
2431 fts_query,
2432 namespace,
2433 tier_str,
2434 min_priority,
2435 now,
2436 since,
2437 until,
2438 tags_filter,
2439 limit,
2440 agent_id,
2441 vis_p,
2442 vis_t,
2443 vis_u,
2444 vis_o,
2445 uri,
2446 ],
2447 row_to_memory,
2448 )?
2449 .collect::<rusqlite::Result<Vec<_>>>()
2450 .map_err(Into::into)
2451 } else {
2452 stmt.query_map(
2453 params![
2454 fts_query,
2455 namespace,
2456 tier_str,
2457 min_priority,
2458 now,
2459 since,
2460 until,
2461 tags_filter,
2462 limit,
2463 agent_id,
2464 vis_p,
2465 vis_t,
2466 vis_u,
2467 vis_o,
2468 ],
2469 row_to_memory,
2470 )?
2471 .collect::<rusqlite::Result<Vec<_>>>()
2472 .map_err(Into::into)
2473 };
2474 rows
2475}
2476
2477pub fn list_by_source_uri(
2492 conn: &Connection,
2493 source_uri: &str,
2494 namespace: Option<&str>,
2495 limit: Option<usize>,
2496 as_agent: Option<&str>,
2497) -> Result<Vec<Memory>> {
2498 let cap = limit.unwrap_or(LIST_DEFAULT_CAP).min(LIST_MAX_LIMIT);
2499 let (vis_p, vis_t, vis_u, vis_o) = compute_visibility_prefixes(as_agent);
2500 let sql = format!(
2503 "SELECT m.id, m.tier, m.namespace, m.title, m.content, m.tags, m.priority,
2504 m.confidence, m.source, m.access_count, m.created_at, m.updated_at,
2505 m.last_accessed_at, m.expires_at, m.metadata, m.reflection_depth,
2506 m.memory_kind, m.entity_id, m.persona_version,
2507 m.citations, m.source_uri, m.source_span,
2508 m.confidence_source, m.confidence_signals, m.confidence_decayed_at,
2509 m.version
2510 FROM memories m
2511 WHERE m.source_uri = ?1
2512 AND (?2 IS NULL OR m.namespace = ?2)
2513 {vis}
2514 ORDER BY m.created_at ASC
2515 LIMIT ?3",
2516 vis = visibility_clause(4, "m"),
2517 );
2518 let mut stmt = conn.prepare(&sql)?;
2519 let rows = stmt.query_map(
2520 params![
2521 source_uri,
2522 namespace,
2523 i64::try_from(cap).unwrap_or(i64::MAX),
2524 vis_p,
2525 vis_t,
2526 vis_u,
2527 vis_o,
2528 ],
2529 row_to_memory,
2530 )?;
2531 rows.collect::<rusqlite::Result<Vec<_>>>()
2532 .map_err(Into::into)
2533}
2534
2535#[must_use]
2540pub fn proximity_boost(agent_ns: &str, memory_ns: &str) -> f64 {
2541 let agent_depth = crate::models::namespace_depth(agent_ns);
2542 let memory_depth = crate::models::namespace_depth(memory_ns);
2543 let distance = agent_depth.saturating_sub(memory_depth);
2544 #[allow(clippy::cast_precision_loss)]
2545 let d = distance as f64;
2546 1.0 / (1.0 + d * 0.3)
2547}
2548
2549fn hierarchy_in_clause(namespace: Option<&str>) -> (Option<String>, bool) {
2569 let Some(ns) = namespace else {
2570 return (None, false);
2571 };
2572 if !ns.contains('/') {
2573 return (None, false);
2574 }
2575
2576 if let Some(cached) = hierarchy_cache_get(ns) {
2582 return (Some(cached), true);
2583 }
2584
2585 let ancestors = crate::models::namespace_ancestors(ns);
2586 if ancestors.is_empty() {
2587 return (None, false);
2588 }
2589 let quoted: Vec<String> = ancestors
2590 .iter()
2591 .map(|a| format!("'{}'", a.replace('\'', "''")))
2592 .collect();
2593 let fragment = format!("AND m.namespace IN ({})", quoted.join(","));
2594 hierarchy_cache_put(ns, &fragment);
2595 (Some(fragment), true)
2596}
2597
2598const HIERARCHY_CACHE_MAX: usize = 256;
2603
2604fn hierarchy_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, String>> {
2605 static CACHE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, String>>> =
2606 std::sync::OnceLock::new();
2607 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
2608}
2609
2610fn hierarchy_cache_get(ns: &str) -> Option<String> {
2611 let cache = hierarchy_cache().lock().ok()?;
2612 cache.get(ns).cloned()
2613}
2614
2615fn hierarchy_cache_put(ns: &str, fragment: &str) {
2616 let Ok(mut cache) = hierarchy_cache().lock() else {
2617 return;
2618 };
2619 if cache.len() >= HIERARCHY_CACHE_MAX {
2620 if let Some(k) = cache.keys().next().cloned() {
2627 cache.remove(&k);
2628 }
2629 }
2630 cache.insert(ns.to_string(), fragment.to_string());
2631}
2632
2633#[cfg(test)]
2634fn hierarchy_cache_clear_for_tests() {
2635 if let Ok(mut cache) = hierarchy_cache().lock() {
2636 cache.clear();
2637 }
2638}
2639
2640fn apply_proximity_boost(scored: Vec<(Memory, f64)>, agent_ns: &str) -> Vec<(Memory, f64)> {
2643 let mut boosted: Vec<(Memory, f64)> = scored
2644 .into_iter()
2645 .map(|(mem, score)| {
2646 let boost = proximity_boost(agent_ns, &mem.namespace);
2647 (mem, score * boost)
2648 })
2649 .collect();
2650 boosted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2651 boosted
2652}
2653
2654#[must_use]
2666pub fn count_tokens_cl100k(text: &str) -> usize {
2667 use std::sync::OnceLock;
2668 static BPE: OnceLock<Option<tiktoken_rs::CoreBPE>> = OnceLock::new();
2669 let bpe = BPE.get_or_init(|| tiktoken_rs::cl100k_base().ok());
2670 if let Some(bpe) = bpe.as_ref() {
2671 bpe.encode_with_special_tokens(text).len()
2672 } else {
2673 text.len() / 4
2677 }
2678}
2679
2680#[must_use]
2685pub fn count_memory_tokens(mem: &Memory) -> usize {
2686 count_tokens_cl100k(&mem.content)
2687}
2688
2689#[must_use]
2695pub fn estimate_memory_tokens(mem: &Memory) -> usize {
2696 count_memory_tokens(mem)
2697}
2698
2699#[derive(Debug, Clone)]
2704pub struct BudgetOutcome {
2705 pub tokens_used: usize,
2707 pub tokens_remaining: Option<usize>,
2709 pub memories_dropped: usize,
2711 pub budget_overflow: bool,
2715}
2716
2717#[must_use]
2737pub fn apply_token_budget(
2738 scored: Vec<(Memory, f64)>,
2739 budget_tokens: Option<usize>,
2740) -> (Vec<(Memory, f64)>, BudgetOutcome) {
2741 let total_candidates = scored.len();
2742
2743 if budget_tokens == Some(0) {
2748 return (
2749 Vec::new(),
2750 BudgetOutcome {
2751 tokens_used: 0,
2752 tokens_remaining: Some(0),
2753 memories_dropped: total_candidates,
2754 budget_overflow: false,
2755 },
2756 );
2757 }
2758
2759 if budget_tokens.is_none() {
2764 let mut used: usize = 0;
2765 let mut out: Vec<(Memory, f64)> = Vec::with_capacity(scored.len());
2766 for (mem, score) in scored {
2767 used = used.saturating_add(mem.content.len() / 4);
2768 out.push((mem, score));
2769 }
2770 return (
2771 out,
2772 BudgetOutcome {
2773 tokens_used: used,
2774 tokens_remaining: None,
2775 memories_dropped: 0,
2776 budget_overflow: false,
2777 },
2778 );
2779 }
2780
2781 let mut used: usize = 0;
2784 let mut out: Vec<(Memory, f64)> = Vec::with_capacity(scored.len());
2785 let mut overflow = false;
2786
2787 for (mem, score) in scored {
2788 let cost = count_memory_tokens(&mem);
2789 if let Some(budget) = budget_tokens
2790 && used.saturating_add(cost) > budget
2791 {
2792 if out.is_empty() {
2795 used = used.saturating_add(cost);
2796 out.push((mem, score));
2797 overflow = true;
2798 }
2799 break;
2800 }
2801 used = used.saturating_add(cost);
2802 out.push((mem, score));
2803 }
2804
2805 let dropped = total_candidates.saturating_sub(out.len());
2806 let tokens_remaining = budget_tokens.map(|b| b.saturating_sub(used));
2807 (
2808 out,
2809 BudgetOutcome {
2810 tokens_used: used,
2811 tokens_remaining,
2812 memories_dropped: dropped,
2813 budget_overflow: overflow,
2814 },
2815 )
2816}
2817
2818#[allow(clippy::too_many_arguments)]
2825#[allow(clippy::too_many_arguments)]
2833pub fn recall_with_telemetry(
2834 conn: &Connection,
2835 context: &str,
2836 namespace: Option<&str>,
2837 limit: usize,
2838 tags_filter: Option<&str>,
2839 since: Option<&str>,
2840 until: Option<&str>,
2841 short_extend: i64,
2842 mid_extend: i64,
2843 as_agent: Option<&str>,
2844 budget_tokens: Option<usize>,
2845 include_archived: bool,
2850 source_uri_prefix: Option<&str>,
2855) -> Result<(
2856 Vec<(Memory, f64)>,
2857 BudgetOutcome,
2858 crate::models::RecallTelemetry,
2859)> {
2860 let (results, outcome) = recall(
2861 conn,
2862 context,
2863 namespace,
2864 limit,
2865 tags_filter,
2866 since,
2867 until,
2868 short_extend,
2869 mid_extend,
2870 as_agent,
2871 budget_tokens,
2872 include_archived,
2873 source_uri_prefix,
2874 )?;
2875 let telemetry = crate::models::RecallTelemetry {
2876 fts_candidates: results.len(),
2877 hnsw_candidates: 0,
2878 blend_weight_avg: 0.0,
2879 embedding_dim_mismatch: 0,
2880 };
2881 Ok((results, outcome, telemetry))
2882}
2883
2884pub fn recall(
2885 conn: &Connection,
2886 context: &str,
2887 namespace: Option<&str>,
2888 limit: usize,
2889 tags_filter: Option<&str>,
2890 since: Option<&str>,
2891 until: Option<&str>,
2892 short_extend: i64,
2893 mid_extend: i64,
2894 as_agent: Option<&str>,
2895 budget_tokens: Option<usize>,
2896 include_archived: bool,
2899 source_uri_prefix: Option<&str>,
2909) -> Result<(Vec<(Memory, f64)>, BudgetOutcome)> {
2910 let now = Utc::now().to_rfc3339();
2911 let fts_query = sanitize_fts_query(context, true);
2912 let (vis_p, vis_t, vis_u, vis_o) = compute_visibility_prefixes(as_agent);
2913
2914 let (hierarchy_in, hierarchy_active) = hierarchy_in_clause(namespace);
2918 let hierarchy_fragment = hierarchy_in.unwrap_or_default();
2919 let effective_namespace = if hierarchy_active { None } else { namespace };
2920
2921 let archived_fragment = archived_source_clause(include_archived, "m");
2925
2926 let (source_uri_fragment, source_uri_param): (&str, Option<String>) = match source_uri_prefix {
2935 Some(prefix) if !prefix.is_empty() => (
2936 "AND m.source_uri LIKE ?12 ESCAPE '\\'",
2937 Some(format!("{}%", escape_like_pattern(prefix))),
2938 ),
2939 _ => ("", None),
2940 };
2941
2942 let sql = format!(
2943 "SELECT m.id, m.tier, m.namespace, m.title, m.content, m.tags, m.priority,
2944 m.confidence, m.source, m.access_count, m.created_at, m.updated_at,
2945 m.last_accessed_at, m.expires_at, m.metadata, m.reflection_depth,
2946 m.memory_kind, m.entity_id, m.persona_version,
2947 m.citations, m.source_uri, m.source_span,
2948 m.confidence_source, m.confidence_signals, m.confidence_decayed_at,
2949 (fts.rank * -1)
2950 + (m.priority * 0.5)
2951 + (MIN(m.access_count, 50) * 0.1)
2952 + (m.confidence * 2.0)
2953 + (CASE m.tier WHEN 'long' THEN 3.0 WHEN 'mid' THEN 1.0 ELSE 0.0 END)
2954 + (1.0 / (1.0 + (julianday('now') - julianday(m.updated_at)) * 0.1))
2955 AS score
2956 FROM memories_fts fts
2957 JOIN memories m ON m.rowid = fts.rowid
2958 WHERE memories_fts MATCH ?1
2959 AND (?2 IS NULL OR m.namespace = ?2)
2960 {hierarchy_fragment}
2961 AND (m.expires_at IS NULL OR m.expires_at > ?3)
2962 AND (?4 IS NULL OR EXISTS (SELECT 1 FROM json_each(m.tags) WHERE json_each.value = ?4))
2963 AND (?5 IS NULL OR m.created_at >= ?5)
2964 AND (?6 IS NULL OR m.created_at <= ?6)
2965 {archived_fragment}
2966 {source_uri_fragment}
2967 {vis}
2968 ORDER BY score DESC
2969 LIMIT ?7",
2970 vis = visibility_clause(8, "m"),
2971 );
2972 let mut stmt = conn.prepare(&sql)?;
2973 let row_handler = |row: &rusqlite::Row<'_>| -> rusqlite::Result<(Memory, f64)> {
2976 let mem = row_to_memory(row)?;
2977 let score: f64 = row.get("score")?;
2984 Ok((mem, score))
2985 };
2986 let results: Vec<(Memory, f64)> = if let Some(ref uri_param) = source_uri_param {
2987 let rows = stmt.query_map(
2988 params![
2989 fts_query,
2990 effective_namespace,
2991 now,
2992 tags_filter,
2993 since,
2994 until,
2995 limit,
2996 vis_p,
2997 vis_t,
2998 vis_u,
2999 vis_o,
3000 uri_param,
3001 ],
3002 row_handler,
3003 )?;
3004 rows.collect::<rusqlite::Result<Vec<_>>>()?
3005 } else {
3006 let rows = stmt.query_map(
3007 params![
3008 fts_query,
3009 effective_namespace,
3010 now,
3011 tags_filter,
3012 since,
3013 until,
3014 limit,
3015 vis_p,
3016 vis_t,
3017 vis_u,
3018 vis_o,
3019 ],
3020 row_handler,
3021 )?;
3022 rows.collect::<rusqlite::Result<Vec<_>>>()?
3023 };
3024
3025 let boosted = if let (true, Some(anchor)) = (hierarchy_active, namespace) {
3027 apply_proximity_boost(results, anchor)
3028 } else {
3029 results
3030 };
3031
3032 let (budgeted, outcome) = apply_token_budget(boosted, budget_tokens);
3035
3036 let touch_ids: Vec<&str> = budgeted.iter().map(|(mem, _)| mem.id.as_str()).collect();
3041 if let Err(e) = touch_many(conn, &touch_ids, short_extend, mid_extend) {
3042 tracing::warn!("touch_many failed for recall set: {}", e);
3043 }
3044 Ok((budgeted, outcome))
3045}
3046
3047pub fn promote_to_namespace(
3062 conn: &Connection,
3063 source_id: &str,
3064 to_namespace: &str,
3065) -> Result<String> {
3066 if to_namespace.is_empty() {
3067 return Err(anyhow::Error::new(StorageError::InvalidArgument {
3069 reason: "to_namespace cannot be empty".to_string(),
3070 }));
3071 }
3072 let source = get(conn, source_id)?.ok_or_else(|| {
3073 anyhow::Error::new(StorageError::MemoryNotFound {
3077 id: source_id.to_string(),
3078 role: Some(LinkEnd::Source),
3079 })
3080 })?;
3081 if to_namespace == source.namespace {
3082 return Err(anyhow::Error::new(StorageError::InvalidArgument {
3084 reason: format!(
3085 "to_namespace must be a proper ancestor of the memory's namespace (got self: {})",
3086 source.namespace
3087 ),
3088 }));
3089 }
3090 let ancestors = namespace_ancestors(&source.namespace);
3091 if !ancestors.iter().any(|a| a == to_namespace) {
3092 return Err(anyhow::Error::new(StorageError::InvalidArgument {
3094 reason: format!(
3095 "to_namespace '{to_namespace}' is not an ancestor of '{}' (ancestors: {ancestors:?})",
3096 source.namespace
3097 ),
3098 }));
3099 }
3100
3101 let now = Utc::now().to_rfc3339();
3102 let clone = Memory {
3103 id: uuid::Uuid::new_v4().to_string(),
3104 tier: source.tier.clone(),
3105 namespace: to_namespace.to_string(),
3106 title: source.title.clone(),
3107 content: source.content.clone(),
3108 tags: source.tags.clone(),
3109 priority: source.priority,
3110 confidence: source.confidence,
3111 source: source.source.clone(),
3112 access_count: 0,
3113 created_at: now.clone(),
3114 updated_at: now,
3115 last_accessed_at: None,
3116 expires_at: source.expires_at.clone(),
3117 metadata: source.metadata.clone(),
3118 reflection_depth: source.reflection_depth,
3119 memory_kind: source.memory_kind.clone(),
3120 entity_id: None,
3121 persona_version: None,
3122 citations: Vec::new(),
3123 source_uri: None,
3124 source_span: None,
3125 confidence_source: ConfidenceSource::CallerProvided,
3126 confidence_signals: None,
3127 confidence_decayed_at: None,
3128 version: 1,
3129 };
3130 let actual_id = insert(conn, &clone)?;
3131 create_link(
3134 conn,
3135 &actual_id,
3136 source_id,
3137 crate::models::MemoryLinkRelation::DerivedFrom.as_str(),
3138 )?;
3139 Ok(actual_id)
3140}
3141
3142pub fn find_by_title_namespace(
3150 conn: &Connection,
3151 title: &str,
3152 namespace: &str,
3153) -> Result<Option<String>> {
3154 let id: Option<String> = conn
3155 .query_row(
3156 "SELECT id FROM memories WHERE title = ?1 AND namespace = ?2 LIMIT 1",
3157 params![title, namespace],
3158 |r| r.get(0),
3159 )
3160 .ok();
3161 Ok(id)
3162}
3163
3164const MAX_VERSION_SUFFIX: u32 = 1024;
3172
3173pub fn next_versioned_title(
3178 conn: &Connection,
3179 base_title: &str,
3180 namespace: &str,
3181) -> Result<String> {
3182 if find_by_title_namespace(conn, base_title, namespace)?.is_none() {
3183 return Ok(base_title.to_string());
3184 }
3185 for n in 2..=MAX_VERSION_SUFFIX {
3186 let candidate = format!("{base_title} ({n})");
3187 if find_by_title_namespace(conn, &candidate, namespace)?.is_none() {
3188 return Ok(candidate);
3189 }
3190 }
3191 Err(anyhow::Error::new(StorageError::UniqueConflict {
3195 reason: format!(
3196 "could not find a free versioned title for '{base_title}' in namespace '{namespace}' \
3197 within {MAX_VERSION_SUFFIX} attempts"
3198 ),
3199 }))
3200}
3201
3202const CONTRADICTION_TITLE_STOPWORDS: &[&str] = &[
3209 "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "have", "in", "is",
3210 "it", "its", "of", "on", "or", "that", "the", "this", "to", "was", "were", "will", "with",
3211];
3212
3213const CONTRADICTION_TITLE_JACCARD_FLOOR: f32 = 0.30;
3231
3232fn contradiction_title_tokens(title: &str) -> std::collections::HashSet<String> {
3237 title
3238 .split(|c: char| !c.is_alphanumeric())
3239 .map(str::to_ascii_lowercase)
3240 .filter(|t| !t.is_empty())
3241 .filter(|t| !CONTRADICTION_TITLE_STOPWORDS.contains(&t.as_str()))
3242 .collect()
3243}
3244
3245#[allow(clippy::cast_precision_loss)]
3249fn contradiction_title_jaccard(
3250 a: &std::collections::HashSet<String>,
3251 b: &std::collections::HashSet<String>,
3252) -> f32 {
3253 if a.is_empty() || b.is_empty() {
3254 return 0.0;
3255 }
3256 let inter = a.intersection(b).count() as f32;
3257 let union = a.union(b).count() as f32;
3258 if union > 0.0 { inter / union } else { 0.0 }
3259}
3260
3261fn find_similar_title_candidates(
3272 conn: &Connection,
3273 title: &str,
3274 namespace: &str,
3275 limit: usize,
3276) -> Result<Vec<Memory>> {
3277 let fts_query = sanitize_fts_query(title, true);
3278 let mut stmt = conn.prepare(
3279 "SELECT m.id, m.tier, m.namespace, m.title, m.content, m.tags, m.priority,
3280 m.confidence, m.source, m.access_count, m.created_at, m.updated_at,
3281 m.last_accessed_at, m.expires_at, m.metadata, m.reflection_depth,
3282 m.memory_kind, m.entity_id, m.persona_version,
3283 m.citations, m.source_uri, m.source_span,
3284 m.confidence_source, m.confidence_signals, m.confidence_decayed_at
3285 FROM memories_fts fts
3286 JOIN memories m ON m.rowid = fts.rowid
3287 WHERE memories_fts MATCH ?1 AND m.namespace = ?2
3288 ORDER BY fts.rank
3289 LIMIT ?3",
3290 )?;
3291 let rows = stmt.query_map(
3292 params![fts_query, namespace, i64::try_from(limit).unwrap_or(20)],
3293 row_to_memory,
3294 )?;
3295 rows.collect::<rusqlite::Result<Vec<_>>>()
3296 .map_err(Into::into)
3297}
3298
3299pub fn find_contradictions(conn: &Connection, title: &str, namespace: &str) -> Result<Vec<Memory>> {
3325 let candidates = find_similar_title_candidates(conn, title, namespace, 20)?;
3329
3330 let seed_tokens = contradiction_title_tokens(title);
3332 let mut filtered: Vec<Memory> = candidates
3333 .into_iter()
3334 .filter(|cand| {
3335 let cand_tokens = contradiction_title_tokens(&cand.title);
3336 contradiction_title_jaccard(&seed_tokens, &cand_tokens)
3337 >= CONTRADICTION_TITLE_JACCARD_FLOOR
3338 })
3339 .collect();
3340 filtered.truncate(5);
3341 Ok(filtered)
3342}
3343
3344pub fn find_synthesis_candidates(
3365 conn: &Connection,
3366 title: &str,
3367 namespace: &str,
3368) -> Result<Vec<Memory>> {
3369 let mut candidates = find_similar_title_candidates(conn, title, namespace, 20)?;
3370 candidates.truncate(5);
3371 Ok(candidates)
3372}
3373
3374pub fn validate_link_pre_create(
3424 conn: &Connection,
3425 source_id: &str,
3426 target_id: &str,
3427 relation: &str,
3428 agent_id: &str,
3429 skip_governance: bool,
3430) -> Result<()> {
3431 if relation == crate::models::MemoryLinkRelation::ReflectsOn.as_str() {
3435 let link_ns = match get(conn, source_id) {
3441 Ok(Some(m)) => m.namespace,
3442 _ => crate::DEFAULT_NAMESPACE.to_string(),
3443 };
3444 let max_depth = resolve_governance_policy(conn, &link_ns)
3445 .unwrap_or_default()
3446 .effective_max_reflection_depth();
3447 if crate::kg::cycle_check::would_create_reflection_cycle(
3448 conn, source_id, target_id, max_depth,
3449 )?
3450 .would_cycle
3451 {
3452 return Err(anyhow::Error::new(StorageError::LinkReflectionCycle {
3454 source_id: source_id.to_string(),
3455 target_id: target_id.to_string(),
3456 }));
3457 }
3458 }
3459
3460 if !skip_governance {
3463 let link_ns = match get(conn, source_id) {
3469 Ok(Some(m)) => m.namespace,
3470 _ => crate::DEFAULT_NAMESPACE.to_string(),
3471 };
3472 evaluate_link_permission(&link_ns, source_id, target_id, relation, agent_id)
3473 .map_err(anyhow::Error::new)?;
3474 }
3475 Ok(())
3476}
3477
3478pub(crate) fn evaluate_link_permission(
3496 link_ns: &str,
3497 source_id: &str,
3498 target_id: &str,
3499 relation: &str,
3500 agent_id: &str,
3501) -> std::result::Result<(), StorageError> {
3502 use crate::permissions::{Decision, Op, PermissionContext, Permissions};
3503 let ctx = PermissionContext {
3504 op: Op::MemoryLink,
3505 namespace: link_ns.to_string(),
3506 agent_id: agent_id.to_string(),
3507 payload: serde_json::json!({
3508 "source_id": source_id,
3509 "target_id": target_id,
3510 "relation": relation,
3511 }),
3512 };
3513 match Permissions::evaluate(&ctx, &[]) {
3514 Decision::Allow | Decision::Modify(_) => Ok(()),
3515 Decision::Deny(reason) => Err(StorageError::LinkPermissionDenied { reason }),
3518 Decision::Ask(prompt) => Err(StorageError::LinkPermissionDenied {
3519 reason: format!("ask deferred to storage layer ({prompt})"),
3520 }),
3521 }
3522}
3523
3524pub fn create_link(
3531 conn: &Connection,
3532 source_id: &str,
3533 target_id: &str,
3534 relation: &str,
3535) -> Result<()> {
3536 create_link_signed(conn, source_id, target_id, relation, None).map(|_| ())
3537}
3538
3539pub fn create_link_signed(
3564 conn: &Connection,
3565 source_id: &str,
3566 target_id: &str,
3567 relation: &str,
3568 keypair: Option<&crate::identity::keypair::AgentKeypair>,
3569) -> Result<&'static str> {
3570 let agent_id_for_eval = keypair
3583 .as_ref()
3584 .map(|kp| kp.agent_id.as_str())
3585 .unwrap_or("system");
3586 validate_link_pre_create(
3587 conn,
3588 source_id,
3589 target_id,
3590 relation,
3591 agent_id_for_eval,
3592 false,
3593 )?;
3594 let source_exists: bool = conn
3596 .query_row(SQL_MEMORY_EXISTS, params![source_id], |r| r.get(0))
3597 .unwrap_or(false);
3598 if !source_exists {
3599 return Err(anyhow::Error::new(StorageError::MemoryNotFound {
3601 id: source_id.to_string(),
3602 role: Some(LinkEnd::Source),
3603 }));
3604 }
3605 let target_exists: bool = conn
3606 .query_row(SQL_MEMORY_EXISTS, params![target_id], |r| r.get(0))
3607 .unwrap_or(false);
3608 if !target_exists {
3609 return Err(anyhow::Error::new(StorageError::MemoryNotFound {
3611 id: target_id.to_string(),
3612 role: Some(LinkEnd::Target),
3613 }));
3614 }
3615 let now = truncate_to_microseconds(Utc::now()).to_rfc3339();
3633
3634 let (signature, attest_level, observed_by_col): (Option<Vec<u8>>, &'static str, Option<&str>) =
3648 match keypair {
3649 Some(kp) if kp.can_sign() => {
3650 let link = crate::identity::sign::SignableLink {
3651 src_id: source_id,
3652 dst_id: target_id,
3653 relation,
3654 observed_by: Some(kp.agent_id.as_str()),
3655 valid_from: Some(now.as_str()),
3656 valid_until: None,
3657 };
3658 let sig = crate::identity::sign::sign(kp, &link)?;
3659 (
3660 Some(sig),
3661 crate::models::AttestLevel::SelfSigned.as_str(),
3662 Some(kp.agent_id.as_str()),
3663 )
3664 }
3665 _ => (None, crate::models::AttestLevel::Unsigned.as_str(), None),
3666 };
3667
3668 let inserted = conn.execute(
3669 "INSERT OR IGNORE INTO memory_links \
3670 (source_id, target_id, relation, created_at, valid_from, signature, attest_level, observed_by) \
3671 VALUES (?1, ?2, ?3, ?4, ?4, ?5, ?6, ?7)",
3672 params![
3673 source_id,
3674 target_id,
3675 relation,
3676 now,
3677 signature,
3678 attest_level,
3679 observed_by_col
3680 ],
3681 )?;
3682
3683 if inserted > 0 {
3704 let agent_for_event = observed_by_col
3705 .map(str::to_string)
3706 .unwrap_or_else(|| "unknown".to_string());
3707 let signable = crate::identity::sign::SignableLink {
3708 src_id: source_id,
3709 dst_id: target_id,
3710 relation,
3711 observed_by: observed_by_col,
3712 valid_from: Some(now.as_str()),
3713 valid_until: None,
3714 };
3715 match crate::identity::sign::canonical_cbor(&signable) {
3716 Ok(cbor) => {
3717 let event = crate::signed_events::SignedEvent {
3718 id: uuid::Uuid::new_v4().to_string(),
3719 agent_id: agent_for_event,
3720 event_type: crate::signed_events::event_types::MEMORY_LINK_CREATED.to_string(),
3721 payload_hash: crate::signed_events::payload_hash(&cbor),
3722 signature: signature.clone(),
3723 attest_level: attest_level.to_string(),
3724 timestamp: Utc::now().to_rfc3339(),
3725 ..crate::signed_events::SignedEvent::default()
3726 };
3727 if let Err(e) = crate::signed_events::append_signed_event(conn, &event) {
3728 tracing::warn!(
3729 target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
3730 source_id, target_id, relation,
3731 "failed to append memory_link.created audit row: {e}"
3732 );
3733 }
3734 }
3735 Err(e) => {
3736 tracing::warn!(
3737 target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
3738 source_id, target_id, relation,
3739 "failed to encode canonical CBOR for memory_link.created audit: {e}"
3740 );
3741 }
3742 }
3743 }
3744
3745 Ok(attest_level)
3746}
3747
3748pub fn strongest_attest_level_for_source(conn: &Connection, source_id: &str) -> Result<String> {
3772 let mut stmt = conn.prepare(
3773 "SELECT attest_level FROM memory_links \
3774 WHERE source_id = ?1",
3775 )?;
3776 let rows = stmt.query_map(params![source_id], |r| r.get::<_, String>(0))?;
3777 let unsigned = crate::models::AttestLevel::Unsigned.as_str();
3778 let self_signed = crate::models::AttestLevel::SelfSigned.as_str();
3779 let peer_attested = crate::models::AttestLevel::PeerAttested.as_str();
3780 let mut strongest = unsigned;
3781 for row in rows {
3782 let level = row?;
3783 if level == peer_attested {
3784 return Ok(peer_attested.to_string());
3785 }
3786 if level == self_signed && strongest == unsigned {
3787 strongest = self_signed;
3788 }
3789 }
3790 Ok(strongest.to_string())
3791}
3792
3793pub fn create_link_inbound(conn: &Connection, link: &MemoryLink, attest_level: &str) -> Result<()> {
3832 let skip_governance = attest_level == crate::models::AttestLevel::PeerAttested.as_str();
3850 let peer_agent_id = link.observed_by.as_deref().unwrap_or("system");
3851 validate_link_pre_create(
3852 conn,
3853 &link.source_id,
3854 &link.target_id,
3855 link.relation.as_str(),
3856 peer_agent_id,
3857 skip_governance,
3858 )?;
3859 let source_exists: bool = conn
3863 .query_row(SQL_MEMORY_EXISTS, params![link.source_id], |r| r.get(0))
3864 .unwrap_or(false);
3865 if !source_exists {
3866 return Err(anyhow::Error::new(StorageError::MemoryNotFound {
3868 id: link.source_id.clone(),
3869 role: Some(LinkEnd::Source),
3870 }));
3871 }
3872 let target_exists: bool = conn
3873 .query_row(SQL_MEMORY_EXISTS, params![link.target_id], |r| r.get(0))
3874 .unwrap_or(false);
3875 if !target_exists {
3876 return Err(anyhow::Error::new(StorageError::MemoryNotFound {
3878 id: link.target_id.clone(),
3879 role: Some(LinkEnd::Target),
3880 }));
3881 }
3882
3883 let now = Utc::now().to_rfc3339();
3884 let valid_from = link.valid_from.clone().unwrap_or_else(|| now.clone());
3887 let created_at = if link.created_at.is_empty() {
3888 now
3889 } else {
3890 link.created_at.clone()
3891 };
3892
3893 let inserted = conn.execute(
3894 "INSERT OR IGNORE INTO memory_links \
3895 (source_id, target_id, relation, created_at, valid_from, valid_until, \
3896 signature, attest_level, observed_by) \
3897 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
3898 params![
3899 link.source_id,
3900 link.target_id,
3901 link.relation.as_str(),
3902 created_at,
3903 valid_from,
3904 link.valid_until,
3905 link.signature,
3906 attest_level,
3907 link.observed_by,
3908 ],
3909 )?;
3910
3911 if inserted > 0 {
3926 let agent_for_event = link
3927 .observed_by
3928 .clone()
3929 .unwrap_or_else(|| "unknown".to_string());
3930 let signable = crate::identity::sign::SignableLink {
3931 src_id: link.source_id.as_str(),
3932 dst_id: link.target_id.as_str(),
3933 relation: link.relation.as_str(),
3934 observed_by: link.observed_by.as_deref(),
3935 valid_from: Some(valid_from.as_str()),
3936 valid_until: link.valid_until.as_deref(),
3937 };
3938 match crate::identity::sign::canonical_cbor(&signable) {
3939 Ok(cbor) => {
3940 let event = crate::signed_events::SignedEvent {
3941 id: uuid::Uuid::new_v4().to_string(),
3942 agent_id: agent_for_event,
3943 event_type: crate::signed_events::event_types::MEMORY_LINK_CREATED.to_string(),
3944 payload_hash: crate::signed_events::payload_hash(&cbor),
3945 signature: link.signature.clone(),
3946 attest_level: attest_level.to_string(),
3947 timestamp: Utc::now().to_rfc3339(),
3948 ..crate::signed_events::SignedEvent::default()
3949 };
3950 if let Err(e) = crate::signed_events::append_signed_event(conn, &event) {
3951 tracing::warn!(
3952 target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
3953 source_id = %link.source_id,
3954 target_id = %link.target_id,
3955 relation = %link.relation,
3956 "failed to append memory_link.created audit row (inbound): {e}"
3957 );
3958 }
3959 }
3960 Err(e) => {
3961 tracing::warn!(
3962 target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
3963 source_id = %link.source_id,
3964 target_id = %link.target_id,
3965 relation = %link.relation,
3966 "failed to encode canonical CBOR for inbound memory_link.created audit: {e}"
3967 );
3968 }
3969 }
3970 }
3971
3972 Ok(())
3973}
3974
3975pub fn get_links(conn: &Connection, id: &str) -> Result<Vec<MemoryLink>> {
3976 let mut stmt = conn.prepare(
3986 "SELECT source_id, target_id, relation, created_at, \
3987 valid_from, valid_until, observed_by, attest_level \
3988 FROM memory_links \
3989 WHERE source_id = ?1 OR target_id = ?1",
3990 )?;
3991 let rows = stmt.query_map(params![id], |row| {
3992 let relation_str: String = row.get(2)?;
3993 Ok(MemoryLink {
3994 source_id: row.get(0)?,
3995 target_id: row.get(1)?,
3996 relation: crate::models::MemoryLinkRelation::from_str(&relation_str)
4003 .unwrap_or_default(),
4004 created_at: row.get(3)?,
4005 signature: None,
4012 valid_from: row.get::<_, Option<String>>(4)?,
4013 valid_until: row.get::<_, Option<String>>(5)?,
4014 observed_by: row.get::<_, Option<String>>(6)?,
4015 attest_level: row.get::<_, Option<String>>(7)?,
4016 })
4017 })?;
4018 rows.collect::<rusqlite::Result<Vec<_>>>()
4019 .map_err(Into::into)
4020}
4021
4022#[allow(dead_code)]
4023pub fn delete_link(conn: &Connection, source_id: &str, target_id: &str) -> Result<bool> {
4024 let changed = conn.execute(
4025 "DELETE FROM memory_links WHERE source_id = ?1 AND target_id = ?2",
4026 params![source_id, target_id],
4027 )?;
4028 Ok(changed > 0)
4029}
4030
4031#[derive(Debug, Clone)]
4043pub struct LinkVerifyRecord {
4044 pub source_id: String,
4045 pub target_id: String,
4046 pub relation: String,
4047 pub signature: Option<Vec<u8>>,
4048 pub observed_by: Option<String>,
4049 pub valid_from: Option<String>,
4050 pub valid_until: Option<String>,
4051 pub attest_level: Option<String>,
4056}
4057
4058pub fn get_link_for_verify(
4071 conn: &Connection,
4072 source_id: &str,
4073 target_id: &str,
4074 relation: &str,
4075) -> Result<Option<LinkVerifyRecord>> {
4076 let mut stmt = conn.prepare(
4077 "SELECT source_id, target_id, relation, signature, observed_by, \
4078 valid_from, valid_until, attest_level \
4079 FROM memory_links \
4080 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
4081 )?;
4082 let mut rows = stmt.query(params![source_id, target_id, relation])?;
4083 if let Some(row) = rows.next()? {
4084 Ok(Some(LinkVerifyRecord {
4085 source_id: row.get(0)?,
4086 target_id: row.get(1)?,
4087 relation: row.get(2)?,
4088 signature: row.get::<_, Option<Vec<u8>>>(3)?,
4089 observed_by: row.get::<_, Option<String>>(4)?,
4090 valid_from: row.get::<_, Option<String>>(5)?,
4091 valid_until: row.get::<_, Option<String>>(6)?,
4092 attest_level: row.get::<_, Option<String>>(7)?,
4093 }))
4094 } else {
4095 Ok(None)
4096 }
4097}
4098
4099pub const CONSOLIDATION_SOURCE: &str = "consolidation";
4106
4107#[allow(clippy::too_many_arguments)]
4110pub fn consolidate(
4111 conn: &Connection,
4112 ids: &[String],
4113 title: &str,
4114 summary: &str,
4115 namespace: &str,
4116 tier: &Tier,
4117 source: &str,
4118 consolidator_agent_id: &str,
4119) -> Result<String> {
4120 let now = Utc::now().to_rfc3339();
4121 let new_id = uuid::Uuid::new_v4().to_string();
4122
4123 conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
4124
4125 let result = (|| -> Result<String> {
4126 let mut max_priority = 5i32;
4128 let mut all_tags: Vec<String> = Vec::new();
4129 let mut total_access = 0i64;
4130 let mut merged_metadata = serde_json::Map::new();
4131 let mut source_agent_ids: Vec<String> = Vec::new();
4135 for id in ids {
4136 match get(conn, id)? {
4137 Some(mem) => {
4138 max_priority = max_priority.max(mem.priority);
4139 all_tags.extend(mem.tags);
4140 total_access = total_access.saturating_add(mem.access_count);
4141 if let serde_json::Value::Object(map) = mem.metadata {
4145 for (k, v) in map {
4146 if k == "agent_id" {
4147 if let serde_json::Value::String(aid) = &v
4148 && !source_agent_ids.contains(aid)
4149 {
4150 source_agent_ids.push(aid.clone());
4151 }
4152 continue;
4153 }
4154 if let Some(existing) = merged_metadata.get(&k)
4155 && std::mem::discriminant(existing) != std::mem::discriminant(&v)
4156 {
4157 tracing::warn!(
4158 "consolidate: key '{}' type changed during merge",
4159 k
4160 );
4161 }
4162 merged_metadata.insert(k, v);
4163 }
4164 } else {
4165 tracing::warn!(
4166 "memory {} has non-object metadata during consolidate, skipping",
4167 id
4168 );
4169 }
4170 }
4171 None => {
4172 return Err(anyhow::Error::new(StorageError::MemoryNotFound {
4174 id: id.to_string(),
4175 role: None,
4176 }));
4177 }
4178 }
4179 }
4180 all_tags.sort();
4181 all_tags.dedup();
4182 let tags_json = serde_json::to_string(&all_tags)?;
4183 merged_metadata.insert(
4185 crate::models::MemoryLinkRelation::DerivedFrom
4186 .as_str()
4187 .to_string(),
4188 serde_json::Value::Array(
4189 ids.iter()
4190 .map(|id| serde_json::Value::String(id.clone()))
4191 .collect(),
4192 ),
4193 );
4194 merged_metadata.insert(
4197 "agent_id".to_string(),
4198 serde_json::Value::String(consolidator_agent_id.to_string()),
4199 );
4200 if !source_agent_ids.is_empty() {
4201 merged_metadata.insert(
4202 "consolidated_from_agents".to_string(),
4203 serde_json::Value::Array(
4204 source_agent_ids
4205 .into_iter()
4206 .map(serde_json::Value::String)
4207 .collect(),
4208 ),
4209 );
4210 }
4211 let merged_metadata_value = serde_json::Value::Object(merged_metadata);
4212 crate::validate::validate_metadata(&merged_metadata_value)
4213 .context("merged metadata exceeds size limit")?;
4214 let metadata_json = serde_json::to_string(&merged_metadata_value)?;
4215
4216 let candidate = Memory {
4227 id: new_id.clone(),
4228 tier: tier.clone(),
4229 namespace: namespace.to_string(),
4230 title: title.to_string(),
4231 content: summary.to_string(),
4232 tags: all_tags.clone(),
4233 priority: max_priority,
4234 confidence: 1.0,
4235 source: source.to_string(),
4236 access_count: total_access,
4237 created_at: now.clone(),
4238 updated_at: now.clone(),
4239 last_accessed_at: None,
4240 expires_at: None,
4241 metadata: merged_metadata_value.clone(),
4242 reflection_depth: 0,
4243 memory_kind: crate::models::MemoryKind::Observation,
4244 entity_id: None,
4245 persona_version: None,
4246 citations: Vec::new(),
4247 source_uri: None,
4248 source_span: None,
4249 confidence_source: crate::models::ConfidenceSource::CuratorDerived,
4255 confidence_signals: None,
4256 confidence_decayed_at: None,
4257 version: crate::models::default_memory_version(),
4258 };
4259 consult_governance_pre_write(&candidate)?;
4260
4261 conn.execute(
4267 "INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, expires_at, metadata, confidence_source)
4268 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1.0, ?8, ?9, ?10, ?10, ?11, ?12, ?13)",
4269 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()],
4270 )?;
4271
4272 for id in ids {
4277 delete(conn, id)?;
4278 }
4279
4280 Ok(new_id.clone())
4281 })();
4282
4283 match result {
4284 Ok(id) => {
4285 conn.execute_batch(connection::SQL_COMMIT)?;
4286 Ok(id)
4287 }
4288 Err(e) => {
4289 if let Err(rb) = conn.execute_batch(connection::SQL_ROLLBACK) {
4290 tracing::error!("ROLLBACK failed in consolidate: {}", rb);
4291 }
4292 Err(e)
4293 }
4294 }
4295}
4296
4297fn strip_invisible(s: &str) -> String {
4303 s.chars()
4304 .filter(|c| {
4305 !matches!(c,
4306 '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' |
4307 '\u{00AD}' | '\u{034F}' | '\u{061C}' |
4308 '\u{180E}' | '\u{2060}' | '\u{2061}'..='\u{2064}' |
4309 '\u{FE00}'..='\u{FE0F}' | '\u{200E}' | '\u{200F}' |
4310 '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
4311 )
4312 })
4313 .collect()
4314}
4315
4316fn sanitize_fts_query(input: &str, use_or: bool) -> String {
4317 let joiner = if use_or { " OR " } else { " " };
4318 let cleaned = strip_invisible(input);
4319 let tokens: Vec<String> = cleaned
4320 .split_whitespace()
4321 .filter(|t| !t.is_empty())
4322 .filter(|t| {
4323 let upper = t.to_uppercase();
4325 upper != "AND" && upper != "OR" && upper != "NOT" && upper != "NEAR"
4326 })
4327 .map(|token| {
4328 let clean: String = token
4341 .chars()
4342 .filter(|c| {
4343 *c != '"'
4344 && *c != '*'
4345 && *c != '^'
4346 && *c != '{'
4347 && *c != '}'
4348 && *c != '('
4349 && *c != ')'
4350 && *c != ':'
4351 && *c != '|'
4352 && *c != '+'
4353 })
4354 .collect();
4355 if clean.is_empty() {
4356 return String::new();
4357 }
4358 format!("\"{clean}\"")
4359 })
4360 .filter(|t| !t.is_empty())
4361 .collect();
4362 if tokens.is_empty() {
4363 return "\"_empty_\"".to_string();
4364 }
4365 tokens.join(joiner)
4366}
4367
4368pub fn list_namespaces(conn: &Connection) -> Result<Vec<NamespaceCount>> {
4369 let now = Utc::now().to_rfc3339();
4370 let mut stmt = conn.prepare(
4371 "SELECT namespace, COUNT(*) FROM memories WHERE expires_at IS NULL OR expires_at > ?1 GROUP BY namespace ORDER BY COUNT(*) DESC",
4372 )?;
4373 let rows = stmt.query_map(params![now], |row| {
4374 Ok(NamespaceCount {
4375 namespace: row.get(0)?,
4376 count: row.get(1)?,
4377 })
4378 })?;
4379 rows.collect::<rusqlite::Result<Vec<_>>>()
4380 .map_err(Into::into)
4381}
4382
4383pub const TAXONOMY_MAX_LIMIT: usize = 10_000;
4389
4390pub const TAXONOMY_DEFAULT_LIMIT: usize = 1000;
4394
4395#[allow(clippy::too_many_lines)]
4416pub fn get_taxonomy(
4417 conn: &Connection,
4418 namespace_prefix: Option<&str>,
4419 max_depth: usize,
4420 limit: usize,
4421) -> Result<Taxonomy> {
4422 let now = Utc::now().to_rfc3339();
4423 let effective_limit = limit.min(TAXONOMY_MAX_LIMIT);
4424 let effective_depth = max_depth.min(MAX_NAMESPACE_DEPTH);
4428
4429 let prefix = namespace_prefix.unwrap_or("");
4430 let descendant_pattern = format!(
4437 "{}/%",
4438 prefix
4439 .replace('\\', "\\\\")
4440 .replace('%', "\\%")
4441 .replace('_', "\\_")
4442 );
4443
4444 let total_count: usize = if prefix.is_empty() {
4448 let v: i64 = conn.query_row(
4449 "SELECT COUNT(*) FROM memories WHERE expires_at IS NULL OR expires_at > ?1",
4450 params![now],
4451 |row| row.get(0),
4452 )?;
4453 usize::try_from(v).unwrap_or(0)
4454 } else {
4455 let v: i64 = conn.query_row(
4456 "SELECT COUNT(*) FROM memories
4457 WHERE (expires_at IS NULL OR expires_at > ?1)
4458 AND (namespace = ?2 OR namespace LIKE ?3 ESCAPE '\\')",
4459 params![now, prefix, descendant_pattern],
4460 |row| row.get(0),
4461 )?;
4462 usize::try_from(v).unwrap_or(0)
4463 };
4464
4465 let groups: Vec<(String, usize)> = if prefix.is_empty() {
4468 let mut stmt = conn.prepare(
4469 "SELECT namespace, COUNT(*) FROM memories
4470 WHERE expires_at IS NULL OR expires_at > ?1
4471 GROUP BY namespace
4472 ORDER BY COUNT(*) DESC, namespace ASC
4473 LIMIT ?2",
4474 )?;
4475 let rows = stmt.query_map(
4476 params![now, i64::try_from(effective_limit).unwrap_or(i64::MAX)],
4477 |row| {
4478 let ns: String = row.get(0)?;
4479 let c: i64 = row.get(1)?;
4480 Ok((ns, usize::try_from(c).unwrap_or(0)))
4481 },
4482 )?;
4483 rows.collect::<rusqlite::Result<Vec<_>>>()?
4484 } else {
4485 let mut stmt = conn.prepare(
4486 "SELECT namespace, COUNT(*) FROM memories
4487 WHERE (expires_at IS NULL OR expires_at > ?1)
4488 AND (namespace = ?2 OR namespace LIKE ?3 ESCAPE '\\')
4489 GROUP BY namespace
4490 ORDER BY COUNT(*) DESC, namespace ASC
4491 LIMIT ?4",
4492 )?;
4493 let rows = stmt.query_map(
4494 params![
4495 now,
4496 prefix,
4497 descendant_pattern,
4498 i64::try_from(effective_limit).unwrap_or(i64::MAX)
4499 ],
4500 |row| {
4501 let ns: String = row.get(0)?;
4502 let c: i64 = row.get(1)?;
4503 Ok((ns, usize::try_from(c).unwrap_or(0)))
4504 },
4505 )?;
4506 rows.collect::<rusqlite::Result<Vec<_>>>()?
4507 };
4508
4509 let walked_count: usize = groups.iter().map(|(_, c)| *c).sum();
4510 let truncated = walked_count < total_count;
4511
4512 let root_name = prefix.rsplit('/').next().unwrap_or("").to_string();
4515 let mut root = TaxonomyNode {
4516 namespace: prefix.to_string(),
4517 name: root_name,
4518 count: 0,
4519 subtree_count: 0,
4520 children: Vec::new(),
4521 };
4522
4523 for (ns, c) in groups {
4524 let suffix: &str = if prefix.is_empty() {
4528 ns.as_str()
4529 } else if ns == prefix {
4530 ""
4531 } else if ns.len() > prefix.len() + 1
4532 && ns.starts_with(prefix)
4533 && ns.as_bytes()[prefix.len()] == b'/'
4534 {
4535 &ns[prefix.len() + 1..]
4536 } else {
4537 continue;
4541 };
4542 let all_segments: Vec<&str> = if suffix.is_empty() {
4543 Vec::new()
4544 } else {
4545 suffix.split('/').collect()
4546 };
4547 let take = all_segments.len().min(effective_depth);
4548 let used = &all_segments[..take];
4549 let exact_match_in_view = take == all_segments.len();
4550
4551 root.subtree_count += c;
4556 if used.is_empty() {
4557 root.count += c;
4558 continue;
4559 }
4560
4561 let mut path_so_far = prefix.to_string();
4562 let mut node = &mut root;
4563 for (i, seg) in used.iter().enumerate() {
4564 if !path_so_far.is_empty() {
4565 path_so_far.push('/');
4566 }
4567 path_so_far.push_str(seg);
4568 let pos = node.children.iter().position(|ch| ch.name == *seg);
4569 let idx = if let Some(p) = pos {
4570 p
4571 } else {
4572 node.children.push(TaxonomyNode {
4573 namespace: path_so_far.clone(),
4574 name: (*seg).to_string(),
4575 count: 0,
4576 subtree_count: 0,
4577 children: Vec::new(),
4578 });
4579 node.children.len() - 1
4580 };
4581 node = &mut node.children[idx];
4582 node.subtree_count += c;
4583 let is_leaf = i + 1 == used.len();
4584 if is_leaf && exact_match_in_view {
4585 node.count += c;
4586 }
4587 }
4588 }
4589
4590 sort_taxonomy(&mut root);
4591
4592 Ok(Taxonomy {
4593 tree: root,
4594 total_count,
4595 truncated,
4596 })
4597}
4598
4599fn sort_taxonomy(node: &mut TaxonomyNode) {
4600 node.children.sort_by(|a, b| a.name.cmp(&b.name));
4601 for child in &mut node.children {
4602 sort_taxonomy(child);
4603 }
4604}
4605
4606#[doc(hidden)]
4620pub fn fold_taxonomy_groups(
4621 prefix: &str,
4622 effective_depth: usize,
4623 total_count: usize,
4624 truncated: bool,
4625 groups: Vec<(String, usize)>,
4626) -> Taxonomy {
4627 let root_name = prefix.rsplit('/').next().unwrap_or("").to_string();
4628 let mut root = TaxonomyNode {
4629 namespace: prefix.to_string(),
4630 name: root_name,
4631 count: 0,
4632 subtree_count: 0,
4633 children: Vec::new(),
4634 };
4635
4636 for (ns, c) in groups {
4637 let suffix: &str = if prefix.is_empty() {
4638 ns.as_str()
4639 } else if ns == prefix {
4640 ""
4641 } else if ns.len() > prefix.len() + 1
4642 && ns.starts_with(prefix)
4643 && ns.as_bytes()[prefix.len()] == b'/'
4644 {
4645 &ns[prefix.len() + 1..]
4646 } else {
4647 continue;
4648 };
4649 let all_segments: Vec<&str> = if suffix.is_empty() {
4650 Vec::new()
4651 } else {
4652 suffix.split('/').collect()
4653 };
4654 let take = all_segments.len().min(effective_depth);
4655 let used = &all_segments[..take];
4656 let exact_match_in_view = take == all_segments.len();
4657
4658 root.subtree_count += c;
4659 if used.is_empty() {
4660 root.count += c;
4661 continue;
4662 }
4663
4664 let mut path_so_far = prefix.to_string();
4665 let mut node = &mut root;
4666 for (i, seg) in used.iter().enumerate() {
4667 if !path_so_far.is_empty() {
4668 path_so_far.push('/');
4669 }
4670 path_so_far.push_str(seg);
4671 let pos = node.children.iter().position(|ch| ch.name == *seg);
4672 let idx = if let Some(p) = pos {
4673 p
4674 } else {
4675 node.children.push(TaxonomyNode {
4676 namespace: path_so_far.clone(),
4677 name: (*seg).to_string(),
4678 count: 0,
4679 subtree_count: 0,
4680 children: Vec::new(),
4681 });
4682 node.children.len() - 1
4683 };
4684 node = &mut node.children[idx];
4685 node.subtree_count += c;
4686 let is_leaf = i + 1 == used.len();
4687 if is_leaf && exact_match_in_view {
4688 node.count += c;
4689 }
4690 }
4691 }
4692
4693 sort_taxonomy(&mut root);
4694
4695 Taxonomy {
4696 tree: root,
4697 total_count,
4698 truncated,
4699 }
4700}
4701
4702pub const LIST_DEFAULT_CAP: usize = 200;
4707
4708pub const LIST_MAX_LIMIT: usize = 1000;
4712
4713pub const LIST_FALLBACK_LIMIT: usize = 100;
4718
4719pub const ARCHIVE_DEFAULT_PAGE_LIMIT: usize = 50;
4723
4724pub const PENDING_DEFAULT_PAGE_LIMIT: usize = 100;
4727
4728pub const DUPLICATE_THRESHOLD_MIN: f32 = 0.5;
4732
4733pub const DUPLICATE_THRESHOLD_DEFAULT: f32 = 0.85;
4738
4739pub fn check_duplicate(
4757 conn: &Connection,
4758 query_embedding: &[f32],
4759 namespace: Option<&str>,
4760 threshold: f32,
4761) -> Result<DuplicateCheck> {
4762 let effective_threshold = threshold.max(DUPLICATE_THRESHOLD_MIN);
4763 let now = Utc::now().to_rfc3339();
4764
4765 let rows: Vec<(String, String, String, Vec<u8>)> = if let Some(ns) = namespace {
4770 let mut stmt = conn.prepare(
4771 "SELECT id, title, namespace, embedding FROM memories
4772 WHERE embedding IS NOT NULL
4773 AND (expires_at IS NULL OR expires_at > ?1)
4774 AND namespace = ?2",
4775 )?;
4776 let mapped = stmt.query_map(params![now, ns], |row| {
4777 Ok((
4778 row.get::<_, String>(0)?,
4779 row.get::<_, String>(1)?,
4780 row.get::<_, String>(2)?,
4781 row.get::<_, Vec<u8>>(3)?,
4782 ))
4783 })?;
4784 mapped.collect::<rusqlite::Result<Vec<_>>>()?
4785 } else {
4786 let mut stmt = conn.prepare(
4787 "SELECT id, title, namespace, embedding FROM memories
4788 WHERE embedding IS NOT NULL
4789 AND (expires_at IS NULL OR expires_at > ?1)",
4790 )?;
4791 let mapped = stmt.query_map(params![now], |row| {
4792 Ok((
4793 row.get::<_, String>(0)?,
4794 row.get::<_, String>(1)?,
4795 row.get::<_, String>(2)?,
4796 row.get::<_, Vec<u8>>(3)?,
4797 ))
4798 })?;
4799 mapped.collect::<rusqlite::Result<Vec<_>>>()?
4800 };
4801
4802 let mut best: Option<DuplicateMatch> = None;
4803 let mut scanned: usize = 0;
4804 for (id, title, ns, bytes) in rows {
4805 if bytes.is_empty() {
4806 continue;
4807 }
4808 let candidate = match crate::embeddings::decode_embedding_blob(&bytes) {
4812 Ok(v) => v,
4813 Err(e) => {
4814 tracing::warn!(
4815 memory_id = %id,
4816 blob_len = bytes.len(),
4817 error = %e,
4818 "skipping duplicate-check candidate with malformed embedding"
4819 );
4820 continue;
4821 }
4822 };
4823 if candidate.len() != query_embedding.len() {
4827 tracing::warn!(
4828 memory_id = %id,
4829 expected = query_embedding.len(),
4830 got = candidate.len(),
4831 "skipping duplicate-check candidate with dimension mismatch"
4832 );
4833 continue;
4834 }
4835 let similarity =
4836 crate::embeddings::Embedder::cosine_similarity(query_embedding, &candidate);
4837 scanned += 1;
4838 let is_better = best.as_ref().is_none_or(|m| similarity > m.similarity);
4839 if is_better {
4840 best = Some(DuplicateMatch {
4841 id,
4842 title,
4843 namespace: ns,
4844 similarity,
4845 });
4846 }
4847 }
4848
4849 let is_duplicate = best
4850 .as_ref()
4851 .is_some_and(|m| m.similarity >= effective_threshold);
4852 Ok(DuplicateCheck {
4853 is_duplicate,
4854 threshold: effective_threshold,
4855 nearest: best,
4856 candidates_scanned: scanned,
4857 })
4858}
4859
4860#[must_use]
4877pub fn canonical_content_hash(text: &str) -> [u8; 32] {
4878 use sha2::{Digest, Sha256};
4879 let mut hasher = Sha256::new();
4880 hasher.update(text.as_bytes());
4881 hasher.finalize().into()
4882}
4883
4884pub const PROACTIVE_CONFLICT_SIM_THRESHOLD: f32 = 0.95;
4912
4913pub const PROACTIVE_CONFLICT_TOP_K: usize = 5;
4917
4918pub const PROACTIVE_CONFLICT_SCAN_LIMIT: usize = 1024;
4936
4937pub const PROACTIVE_CONFLICT_INDEX_K: usize = 32;
4949
4950pub const PROACTIVE_CONFLICT_CONTENT_JACCARD_FLOOR: f32 = 0.30;
4972
4973#[derive(Debug, Clone)]
4977pub struct ProactiveConflict {
4978 pub existing_id: String,
4980 pub existing_title: String,
4982 pub similarity: f32,
4985 pub reason: &'static str,
4990}
4991
4992pub fn proactive_conflict_check(
5031 conn: &Connection,
5032 mem: &Memory,
5033 query_embedding: &[f32],
5034) -> Result<Option<ProactiveConflict>> {
5035 if query_embedding.is_empty() {
5036 return Ok(None);
5037 }
5038 let now = Utc::now().to_rfc3339();
5039
5040 let mut stmt = conn.prepare(
5054 "SELECT id, title, content, embedding FROM memories
5055 WHERE embedding IS NOT NULL
5056 AND (expires_at IS NULL OR expires_at > ?1)
5057 AND namespace = ?2
5058 ORDER BY updated_at DESC
5059 LIMIT ?3",
5060 )?;
5061 let rows: Vec<(String, String, String, Vec<u8>)> = stmt
5062 .query_map(
5063 params![
5064 now,
5065 &mem.namespace,
5066 i64::try_from(PROACTIVE_CONFLICT_SCAN_LIMIT).unwrap_or(i64::MAX)
5067 ],
5068 |row| {
5069 Ok((
5070 row.get::<_, String>(0)?,
5071 row.get::<_, String>(1)?,
5072 row.get::<_, String>(2)?,
5073 row.get::<_, Vec<u8>>(3)?,
5074 ))
5075 },
5076 )?
5077 .collect::<rusqlite::Result<Vec<_>>>()?;
5078
5079 Ok(proactive_conflict_verdict(mem, query_embedding, rows))
5080}
5081
5082pub fn proactive_conflict_check_with_index(
5121 conn: &Connection,
5122 mem: &Memory,
5123 query_embedding: &[f32],
5124 vector_index: Option<&crate::hnsw::VectorIndex>,
5125) -> Result<Option<ProactiveConflict>> {
5126 if query_embedding.is_empty() {
5127 return Ok(None);
5128 }
5129 if let Some(idx) = vector_index
5130 && idx.is_fully_searchable()
5131 && !idx.is_empty()
5136 {
5137 let hits = idx.search(query_embedding, PROACTIVE_CONFLICT_INDEX_K);
5138 let ids: Vec<String> = hits.into_iter().map(|h| h.id).collect();
5139 return proactive_conflict_check_candidates(conn, mem, query_embedding, &ids);
5140 }
5141 tracing::trace!(
5142 target: "proactive_conflict",
5143 namespace = %mem.namespace,
5144 "no fully-searchable (or empty) vector index — bounded recency-scan fallback (#1579 A5)"
5145 );
5146 proactive_conflict_check(conn, mem, query_embedding)
5147}
5148
5149pub fn proactive_conflict_check_candidates(
5163 conn: &Connection,
5164 mem: &Memory,
5165 query_embedding: &[f32],
5166 candidate_ids: &[String],
5167) -> Result<Option<ProactiveConflict>> {
5168 if query_embedding.is_empty() || candidate_ids.is_empty() {
5169 return Ok(None);
5170 }
5171 let now = Utc::now().to_rfc3339();
5172 let placeholders = std::iter::repeat_n("?", candidate_ids.len())
5173 .collect::<Vec<_>>()
5174 .join(",");
5175 let sql = format!(
5176 "SELECT id, title, content, embedding FROM memories
5177 WHERE id IN ({placeholders})
5178 AND embedding IS NOT NULL
5179 AND (expires_at IS NULL OR expires_at > ?{p_now})
5180 AND namespace = ?{p_ns}",
5181 p_now = candidate_ids.len() + 1,
5182 p_ns = candidate_ids.len() + 2,
5183 );
5184 let mut stmt = conn.prepare(&sql)?;
5185 let bind_iter = candidate_ids
5186 .iter()
5187 .map(String::as_str)
5188 .chain([now.as_str(), mem.namespace.as_str()]);
5189 let rows: Vec<(String, String, String, Vec<u8>)> = stmt
5190 .query_map(rusqlite::params_from_iter(bind_iter), |row| {
5191 Ok((
5192 row.get::<_, String>(0)?,
5193 row.get::<_, String>(1)?,
5194 row.get::<_, String>(2)?,
5195 row.get::<_, Vec<u8>>(3)?,
5196 ))
5197 })?
5198 .collect::<rusqlite::Result<Vec<_>>>()?;
5199
5200 Ok(proactive_conflict_verdict(mem, query_embedding, rows))
5201}
5202
5203fn proactive_conflict_verdict(
5217 mem: &Memory,
5218 query_embedding: &[f32],
5219 rows: Vec<(String, String, String, Vec<u8>)>,
5220) -> Option<ProactiveConflict> {
5221 let mut scored: Vec<(f32, String, String, String)> = Vec::with_capacity(rows.len());
5223 for (id, title, content, blob) in rows {
5224 if blob.is_empty() {
5225 continue;
5226 }
5227 if id == mem.id {
5230 continue;
5231 }
5232 let candidate = match crate::embeddings::decode_embedding_blob(&blob) {
5233 Ok(v) => v,
5234 Err(e) => {
5235 tracing::warn!(
5236 memory_id = %id,
5237 blob_len = blob.len(),
5238 error = %e,
5239 "proactive_conflict_check: skipping candidate with malformed embedding"
5240 );
5241 continue;
5242 }
5243 };
5244 if candidate.len() != query_embedding.len() {
5245 tracing::warn!(
5246 memory_id = %id,
5247 expected = query_embedding.len(),
5248 got = candidate.len(),
5249 "proactive_conflict_check: skipping candidate with dimension mismatch"
5250 );
5251 continue;
5252 }
5253 let sim = crate::embeddings::Embedder::cosine_similarity(query_embedding, &candidate);
5254 scored.push((sim, id, title, content));
5255 }
5256 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
5259 let incoming_tokens = contradiction_title_tokens(&mem.content);
5260 for (sim, id, title, content) in scored.into_iter().take(PROACTIVE_CONFLICT_TOP_K) {
5261 if sim < PROACTIVE_CONFLICT_SIM_THRESHOLD {
5262 break;
5265 }
5266 if content != mem.content
5279 && contradiction_title_jaccard(&incoming_tokens, &contradiction_title_tokens(&content))
5280 >= PROACTIVE_CONFLICT_CONTENT_JACCARD_FLOOR
5281 {
5282 return Some(ProactiveConflict {
5283 existing_id: id,
5284 existing_title: title,
5285 similarity: sim,
5286 reason: "near_duplicate_with_differing_content",
5287 });
5288 }
5289 }
5290 None
5291}
5292
5293pub fn check_duplicate_with_text(
5326 conn: &Connection,
5327 query_embedding: &[f32],
5328 query_text: &str,
5329 namespace: Option<&str>,
5330 threshold: f32,
5331) -> Result<DuplicateCheck> {
5332 let effective_threshold = threshold.max(DUPLICATE_THRESHOLD_MIN);
5333 let now = Utc::now().to_rfc3339();
5334 let query_hash = canonical_content_hash(query_text);
5335
5336 let rows: Vec<(String, String, String, String)> = if let Some(ns) = namespace {
5342 let mut stmt = conn.prepare(
5343 "SELECT id, title, namespace, content FROM memories
5344 WHERE (expires_at IS NULL OR expires_at > ?1)
5345 AND namespace = ?2",
5346 )?;
5347 let mapped = stmt.query_map(params![now, ns], |row| {
5348 Ok((
5349 row.get::<_, String>(0)?,
5350 row.get::<_, String>(1)?,
5351 row.get::<_, String>(2)?,
5352 row.get::<_, String>(3)?,
5353 ))
5354 })?;
5355 mapped.collect::<rusqlite::Result<Vec<_>>>()?
5356 } else {
5357 let mut stmt = conn.prepare(
5358 "SELECT id, title, namespace, content FROM memories
5359 WHERE (expires_at IS NULL OR expires_at > ?1)",
5360 )?;
5361 let mapped = stmt.query_map(params![now], |row| {
5362 Ok((
5363 row.get::<_, String>(0)?,
5364 row.get::<_, String>(1)?,
5365 row.get::<_, String>(2)?,
5366 row.get::<_, String>(3)?,
5367 ))
5368 })?;
5369 mapped.collect::<rusqlite::Result<Vec<_>>>()?
5370 };
5371
5372 for (id, title, ns, content) in &rows {
5378 let row_text = crate::embeddings::embedding_document(title, content);
5379 let row_hash = canonical_content_hash(&row_text);
5380 if row_hash == query_hash {
5381 return Ok(DuplicateCheck {
5382 is_duplicate: true,
5383 threshold: effective_threshold,
5384 nearest: Some(DuplicateMatch {
5385 id: id.clone(),
5386 title: title.clone(),
5387 namespace: ns.clone(),
5388 similarity: 1.0,
5389 }),
5390 candidates_scanned: rows.len(),
5393 });
5394 }
5395 }
5396
5397 check_duplicate(conn, query_embedding, namespace, threshold)
5401}
5402
5403pub fn entity_register(
5432 conn: &Connection,
5433 canonical_name: &str,
5434 namespace: &str,
5435 aliases: &[String],
5436 extra_metadata: &serde_json::Value,
5437 agent_id: Option<&str>,
5438) -> Result<crate::models::EntityRegistration> {
5439 use crate::models::{ENTITY_KIND, ENTITY_TAG, EntityRegistration};
5440
5441 let existing_id: Option<String> = match conn.query_row(
5445 "SELECT id FROM memories
5446 WHERE namespace = ?1 AND title = ?2
5447 AND COALESCE(json_extract(metadata, '$.kind'), '') = ?3",
5448 params![namespace, canonical_name, ENTITY_KIND],
5449 |r| r.get::<_, String>(0),
5450 ) {
5451 Ok(id) => Some(id),
5452 Err(rusqlite::Error::QueryReturnedNoRows) => None,
5453 Err(e) => return Err(e.into()),
5454 };
5455
5456 let (entity_id, created) = if let Some(id) = existing_id {
5457 (id, false)
5458 } else {
5459 let collision: Option<String> = match conn.query_row(
5460 "SELECT id FROM memories
5461 WHERE namespace = ?1 AND title = ?2
5462 AND COALESCE(json_extract(metadata, '$.kind'), '') != ?3",
5463 params![namespace, canonical_name, ENTITY_KIND],
5464 |r| r.get::<_, String>(0),
5465 ) {
5466 Ok(id) => Some(id),
5467 Err(rusqlite::Error::QueryReturnedNoRows) => None,
5468 Err(e) => return Err(e.into()),
5469 };
5470 if collision.is_some() {
5471 return Err(anyhow::Error::new(StorageError::UniqueConflict {
5473 reason: format!(
5474 "entity_register: title '{canonical_name}' in namespace '{namespace}' is already used by a non-entity memory"
5475 ),
5476 }));
5477 }
5478
5479 let mut meta_map = match extra_metadata {
5482 serde_json::Value::Object(m) => m.clone(),
5483 _ => serde_json::Map::new(),
5484 };
5485 meta_map.insert(
5486 "kind".to_string(),
5487 serde_json::Value::String(ENTITY_KIND.to_string()),
5488 );
5489 if let Some(a) = agent_id {
5490 meta_map
5491 .entry("agent_id".to_string())
5492 .or_insert(serde_json::Value::String(a.to_string()));
5493 }
5494 let metadata = serde_json::Value::Object(meta_map);
5495
5496 let now = Utc::now().to_rfc3339();
5497 let mem = Memory {
5498 id: uuid::Uuid::new_v4().to_string(),
5499 tier: Tier::Long,
5500 namespace: namespace.to_string(),
5501 title: canonical_name.to_string(),
5502 content: canonical_name.to_string(),
5503 tags: vec![ENTITY_TAG.to_string()],
5504 priority: 7,
5505 confidence: 1.0,
5506 source: "api".to_string(),
5507 access_count: 0,
5508 created_at: now.clone(),
5509 updated_at: now,
5510 last_accessed_at: None,
5511 expires_at: None,
5512 metadata,
5513 reflection_depth: 0,
5514 memory_kind: crate::models::MemoryKind::Observation,
5515 entity_id: None,
5516 persona_version: None,
5517 citations: Vec::new(),
5518 source_uri: None,
5519 source_span: None,
5520 confidence_source: ConfidenceSource::CallerProvided,
5521 confidence_signals: None,
5522 confidence_decayed_at: None,
5523 version: 1,
5524 };
5525 let id = insert(conn, &mem).context("insert entity memory")?;
5526 (id, true)
5527 };
5528
5529 let now = Utc::now().to_rfc3339();
5530 {
5531 let mut stmt = conn.prepare(
5532 "INSERT OR IGNORE INTO entity_aliases (entity_id, alias, created_at)
5533 VALUES (?1, ?2, ?3)",
5534 )?;
5535 stmt.execute(params![entity_id, canonical_name, now])?;
5539 for alias in aliases {
5540 let trimmed = alias.trim();
5541 if trimmed.is_empty() || trimmed == canonical_name {
5542 continue;
5543 }
5544 stmt.execute(params![entity_id, trimmed, now])?;
5545 }
5546 }
5547
5548 let aliases_out = list_entity_aliases(conn, &entity_id)?;
5549
5550 Ok(EntityRegistration {
5551 entity_id,
5552 canonical_name: canonical_name.to_string(),
5553 namespace: namespace.to_string(),
5554 aliases: aliases_out,
5555 created,
5556 })
5557}
5558
5559pub fn entity_get_by_alias(
5570 conn: &Connection,
5571 alias: &str,
5572 namespace: Option<&str>,
5573) -> Result<Option<crate::models::EntityRecord>> {
5574 use crate::models::{ENTITY_KIND, EntityRecord};
5575
5576 let trimmed = alias.trim();
5577 if trimmed.is_empty() {
5578 return Ok(None);
5579 }
5580
5581 let row: std::result::Result<(String, String, String), rusqlite::Error> =
5582 if let Some(ns) = namespace {
5583 conn.query_row(
5584 "SELECT m.id, m.title, m.namespace
5585 FROM entity_aliases ea
5586 JOIN memories m ON m.id = ea.entity_id
5587 WHERE ea.alias = ?1
5588 AND m.namespace = ?2
5589 AND COALESCE(json_extract(m.metadata, '$.kind'), '') = ?3
5590 ORDER BY m.created_at DESC
5591 LIMIT 1",
5592 params![trimmed, ns, ENTITY_KIND],
5593 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
5594 )
5595 } else {
5596 conn.query_row(
5597 "SELECT m.id, m.title, m.namespace
5598 FROM entity_aliases ea
5599 JOIN memories m ON m.id = ea.entity_id
5600 WHERE ea.alias = ?1
5601 AND COALESCE(json_extract(m.metadata, '$.kind'), '') = ?2
5602 ORDER BY m.created_at DESC
5603 LIMIT 1",
5604 params![trimmed, ENTITY_KIND],
5605 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
5606 )
5607 };
5608
5609 let (entity_id, canonical_name, ns) = match row {
5610 Ok(t) => t,
5611 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
5612 Err(e) => return Err(e.into()),
5613 };
5614
5615 let aliases = list_entity_aliases(conn, &entity_id)?;
5616 Ok(Some(EntityRecord {
5617 entity_id,
5618 canonical_name,
5619 namespace: ns,
5620 aliases,
5621 }))
5622}
5623
5624pub const KG_TIMELINE_DEFAULT_LIMIT: usize = 200;
5629
5630pub const KG_TIMELINE_MAX_LIMIT: usize = 1000;
5633
5634pub fn kg_timeline(
5673 conn: &Connection,
5674 source_id: &str,
5675 since: Option<&str>,
5676 until: Option<&str>,
5677 limit: Option<usize>,
5678) -> Result<Vec<crate::models::KgTimelineEvent>> {
5679 use crate::models::KgTimelineEvent;
5680
5681 let cap = limit
5682 .unwrap_or(KG_TIMELINE_DEFAULT_LIMIT)
5683 .clamp(1, KG_TIMELINE_MAX_LIMIT);
5684
5685 let mut sql = String::from(
5688 "SELECT ml.target_id, ml.relation, ml.valid_from, ml.valid_until,
5689 ml.observed_by, m.title, m.namespace, ml.created_at
5690 FROM memory_links ml
5691 JOIN memories m ON m.id = ml.target_id
5692 WHERE ml.source_id = ?1
5693 AND ml.valid_from IS NOT NULL",
5694 );
5695 let mut binds: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(source_id.to_string())];
5696 if let Some(s) = since {
5697 sql.push_str(" AND ml.valid_from >= ?");
5698 sql.push_str(&(binds.len() + 1).to_string());
5699 binds.push(Box::new(s.to_string()));
5700 }
5701 if let Some(u) = until {
5702 sql.push_str(" AND ml.valid_from <= ?");
5703 sql.push_str(&(binds.len() + 1).to_string());
5704 binds.push(Box::new(u.to_string()));
5705 }
5706 sql.push_str(" ORDER BY ml.valid_from ASC, ml.created_at ASC LIMIT ?");
5707 sql.push_str(&(binds.len() + 1).to_string());
5708 binds.push(Box::new(i64::try_from(cap).unwrap_or(i64::MAX)));
5709
5710 let mut stmt = conn.prepare(&sql)?;
5711 let bind_refs: Vec<&dyn rusqlite::ToSql> = binds.iter().map(AsRef::as_ref).collect();
5712 let rows = stmt.query_map(rusqlite::params_from_iter(bind_refs), |row| {
5713 Ok(KgTimelineEvent {
5714 target_id: row.get(0)?,
5715 relation: row.get(1)?,
5716 valid_from: row.get(2)?,
5717 valid_until: row.get(3)?,
5718 observed_by: row.get(4)?,
5719 title: row.get(5)?,
5720 target_namespace: row.get(6)?,
5721 })
5722 })?;
5723 rows.collect::<rusqlite::Result<Vec<_>>>()
5724 .map_err(Into::into)
5725}
5726
5727#[derive(Debug, Clone, PartialEq, Eq)]
5733pub struct InvalidateResult {
5734 pub valid_until: String,
5735 pub previous_valid_until: Option<String>,
5736}
5737
5738pub fn invalidate_link(
5778 conn: &Connection,
5779 source_id: &str,
5780 target_id: &str,
5781 relation: &str,
5782 valid_until: Option<&str>,
5783) -> Result<Option<InvalidateResult>> {
5784 let stamp = valid_until.map_or_else(|| Utc::now().to_rfc3339(), str::to_string);
5785
5786 conn.execute(connection::SQL_BEGIN_IMMEDIATE, [])?;
5793 let rollback = || {
5795 let _ = conn.execute(connection::SQL_ROLLBACK, []);
5796 };
5797
5798 let prior_row: (
5802 Option<String>,
5803 Option<Vec<u8>>,
5804 Option<String>,
5805 Option<String>,
5806 Option<String>,
5807 ) = match conn.query_row(
5808 "SELECT valid_until, signature, attest_level, observed_by, valid_from \
5809 FROM memory_links \
5810 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
5811 params![source_id, target_id, relation],
5812 |r| {
5813 Ok((
5814 r.get::<_, Option<String>>(0)?,
5815 r.get::<_, Option<Vec<u8>>>(1)?,
5816 r.get::<_, Option<String>>(2)?,
5817 r.get::<_, Option<String>>(3)?,
5818 r.get::<_, Option<String>>(4)?,
5819 ))
5820 },
5821 ) {
5822 Ok(v) => v,
5823 Err(rusqlite::Error::QueryReturnedNoRows) => {
5824 rollback();
5825 return Ok(None);
5826 }
5827 Err(e) => {
5828 rollback();
5829 return Err(e.into());
5830 }
5831 };
5832 let (prior, prior_signature, _prior_attest, observed_by, valid_from) = prior_row;
5833 let was_signed = prior_signature.is_some();
5834
5835 let update_result = if was_signed {
5836 conn.execute(
5841 "UPDATE memory_links \
5842 SET valid_until = ?4, signature = NULL, attest_level = 'unsigned' \
5843 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
5844 params![source_id, target_id, relation, &stamp],
5845 )
5846 } else {
5847 conn.execute(
5848 "UPDATE memory_links SET valid_until = ?4 \
5849 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
5850 params![source_id, target_id, relation, &stamp],
5851 )
5852 };
5853 if let Err(e) = update_result {
5854 rollback();
5855 return Err(e.into());
5856 }
5857
5858 if was_signed {
5872 let signable = crate::identity::sign::SignableLink {
5873 src_id: source_id,
5874 dst_id: target_id,
5875 relation,
5876 observed_by: observed_by.as_deref(),
5877 valid_from: valid_from.as_deref(),
5878 valid_until: Some(stamp.as_str()),
5879 };
5880 match crate::identity::sign::canonical_cbor(&signable) {
5881 Ok(cbor) => {
5882 let event = crate::signed_events::SignedEvent {
5883 id: uuid::Uuid::new_v4().to_string(),
5884 agent_id: observed_by.clone().unwrap_or_else(|| "unknown".to_string()),
5892 event_type: crate::signed_events::event_types::MEMORY_LINK_INVALIDATED
5893 .to_string(),
5894 payload_hash: crate::signed_events::payload_hash(&cbor),
5895 signature: prior_signature,
5896 attest_level: crate::models::AttestLevel::Unsigned.as_str().to_string(),
5897 timestamp: Utc::now().to_rfc3339(),
5898 ..crate::signed_events::SignedEvent::default()
5899 };
5900 if let Err(e) = crate::signed_events::append_signed_event_no_tx(conn, &event) {
5907 rollback();
5912 return Err(anyhow::anyhow!(
5913 "failed to append memory_link.invalidated audit row \
5914 (rolled back signature clearing): {e}"
5915 ));
5916 }
5917 }
5918 Err(e) => {
5919 rollback();
5920 return Err(anyhow::anyhow!(
5921 "failed to encode canonical CBOR for invalidation audit \
5922 (rolled back signature clearing): {e}"
5923 ));
5924 }
5925 }
5926 }
5927
5928 conn.execute(connection::SQL_COMMIT, [])?;
5929 Ok(Some(InvalidateResult {
5930 valid_until: stamp,
5931 previous_valid_until: prior,
5932 }))
5933}
5934
5935pub const KG_QUERY_DEFAULT_LIMIT: usize = 200;
5939
5940pub const KG_QUERY_MAX_LIMIT: usize = 1000;
5944
5945pub const KG_QUERY_MAX_SUPPORTED_DEPTH: usize = 5;
5950
5951pub fn kg_query(
5983 conn: &Connection,
5984 source_id: &str,
5985 max_depth: usize,
5986 valid_at: Option<&str>,
5987 allowed_agents: Option<&[String]>,
5988 limit: Option<usize>,
5989 include_invalidated: bool,
5990) -> Result<Vec<crate::models::KgQueryNode>> {
5991 use crate::models::KgQueryNode;
5992
5993 if max_depth == 0 {
5994 return Err(anyhow::Error::new(StorageError::InvalidArgument {
5996 reason: crate::errors::msg::MAX_DEPTH_MIN.to_string(),
5997 }));
5998 }
5999 if max_depth > KG_QUERY_MAX_SUPPORTED_DEPTH {
6000 return Err(anyhow::Error::new(StorageError::InvalidArgument {
6002 reason: format!(
6003 "max_depth={max_depth} exceeds supported depth={KG_QUERY_MAX_SUPPORTED_DEPTH}"
6004 ),
6005 }));
6006 }
6007
6008 if let Some(agents) = allowed_agents
6011 && agents.is_empty()
6012 {
6013 return Ok(Vec::new());
6014 }
6015
6016 let cap = limit
6017 .unwrap_or(KG_QUERY_DEFAULT_LIMIT)
6018 .clamp(1, KG_QUERY_MAX_LIMIT);
6019
6020 let mut binds: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
6024 let mut hop_filter = String::new();
6025 if let Some(t) = valid_at {
6026 hop_filter.push_str(" AND ml.valid_from IS NOT NULL AND ml.valid_from <= ?");
6027 binds.push(Box::new(t.to_string()));
6028 hop_filter.push_str(&binds.len().to_string());
6029 hop_filter.push_str(" AND (ml.valid_until IS NULL OR ml.valid_until > ?");
6030 binds.push(Box::new(t.to_string()));
6031 hop_filter.push_str(&binds.len().to_string());
6032 hop_filter.push(')');
6033 } else if !include_invalidated {
6034 hop_filter.push_str(
6041 " AND (ml.valid_until IS NULL OR ml.valid_until > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
6042 );
6043 }
6044 if let Some(agents) = allowed_agents {
6045 hop_filter.push_str(" AND ml.observed_by IN (");
6047 for (i, a) in agents.iter().enumerate() {
6048 binds.push(Box::new(a.clone()));
6049 if i > 0 {
6050 hop_filter.push_str(", ");
6051 }
6052 hop_filter.push('?');
6053 hop_filter.push_str(&binds.len().to_string());
6054 }
6055 hop_filter.push(')');
6056 }
6057
6058 binds.push(Box::new(source_id.to_string()));
6062 let source_ph = binds.len();
6063 binds.push(Box::new(i64::try_from(max_depth).unwrap_or(i64::MAX)));
6064 let max_depth_ph = binds.len();
6065 binds.push(Box::new(i64::try_from(cap).unwrap_or(i64::MAX)));
6066 let limit_ph = binds.len();
6067
6068 let sql = format!(
6085 "WITH RECURSIVE traversal(\
6086 target_id, relation, valid_from, valid_until, observed_by, \
6087 link_created_at, depth, path\
6088 ) AS (\
6089 SELECT ml.target_id, ml.relation, ml.valid_from, ml.valid_until, \
6090 ml.observed_by, ml.created_at, 1, \
6091 json_array(ml.source_id, ml.target_id) \
6092 FROM memory_links ml \
6093 WHERE ml.source_id = ?{source_ph}{hop_filter} \
6094 UNION ALL \
6095 SELECT ml.target_id, ml.relation, ml.valid_from, ml.valid_until, \
6096 ml.observed_by, ml.created_at, t.depth + 1, \
6097 json_insert(t.path, '$[' || json_array_length(t.path) || ']', ml.target_id) \
6098 FROM memory_links ml \
6099 JOIN traversal t ON ml.source_id = t.target_id \
6100 WHERE t.depth < ?{max_depth_ph} \
6101 AND NOT EXISTS (SELECT 1 FROM json_each(t.path) WHERE value = ml.target_id)\
6102 {hop_filter}\
6103 ) \
6104 SELECT t.target_id, t.relation, t.valid_from, t.valid_until, \
6105 t.observed_by, m.title, m.namespace, t.depth, \
6106 (SELECT group_concat(value, '->') FROM json_each(t.path)) \
6107 FROM traversal t \
6108 JOIN memories m ON m.id = t.target_id \
6109 ORDER BY t.depth ASC, COALESCE(t.valid_from, t.link_created_at) ASC, \
6110 t.link_created_at ASC \
6111 LIMIT ?{limit_ph}",
6112 );
6113
6114 let mut stmt = conn.prepare(&sql)?;
6115 let bind_refs: Vec<&dyn rusqlite::ToSql> = binds.iter().map(AsRef::as_ref).collect();
6116 let rows = stmt.query_map(rusqlite::params_from_iter(bind_refs), |row| {
6117 let target_id: String = row.get(0)?;
6118 let depth: i64 = row.get(7)?;
6119 Ok(KgQueryNode {
6120 target_id,
6121 relation: row.get(1)?,
6122 valid_from: row.get(2)?,
6123 valid_until: row.get(3)?,
6124 observed_by: row.get(4)?,
6125 title: row.get(5)?,
6126 target_namespace: row.get(6)?,
6127 depth: usize::try_from(depth).unwrap_or(0),
6128 path: row.get(8)?,
6129 })
6130 })?;
6131 rows.collect::<rusqlite::Result<Vec<_>>>()
6132 .map_err(Into::into)
6133}
6134
6135pub const FIND_PATHS_DEFAULT_LIMIT: usize = 10;
6138
6139pub const FIND_PATHS_MAX_LIMIT: usize = 50;
6143
6144pub const FIND_PATHS_MAX_DEPTH: usize = 7;
6156
6157pub const FIND_PATHS_DEFAULT_DEPTH: usize = 4;
6160
6161pub fn find_paths(
6213 conn: &Connection,
6214 source_id: &str,
6215 target_id: &str,
6216 max_depth: Option<usize>,
6217 max_results: Option<usize>,
6218 include_invalidated: bool,
6219) -> Result<Vec<Vec<String>>> {
6220 let depth = max_depth.unwrap_or(FIND_PATHS_DEFAULT_DEPTH);
6221 if depth == 0 {
6222 return Err(anyhow::Error::new(StorageError::InvalidArgument {
6224 reason: crate::errors::msg::MAX_DEPTH_MIN.to_string(),
6225 }));
6226 }
6227 if depth > FIND_PATHS_MAX_DEPTH {
6228 return Err(anyhow::Error::new(StorageError::InvalidArgument {
6230 reason: format!(
6231 "max_depth={depth} exceeds supported depth={FIND_PATHS_MAX_DEPTH} (FIND_PATHS_MAX_DEPTH); contact maintainers to raise this bound after benchmarking"
6232 ),
6233 }));
6234 }
6235 let cap = max_results
6236 .unwrap_or(FIND_PATHS_DEFAULT_LIMIT)
6237 .clamp(1, FIND_PATHS_MAX_LIMIT);
6238
6239 if source_id == target_id {
6243 return Ok(vec![vec![source_id.to_string()]]);
6244 }
6245
6246 let invalidated_filter = if include_invalidated {
6252 ""
6253 } else {
6254 " WHERE (valid_until IS NULL OR valid_until > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
6255 };
6256
6257 let sql = format!(
6272 "WITH RECURSIVE traversal(current_id, depth, path) AS (
6273 SELECT ?1, 0, json_array(?1)
6274 UNION ALL
6275 SELECT next_id, t.depth + 1,
6276 json_insert(t.path, '$[' || json_array_length(t.path) || ']', next_id)
6277 FROM traversal t
6278 JOIN (
6279 SELECT source_id AS from_id, target_id AS next_id
6280 FROM memory_links{invalidated_filter}
6281 UNION
6282 SELECT target_id AS from_id, source_id AS next_id
6283 FROM memory_links{invalidated_filter}
6284 ) edges ON edges.from_id = t.current_id
6285 WHERE t.depth < ?3
6286 AND NOT EXISTS (
6287 SELECT 1 FROM json_each(t.path) WHERE value = next_id
6288 )
6289 )
6290 SELECT path
6291 FROM traversal
6292 WHERE current_id = ?2 AND depth >= 1
6293 ORDER BY depth ASC, path ASC
6294 LIMIT ?4"
6295 );
6296
6297 let depth_i64 = i64::try_from(depth).unwrap_or(i64::MAX);
6298 let cap_i64 = i64::try_from(cap).unwrap_or(i64::MAX);
6299
6300 let mut stmt = conn.prepare(&sql)?;
6301 let rows = stmt.query_map(params![source_id, target_id, depth_i64, cap_i64], |row| {
6302 let json_path: String = row.get(0)?;
6303 Ok(json_path)
6304 })?;
6305
6306 let mut paths: Vec<Vec<String>> = Vec::new();
6307 for row in rows {
6308 let json = row?;
6309 let parsed: Vec<String> = serde_json::from_str(&json).map_err(|e| {
6310 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
6311 })?;
6312 paths.push(parsed);
6313 }
6314
6315 Ok(paths)
6316}
6317
6318fn list_entity_aliases(conn: &Connection, entity_id: &str) -> Result<Vec<String>> {
6321 let mut stmt = conn.prepare(
6322 "SELECT alias FROM entity_aliases
6323 WHERE entity_id = ?1
6324 ORDER BY created_at ASC, alias ASC",
6325 )?;
6326 let aliases: Vec<String> = stmt
6327 .query_map(params![entity_id], |r| r.get::<_, String>(0))?
6328 .collect::<rusqlite::Result<Vec<_>>>()?;
6329 Ok(aliases)
6330}
6331
6332pub fn register_agent(
6341 conn: &Connection,
6342 agent_id: &str,
6343 agent_type: &str,
6344 capabilities: &[String],
6345) -> Result<String> {
6346 let title = crate::models::agent_registration_title(agent_id);
6347 let now = Utc::now().to_rfc3339();
6348
6349 let registered_at = conn
6351 .query_row(
6352 "SELECT json_extract(metadata, '$.registered_at') FROM memories
6353 WHERE namespace = ?1 AND title = ?2",
6354 params![AGENTS_NAMESPACE, &title],
6355 |row| row.get::<_, Option<String>>(0),
6356 )
6357 .ok()
6358 .flatten()
6359 .unwrap_or_else(|| now.clone());
6360
6361 let caps_json: Vec<serde_json::Value> = capabilities
6362 .iter()
6363 .map(|c| serde_json::Value::String(c.clone()))
6364 .collect();
6365
6366 let metadata = serde_json::json!({
6367 "agent_id": agent_id,
6368 (field_names::AGENT_TYPE): agent_type,
6369 (field_names::CAPABILITIES): caps_json,
6370 (field_names::REGISTERED_AT): registered_at,
6371 (field_names::LAST_SEEN_AT): now,
6372 "scope": crate::models::MemoryScope::Collective.as_str(),
6379 });
6380
6381 let content = serde_json::to_string(&metadata)
6382 .context("failed to serialize agent registration content")?;
6383
6384 let mem = Memory {
6385 id: uuid::Uuid::new_v4().to_string(),
6386 tier: Tier::Long,
6387 namespace: AGENTS_NAMESPACE.to_string(),
6388 title,
6389 content,
6390 tags: vec!["agent-registration".to_string()],
6391 priority: 5,
6392 confidence: 1.0,
6393 source: "system".to_string(),
6394 access_count: 0,
6395 created_at: now.clone(),
6396 updated_at: now,
6397 last_accessed_at: None,
6398 expires_at: None,
6399 metadata,
6400 reflection_depth: 0,
6401 memory_kind: crate::models::MemoryKind::Observation,
6402 entity_id: None,
6403 persona_version: None,
6404 citations: Vec::new(),
6405 source_uri: None,
6406 source_span: None,
6407 confidence_source: ConfidenceSource::CallerProvided,
6408 confidence_signals: None,
6409 confidence_decayed_at: None,
6410 version: 1,
6411 };
6412
6413 insert(conn, &mem)
6414}
6415
6416pub fn list_agents(conn: &Connection) -> Result<Vec<AgentRegistration>> {
6419 let now = Utc::now().to_rfc3339();
6420 let mut stmt = conn.prepare(
6421 "SELECT metadata FROM memories
6422 WHERE namespace = ?1
6423 AND (expires_at IS NULL OR expires_at > ?2)
6424 ORDER BY json_extract(metadata, '$.registered_at') ASC",
6425 )?;
6426 let rows = stmt.query_map(params![AGENTS_NAMESPACE, now], |row| {
6427 row.get::<_, String>(0)
6428 })?;
6429
6430 let mut agents = Vec::new();
6431 for r in rows {
6432 let raw = r?;
6433 let meta: serde_json::Value =
6434 serde_json::from_str(&raw).context("failed to parse agent metadata as JSON")?;
6435 let agent_id = meta
6436 .get("agent_id")
6437 .and_then(serde_json::Value::as_str)
6438 .unwrap_or_default()
6439 .to_string();
6440 let agent_type = meta
6441 .get(field_names::AGENT_TYPE)
6442 .and_then(serde_json::Value::as_str)
6443 .unwrap_or_default()
6444 .to_string();
6445 let capabilities: Vec<String> = meta
6446 .get(field_names::CAPABILITIES)
6447 .and_then(serde_json::Value::as_array)
6448 .map(|arr| {
6449 arr.iter()
6450 .filter_map(|v| v.as_str().map(String::from))
6451 .collect()
6452 })
6453 .unwrap_or_default();
6454 let registered_at = meta
6455 .get(field_names::REGISTERED_AT)
6456 .and_then(serde_json::Value::as_str)
6457 .unwrap_or_default()
6458 .to_string();
6459 let last_seen_at = meta
6460 .get(field_names::LAST_SEEN_AT)
6461 .and_then(serde_json::Value::as_str)
6462 .unwrap_or_default()
6463 .to_string();
6464 agents.push(AgentRegistration {
6465 agent_id,
6466 agent_type,
6467 capabilities,
6468 registered_at,
6469 last_seen_at,
6470 });
6471 }
6472 Ok(agents)
6473}
6474
6475pub fn bind_agent_pubkey(conn: &Connection, agent_id: &str, pubkey_b64: &str) -> Result<()> {
6500 let title = crate::models::agent_registration_title(agent_id);
6501 let now = Utc::now().to_rfc3339();
6502 let affected = conn.execute(
6503 "UPDATE memories SET
6504 metadata = json_set(metadata, '$.agent_pubkey', ?3, '$.pubkey_bound_at', ?4),
6505 content = json_set(content, '$.agent_pubkey', ?3, '$.pubkey_bound_at', ?4),
6506 updated_at = ?4
6507 WHERE namespace = ?1 AND title = ?2",
6508 params![AGENTS_NAMESPACE, &title, pubkey_b64, &now],
6509 )?;
6510 if affected == 0 {
6511 anyhow::bail!(
6512 "cannot bind pubkey: agent '{agent_id}' is not registered (register it first)"
6513 );
6514 }
6515 Ok(())
6516}
6517
6518pub fn agent_pubkey(conn: &Connection, agent_id: &str) -> Result<Option<String>> {
6532 let title = crate::models::agent_registration_title(agent_id);
6533 let pubkey = conn
6534 .query_row(
6535 "SELECT json_extract(metadata, '$.agent_pubkey') FROM memories
6536 WHERE namespace = ?1 AND title = ?2",
6537 params![AGENTS_NAMESPACE, &title],
6538 |row| row.get::<_, Option<String>>(0),
6539 )
6540 .ok()
6541 .flatten();
6542 Ok(pubkey)
6543}
6544
6545pub fn revoke_agent_pubkey(conn: &Connection, agent_id: &str) -> Result<()> {
6562 let title = crate::models::agent_registration_title(agent_id);
6563 let now = Utc::now().to_rfc3339();
6564 let affected = conn.execute(
6565 "UPDATE memories SET
6566 metadata = json_set(
6567 json_remove(metadata, '$.agent_pubkey', '$.pubkey_bound_at'),
6568 '$.pubkey_revoked_at', ?3),
6569 content = json_set(
6570 json_remove(content, '$.agent_pubkey', '$.pubkey_bound_at'),
6571 '$.pubkey_revoked_at', ?3),
6572 updated_at = ?3
6573 WHERE namespace = ?1 AND title = ?2",
6574 params![AGENTS_NAMESPACE, &title, &now],
6575 )?;
6576 if affected == 0 {
6577 anyhow::bail!(
6578 "cannot revoke pubkey: agent '{agent_id}' is not registered (register it first)"
6579 );
6580 }
6581 Ok(())
6582}
6583
6584pub fn stats(conn: &Connection, db_path: &Path) -> Result<Stats> {
6585 let total: usize = conn.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))?;
6586
6587 let mut stmt =
6588 conn.prepare("SELECT tier, COUNT(*) FROM memories GROUP BY tier ORDER BY COUNT(*) DESC")?;
6589 let by_tier = stmt
6590 .query_map([], |row| {
6591 Ok(TierCount {
6592 tier: row.get(0)?,
6593 count: row.get(1)?,
6594 })
6595 })?
6596 .collect::<rusqlite::Result<Vec<_>>>()?;
6597
6598 let mut stmt = conn.prepare(
6599 "SELECT namespace, COUNT(*) FROM memories GROUP BY namespace ORDER BY COUNT(*) DESC",
6600 )?;
6601 let by_namespace = stmt
6602 .query_map([], |row| {
6603 Ok(NamespaceCount {
6604 namespace: row.get(0)?,
6605 count: row.get(1)?,
6606 })
6607 })?
6608 .collect::<rusqlite::Result<Vec<_>>>()?;
6609
6610 let now = Utc::now().to_rfc3339();
6611 let one_hour = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
6612 let expiring_soon: usize = conn.query_row(
6613 "SELECT COUNT(*) FROM memories WHERE expires_at IS NOT NULL AND expires_at > ?1 AND expires_at <= ?2",
6614 params![now, one_hour], |r| r.get(0),
6615 )?;
6616
6617 let links_count: usize = conn
6618 .query_row("SELECT COUNT(*) FROM memory_links", [], |r| r.get(0))
6619 .unwrap_or(0);
6620 let db_size_bytes = std::fs::metadata(db_path).map_or(0, |m| m.len());
6621 let dim_violations = dim_violations(conn).unwrap_or(0);
6624
6625 let index_evictions_total = crate::hnsw::index_evictions_total();
6630
6631 Ok(Stats {
6632 total,
6633 by_tier,
6634 by_namespace,
6635 expiring_soon,
6636 links_count,
6637 db_size_bytes,
6638 dim_violations,
6639 index_evictions_total,
6640 })
6641}
6642
6643pub fn gc_if_needed(conn: &Connection, archive: bool) -> Result<usize> {
6645 let now = Utc::now().to_rfc3339();
6646 let has_expired: bool = conn
6647 .query_row(
6648 "SELECT EXISTS(SELECT 1 FROM memories WHERE expires_at IS NOT NULL AND expires_at < ?1)",
6649 params![now],
6650 |r| r.get(0),
6651 )
6652 .unwrap_or(false);
6653 if has_expired {
6654 gc(conn, archive)
6655 } else {
6656 Ok(0)
6657 }
6658}
6659
6660pub fn auto_purge_archive(conn: &Connection, max_days: Option<i64>) -> Result<usize> {
6662 match max_days {
6663 Some(days) if days > 0 => purge_archive(conn, Some(days)),
6664 _ => Ok(0),
6665 }
6666}
6667
6668const GC_CHUNK_ROWS: usize = 500;
6680
6681const SQL_GC_EXPIRED_CHUNK_IDS: &str = "SELECT id FROM memories \
6689 WHERE expires_at IS NOT NULL AND expires_at < ?1 \
6690 ORDER BY rowid LIMIT ?2";
6691
6692pub fn gc(conn: &Connection, archive: bool) -> Result<usize> {
6693 let now = Utc::now().to_rfc3339();
6694 let mut total = 0usize;
6705 loop {
6706 conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
6707 let result = (|| -> Result<usize> {
6708 if archive {
6709 let mut archive_stmt = conn.prepare_cached(&format!(
6711 "INSERT OR REPLACE INTO archived_memories
6712 (id, tier, namespace, title, content, tags, priority, confidence,
6713 source, access_count, created_at, updated_at, last_accessed_at,
6714 expires_at, archived_at, archive_reason, metadata,
6715 embedding, embedding_dim, original_tier, original_expires_at,
6716 reflection_depth, atomised_into, atom_of, memory_kind,
6717 entity_id, persona_version, citations, source_uri, source_span,
6718 confidence_source, confidence_signals, confidence_decayed_at,
6719 mentioned_entity_id, version)
6720 SELECT id, tier, namespace, title, content, tags, priority, confidence,
6721 source, access_count, created_at, updated_at, last_accessed_at,
6722 expires_at, ?1, 'ttl_expired', metadata,
6723 embedding, embedding_dim, tier, expires_at,
6724 reflection_depth, atomised_into, atom_of, memory_kind,
6725 entity_id, persona_version, citations, source_uri, source_span,
6726 confidence_source, confidence_signals, confidence_decayed_at,
6727 mentioned_entity_id, version
6728 FROM memories
6729 WHERE id IN ({SQL_GC_EXPIRED_CHUNK_IDS})"
6730 ))?;
6731 archive_stmt.execute(params![now, GC_CHUNK_ROWS])?;
6732 }
6733 let mut delete_stmt = conn.prepare_cached(&format!(
6734 "DELETE FROM memories WHERE id IN ({SQL_GC_EXPIRED_CHUNK_IDS})"
6735 ))?;
6736 let deleted = delete_stmt.execute(params![now, GC_CHUNK_ROWS])?;
6737 Ok(deleted)
6738 })();
6739 match result {
6740 Ok(n) => {
6741 conn.execute_batch(connection::SQL_COMMIT)?;
6742 total += n;
6743 if n < GC_CHUNK_ROWS {
6744 break;
6745 }
6746 }
6747 Err(e) => {
6748 let _ = conn.execute_batch(connection::SQL_ROLLBACK);
6749 return Err(e);
6750 }
6751 }
6752 }
6753 let _ = conn.execute(
6760 "DELETE FROM namespace_meta WHERE NOT EXISTS \
6761 (SELECT 1 FROM memories WHERE memories.id = namespace_meta.standard_id)",
6762 [],
6763 );
6764 Ok(total)
6765}
6766
6767pub fn list_archived(
6772 conn: &Connection,
6773 namespace: Option<&str>,
6774 limit: usize,
6775 offset: usize,
6776) -> Result<Vec<serde_json::Value>> {
6777 let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match namespace {
6778 Some(ns) => (
6779 "SELECT id, tier, namespace, title, content, tags, priority, confidence, \
6780 source, access_count, created_at, updated_at, last_accessed_at, \
6781 expires_at, archived_at, archive_reason, metadata, \
6782 reflection_depth, memory_kind, entity_id, persona_version, \
6783 citations, source_uri, source_span, confidence_source, \
6784 confidence_signals, confidence_decayed_at, version, \
6785 atomised_into, atom_of, mentioned_entity_id \
6786 FROM archived_memories WHERE namespace = ?1 \
6787 ORDER BY archived_at DESC LIMIT ?2 OFFSET ?3"
6788 .to_string(),
6789 vec![Box::new(ns.to_string()), Box::new(limit), Box::new(offset)],
6790 ),
6791 None => (
6792 "SELECT id, tier, namespace, title, content, tags, priority, confidence, \
6793 source, access_count, created_at, updated_at, last_accessed_at, \
6794 expires_at, archived_at, archive_reason, metadata, \
6795 reflection_depth, memory_kind, entity_id, persona_version, \
6796 citations, source_uri, source_span, confidence_source, \
6797 confidence_signals, confidence_decayed_at, version, \
6798 atomised_into, atom_of, mentioned_entity_id \
6799 FROM archived_memories \
6800 ORDER BY archived_at DESC LIMIT ?1 OFFSET ?2"
6801 .to_string(),
6802 vec![Box::new(limit), Box::new(offset)],
6803 ),
6804 };
6805 let params_refs: Vec<&dyn rusqlite::types::ToSql> =
6806 params_vec.iter().map(std::convert::AsRef::as_ref).collect();
6807 let mut stmt = conn.prepare(&sql)?;
6808 let rows = stmt.query_map(params_refs.as_slice(), |row| {
6809 let metadata_str = row
6820 .get::<_, String>(16)
6821 .unwrap_or_else(|_| "{}".to_string());
6822 let metadata: serde_json::Value =
6823 serde_json::from_str(&metadata_str).unwrap_or_else(|_| serde_json::json!({}));
6824 let tags_str = row.get::<_, String>(5).unwrap_or_else(|_| "[]".to_string());
6835 let tags: serde_json::Value =
6836 serde_json::from_str(&tags_str).unwrap_or_else(|_| serde_json::json!([]));
6837 Ok(serde_json::json!({
6838 "id": row.get::<_, String>(0)?,
6839 "tier": row.get::<_, String>(1)?,
6840 "namespace": row.get::<_, String>(2)?,
6841 "title": row.get::<_, String>(3)?,
6842 "content": row.get::<_, String>(4)?,
6843 "tags": tags,
6844 "priority": row.get::<_, i32>(6)?,
6845 (field_names::CONFIDENCE): row.get::<_, f64>(7)?,
6846 "source": row.get::<_, String>(8)?,
6847 (field_names::ACCESS_COUNT): row.get::<_, i64>(9)?,
6848 (field_names::CREATED_AT): row.get::<_, String>(10)?,
6849 (field_names::UPDATED_AT): row.get::<_, String>(11)?,
6850 (field_names::LAST_ACCESSED_AT): row.get::<_, Option<String>>(12)?,
6851 (field_names::EXPIRES_AT): row.get::<_, Option<String>>(13)?,
6852 (field_names::ARCHIVED_AT): row.get::<_, String>(14)?,
6853 (field_names::ARCHIVE_REASON): row.get::<_, String>(15)?,
6854 "metadata": metadata,
6855 (field_names::REFLECTION_DEPTH): row.get::<_, Option<i64>>(17)?.unwrap_or(0),
6861 (field_names::MEMORY_KIND): row.get::<_, Option<String>>(18)?,
6862 "entity_id": row.get::<_, Option<String>>(19)?,
6863 (field_names::PERSONA_VERSION): row.get::<_, Option<i64>>(20)?,
6864 "citations": row
6865 .get::<_, Option<String>>(21)?
6866 .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
6867 .unwrap_or_else(|| serde_json::json!([])),
6868 (field_names::SOURCE_URI): row.get::<_, Option<String>>(22)?,
6869 (field_names::SOURCE_SPAN): row
6870 .get::<_, Option<String>>(23)?
6871 .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok()),
6872 (field_names::CONFIDENCE_SOURCE): row.get::<_, Option<String>>(24)?,
6873 (field_names::CONFIDENCE_SIGNALS): row
6874 .get::<_, Option<String>>(25)?
6875 .and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok()),
6876 (field_names::CONFIDENCE_DECAYED_AT): row.get::<_, Option<String>>(26)?,
6877 "version": row.get::<_, Option<i64>>(27)?.unwrap_or(1),
6878 (field_names::ATOMISED_INTO): row.get::<_, Option<i64>>(28)?,
6879 (field_names::ATOM_OF): row.get::<_, Option<String>>(29)?,
6880 (field_names::MENTIONED_ENTITY_ID): row.get::<_, Option<String>>(30)?,
6881 }))
6882 })?;
6883 rows.collect::<rusqlite::Result<Vec<_>>>()
6884 .map_err(Into::into)
6885}
6886
6887pub fn restore_archived(conn: &Connection, id: &str) -> Result<bool> {
6888 let now = Utc::now().to_rfc3339();
6889 conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
6890 let result = (|| -> Result<bool> {
6891 let exists: bool = conn
6892 .query_row(
6893 "SELECT COUNT(*) > 0 FROM archived_memories WHERE id = ?1",
6894 params![id],
6895 |r| r.get(0),
6896 )
6897 .unwrap_or(false);
6898 if !exists {
6899 return Ok(false);
6900 }
6901 let active_exists: bool = conn
6903 .query_row(SQL_MEMORY_EXISTS_COUNT, params![id], |r| r.get(0))
6904 .unwrap_or(false);
6905 if active_exists {
6906 return Err(anyhow::Error::new(StorageError::ArchiveRestoreCollision {
6908 id: id.to_string(),
6909 }));
6910 }
6911 let archived_metadata: String = conn
6913 .query_row(
6914 "SELECT metadata FROM archived_memories WHERE id = ?1",
6915 params![id],
6916 |r| r.get(0),
6917 )
6918 .unwrap_or_else(|_| "{}".to_string());
6919 let meta_value: serde_json::Value =
6920 serde_json::from_str(&archived_metadata).unwrap_or_else(|_| serde_json::json!({}));
6921 if let Err(e) = crate::validate::validate_metadata(&meta_value) {
6922 tracing::warn!("archived memory {id} has invalid metadata, resetting to {{}}: {e}");
6923 conn.execute(
6924 "UPDATE archived_memories SET metadata = '{}' WHERE id = ?1",
6925 params![id],
6926 )?;
6927 }
6928 let candidate = load_archived_as_memory(conn, id)?;
6938 consult_governance_pre_write(&candidate)?;
6939
6940 conn.execute(
6953 "INSERT INTO memories
6954 (id, tier, namespace, title, content, tags, priority, confidence,
6955 source, access_count, created_at, updated_at, last_accessed_at,
6956 expires_at, metadata, embedding, embedding_dim,
6957 reflection_depth, atomised_into, atom_of, memory_kind,
6958 entity_id, persona_version, citations, source_uri, source_span,
6959 confidence_source, confidence_signals, confidence_decayed_at,
6960 mentioned_entity_id, version)
6961 SELECT id, COALESCE(original_tier, 'long'), namespace, title, content,
6962 tags, priority, confidence, source, access_count, created_at,
6963 ?1, last_accessed_at, original_expires_at, metadata,
6964 embedding, embedding_dim,
6965 COALESCE(reflection_depth, 0),
6966 atomised_into,
6967 atom_of,
6968 COALESCE(memory_kind, 'observation'),
6969 entity_id, persona_version,
6970 COALESCE(citations, '[]'),
6971 source_uri, source_span,
6972 COALESCE(confidence_source, 'caller_provided'),
6973 confidence_signals, confidence_decayed_at,
6974 mentioned_entity_id,
6975 COALESCE(version, 1)
6976 FROM archived_memories WHERE id = ?2",
6977 params![now, id],
6978 )?;
6979 conn.execute("DELETE FROM archived_memories WHERE id = ?1", params![id])?;
6980 Ok(true)
6981 })();
6982 match result {
6983 Ok(v) => {
6984 conn.execute_batch(connection::SQL_COMMIT)?;
6985 Ok(v)
6986 }
6987 Err(e) => {
6988 let _ = conn.execute_batch(connection::SQL_ROLLBACK);
6989 Err(e)
6990 }
6991 }
6992}
6993
6994pub fn restore_archived_for_caller(conn: &Connection, id: &str, caller: &str) -> Result<bool> {
7011 let now = Utc::now().to_rfc3339();
7012 conn.execute_batch(connection::SQL_BEGIN_IMMEDIATE)?;
7013 let result = (|| -> Result<bool> {
7014 let owned: bool = conn
7019 .query_row(
7020 "SELECT COUNT(*) > 0 FROM archived_memories \
7021 WHERE id = ?1 \
7022 AND ( \
7023 json_extract(metadata, '$.agent_id') = ?2 OR \
7024 json_extract(metadata, '$.target_agent_id') = ?2 OR \
7025 json_extract(metadata, '$.agent_id') IS NULL OR \
7026 json_extract(metadata, '$.agent_id') = '' \
7027 )",
7028 params![id, caller],
7029 |r| r.get(0),
7030 )
7031 .unwrap_or(false);
7032 if !owned {
7033 return Ok(false);
7034 }
7035 let active_exists: bool = conn
7037 .query_row(SQL_MEMORY_EXISTS_COUNT, params![id], |r| r.get(0))
7038 .unwrap_or(false);
7039 if active_exists {
7040 return Err(anyhow::Error::new(StorageError::ArchiveRestoreCollision {
7042 id: id.to_string(),
7043 }));
7044 }
7045 let archived_metadata: String = conn
7047 .query_row(
7048 "SELECT metadata FROM archived_memories WHERE id = ?1",
7049 params![id],
7050 |r| r.get(0),
7051 )
7052 .unwrap_or_else(|_| "{}".to_string());
7053 let meta_value: serde_json::Value =
7054 serde_json::from_str(&archived_metadata).unwrap_or_else(|_| serde_json::json!({}));
7055 if let Err(e) = crate::validate::validate_metadata(&meta_value) {
7056 tracing::warn!("archived memory {id} has invalid metadata, resetting to {{}}: {e}");
7057 conn.execute(
7058 "UPDATE archived_memories SET metadata = '{}' WHERE id = ?1",
7059 params![id],
7060 )?;
7061 }
7062 let candidate = load_archived_as_memory(conn, id)?;
7069 consult_governance_pre_write(&candidate)?;
7070 conn.execute(
7078 "INSERT INTO memories
7079 (id, tier, namespace, title, content, tags, priority, confidence,
7080 source, access_count, created_at, updated_at, last_accessed_at,
7081 expires_at, metadata, embedding, embedding_dim,
7082 reflection_depth, atomised_into, atom_of, memory_kind,
7083 entity_id, persona_version, citations, source_uri, source_span,
7084 confidence_source, confidence_signals, confidence_decayed_at,
7085 mentioned_entity_id, version)
7086 SELECT id, COALESCE(original_tier, 'long'), namespace, title, content,
7087 tags, priority, confidence, source, access_count, created_at,
7088 ?1, last_accessed_at, original_expires_at, metadata,
7089 embedding, embedding_dim,
7090 COALESCE(reflection_depth, 0),
7091 atomised_into,
7092 atom_of,
7093 COALESCE(memory_kind, 'observation'),
7094 entity_id, persona_version,
7095 COALESCE(citations, '[]'),
7096 source_uri, source_span,
7097 COALESCE(confidence_source, 'caller_provided'),
7098 confidence_signals, confidence_decayed_at,
7099 mentioned_entity_id,
7100 COALESCE(version, 1)
7101 FROM archived_memories WHERE id = ?2",
7102 params![now, id],
7103 )?;
7104 conn.execute("DELETE FROM archived_memories WHERE id = ?1", params![id])?;
7105 Ok(true)
7106 })();
7107 match result {
7108 Ok(v) => {
7109 conn.execute_batch(connection::SQL_COMMIT)?;
7110 Ok(v)
7111 }
7112 Err(e) => {
7113 let _ = conn.execute_batch(connection::SQL_ROLLBACK);
7114 Err(e)
7115 }
7116 }
7117}
7118
7119fn load_archived_as_memory(conn: &Connection, id: &str) -> Result<Memory> {
7130 let mut stmt = conn.prepare(
7131 "SELECT id, COALESCE(original_tier, tier) AS tier, namespace, title, content,
7132 tags, priority, confidence, source, access_count, created_at,
7133 updated_at, last_accessed_at,
7134 COALESCE(original_expires_at, expires_at) AS expires_at, metadata,
7135 COALESCE(reflection_depth, 0) AS reflection_depth,
7136 COALESCE(memory_kind, 'observation') AS memory_kind,
7137 entity_id, persona_version,
7138 COALESCE(citations, '[]') AS citations,
7139 source_uri, source_span,
7140 COALESCE(confidence_source, 'caller_provided') AS confidence_source,
7141 confidence_signals, confidence_decayed_at,
7142 COALESCE(version, 1) AS version
7143 FROM archived_memories WHERE id = ?1",
7144 )?;
7145 let mem = stmt.query_row(params![id], row_to_memory)?;
7146 Ok(mem)
7147}
7148
7149pub fn purge_archive(conn: &Connection, older_than_days: Option<i64>) -> Result<usize> {
7150 match older_than_days {
7151 Some(days) if days < 0 => {
7152 return Err(anyhow::Error::new(StorageError::InvalidArgument {
7154 reason: crate::errors::msg::older_than_days_negative(days),
7155 }));
7156 }
7157 Some(days) => {
7158 let cutoff = (Utc::now() - chrono::Duration::days(days)).to_rfc3339();
7159 let deleted = conn.execute(
7160 "DELETE FROM archived_memories WHERE archived_at < ?1",
7161 params![cutoff],
7162 )?;
7163 Ok(deleted)
7164 }
7165 None => {
7166 let deleted = conn.execute("DELETE FROM archived_memories", [])?;
7167 Ok(deleted)
7168 }
7169 }
7170}
7171
7172pub fn purge_archive_for_caller(
7194 conn: &Connection,
7195 caller: &str,
7196 older_than_days: Option<i64>,
7197) -> Result<usize> {
7198 match older_than_days {
7199 Some(days) if days < 0 => {
7200 return Err(anyhow::Error::new(StorageError::InvalidArgument {
7202 reason: crate::errors::msg::older_than_days_negative(days),
7203 }));
7204 }
7205 Some(days) => {
7206 let cutoff = (Utc::now() - chrono::Duration::days(days)).to_rfc3339();
7207 let deleted = conn.execute(
7208 "DELETE FROM archived_memories \
7209 WHERE archived_at < ?1 \
7210 AND ( \
7211 json_extract(metadata, '$.agent_id') = ?2 OR \
7212 json_extract(metadata, '$.target_agent_id') = ?2 \
7213 )",
7214 params![cutoff, caller],
7215 )?;
7216 Ok(deleted)
7217 }
7218 None => {
7219 let deleted = conn.execute(
7220 "DELETE FROM archived_memories \
7221 WHERE \
7222 json_extract(metadata, '$.agent_id') = ?1 OR \
7223 json_extract(metadata, '$.target_agent_id') = ?1",
7224 params![caller],
7225 )?;
7226 Ok(deleted)
7227 }
7228 }
7229}
7230
7231pub fn archive_stats(conn: &Connection) -> Result<serde_json::Value> {
7232 let total: i64 = conn.query_row("SELECT COUNT(*) FROM archived_memories", [], |r| r.get(0))?;
7233 let mut stmt = conn.prepare(
7234 "SELECT namespace, COUNT(*) FROM archived_memories GROUP BY namespace ORDER BY COUNT(*) DESC",
7235 )?;
7236 let by_ns: Vec<serde_json::Value> = stmt
7237 .query_map([], |row| {
7238 Ok(serde_json::json!({
7239 "namespace": row.get::<_, String>(0)?,
7240 "count": row.get::<_, i64>(1)?,
7241 }))
7242 })?
7243 .collect::<rusqlite::Result<Vec<_>>>()?;
7244 Ok(serde_json::json!({
7245 "archived_total": total,
7246 (field_names::BY_NAMESPACE): by_ns,
7247 }))
7248}
7249
7250pub fn export_all(conn: &Connection) -> Result<Vec<Memory>> {
7251 let now = Utc::now().to_rfc3339();
7252 let mut stmt = conn.prepare(
7253 "SELECT * FROM memories WHERE expires_at IS NULL OR expires_at > ?1 ORDER BY created_at ASC",
7254 )?;
7255 let rows = stmt.query_map(params![now], row_to_memory)?;
7256 rows.collect::<rusqlite::Result<Vec<_>>>()
7257 .map_err(Into::into)
7258}
7259
7260pub fn export_links(conn: &Connection) -> Result<Vec<MemoryLink>> {
7261 let now = Utc::now().to_rfc3339();
7262 let mut stmt = conn.prepare(
7268 "SELECT ml.source_id, ml.target_id, ml.relation, ml.created_at,
7269 ml.signature, ml.observed_by, ml.valid_from, ml.valid_until
7270 FROM memory_links ml
7271 JOIN memories ms ON ms.id = ml.source_id AND (ms.expires_at IS NULL OR ms.expires_at > ?1)
7272 JOIN memories mt ON mt.id = ml.target_id AND (mt.expires_at IS NULL OR mt.expires_at > ?1)",
7273 )?;
7274 let rows = stmt.query_map(params![now], |row| {
7275 let relation_str: String = row.get(2)?;
7276 Ok(MemoryLink {
7277 source_id: row.get(0)?,
7278 target_id: row.get(1)?,
7279 relation: crate::models::MemoryLinkRelation::from_str(&relation_str)
7281 .unwrap_or_default(),
7282 created_at: row.get(3)?,
7283 signature: row.get::<_, Option<Vec<u8>>>(4)?,
7284 observed_by: row.get::<_, Option<String>>(5)?,
7285 valid_from: row.get::<_, Option<String>>(6)?,
7286 valid_until: row.get::<_, Option<String>>(7)?,
7287 attest_level: None,
7292 })
7293 })?;
7294 rows.collect::<rusqlite::Result<Vec<_>>>()
7295 .map_err(Into::into)
7296}
7297
7298pub fn insert_if_newer(conn: &Connection, mem: &Memory) -> Result<String> {
7310 consult_governance_pre_write(mem)?;
7317
7318 let tags_json = serde_json::to_string(&mem.tags)?;
7319 let metadata_json = serde_json::to_string(&mem.metadata)?;
7320 let citations_json = serde_json::to_string(&mem.citations)?;
7326 let source_span_json = match mem.source_span {
7327 Some(span) => Some(serde_json::to_string(&span)?),
7328 None => None,
7329 };
7330 let confidence_signals_json = match &mem.confidence_signals {
7336 Some(s) => Some(serde_json::to_string(s)?),
7337 None => None,
7338 };
7339 let mentioned_entity_id = extract_mentioned_entity_id(mem);
7346 let mut newer_wins_stmt = conn.prepare_cached(
7350 "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)
7351 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)
7352 ON CONFLICT(title, namespace) DO UPDATE SET
7353 content = CASE WHEN excluded.updated_at > memories.updated_at
7354 OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
7355 THEN excluded.content ELSE memories.content END,
7356 tags = CASE WHEN excluded.updated_at > memories.updated_at
7357 OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
7358 THEN excluded.tags ELSE memories.tags END,
7359 priority = MAX(memories.priority, excluded.priority),
7360 confidence = MAX(memories.confidence, excluded.confidence),
7361 source = CASE WHEN excluded.updated_at > memories.updated_at
7362 OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
7363 THEN excluded.source ELSE memories.source END,
7364 tier = CASE WHEN excluded.tier = 'long' THEN 'long'
7365 WHEN memories.tier = 'long' THEN 'long'
7366 WHEN excluded.tier = 'mid' THEN 'mid'
7367 ELSE memories.tier END,
7368 updated_at = MAX(memories.updated_at, excluded.updated_at),
7369 access_count = MAX(memories.access_count, excluded.access_count),
7370 expires_at = CASE WHEN excluded.tier = 'long' OR memories.tier = 'long' THEN NULL
7371 ELSE COALESCE(excluded.expires_at, memories.expires_at) END,
7372 -- Preserve metadata.agent_id across newer-wins merge (NHI provenance immutable).
7373 metadata = CASE
7374 WHEN json_extract(memories.metadata, '$.agent_id') IS NOT NULL
7375 THEN json_set(
7376 CASE WHEN excluded.updated_at > memories.updated_at
7377 OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
7378 THEN excluded.metadata
7379 ELSE memories.metadata END,
7380 '$.agent_id',
7381 json_extract(memories.metadata, '$.agent_id')
7382 )
7383 ELSE CASE WHEN excluded.updated_at > memories.updated_at
7384 OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
7385 THEN excluded.metadata
7386 ELSE memories.metadata END
7387 END,
7388 -- v0.7.0 Task 1/8 — recursion depth takes max so the reflection
7389 -- signal isn't lost on newer-wins federation merges.
7390 reflection_depth = MAX(memories.reflection_depth, excluded.reflection_depth),
7391 -- v0.7.0 L1-1 — kind is sticky across federation merges: a
7392 -- reflection row must not be downgraded to observation by a
7393 -- newer-wins merge from a peer that doesn't know about the kind.
7394 -- v0.7.0 QW-2 — Persona is similarly sticky.
7395 memory_kind = CASE WHEN memories.memory_kind = 'reflection' THEN 'reflection'
7396 WHEN memories.memory_kind = 'persona' THEN 'persona'
7397 ELSE excluded.memory_kind END,
7398 -- v0.7.0 QW-2 — entity_id + persona_version are immutable
7399 -- once set so a federation merge can't drop the persona
7400 -- discriminator off a `memory_kind = 'persona'` row.
7401 entity_id = COALESCE(memories.entity_id, excluded.entity_id),
7402 persona_version = COALESCE(memories.persona_version, excluded.persona_version),
7403 -- v0.7.0 Form 4 — fact-provenance: replace the stored
7404 -- citations array only when the incoming row wins the
7405 -- newer-wins tiebreak; source_uri / source_span follow
7406 -- COALESCE semantics so a federation merge that lacks
7407 -- provenance does not blank out a value the local row
7408 -- already had.
7409 citations = CASE WHEN excluded.updated_at > memories.updated_at
7410 OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
7411 THEN excluded.citations ELSE memories.citations END,
7412 source_uri = COALESCE(excluded.source_uri, memories.source_uri),
7413 source_span = COALESCE(excluded.source_span, memories.source_span),
7414 -- v0.7.0 Form 5 — confidence-provenance follows the newer-
7415 -- wins shape established for the other Form 4 columns.
7416 -- A peer pushing an auto-derived/calibrated value wins on
7417 -- the timestamp tiebreak; otherwise the local row's
7418 -- provenance is preserved so a stale peer cannot blank out
7419 -- a fresher local calibration.
7420 confidence_source = CASE WHEN excluded.updated_at > memories.updated_at
7421 OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
7422 THEN excluded.confidence_source ELSE memories.confidence_source END,
7423 confidence_signals = CASE WHEN excluded.updated_at > memories.updated_at
7424 OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
7425 THEN excluded.confidence_signals ELSE memories.confidence_signals END,
7426 confidence_decayed_at = CASE WHEN excluded.updated_at > memories.updated_at
7427 OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
7428 THEN excluded.confidence_decayed_at ELSE memories.confidence_decayed_at END,
7429 -- v0.7.0 polish PERF-8 (#781) — newer-wins on the mention
7430 -- tag (the winning row's content is the one a future matcher
7431 -- query expects to find); otherwise preserve the local tag
7432 -- so a stale peer that lacks the structured entity_id
7433 -- metadata cannot blank out a value the index serves.
7434 mentioned_entity_id = CASE WHEN excluded.updated_at > memories.updated_at
7435 OR (excluded.updated_at = memories.updated_at AND excluded.id > memories.id)
7436 THEN COALESCE(excluded.mentioned_entity_id, memories.mentioned_entity_id)
7437 ELSE memories.mentioned_entity_id END,
7438 -- #1631 (decide-once, #1029 contract) — `version` IS
7439 -- replicated state on the federation merge path: merge via
7440 -- MAX(local, remote) so an out-of-order peer push can't
7441 -- roll the Gap-1 optimistic-concurrency counter backwards.
7442 -- Matches the pg `apply_remote_memory` GREATEST arm.
7443 version = MAX(memories.version, excluded.version)
7444 RETURNING id",
7445 )?;
7446 let actual_id: String = newer_wins_stmt.query_row(
7447 params![
7448 mem.id,
7449 mem.tier.as_str(),
7450 mem.namespace,
7451 mem.title,
7452 mem.content,
7453 tags_json,
7454 mem.priority,
7455 mem.confidence,
7456 mem.source,
7457 mem.access_count,
7458 mem.created_at,
7459 mem.updated_at,
7460 mem.last_accessed_at,
7461 mem.effective_expires_at(),
7462 metadata_json,
7463 mem.reflection_depth,
7464 mem.memory_kind.as_str(),
7465 mem.entity_id,
7466 mem.persona_version,
7467 citations_json,
7468 mem.source_uri,
7469 source_span_json,
7470 mem.confidence_source.as_str(),
7471 confidence_signals_json,
7472 mem.confidence_decayed_at,
7473 mentioned_entity_id,
7474 mem.version,
7475 ],
7476 |r| r.get(0),
7477 )?;
7478 Ok(actual_id)
7479}
7480
7481#[derive(Debug)]
7489pub struct EmbeddingDimMismatch {
7490 pub namespace: String,
7491 pub established: usize,
7492 pub attempted: usize,
7493}
7494
7495impl std::fmt::Display for EmbeddingDimMismatch {
7496 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
7497 write!(
7498 f,
7499 "embedding dim mismatch in namespace '{}': established {}-dim, refused {}-dim write",
7500 self.namespace, self.established, self.attempted
7501 )
7502 }
7503}
7504
7505impl std::error::Error for EmbeddingDimMismatch {}
7506
7507pub fn namespace_embedding_dim(conn: &Connection, namespace: &str) -> Result<Option<usize>> {
7514 let dim: Option<i64> = conn
7516 .query_row(
7517 "SELECT embedding_dim FROM memories \
7518 WHERE namespace = ?1 AND embedding_dim IS NOT NULL \
7519 LIMIT 1",
7520 params![namespace],
7521 |r| r.get(0),
7522 )
7523 .ok();
7524 Ok(dim.and_then(|d| usize::try_from(d).ok()))
7525}
7526
7527pub fn dim_violations(conn: &Connection) -> Result<u64> {
7535 let n: i64 = conn
7540 .query_row(
7541 "SELECT COUNT(*) FROM memories \
7542 WHERE embedding IS NOT NULL \
7543 AND length(embedding) >= 4 \
7544 AND ( \
7545 embedding_dim IS NULL \
7546 OR ( \
7547 (length(embedding) % 4 = 0 AND embedding_dim != length(embedding)/4) \
7548 OR (length(embedding) % 4 = 1 AND embedding_dim != (length(embedding)-1)/4) \
7549 OR (length(embedding) % 4 NOT IN (0,1)) \
7550 ) \
7551 )",
7552 [],
7553 |r| r.get(0),
7554 )
7555 .unwrap_or(0);
7556 Ok(u64::try_from(n).unwrap_or(0))
7557}
7558
7559const SQL_UPDATE_EMBEDDING_WITH_DIM: &str =
7564 "UPDATE memories SET embedding = ?1, embedding_dim = ?2 WHERE id = ?3";
7565const SQL_UPDATE_EMBEDDING_NULL_DIM: &str =
7568 "UPDATE memories SET embedding = ?1, embedding_dim = NULL WHERE id = ?2";
7569
7570pub fn set_embedding(conn: &Connection, id: &str, embedding: &[f32]) -> Result<()> {
7584 let namespace: Option<String> = conn
7586 .query_row(
7587 "SELECT namespace FROM memories WHERE id = ?1",
7588 params![id],
7589 |r| r.get(0),
7590 )
7591 .ok();
7592 let attempted = embedding.len();
7593 if attempted == 0 {
7594 let bytes = crate::embeddings::encode_embedding_blob(embedding);
7598 conn.execute(SQL_UPDATE_EMBEDDING_NULL_DIM, params![bytes, id])?;
7599 return Ok(());
7600 }
7601 if let Some(ref ns) = namespace
7602 && let Some(established) = namespace_embedding_dim(conn, ns)?
7603 && established != attempted
7604 {
7605 return Err(EmbeddingDimMismatch {
7606 namespace: ns.clone(),
7607 established,
7608 attempted,
7609 }
7610 .into());
7611 }
7612 let bytes = crate::embeddings::encode_embedding_blob(embedding);
7613 let dim_i64 = i64::try_from(attempted).unwrap_or(i64::MAX);
7614 conn.execute(SQL_UPDATE_EMBEDDING_WITH_DIM, params![bytes, dim_i64, id])?;
7615 Ok(())
7616}
7617
7618pub fn set_embeddings_batch(
7654 conn: &mut Connection,
7655 entries: &[(String, Vec<f32>)],
7656) -> Result<usize> {
7657 if entries.is_empty() {
7658 return Ok(0);
7659 }
7660
7661 let mut ns_by_id: HashMap<String, Option<String>> = HashMap::with_capacity(entries.len());
7665 {
7666 let mut stmt = conn.prepare("SELECT namespace FROM memories WHERE id = ?1")?;
7667 for (id, _) in entries {
7668 if ns_by_id.contains_key(id) {
7669 continue;
7670 }
7671 let ns: Option<String> = stmt
7672 .query_row(params![id], |r| r.get::<_, Option<String>>(0))
7673 .ok()
7674 .flatten();
7675 ns_by_id.insert(id.clone(), ns);
7676 }
7677 }
7678
7679 let mut ns_dim_cache: HashMap<String, Option<usize>> = HashMap::new();
7684
7685 let tx = conn.transaction()?;
7686 {
7687 let mut update = tx.prepare(SQL_UPDATE_EMBEDDING_WITH_DIM)?;
7688 let mut update_empty = tx.prepare(SQL_UPDATE_EMBEDDING_NULL_DIM)?;
7689
7690 let mut rows_updated = 0usize;
7691 for (id, embedding) in entries {
7692 let attempted = embedding.len();
7693 if attempted == 0 {
7694 let bytes = crate::embeddings::encode_embedding_blob(embedding);
7695 rows_updated += update_empty.execute(params![bytes, id])?;
7696 continue;
7697 }
7698 if let Some(Some(ns)) = ns_by_id.get(id) {
7699 let established = if let Some(cached) = ns_dim_cache.get(ns) {
7700 *cached
7701 } else {
7702 let resolved = namespace_embedding_dim(&tx, ns)?;
7703 ns_dim_cache.insert(ns.clone(), resolved);
7704 resolved
7705 };
7706 if let Some(established) = established
7707 && established != attempted
7708 {
7709 return Err(EmbeddingDimMismatch {
7710 namespace: ns.clone(),
7711 established,
7712 attempted,
7713 }
7714 .into());
7715 }
7716 if established.is_none() {
7723 ns_dim_cache.insert(ns.clone(), Some(attempted));
7724 }
7725 }
7726 let bytes = crate::embeddings::encode_embedding_blob(embedding);
7727 let dim_i64 = i64::try_from(attempted).unwrap_or(i64::MAX);
7728 rows_updated += update.execute(params![bytes, dim_i64, id])?;
7729 }
7730
7731 drop(update);
7732 drop(update_empty);
7733 tx.commit()?;
7734 Ok(rows_updated)
7735 }
7736}
7737
7738pub fn get_embedding(conn: &Connection, id: &str) -> Result<Option<Vec<f32>>> {
7749 let result: Option<Vec<u8>> = conn
7750 .query_row(
7751 "SELECT embedding FROM memories WHERE id = ?1",
7752 params![id],
7753 |row| row.get(0),
7754 )
7755 .ok();
7756 match result {
7757 Some(bytes) if !bytes.is_empty() => {
7758 let floats = crate::embeddings::decode_embedding_blob(&bytes)?;
7759 Ok(Some(floats))
7760 }
7761 _ => Ok(None),
7762 }
7763}
7764
7765pub fn get_unembedded_ids(conn: &Connection) -> Result<Vec<(String, String, String)>> {
7773 let mut stmt =
7774 conn.prepare("SELECT id, title, content FROM memories WHERE embedding IS NULL")?;
7775 let rows = stmt.query_map([], |row| {
7776 Ok((
7777 row.get::<_, String>(0)?,
7778 row.get::<_, String>(1)?,
7779 row.get::<_, String>(2)?,
7780 ))
7781 })?;
7782 rows.collect::<rusqlite::Result<Vec<_>>>()
7783 .map_err(Into::into)
7784}
7785
7786pub fn get_unembedded_ids_batch(
7797 conn: &Connection,
7798 limit: usize,
7799) -> Result<Vec<(String, String, String)>> {
7800 let mut stmt = conn.prepare_cached(
7801 "SELECT id, title, content FROM memories WHERE embedding IS NULL LIMIT ?1",
7802 )?;
7803 let rows = stmt.query_map(params![limit], |row| {
7804 Ok((
7805 row.get::<_, String>(0)?,
7806 row.get::<_, String>(1)?,
7807 row.get::<_, String>(2)?,
7808 ))
7809 })?;
7810 rows.collect::<rusqlite::Result<Vec<_>>>()
7811 .map_err(Into::into)
7812}
7813
7814pub fn get_unembedded_ids_batch_after(
7834 conn: &Connection,
7835 after_id: Option<&str>,
7836 limit: usize,
7837) -> Result<Vec<(String, String, String)>> {
7838 let map_row = |row: &rusqlite::Row<'_>| {
7839 Ok((
7840 row.get::<_, String>(0)?,
7841 row.get::<_, String>(1)?,
7842 row.get::<_, String>(2)?,
7843 ))
7844 };
7845 let rows = if let Some(after) = after_id {
7846 let mut stmt = conn.prepare_cached(
7847 "SELECT id, title, content FROM memories \
7848 WHERE embedding IS NULL AND id > ?1 ORDER BY id LIMIT ?2",
7849 )?;
7850 let rows = stmt.query_map(params![after, limit], map_row)?;
7851 rows.collect::<rusqlite::Result<Vec<_>>>()?
7852 } else {
7853 let mut stmt = conn.prepare_cached(
7854 "SELECT id, title, content FROM memories \
7855 WHERE embedding IS NULL ORDER BY id LIMIT ?1",
7856 )?;
7857 let rows = stmt.query_map(params![limit], map_row)?;
7858 rows.collect::<rusqlite::Result<Vec<_>>>()?
7859 };
7860 Ok(rows)
7861}
7862
7863pub fn get_memory_texts_batch(
7875 conn: &Connection,
7876 namespace: Option<&str>,
7877 after_id: Option<&str>,
7878 limit: usize,
7879) -> Result<Vec<(String, String, String)>> {
7880 let map_row = |row: &rusqlite::Row<'_>| {
7881 Ok((
7882 row.get::<_, String>(0)?,
7883 row.get::<_, String>(1)?,
7884 row.get::<_, String>(2)?,
7885 ))
7886 };
7887 let rows = match (namespace, after_id) {
7888 (Some(ns), Some(after)) => {
7889 let mut stmt = conn.prepare_cached(
7890 "SELECT id, title, content FROM memories \
7891 WHERE namespace = ?1 AND id > ?2 ORDER BY id LIMIT ?3",
7892 )?;
7893 let rows = stmt.query_map(params![ns, after, limit], map_row)?;
7894 rows.collect::<rusqlite::Result<Vec<_>>>()?
7895 }
7896 (Some(ns), None) => {
7897 let mut stmt = conn.prepare_cached(
7898 "SELECT id, title, content FROM memories \
7899 WHERE namespace = ?1 ORDER BY id LIMIT ?2",
7900 )?;
7901 let rows = stmt.query_map(params![ns, limit], map_row)?;
7902 rows.collect::<rusqlite::Result<Vec<_>>>()?
7903 }
7904 (None, Some(after)) => {
7905 let mut stmt = conn.prepare_cached(
7906 "SELECT id, title, content FROM memories \
7907 WHERE id > ?1 ORDER BY id LIMIT ?2",
7908 )?;
7909 let rows = stmt.query_map(params![after, limit], map_row)?;
7910 rows.collect::<rusqlite::Result<Vec<_>>>()?
7911 }
7912 (None, None) => {
7913 let mut stmt = conn
7914 .prepare_cached("SELECT id, title, content FROM memories ORDER BY id LIMIT ?1")?;
7915 let rows = stmt.query_map(params![limit], map_row)?;
7916 rows.collect::<rusqlite::Result<Vec<_>>>()?
7917 }
7918 };
7919 Ok(rows)
7920}
7921
7922pub fn set_embeddings_batch_reembed(
7942 conn: &mut Connection,
7943 entries: &[(String, Vec<f32>)],
7944) -> Result<usize> {
7945 if entries.is_empty() {
7946 return Ok(0);
7947 }
7948 let tx = conn.transaction()?;
7949 let mut rows_updated = 0usize;
7950 {
7951 let mut update = tx.prepare(SQL_UPDATE_EMBEDDING_WITH_DIM)?;
7952 let mut update_empty = tx.prepare(SQL_UPDATE_EMBEDDING_NULL_DIM)?;
7953 for (id, embedding) in entries {
7954 let bytes = crate::embeddings::encode_embedding_blob(embedding);
7955 if embedding.is_empty() {
7956 rows_updated += update_empty.execute(params![bytes, id])?;
7958 } else {
7959 let dim_i64 = i64::try_from(embedding.len()).unwrap_or(i64::MAX);
7960 rows_updated += update.execute(params![bytes, dim_i64, id])?;
7961 }
7962 }
7963 }
7964 tx.commit()?;
7965 Ok(rows_updated)
7966}
7967
7968pub fn embedding_coverage(conn: &Connection, namespace: Option<&str>) -> Result<(u64, u64)> {
7976 let (total, embedded): (i64, i64) = if let Some(ns) = namespace {
7977 conn.query_row(
7978 "SELECT COUNT(*), COUNT(embedding) FROM memories WHERE namespace = ?1",
7979 params![ns],
7980 |r| Ok((r.get(0)?, r.get(1)?)),
7981 )?
7982 } else {
7983 conn.query_row("SELECT COUNT(*), COUNT(embedding) FROM memories", [], |r| {
7984 Ok((r.get(0)?, r.get(1)?))
7985 })?
7986 };
7987 Ok((
7988 u64::try_from(total).unwrap_or(0),
7989 u64::try_from(embedded).unwrap_or(0),
7990 ))
7991}
7992
7993pub fn distinct_embedding_dims(conn: &Connection, namespace: Option<&str>) -> Result<Vec<usize>> {
8005 const DIM_EXPR: &str = "COALESCE(embedding_dim, \
8006 CASE WHEN length(embedding) % 4 = 1 THEN (length(embedding)-1)/4 \
8007 ELSE length(embedding)/4 END)";
8008 let collect = |stmt: &mut rusqlite::Statement<'_>,
8009 params: &[&dyn rusqlite::ToSql]|
8010 -> Result<Vec<usize>> {
8011 let rows = stmt.query_map(params, |r| r.get::<_, i64>(0))?;
8012 Ok(rows
8013 .collect::<rusqlite::Result<Vec<_>>>()?
8014 .into_iter()
8015 .filter_map(|d| usize::try_from(d).ok())
8016 .collect())
8017 };
8018 if let Some(ns) = namespace {
8019 let mut stmt = conn.prepare(&format!(
8020 "SELECT DISTINCT {DIM_EXPR} AS dim FROM memories \
8021 WHERE embedding IS NOT NULL AND namespace = ?1 ORDER BY dim"
8022 ))?;
8023 collect(&mut stmt, &[&ns])
8024 } else {
8025 let mut stmt = conn.prepare(&format!(
8026 "SELECT DISTINCT {DIM_EXPR} AS dim FROM memories \
8027 WHERE embedding IS NOT NULL ORDER BY dim"
8028 ))?;
8029 collect(&mut stmt, &[])
8030 }
8031}
8032
8033pub fn count_embedded_memories(conn: &Connection) -> Result<i64> {
8043 conn.query_row(
8044 "SELECT COUNT(*) FROM memories WHERE embedding IS NOT NULL",
8045 [],
8046 |row| row.get(0),
8047 )
8048 .map_err(Into::into)
8049}
8050
8051pub fn get_all_embeddings(conn: &Connection) -> Result<Vec<(String, Vec<f32>)>> {
8058 let mut stmt =
8059 conn.prepare("SELECT id, embedding FROM memories WHERE embedding IS NOT NULL")?;
8060 let rows = stmt.query_map([], |row| {
8061 let id: String = row.get(0)?;
8062 let bytes: Vec<u8> = row.get(1)?;
8063 Ok((id, bytes))
8064 })?;
8065 let mut entries = Vec::new();
8066 for row in rows {
8067 let (id, bytes) = row?;
8068 if bytes.is_empty() {
8069 continue;
8070 }
8071 match crate::embeddings::decode_embedding_blob(&bytes) {
8072 Ok(floats) => entries.push((id, floats)),
8073 Err(e) => {
8074 tracing::warn!(
8075 memory_id = %id,
8076 error = %e,
8077 "skipping memory with malformed embedding BLOB during HNSW build"
8078 );
8079 }
8080 }
8081 }
8082 Ok(entries)
8083}
8084
8085#[allow(clippy::too_many_arguments)]
8090#[allow(clippy::too_many_arguments)]
8096pub fn recall_hybrid(
8097 conn: &Connection,
8098 context: &str,
8099 query_embedding: &[f32],
8100 namespace: Option<&str>,
8101 limit: usize,
8102 tags_filter: Option<&str>,
8103 since: Option<&str>,
8104 until: Option<&str>,
8105 vector_index: Option<&crate::hnsw::VectorIndex>,
8106 short_extend: i64,
8107 mid_extend: i64,
8108 as_agent: Option<&str>,
8109 budget_tokens: Option<usize>,
8110 scoring: &crate::config::ResolvedScoring,
8111 include_archived: bool,
8114 source_uri_prefix: Option<&str>,
8119) -> Result<(Vec<(Memory, f64)>, BudgetOutcome)> {
8120 let (results, outcome, _telemetry) = recall_hybrid_with_telemetry(
8121 conn,
8122 context,
8123 query_embedding,
8124 namespace,
8125 limit,
8126 tags_filter,
8127 since,
8128 until,
8129 vector_index,
8130 short_extend,
8131 mid_extend,
8132 as_agent,
8133 budget_tokens,
8134 scoring,
8135 include_archived,
8136 source_uri_prefix,
8137 )?;
8138 Ok((results, outcome))
8139}
8140
8141#[allow(clippy::too_many_arguments)]
8148pub fn recall_hybrid_precomputed_hnsw(
8149 conn: &Connection,
8150 context: &str,
8151 query_embedding: &[f32],
8152 namespace: Option<&str>,
8153 limit: usize,
8154 tags_filter: Option<&str>,
8155 since: Option<&str>,
8156 until: Option<&str>,
8157 precomputed_hnsw_hits: &[crate::hnsw::VectorHit],
8158 short_extend: i64,
8159 mid_extend: i64,
8160 as_agent: Option<&str>,
8161 budget_tokens: Option<usize>,
8162 scoring: &crate::config::ResolvedScoring,
8163 include_archived: bool,
8164 source_uri_prefix: Option<&str>,
8165) -> Result<(Vec<(Memory, f64)>, BudgetOutcome)> {
8166 let (results, outcome, _telemetry) = recall_hybrid_with_telemetry_precomputed_hnsw(
8167 conn,
8168 context,
8169 query_embedding,
8170 namespace,
8171 limit,
8172 tags_filter,
8173 since,
8174 until,
8175 precomputed_hnsw_hits,
8176 short_extend,
8177 mid_extend,
8178 as_agent,
8179 budget_tokens,
8180 scoring,
8181 include_archived,
8182 source_uri_prefix,
8183 )?;
8184 Ok((results, outcome))
8185}
8186
8187struct HybridPrep<'a> {
8224 fts_query: String,
8225 now: String,
8226 prefixes: VisibilityPrefixes,
8227 fts_hierarchy_fragment: String,
8228 sem_hierarchy_fragment: String,
8229 effective_namespace: Option<&'a str>,
8230 hierarchy_active: bool,
8231 fts_archived_fragment: &'static str,
8232 sem_archived_fragment: &'static str,
8233 fts_source_uri_fragment: &'static str,
8234 sem_source_uri_fragment: &'static str,
8235 source_uri_like_param: Option<String>,
8236}
8237
8238fn prepare_hybrid_query<'a>(
8247 context: &str,
8248 namespace: Option<&'a str>,
8249 as_agent: Option<&str>,
8250 include_archived: bool,
8251 source_uri_prefix: Option<&str>,
8252) -> HybridPrep<'a> {
8253 let now = Utc::now().to_rfc3339();
8254 let fts_query = sanitize_fts_query(context, true);
8255 let prefixes = compute_visibility_prefixes(as_agent);
8256 let (fts_hierarchy_in, hierarchy_active) = hierarchy_in_clause(namespace);
8257 let fts_hierarchy_fragment = fts_hierarchy_in.unwrap_or_default();
8258 let sem_hierarchy_fragment = if hierarchy_active {
8259 if let Some(ns) = namespace {
8260 let ancestors = crate::models::namespace_ancestors(ns);
8261 let quoted: Vec<String> = ancestors
8262 .iter()
8263 .map(|a| format!("'{}'", a.replace('\'', "''")))
8264 .collect();
8265 format!("AND memories.namespace IN ({})", quoted.join(","))
8266 } else {
8267 String::new()
8268 }
8269 } else {
8270 String::new()
8271 };
8272 let effective_namespace = if hierarchy_active { None } else { namespace };
8273 let fts_archived_fragment = archived_source_clause(include_archived, "m");
8274 let sem_archived_fragment = archived_source_clause(include_archived, "memories");
8275 let source_uri_like_param: Option<String> = match source_uri_prefix {
8276 Some(prefix) if !prefix.is_empty() => Some(format!("{}%", escape_like_pattern(prefix))),
8277 _ => None,
8278 };
8279 let fts_source_uri_fragment = if source_uri_like_param.is_some() {
8280 "AND m.source_uri LIKE ?12 ESCAPE '\\'"
8281 } else {
8282 ""
8283 };
8284 let sem_source_uri_fragment = if source_uri_like_param.is_some() {
8285 "AND memories.source_uri LIKE ?10 ESCAPE '\\'"
8286 } else {
8287 ""
8288 };
8289 HybridPrep {
8290 fts_query,
8291 now,
8292 prefixes,
8293 fts_hierarchy_fragment,
8294 sem_hierarchy_fragment,
8295 effective_namespace,
8296 hierarchy_active,
8297 fts_archived_fragment,
8298 sem_archived_fragment,
8299 fts_source_uri_fragment,
8300 sem_source_uri_fragment,
8301 source_uri_like_param,
8302 }
8303}
8304
8305fn fts_keyword_phase(
8312 conn: &Connection,
8313 prep: &HybridPrep<'_>,
8314 tags_filter: Option<&str>,
8315 since: Option<&str>,
8316 until: Option<&str>,
8317 limit: usize,
8318) -> Result<Vec<(Memory, f64, Option<Vec<u8>>)>> {
8319 let fts_limit = (limit * 3).max(30);
8320 let fts_sql = format!(
8321 "SELECT m.id, m.tier, m.namespace, m.title, m.content, m.tags, m.priority,
8322 m.confidence, m.source, m.access_count, m.created_at, m.updated_at,
8323 m.last_accessed_at, m.expires_at, m.metadata, m.reflection_depth,
8324 m.memory_kind, m.entity_id, m.persona_version,
8325 m.citations, m.source_uri, m.source_span,
8326 m.confidence_source, m.confidence_signals, m.confidence_decayed_at, m.embedding,
8327 (fts.rank * -1) + (m.priority * 0.5) + (MIN(m.access_count, 50) * 0.1)
8328 + (m.confidence * 2.0)
8329 + (CASE m.tier WHEN 'long' THEN 3.0 WHEN 'mid' THEN 1.0 ELSE 0.0 END)
8330 + (1.0 / (1.0 + (julianday('now') - julianday(m.updated_at)) * 0.1))
8331 AS fts_score
8332 FROM memories_fts fts
8333 JOIN memories m ON m.rowid = fts.rowid
8334 WHERE memories_fts MATCH ?1
8335 AND (?2 IS NULL OR m.namespace = ?2)
8336 {fts_hierarchy_fragment}
8337 AND (m.expires_at IS NULL OR m.expires_at > ?3)
8338 AND (?4 IS NULL OR EXISTS (SELECT 1 FROM json_each(m.tags) WHERE json_each.value = ?4))
8339 AND (?5 IS NULL OR m.created_at >= ?5)
8340 AND (?6 IS NULL OR m.created_at <= ?6)
8341 {fts_archived_fragment}
8342 {fts_source_uri_fragment}
8343 {vis}
8344 ORDER BY fts_score DESC
8345 LIMIT ?7",
8346 fts_hierarchy_fragment = prep.fts_hierarchy_fragment,
8347 fts_archived_fragment = prep.fts_archived_fragment,
8348 fts_source_uri_fragment = prep.fts_source_uri_fragment,
8349 vis = visibility_clause(8, "m"),
8350 );
8351 let mut fts_stmt = conn.prepare_cached(&fts_sql)?;
8355 let fts_row_handler =
8356 |row: &rusqlite::Row<'_>| -> rusqlite::Result<(Memory, f64, Option<Vec<u8>>)> {
8357 let mem = row_to_memory(row)?;
8358 let fts_score: f64 = row.get("fts_score")?;
8359 let embedding_bytes: Option<Vec<u8>> = row.get(25)?;
8363 Ok((mem, fts_score, embedding_bytes))
8364 };
8365 let (vis_p, vis_t, vis_u, vis_o) = prep.prefixes.clone();
8366 let rows: Vec<(Memory, f64, Option<Vec<u8>>)> =
8367 if let Some(ref uri_param) = prep.source_uri_like_param {
8368 fts_stmt
8369 .query_map(
8370 params![
8371 prep.fts_query,
8372 prep.effective_namespace,
8373 prep.now,
8374 tags_filter,
8375 since,
8376 until,
8377 fts_limit,
8378 vis_p,
8379 vis_t,
8380 vis_u,
8381 vis_o,
8382 uri_param,
8383 ],
8384 fts_row_handler,
8385 )?
8386 .collect::<rusqlite::Result<Vec<_>>>()?
8387 } else {
8388 fts_stmt
8389 .query_map(
8390 params![
8391 prep.fts_query,
8392 prep.effective_namespace,
8393 prep.now,
8394 tags_filter,
8395 since,
8396 until,
8397 fts_limit,
8398 vis_p,
8399 vis_t,
8400 vis_u,
8401 vis_o,
8402 ],
8403 fts_row_handler,
8404 )?
8405 .collect::<rusqlite::Result<Vec<_>>>()?
8406 };
8407 Ok(rows)
8408}
8409
8410#[allow(clippy::too_many_arguments)]
8428fn semantic_phase(
8429 conn: &Connection,
8430 prep: &HybridPrep<'_>,
8431 query_embedding: &[f32],
8432 vector_index: Option<&crate::hnsw::VectorIndex>,
8433 precomputed_hnsw_hits: Option<&[crate::hnsw::VectorHit]>,
8446 namespace: Option<&str>,
8447 tags_filter: Option<&str>,
8448 since: Option<&str>,
8449 until: Option<&str>,
8450 limit: usize,
8451 include_archived: bool,
8452 source_uri_prefix: Option<&str>,
8453 scored: &mut HashMap<String, (Memory, f64, f64)>,
8454 dim_mismatch_count: &mut usize,
8458) -> Result<usize> {
8459 let mut hnsw_candidates_count: usize = 0;
8460 let now = prep.now.as_str();
8461 if precomputed_hnsw_hits.is_some() || vector_index.is_some() {
8467 let owned_hits;
8468 let hits: &[crate::hnsw::VectorHit] = if let Some(pre) = precomputed_hnsw_hits {
8469 pre
8470 } else {
8471 let ann_limit = (limit * 5).max(50);
8472 owned_hits = vector_index
8473 .expect("vector_index set in legacy branch")
8474 .search(query_embedding, ann_limit);
8475 owned_hits.as_slice()
8476 };
8477 let mut needed_ids: Vec<String> = Vec::with_capacity(hits.len());
8487 let mut hit_meta: Vec<(String, f64)> = Vec::with_capacity(hits.len());
8488 for hit in hits {
8489 if scored.contains_key(&hit.id) {
8490 continue;
8491 }
8492 let cosine = f64::from(1.0 - hit.distance);
8493 if cosine > crate::RECALL_COSINE_GATE {
8496 needed_ids.push(hit.id.clone());
8497 hit_meta.push((hit.id.clone(), cosine));
8498 }
8499 }
8500 let fetched = get_many(conn, &needed_ids)?;
8501 for (id, cosine) in hit_meta {
8502 let Some(mem) = fetched.get(&id) else {
8503 continue;
8504 };
8505 if let Some(ns) = namespace {
8506 if prep.hierarchy_active {
8507 let ancestors = crate::models::namespace_ancestors(ns);
8508 if !ancestors.iter().any(|a| a == &mem.namespace) {
8509 continue;
8510 }
8511 } else if mem.namespace != ns {
8512 continue;
8513 }
8514 }
8515 if let Some(exp) = &mem.expires_at
8516 && exp.as_str() <= now
8517 {
8518 continue;
8519 }
8520 if let Some(tf) = tags_filter
8521 && !mem.tags.iter().any(|t| t == tf)
8522 {
8523 continue;
8524 }
8525 if let Some(s) = since
8526 && mem.created_at.as_str() < s
8527 {
8528 continue;
8529 }
8530 if let Some(u) = until
8531 && mem.created_at.as_str() > u
8532 {
8533 continue;
8534 }
8535 if !is_visible(mem, &prep.prefixes) {
8536 continue;
8537 }
8538 if !include_archived && is_archived_source(mem) {
8539 continue;
8540 }
8541 if let Some(prefix) = source_uri_prefix
8542 && !prefix.is_empty()
8543 && !mem
8544 .source_uri
8545 .as_deref()
8546 .is_some_and(|u| u.starts_with(prefix))
8547 {
8548 continue;
8549 }
8550 scored.insert(mem.id.clone(), (mem.clone(), 0.0, cosine));
8554 hnsw_candidates_count += 1;
8555 }
8556 return Ok(hnsw_candidates_count);
8557 }
8558
8559 let sem_sql = format!(
8561 "SELECT id, tier, namespace, title, content, tags, priority,
8562 confidence, source, access_count, created_at, updated_at,
8563 last_accessed_at, expires_at, metadata, reflection_depth, memory_kind, embedding
8564 FROM memories
8565 WHERE embedding IS NOT NULL
8566 AND (?1 IS NULL OR namespace = ?1)
8567 {sem_hierarchy_fragment}
8568 AND (expires_at IS NULL OR expires_at > ?2)
8569 AND (?3 IS NULL OR EXISTS (SELECT 1 FROM json_each(memories.tags) WHERE json_each.value = ?3))
8570 AND (?4 IS NULL OR created_at >= ?4)
8571 AND (?5 IS NULL OR created_at <= ?5)
8572 {sem_archived_fragment}
8573 {sem_source_uri_fragment}
8574 {vis}",
8575 sem_hierarchy_fragment = prep.sem_hierarchy_fragment,
8576 sem_archived_fragment = prep.sem_archived_fragment,
8577 sem_source_uri_fragment = prep.sem_source_uri_fragment,
8578 vis = visibility_clause(6, "memories"),
8579 );
8580 let mut sem_stmt = conn.prepare_cached(&sem_sql)?;
8582 let sem_row_handler = |row: &rusqlite::Row<'_>| -> rusqlite::Result<(Memory, Option<Vec<u8>>)> {
8583 let mem = row_to_memory(row)?;
8584 let emb_bytes: Option<Vec<u8>> = row.get(17)?;
8588 Ok((mem, emb_bytes))
8589 };
8590 let (vis_p, vis_t, vis_u, vis_o) = prep.prefixes.clone();
8591 let sem_results: Vec<(Memory, Option<Vec<u8>>)> =
8592 if let Some(ref uri_param) = prep.source_uri_like_param {
8593 sem_stmt
8594 .query_map(
8595 params![
8596 prep.effective_namespace,
8597 prep.now,
8598 tags_filter,
8599 since,
8600 until,
8601 vis_p,
8602 vis_t,
8603 vis_u,
8604 vis_o,
8605 uri_param,
8606 ],
8607 sem_row_handler,
8608 )?
8609 .collect::<rusqlite::Result<Vec<_>>>()?
8610 } else {
8611 sem_stmt
8612 .query_map(
8613 params![
8614 prep.effective_namespace,
8615 prep.now,
8616 tags_filter,
8617 since,
8618 until,
8619 vis_p,
8620 vis_t,
8621 vis_u,
8622 vis_o,
8623 ],
8624 sem_row_handler,
8625 )?
8626 .collect::<rusqlite::Result<Vec<_>>>()?
8627 };
8628 for (mem, emb_bytes) in sem_results {
8629 if scored.contains_key(&mem.id) {
8630 continue;
8631 }
8632 if let Some(bytes) = emb_bytes
8633 && !bytes.is_empty()
8634 {
8635 let Ok(emb) = crate::embeddings::decode_embedding_blob(&bytes) else {
8639 tracing::warn!(
8640 memory_id = %mem.id,
8641 "skipping malformed embedding BLOB during semantic recall"
8642 );
8643 continue;
8644 };
8645 let cosine =
8646 match crate::embeddings::Embedder::cosine_similarity_checked(query_embedding, &emb)
8647 {
8648 crate::embeddings::CosineComparison::Comparable(c) => f64::from(c),
8649 crate::embeddings::CosineComparison::DimensionMismatch { .. } => {
8650 *dim_mismatch_count += 1;
8654 continue;
8655 }
8656 };
8657 if cosine > crate::RECALL_COSINE_GATE {
8658 scored.insert(mem.id.clone(), (mem, 0.0, cosine));
8659 hnsw_candidates_count += 1;
8660 }
8661 }
8662 }
8663 Ok(hnsw_candidates_count)
8664}
8665
8666fn blend_and_rank(
8676 scored: HashMap<String, (Memory, f64, f64)>,
8677 max_fts_score: f64,
8678 scoring: &crate::config::ResolvedScoring,
8679 limit: usize,
8680) -> (Vec<(Memory, f64)>, Vec<f64>) {
8681 let now_utc = Utc::now();
8682 let mut weights: Vec<f64> = Vec::new();
8683 let mut results: Vec<(Memory, f64)> = scored
8684 .into_values()
8685 .map(|(mem, fts_score, cosine)| {
8686 let norm_fts = if max_fts_score > 0.0 {
8687 fts_score / max_fts_score
8688 } else {
8689 0.0
8690 };
8691 let content_len = f64::from(i32::try_from(mem.content.len()).unwrap_or(i32::MAX));
8697 let semantic_weight = if content_len <= 500.0 {
8698 0.50
8699 } else if content_len >= 5000.0 {
8700 0.15
8701 } else {
8702 0.50 - 0.35 * ((content_len - 500.0) / 4500.0)
8703 };
8704 weights.push(semantic_weight);
8705 let blended = semantic_weight * cosine + (1.0 - semantic_weight) * norm_fts;
8706 let age_days = chrono::DateTime::parse_from_rfc3339(&mem.created_at)
8707 .ok()
8708 .map_or(0.0, |ts| {
8709 let secs = (now_utc - ts.with_timezone(&Utc)).num_seconds();
8710 #[allow(clippy::cast_precision_loss)]
8711 {
8712 secs as f64 / crate::SECS_PER_DAY as f64
8713 }
8714 });
8715 let decay = scoring.decay_multiplier(&mem.tier, age_days);
8716 (mem, blended * decay)
8717 })
8718 .collect();
8719 results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
8720 results.truncate(limit);
8721 (results, weights)
8722}
8723
8724fn apply_recall_post_ops(
8729 conn: &Connection,
8730 results: Vec<(Memory, f64)>,
8731 hierarchy_active: bool,
8732 namespace: Option<&str>,
8733 budget_tokens: Option<usize>,
8734 short_extend: i64,
8735 mid_extend: i64,
8736) -> (Vec<(Memory, f64)>, BudgetOutcome) {
8737 let boosted = if let (true, Some(anchor)) = (hierarchy_active, namespace) {
8738 apply_proximity_boost(results, anchor)
8739 } else {
8740 results
8741 };
8742 let (budgeted, outcome) = apply_token_budget(boosted, budget_tokens);
8743 let touch_ids: Vec<&str> = budgeted.iter().map(|(mem, _)| mem.id.as_str()).collect();
8744 if let Err(e) = touch_many(conn, &touch_ids, short_extend, mid_extend) {
8745 tracing::warn!("touch_many failed for hybrid recall set: {}", e);
8746 }
8747 (budgeted, outcome)
8748}
8749
8750fn assemble_recall_telemetry(
8755 fts_candidates: usize,
8756 hnsw_candidates: usize,
8757 blend_weights: &[f64],
8758 embedding_dim_mismatch: usize,
8759) -> crate::models::RecallTelemetry {
8760 let blend_weight_avg = if blend_weights.is_empty() {
8761 0.0
8762 } else {
8763 #[allow(clippy::cast_precision_loss)]
8764 let n = blend_weights.len() as f64;
8765 blend_weights.iter().sum::<f64>() / n
8766 };
8767 crate::models::RecallTelemetry {
8768 fts_candidates,
8769 hnsw_candidates,
8770 blend_weight_avg,
8771 embedding_dim_mismatch,
8772 }
8773}
8774
8775#[allow(clippy::too_many_arguments)]
8776pub fn recall_hybrid_with_telemetry(
8777 conn: &Connection,
8778 context: &str,
8779 query_embedding: &[f32],
8780 namespace: Option<&str>,
8781 limit: usize,
8782 tags_filter: Option<&str>,
8783 since: Option<&str>,
8784 until: Option<&str>,
8785 vector_index: Option<&crate::hnsw::VectorIndex>,
8786 short_extend: i64,
8787 mid_extend: i64,
8788 as_agent: Option<&str>,
8789 budget_tokens: Option<usize>,
8790 scoring: &crate::config::ResolvedScoring,
8791 include_archived: bool,
8794 source_uri_prefix: Option<&str>,
8799) -> Result<(
8800 Vec<(Memory, f64)>,
8801 BudgetOutcome,
8802 crate::models::RecallTelemetry,
8803)> {
8804 recall_hybrid_with_telemetry_inner(
8805 conn,
8806 context,
8807 query_embedding,
8808 namespace,
8809 limit,
8810 tags_filter,
8811 since,
8812 until,
8813 vector_index,
8814 None,
8815 short_extend,
8816 mid_extend,
8817 as_agent,
8818 budget_tokens,
8819 scoring,
8820 include_archived,
8821 source_uri_prefix,
8822 )
8823}
8824
8825#[allow(clippy::too_many_arguments)]
8843pub fn recall_hybrid_with_telemetry_precomputed_hnsw(
8844 conn: &Connection,
8845 context: &str,
8846 query_embedding: &[f32],
8847 namespace: Option<&str>,
8848 limit: usize,
8849 tags_filter: Option<&str>,
8850 since: Option<&str>,
8851 until: Option<&str>,
8852 precomputed_hnsw_hits: &[crate::hnsw::VectorHit],
8853 short_extend: i64,
8854 mid_extend: i64,
8855 as_agent: Option<&str>,
8856 budget_tokens: Option<usize>,
8857 scoring: &crate::config::ResolvedScoring,
8858 include_archived: bool,
8859 source_uri_prefix: Option<&str>,
8860) -> Result<(
8861 Vec<(Memory, f64)>,
8862 BudgetOutcome,
8863 crate::models::RecallTelemetry,
8864)> {
8865 recall_hybrid_with_telemetry_inner(
8866 conn,
8867 context,
8868 query_embedding,
8869 namespace,
8870 limit,
8871 tags_filter,
8872 since,
8873 until,
8874 None,
8875 Some(precomputed_hnsw_hits),
8876 short_extend,
8877 mid_extend,
8878 as_agent,
8879 budget_tokens,
8880 scoring,
8881 include_archived,
8882 source_uri_prefix,
8883 )
8884}
8885
8886#[allow(clippy::too_many_arguments)]
8893fn recall_hybrid_with_telemetry_inner(
8894 conn: &Connection,
8895 context: &str,
8896 query_embedding: &[f32],
8897 namespace: Option<&str>,
8898 limit: usize,
8899 tags_filter: Option<&str>,
8900 since: Option<&str>,
8901 until: Option<&str>,
8902 vector_index: Option<&crate::hnsw::VectorIndex>,
8903 precomputed_hnsw_hits: Option<&[crate::hnsw::VectorHit]>,
8904 short_extend: i64,
8905 mid_extend: i64,
8906 as_agent: Option<&str>,
8907 budget_tokens: Option<usize>,
8908 scoring: &crate::config::ResolvedScoring,
8909 include_archived: bool,
8910 source_uri_prefix: Option<&str>,
8911) -> Result<(
8912 Vec<(Memory, f64)>,
8913 BudgetOutcome,
8914 crate::models::RecallTelemetry,
8915)> {
8916 let prep = prepare_hybrid_query(
8919 context,
8920 namespace,
8921 as_agent,
8922 include_archived,
8923 source_uri_prefix,
8924 );
8925
8926 let fts_results = fts_keyword_phase(conn, &prep, tags_filter, since, until, limit)?;
8928
8929 let scored_cap = fts_results
8941 .len()
8942 .saturating_add(limit.saturating_mul(5).max(50));
8943 let mut scored: HashMap<String, (Memory, f64, f64)> = HashMap::with_capacity(scored_cap);
8944 let mut max_fts_score: f64 = 1.0;
8945 let mut fts_candidates_count: usize = 0;
8946 let mut dim_mismatch_count: usize = 0;
8950 for (mem, fts_score, embedding_bytes) in fts_results {
8951 if fts_score > max_fts_score {
8952 max_fts_score = fts_score;
8953 }
8954 let cosine = match embedding_bytes {
8958 Some(bytes) if !bytes.is_empty() => {
8959 match crate::embeddings::decode_embedding_blob(&bytes) {
8960 Ok(emb) => match crate::embeddings::Embedder::cosine_similarity_checked(
8961 query_embedding,
8962 &emb,
8963 ) {
8964 crate::embeddings::CosineComparison::Comparable(c) => f64::from(c),
8965 crate::embeddings::CosineComparison::DimensionMismatch { .. } => {
8966 dim_mismatch_count += 1;
8970 0.0
8971 }
8972 },
8973 Err(_) => {
8974 tracing::warn!(
8975 memory_id = %mem.id,
8976 "skipping malformed embedding BLOB during hybrid recall (FTS branch)"
8977 );
8978 0.0
8979 }
8980 }
8981 }
8982 _ => 0.0,
8983 };
8984 scored.insert(mem.id.clone(), (mem, fts_score, cosine));
8985 fts_candidates_count += 1;
8986 }
8987
8988 let hnsw_candidates_count = semantic_phase(
8993 conn,
8994 &prep,
8995 query_embedding,
8996 vector_index,
8997 precomputed_hnsw_hits,
8998 namespace,
8999 tags_filter,
9000 since,
9001 until,
9002 limit,
9003 include_archived,
9004 source_uri_prefix,
9005 &mut scored,
9006 &mut dim_mismatch_count,
9007 )?;
9008
9009 if dim_mismatch_count > 0 {
9015 tracing::warn!(
9016 dim_mismatch_count,
9017 active_query_dim = query_embedding.len(),
9018 "recall skipped {dim_mismatch_count} stored embedding(s) with mismatched \
9019 dimensionality — the embedder model appears to have changed; re-embed the \
9020 affected memories to restore their semantic recall signal"
9021 );
9022 }
9023
9024 let (results, blend_weights) = blend_and_rank(scored, max_fts_score, scoring, limit);
9026
9027 let (budgeted, outcome) = apply_recall_post_ops(
9029 conn,
9030 results,
9031 prep.hierarchy_active,
9032 namespace,
9033 budget_tokens,
9034 short_extend,
9035 mid_extend,
9036 );
9037
9038 let telemetry = assemble_recall_telemetry(
9040 fts_candidates_count,
9041 hnsw_candidates_count,
9042 &blend_weights,
9043 dim_mismatch_count,
9044 );
9045
9046 Ok((budgeted, outcome, telemetry))
9047}
9048
9049pub fn checkpoint(conn: &Connection) -> Result<()> {
9051 conn.pragma_update(None, "wal_checkpoint", "TRUNCATE")?;
9052 Ok(())
9053}
9054
9055pub fn sync_state_observe(
9068 conn: &Connection,
9069 agent_id: &str,
9070 peer_id: &str,
9071 seen_at: &str,
9072) -> Result<()> {
9073 let now = Utc::now().to_rfc3339();
9074 conn.execute(
9075 "INSERT INTO sync_state (agent_id, peer_id, last_seen_at, last_pulled_at) \
9076 VALUES (?1, ?2, ?3, ?4) \
9077 ON CONFLICT(agent_id, peer_id) DO UPDATE SET \
9078 last_seen_at = CASE WHEN excluded.last_seen_at > last_seen_at \
9079 THEN excluded.last_seen_at \
9080 ELSE last_seen_at END, \
9081 last_pulled_at = excluded.last_pulled_at",
9082 params![agent_id, peer_id, seen_at, now],
9083 )?;
9084 Ok(())
9085}
9086
9087pub fn sync_state_load(conn: &Connection, agent_id: &str) -> Result<crate::models::VectorClock> {
9090 let mut stmt =
9091 conn.prepare("SELECT peer_id, last_seen_at FROM sync_state WHERE agent_id = ?1")?;
9092 let rows = stmt.query_map(params![agent_id], |row| {
9093 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9094 })?;
9095 let mut clock = crate::models::VectorClock::default();
9096 for row in rows {
9097 let (peer, at) = row?;
9098 clock.entries.insert(peer, at);
9099 }
9100 Ok(clock)
9101}
9102
9103#[must_use]
9107#[allow(dead_code)] pub fn sync_state_last_pushed(conn: &Connection, agent_id: &str, peer_id: &str) -> Option<String> {
9109 conn.query_row(
9110 "SELECT last_pushed_at FROM sync_state WHERE agent_id = ?1 AND peer_id = ?2",
9111 params![agent_id, peer_id],
9112 |r| r.get::<_, Option<String>>(0),
9113 )
9114 .ok()
9115 .flatten()
9116}
9117
9118#[allow(dead_code)] pub fn sync_state_record_push(
9122 conn: &Connection,
9123 agent_id: &str,
9124 peer_id: &str,
9125 pushed_at: &str,
9126) -> Result<()> {
9127 let now = Utc::now().to_rfc3339();
9128 conn.execute(
9129 "INSERT INTO sync_state (agent_id, peer_id, last_seen_at, last_pulled_at, last_pushed_at) \
9130 VALUES (?1, ?2, ?3, ?3, ?4) \
9131 ON CONFLICT(agent_id, peer_id) DO UPDATE SET \
9132 last_pushed_at = CASE \
9133 WHEN excluded.last_pushed_at IS NULL THEN last_pushed_at \
9134 WHEN last_pushed_at IS NULL THEN excluded.last_pushed_at \
9135 WHEN excluded.last_pushed_at > last_pushed_at THEN excluded.last_pushed_at \
9136 ELSE last_pushed_at END",
9137 params![agent_id, peer_id, now, pushed_at],
9138 )?;
9139 Ok(())
9140}
9141
9142pub fn memories_updated_since(
9146 conn: &Connection,
9147 since: Option<&str>,
9148 limit: usize,
9149) -> Result<Vec<Memory>> {
9150 const COLS: &str = "SELECT id, tier, namespace, title, content, tags, priority, confidence, \
9172 source, access_count, created_at, updated_at, last_accessed_at, \
9173 expires_at, metadata \
9174 FROM memories ";
9175 let rows = match since {
9176 None => {
9177 let mut stmt = conn.prepare(&format!("{COLS} ORDER BY updated_at ASC LIMIT ?1"))?;
9178 stmt.query_map(params![limit], row_to_memory)?
9179 .collect::<rusqlite::Result<Vec<_>>>()
9180 }
9181 Some(s) => {
9182 let mut stmt = conn.prepare(&format!(
9183 "{COLS} WHERE updated_at > ?1 ORDER BY updated_at ASC LIMIT ?2"
9184 ))?;
9185 stmt.query_map(params![s, limit], row_to_memory)?
9186 .collect::<rusqlite::Result<Vec<_>>>()
9187 }
9188 };
9189 rows.map_err(Into::into)
9190}
9191
9192pub fn health_check(conn: &Connection) -> Result<bool> {
9194 let _: i64 = conn.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))?;
9195 conn.execute(
9196 "INSERT INTO memories_fts(memories_fts) VALUES('integrity-check')",
9197 [],
9198 )?;
9199 Ok(true)
9200}
9201
9202pub fn set_namespace_standard(
9208 conn: &Connection,
9209 namespace: &str,
9210 standard_id: &str,
9211 parent: Option<&str>,
9212) -> Result<()> {
9213 let _mem = get(conn, standard_id)?.ok_or_else(|| {
9215 anyhow::Error::new(StorageError::MemoryNotFound {
9217 id: standard_id.to_string(),
9218 role: None,
9219 })
9220 })?;
9221 let resolved_parent = match parent {
9223 Some(p) => {
9224 if p == namespace {
9225 return Err(anyhow::Error::new(StorageError::InvalidArgument {
9227 reason: "namespace cannot be its own parent".to_string(),
9228 }));
9229 }
9230 Some(p.to_string())
9231 }
9232 None => auto_detect_parent(conn, namespace),
9233 };
9234 let now = chrono::Utc::now().to_rfc3339();
9235 conn.execute(
9236 "INSERT INTO namespace_meta (namespace, standard_id, updated_at, parent_namespace)
9237 VALUES (?1, ?2, ?3, ?4)
9238 ON CONFLICT(namespace) DO UPDATE SET standard_id = ?2, updated_at = ?3, parent_namespace = ?4",
9239 params![namespace, standard_id, now, resolved_parent],
9240 )?;
9241 Ok(())
9242}
9243
9244fn auto_detect_parent(conn: &Connection, namespace: &str) -> Option<String> {
9247 let mut candidate = namespace.to_string();
9248 while let Some(pos) = candidate.rfind('-') {
9249 candidate.truncate(pos);
9250 if candidate.is_empty() {
9251 break;
9252 }
9253 if get_namespace_standard(conn, &candidate)
9255 .ok()
9256 .flatten()
9257 .is_some()
9258 {
9259 return Some(candidate);
9260 }
9261 }
9262 None
9263}
9264
9265#[allow(clippy::unnecessary_wraps)]
9267pub fn get_namespace_standard(conn: &Connection, namespace: &str) -> Result<Option<String>> {
9268 let result = conn
9269 .query_row(
9270 "SELECT standard_id FROM namespace_meta WHERE namespace = ?1",
9271 params![namespace],
9272 |r| r.get(0),
9273 )
9274 .ok();
9275 Ok(result)
9276}
9277
9278pub fn get_namespace_parent(conn: &Connection, namespace: &str) -> Option<String> {
9280 conn.query_row(
9281 "SELECT parent_namespace FROM namespace_meta WHERE namespace = ?1 AND parent_namespace IS NOT NULL",
9282 params![namespace],
9283 |r| r.get(0),
9284 )
9285 .ok()
9286}
9287
9288#[allow(clippy::unnecessary_wraps)]
9293pub fn get_namespace_meta_entry(
9294 conn: &Connection,
9295 namespace: &str,
9296) -> Result<Option<crate::models::NamespaceMetaEntry>> {
9297 let row = conn
9298 .query_row(
9299 "SELECT namespace, standard_id, parent_namespace, updated_at
9300 FROM namespace_meta WHERE namespace = ?1",
9301 params![namespace],
9302 |r| {
9303 Ok(crate::models::NamespaceMetaEntry {
9304 namespace: r.get(0)?,
9305 standard_id: r.get(1)?,
9306 parent_namespace: r.get(2)?,
9307 updated_at: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
9308 })
9309 },
9310 )
9311 .ok();
9312 Ok(row)
9313}
9314
9315pub fn clear_namespace_standard(conn: &Connection, namespace: &str) -> Result<bool> {
9317 let changed = conn.execute(
9318 "DELETE FROM namespace_meta WHERE namespace = ?1",
9319 params![namespace],
9320 )?;
9321 Ok(changed > 0)
9322}
9323
9324#[must_use]
9345pub fn build_namespace_chain(conn: &Connection, namespace: &str) -> Vec<String> {
9346 const MAX_EXPLICIT_DEPTH: usize = 8;
9347 let mut chain: Vec<String> = Vec::new();
9348
9349 if namespace == "*" {
9350 chain.push("*".to_string());
9351 return chain;
9352 }
9353
9354 chain.push("*".to_string());
9356
9357 let mut hierarchy_chain: Vec<String> = crate::models::namespace_ancestors(namespace)
9360 .into_iter()
9361 .rev()
9362 .collect();
9363
9364 if let Some(root) = hierarchy_chain.first().cloned() {
9368 let mut explicit_above: Vec<String> = Vec::new();
9369 let mut current = root;
9370 for _ in 0..MAX_EXPLICIT_DEPTH {
9371 match get_namespace_parent(conn, ¤t) {
9372 Some(p)
9373 if p != "*"
9374 && !explicit_above.contains(&p)
9375 && !hierarchy_chain.contains(&p) =>
9376 {
9377 explicit_above.push(p.clone());
9378 current = p;
9379 }
9380 _ => break,
9381 }
9382 }
9383 for p in explicit_above.into_iter().rev() {
9386 chain.push(p);
9387 }
9388 }
9389
9390 for entry in hierarchy_chain.drain(..) {
9392 if !chain.contains(&entry) {
9393 chain.push(entry);
9394 }
9395 }
9396
9397 chain
9398}
9399
9400fn read_namespace_policy(conn: &Connection, namespace: &str) -> Option<GovernancePolicy> {
9416 let standard_id = get_namespace_standard(conn, namespace).ok()??;
9417 let mem = get(conn, &standard_id).ok()??;
9418 match GovernancePolicy::from_metadata(&mem.metadata) {
9419 Some(Ok(p)) => Some(p),
9420 Some(Err(parse_err)) => {
9436 tracing::warn!(
9437 target: "ai_memory::governance::policy_read",
9438 namespace = %namespace,
9439 standard_id = %standard_id,
9440 error = %parse_err,
9441 "stored metadata.governance failed typed deserialise — \
9442 inheritance walk will continue past this namespace as \
9443 if no policy were set. Likely cause: direct SQL update, \
9444 older binary, or corrupted migration. Operator should \
9445 re-run `memory_namespace_set_standard` to restore the \
9446 typed shape."
9447 );
9448 None
9449 }
9450 None => None,
9451 }
9452}
9453
9454pub fn resolve_governance_policy(conn: &Connection, namespace: &str) -> Option<GovernancePolicy> {
9499 let chain = build_namespace_chain(conn, namespace);
9503 for level in chain.into_iter().rev() {
9504 if let Some(policy) = read_namespace_policy(conn, &level) {
9512 return Some(policy);
9513 }
9514 }
9518 None
9519}
9520
9521pub fn resolve_require_approval_above_depth(conn: &Connection, namespace: &str) -> Option<u32> {
9541 let chain = build_namespace_chain(conn, namespace);
9542 for level in chain.into_iter().rev() {
9543 let standard_id = match get_namespace_standard(conn, &level) {
9544 Ok(Some(id)) => id,
9545 _ => continue,
9546 };
9547 let mem = match get(conn, &standard_id) {
9548 Ok(Some(m)) => m,
9549 _ => continue,
9550 };
9551 let gov = match mem.metadata.get(crate::META_KEY_GOVERNANCE) {
9553 Some(g) if !g.is_null() => g,
9554 _ => continue,
9555 };
9556 if let Some(threshold) = gov.get("require_approval_above_depth") {
9560 if let Some(n) = threshold.as_u64() {
9561 return Some(u32::try_from(n).unwrap_or(0));
9575 }
9576 }
9578 if GovernancePolicy::from_metadata(&mem.metadata).is_some() {
9583 return None;
9584 }
9585 }
9586 None
9587}
9588
9589pub fn resolve_skill_promotion_min_depth(conn: &Connection, namespace: &str) -> Option<u32> {
9610 let chain = build_namespace_chain(conn, namespace);
9611 for level in chain.into_iter().rev() {
9612 let standard_id = match get_namespace_standard(conn, &level) {
9613 Ok(Some(id)) => id,
9614 _ => continue,
9615 };
9616 let mem = match get(conn, &standard_id) {
9617 Ok(Some(m)) => m,
9618 _ => continue,
9619 };
9620 let gov = match mem.metadata.get(crate::META_KEY_GOVERNANCE) {
9621 Some(g) if !g.is_null() => g,
9622 _ => continue,
9623 };
9624 if let Some(threshold) = gov.get("skill_promotion_min_depth") {
9625 if let Some(n) = threshold.as_u64() {
9626 return Some(u32::try_from(n).unwrap_or(u32::MAX));
9640 }
9641 }
9643 if GovernancePolicy::from_metadata(&mem.metadata).is_some() {
9646 return None;
9647 }
9648 }
9649 None
9650}
9651
9652pub fn is_registered_agent(conn: &Connection, agent_id: &str) -> bool {
9654 let title = crate::models::agent_registration_title(agent_id);
9655 conn.query_row(
9656 "SELECT 1 FROM memories WHERE namespace = ?1 AND title = ?2",
9657 params![AGENTS_NAMESPACE, &title],
9658 |r| r.get::<_, i64>(0),
9659 )
9660 .is_ok()
9661}
9662
9663fn evaluate_level(
9681 conn: &Connection,
9682 action: GovernedAction,
9683 namespace: &str,
9684 level: &GovernanceLevel,
9685 agent_id: &str,
9686 memory_owner: Option<&str>,
9687 namespace_owner: Option<&str>,
9688) -> GovernanceDecision {
9689 use crate::governance::GovernanceRefusal;
9690 match level {
9691 GovernanceLevel::Any => GovernanceDecision::Allow,
9692 GovernanceLevel::Registered => {
9693 if is_registered_agent(conn, agent_id) {
9694 GovernanceDecision::Allow
9695 } else {
9696 GovernanceDecision::Deny(
9697 GovernanceRefusal::new(
9698 action,
9699 GovernanceLevel::Registered,
9700 agent_id,
9701 format!("caller '{agent_id}' is not a registered agent"),
9702 )
9703 .with_namespace(namespace),
9704 )
9705 }
9706 }
9707 GovernanceLevel::Owner => {
9708 let owner = memory_owner.or(namespace_owner);
9709 match owner {
9710 Some(o) if o == agent_id => GovernanceDecision::Allow,
9711 Some(o) => GovernanceDecision::Deny(
9712 GovernanceRefusal::new(
9713 action,
9714 GovernanceLevel::Owner,
9715 agent_id,
9716 format!("caller '{agent_id}' is not the owner ('{o}')"),
9717 )
9718 .with_namespace(namespace)
9719 .with_owner(o),
9720 ),
9721 None => GovernanceDecision::Deny(
9722 GovernanceRefusal::new(
9723 action,
9724 GovernanceLevel::Owner,
9725 agent_id,
9726 "owner-level action has no resolvable owner",
9727 )
9728 .with_namespace(namespace),
9729 ),
9730 }
9731 }
9732 GovernanceLevel::Approve => {
9733 GovernanceDecision::Pending(String::new())
9737 }
9738 }
9739}
9740
9741fn namespace_owner(conn: &Connection, namespace: &str) -> Option<String> {
9757 let chain = build_namespace_chain(conn, namespace);
9761 for level in chain.into_iter().rev() {
9762 let Some(standard_id) = get_namespace_standard(conn, &level).ok().flatten() else {
9763 continue;
9764 };
9765 let Some(mem) = get(conn, &standard_id).ok().flatten() else {
9766 continue;
9767 };
9768 if let Some(owner) = mem
9769 .metadata
9770 .get("agent_id")
9771 .and_then(|v| v.as_str())
9772 .map(str::to_string)
9773 {
9774 return Some(owner);
9775 }
9776 }
9777 None
9778}
9779
9780pub fn enforce_governance(
9807 conn: &Connection,
9808 action: GovernedAction,
9809 namespace: &str,
9810 agent_id: &str,
9811 memory_id: Option<&str>,
9812 memory_owner: Option<&str>,
9813 payload: &serde_json::Value,
9814) -> Result<GovernanceDecision> {
9815 use crate::config::{PermissionsMode, active_permissions_mode, record_permissions_decision};
9816
9817 let mode = active_permissions_mode();
9818 record_permissions_decision(mode);
9819
9820 if mode == PermissionsMode::Off {
9822 return Ok(GovernanceDecision::Allow);
9823 }
9824
9825 let Some(policy) = resolve_governance_policy(conn, namespace) else {
9827 return Ok(GovernanceDecision::Allow);
9828 };
9829 let level = match action {
9832 GovernedAction::Store => &policy.core.write,
9833 GovernedAction::Delete => &policy.core.delete,
9834 GovernedAction::Promote => &policy.core.promote,
9835 GovernedAction::Reflect => &policy.core.write,
9841 };
9842 let ns_owner = if matches!(action, GovernedAction::Store) {
9843 namespace_owner(conn, namespace)
9844 } else {
9845 None
9846 };
9847
9848 let decision = evaluate_level(
9849 conn,
9850 action,
9851 namespace,
9852 level,
9853 agent_id,
9854 memory_owner,
9855 ns_owner.as_deref(),
9856 );
9857
9858 if mode == PermissionsMode::Advisory {
9863 match &decision {
9864 GovernanceDecision::Allow => {}
9865 GovernanceDecision::Deny(refusal) => {
9866 tracing::warn!(
9867 target: "ai_memory::governance",
9868 namespace = %namespace,
9869 agent_id = %agent_id,
9870 action = ?action,
9871 reason = %refusal.reason,
9872 denied_level = %refusal.denied_level.as_str(),
9873 "permissions.mode=advisory: would-deny suppressed (allowing)"
9874 );
9875 }
9876 GovernanceDecision::Pending(_) => {
9877 tracing::warn!(
9878 target: "ai_memory::governance",
9879 namespace = %namespace,
9880 agent_id = %agent_id,
9881 action = ?action,
9882 "permissions.mode=advisory: would-queue-approval suppressed (allowing)"
9883 );
9884 }
9885 }
9886 return Ok(GovernanceDecision::Allow);
9887 }
9888
9889 if let GovernanceDecision::Pending(_) = decision {
9892 let pending_id =
9893 queue_pending_action(conn, action, namespace, memory_id, agent_id, payload)?;
9894 return Ok(GovernanceDecision::Pending(pending_id));
9895 }
9896 Ok(decision)
9897}
9898
9899pub fn queue_pending_action(
9901 conn: &Connection,
9902 action: GovernedAction,
9903 namespace: &str,
9904 memory_id: Option<&str>,
9905 requested_by: &str,
9906 payload: &serde_json::Value,
9907) -> Result<String> {
9908 let id = uuid::Uuid::new_v4().to_string();
9909 let now = Utc::now().to_rfc3339();
9910 let payload_json = serde_json::to_string(payload)?;
9911 conn.execute(
9912 "INSERT INTO pending_actions (id, action_type, memory_id, namespace, payload, requested_by, requested_at, status)
9913 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending')",
9914 params![
9915 id,
9916 action.as_str(),
9917 memory_id,
9918 namespace,
9919 payload_json,
9920 requested_by,
9921 now,
9922 ],
9923 )?;
9924 Ok(id)
9925}
9926
9927pub fn upsert_pending_action(conn: &Connection, pa: &PendingAction) -> Result<()> {
9935 let payload_json = serde_json::to_string(&pa.payload)?;
9936 let approvals_json = serde_json::to_string(&pa.approvals)?;
9937 conn.execute(
9938 "INSERT INTO pending_actions
9939 (id, action_type, memory_id, namespace, payload, requested_by,
9940 requested_at, status, decided_by, decided_at, approvals)
9941 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
9942 ON CONFLICT(id) DO UPDATE SET
9943 action_type = excluded.action_type,
9944 memory_id = excluded.memory_id,
9945 namespace = excluded.namespace,
9946 payload = excluded.payload,
9947 requested_by = excluded.requested_by,
9948 requested_at = excluded.requested_at,
9949 status = excluded.status,
9950 decided_by = excluded.decided_by,
9951 decided_at = excluded.decided_at,
9952 approvals = excluded.approvals",
9953 params![
9954 pa.id,
9955 pa.action_type,
9956 pa.memory_id,
9957 pa.namespace,
9958 payload_json,
9959 pa.requested_by,
9960 pa.requested_at,
9961 pa.status,
9962 pa.decided_by,
9963 pa.decided_at,
9964 approvals_json,
9965 ],
9966 )?;
9967 Ok(())
9968}
9969
9970pub fn list_pending_actions(
9971 conn: &Connection,
9972 status: Option<&str>,
9973 limit: usize,
9974) -> Result<Vec<PendingAction>> {
9975 let mut stmt = conn.prepare(
9976 "SELECT id, action_type, memory_id, namespace, payload, requested_by,
9977 requested_at, status, decided_by, decided_at, approvals
9978 FROM pending_actions
9979 WHERE (?1 IS NULL OR status = ?1)
9980 ORDER BY requested_at DESC
9981 LIMIT ?2",
9982 )?;
9983 let rows = stmt.query_map(params![status, limit], |row| {
9984 let payload_str: String = row.get(4)?;
9985 let payload: serde_json::Value =
9986 serde_json::from_str(&payload_str).unwrap_or(serde_json::Value::Null);
9987 let approvals_str: String = row.get(10)?;
9988 let approvals: Vec<Approval> = serde_json::from_str(&approvals_str).unwrap_or_default();
9989 Ok(PendingAction {
9990 id: row.get(0)?,
9991 action_type: row.get(1)?,
9992 memory_id: row.get(2)?,
9993 namespace: row.get(3)?,
9994 payload,
9995 requested_by: row.get(5)?,
9996 requested_at: row.get(6)?,
9997 status: row.get(7)?,
9998 decided_by: row.get(8)?,
9999 decided_at: row.get(9)?,
10000 approvals,
10001 })
10002 })?;
10003 rows.collect::<rusqlite::Result<Vec<_>>>()
10004 .map_err(Into::into)
10005}
10006
10007pub fn get_pending_action(conn: &Connection, id: &str) -> Result<Option<PendingAction>> {
10008 let row = conn.query_row(
10009 "SELECT id, action_type, memory_id, namespace, payload, requested_by,
10010 requested_at, status, decided_by, decided_at, approvals
10011 FROM pending_actions WHERE id = ?1",
10012 params![id],
10013 |row| {
10014 let payload_str: String = row.get(4)?;
10015 let payload: serde_json::Value =
10016 serde_json::from_str(&payload_str).unwrap_or(serde_json::Value::Null);
10017 let approvals_str: String = row.get(10)?;
10018 let approvals: Vec<Approval> = serde_json::from_str(&approvals_str).unwrap_or_default();
10019 Ok(PendingAction {
10020 id: row.get(0)?,
10021 action_type: row.get(1)?,
10022 memory_id: row.get(2)?,
10023 namespace: row.get(3)?,
10024 payload,
10025 requested_by: row.get(5)?,
10026 requested_at: row.get(6)?,
10027 status: row.get(7)?,
10028 decided_by: row.get(8)?,
10029 decided_at: row.get(9)?,
10030 approvals,
10031 })
10032 },
10033 );
10034 match row {
10035 Ok(p) => Ok(Some(p)),
10036 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
10037 Err(e) => Err(e.into()),
10038 }
10039}
10040
10041pub fn decide_pending_action(
10053 conn: &Connection,
10054 id: &str,
10055 approve: bool,
10056 decided_by: &str,
10057) -> Result<bool> {
10058 let new_status = if approve { "approved" } else { "rejected" };
10059 let now = Utc::now().to_rfc3339();
10060 let updated = conn.execute(
10061 "UPDATE pending_actions SET status = ?1, decided_by = ?2, decided_at = ?3
10062 WHERE id = ?4 AND status = 'pending'",
10063 params![new_status, decided_by, now, id],
10064 )?;
10065 if updated > 0 && !approve {
10070 if let Ok(Some(pa)) = get_pending_action(conn, id) {
10071 emit_pending_action_event(conn, &pa, "pending_action.denied", Some(decided_by));
10072 }
10073 }
10074 Ok(updated > 0)
10075}
10076
10077fn emit_pending_action_event(
10099 conn: &Connection,
10100 pa: &PendingAction,
10101 event_type: &str,
10102 decided_by_override: Option<&str>,
10103) {
10104 use std::collections::BTreeMap;
10111 let decided_by = decided_by_override
10112 .map(str::to_string)
10113 .or_else(|| pa.decided_by.clone())
10114 .unwrap_or_default();
10115 let timestamp = Utc::now().to_rfc3339();
10116 let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
10117 map.insert(
10118 field_names::PENDING_ID,
10119 ciborium::Value::Text(pa.id.clone()),
10120 );
10121 map.insert(
10122 field_names::ACTION_TYPE,
10123 ciborium::Value::Text(pa.action_type.clone()),
10124 );
10125 map.insert("namespace", ciborium::Value::Text(pa.namespace.clone()));
10126 map.insert(
10127 field_names::REQUESTED_BY,
10128 ciborium::Value::Text(pa.requested_by.clone()),
10129 );
10130 map.insert(
10131 field_names::DECIDED_BY,
10132 ciborium::Value::Text(decided_by.clone()),
10133 );
10134 map.insert("status", ciborium::Value::Text(pa.status.clone()));
10135 map.insert("timestamp", ciborium::Value::Text(timestamp.clone()));
10136 let entries: Vec<(ciborium::Value, ciborium::Value)> = map
10137 .into_iter()
10138 .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
10139 .collect();
10140 let value = ciborium::Value::Map(entries);
10141 let mut cbor: Vec<u8> = Vec::with_capacity(128);
10142 if let Err(e) = ciborium::ser::into_writer(&value, &mut cbor) {
10143 tracing::warn!(
10144 target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
10145 pending_id = %pa.id,
10146 event_type,
10147 "failed to encode canonical CBOR for pending_action event: {e}"
10148 );
10149 return;
10150 }
10151
10152 let agent_id = if event_type == "pending_action.timed_out" {
10157 pa.requested_by.clone()
10158 } else {
10159 decided_by
10160 };
10161
10162 let event = crate::signed_events::SignedEvent::with_daemon_signature(
10171 crate::signed_events::payload_hash(&cbor),
10172 agent_id,
10173 event_type.to_string(),
10174 timestamp,
10175 );
10176 if let Err(e) = crate::signed_events::append_signed_event(conn, &event) {
10177 tracing::warn!(
10178 target: crate::signed_events::SIGNED_EVENTS_TRACE_TARGET,
10179 pending_id = %pa.id,
10180 event_type,
10181 "failed to append pending_action audit row: {e}"
10182 );
10183 }
10184}
10185
10186fn verify_payload_agent_id(pa: &PendingAction) -> Result<()> {
10206 let payload_agent_id = pa
10207 .payload
10208 .get("agent_id")
10209 .and_then(serde_json::Value::as_str)
10210 .or_else(|| {
10211 pa.payload
10212 .get("metadata")
10213 .and_then(|m| m.get("agent_id"))
10214 .and_then(serde_json::Value::as_str)
10215 });
10216 if let Some(claimed) = payload_agent_id
10217 && claimed != pa.requested_by
10218 {
10219 return Err(anyhow::Error::new(StorageError::ApproverLaundering {
10222 pending_id: pa.id.clone(),
10223 claimed: claimed.to_string(),
10224 requester: pa.requested_by.clone(),
10225 }));
10226 }
10227 Ok(())
10228}
10229
10230#[derive(Debug, Clone, PartialEq, Eq)]
10232pub enum ApproveOutcome {
10233 NotFound,
10238 Rejected(String),
10240 Pending { votes: usize, quorum: u32 },
10242 Approved,
10246}
10247
10248pub fn approve_with_approver_type(
10251 conn: &Connection,
10252 pending_id: &str,
10253 approver_agent_id: &str,
10254) -> Result<ApproveOutcome> {
10255 let Some(pa) = get_pending_action(conn, pending_id)? else {
10256 return Ok(ApproveOutcome::NotFound);
10258 };
10259 if pa.status != "pending" {
10260 return Ok(ApproveOutcome::Rejected(format!(
10261 "already decided: status={}",
10262 pa.status
10263 )));
10264 }
10265 let approver = resolve_governance_policy(conn, &pa.namespace)
10270 .map_or(ApproverType::Human, |p| p.core.approver);
10271
10272 match approver {
10273 ApproverType::Human => {
10274 let ok = decide_pending_action(conn, pending_id, true, approver_agent_id)?;
10275 if ok {
10276 Ok(ApproveOutcome::Approved)
10277 } else {
10278 Ok(ApproveOutcome::Rejected(
10279 crate::errors::msg::DECISION_WRITE_FAILED.into(),
10280 ))
10281 }
10282 }
10283 ApproverType::Agent(required) => {
10284 if approver_agent_id != required {
10285 return Ok(ApproveOutcome::Rejected(format!(
10286 "designated approver is '{required}'; got '{approver_agent_id}'"
10287 )));
10288 }
10289 let ok = decide_pending_action(conn, pending_id, true, approver_agent_id)?;
10290 if ok {
10291 Ok(ApproveOutcome::Approved)
10292 } else {
10293 Ok(ApproveOutcome::Rejected(
10294 crate::errors::msg::DECISION_WRITE_FAILED.into(),
10295 ))
10296 }
10297 }
10298 ApproverType::Consensus(quorum) => {
10299 if !is_registered_agent(conn, approver_agent_id) {
10311 return Ok(ApproveOutcome::Rejected(format!(
10312 "consensus voter '{approver_agent_id}' is not a registered agent"
10313 )));
10314 }
10315 let canonical_id = approver_agent_id.to_ascii_lowercase();
10316 let mut approvals = pa.approvals.clone();
10317 if approvals
10318 .iter()
10319 .any(|a| a.agent_id.eq_ignore_ascii_case(&canonical_id))
10320 {
10321 return Ok(ApproveOutcome::Pending {
10322 votes: approvals.len(),
10323 quorum,
10324 });
10325 }
10326 approvals.push(Approval {
10327 agent_id: canonical_id.clone(),
10328 approved_at: Utc::now().to_rfc3339(),
10329 });
10330 let approvals_json = serde_json::to_string(&approvals)?;
10331 conn.execute(
10332 "UPDATE pending_actions SET approvals = ?1 WHERE id = ?2 AND status = 'pending'",
10333 params![approvals_json, pending_id],
10334 )?;
10335 let votes = approvals.len();
10336 if u32::try_from(votes).unwrap_or(u32::MAX) >= quorum {
10337 let ok = decide_pending_action(conn, pending_id, true, &canonical_id)?;
10339 if ok {
10340 return Ok(ApproveOutcome::Approved);
10341 }
10342 return Ok(ApproveOutcome::Rejected(
10343 "decision write failed at consensus threshold".into(),
10344 ));
10345 }
10346 Ok(ApproveOutcome::Pending { votes, quorum })
10347 }
10348 }
10349}
10350
10351pub fn execute_pending_action(conn: &Connection, pending_id: &str) -> Result<Option<String>> {
10376 let Some(pa) = get_pending_action(conn, pending_id)? else {
10377 return Err(anyhow::Error::new(StorageError::PendingActionNotFound {
10379 pending_id: pending_id.to_string(),
10380 }));
10381 };
10382 if pa.status != "approved" {
10383 return Err(anyhow::Error::new(
10385 StorageError::PendingActionStateInvalid {
10386 pending_id: pending_id.to_string(),
10387 status: pa.status.clone(),
10388 },
10389 ));
10390 }
10391 if let Err(e) = verify_payload_agent_id(&pa) {
10396 emit_pending_action_event(conn, &pa, "pending_action.refused_agent_id_mismatch", None);
10397 return Err(e);
10398 }
10399 let memory_id = match pa.action_type.as_str() {
10400 "store" => {
10401 let mut mem: Memory = serde_json::from_value(pa.payload.clone()).map_err(|e| {
10402 anyhow::Error::new(StorageError::InvalidArgument {
10404 reason: format!("invalid store payload: {e}"),
10405 })
10406 })?;
10407 mem.id = uuid::Uuid::new_v4().to_string();
10409 let now = Utc::now().to_rfc3339();
10410 mem.created_at.clone_from(&now);
10411 mem.updated_at = now;
10412 mem.access_count = 0;
10413 let actual_id = insert(conn, &mem)?;
10414 Some(actual_id)
10415 }
10416 "delete" => {
10417 if let Some(mid) = pa.memory_id.clone() {
10418 delete(conn, &mid)?;
10419 Some(mid)
10420 } else {
10421 None
10422 }
10423 }
10424 "promote" => {
10425 if let Some(mid) = pa.memory_id.clone() {
10426 if let Some(to_ns) = pa
10427 .payload
10428 .get(field_names::TO_NAMESPACE)
10429 .and_then(|v| v.as_str())
10430 {
10431 let clone_id = promote_to_namespace(conn, &mid, to_ns)?;
10433 Some(clone_id)
10434 } else {
10435 let (_found, _changed) = update(
10437 conn,
10438 &mid,
10439 None,
10440 None,
10441 Some(&Tier::Long),
10442 None,
10443 None,
10444 None,
10445 None,
10446 Some(""),
10447 None,
10448 )?;
10449 Some(mid)
10450 }
10451 } else {
10452 None
10453 }
10454 }
10455 "reflect" => execute_reflect_from_payload(conn, &pa)?,
10456 other => {
10457 return Err(anyhow::Error::new(StorageError::InvalidArgument {
10459 reason: format!("unknown action_type: {other}"),
10460 }));
10461 }
10462 };
10463 emit_pending_action_event(
10468 conn,
10469 &pa,
10470 "pending_action.approved",
10471 pa.decided_by.as_deref(),
10472 );
10473 Ok(memory_id)
10474}
10475
10476fn execute_reflect_from_payload(conn: &Connection, pa: &PendingAction) -> Result<Option<String>> {
10504 let payload = &pa.payload;
10505 let source_ids: Vec<String> = payload
10506 .get(field_names::SOURCE_IDS)
10507 .and_then(|v| v.as_array())
10508 .map(|arr| {
10509 arr.iter()
10510 .filter_map(|v| v.as_str().map(str::to_string))
10511 .collect()
10512 })
10513 .unwrap_or_default();
10514 if source_ids.is_empty() {
10515 return Err(anyhow::Error::new(StorageError::InvalidArgument {
10517 reason: "invalid reflect payload: source_ids missing or empty".to_string(),
10518 }));
10519 }
10520 let title = payload
10521 .get("title")
10522 .and_then(|v| v.as_str())
10523 .ok_or_else(|| {
10524 anyhow::Error::new(StorageError::InvalidArgument {
10526 reason: "invalid reflect payload: title missing".to_string(),
10527 })
10528 })?
10529 .to_string();
10530 let content = payload
10531 .get("content")
10532 .and_then(|v| v.as_str())
10533 .ok_or_else(|| {
10534 anyhow::Error::new(StorageError::InvalidArgument {
10536 reason: "invalid reflect payload: content missing".to_string(),
10537 })
10538 })?
10539 .to_string();
10540 let namespace = payload
10541 .get("namespace")
10542 .and_then(|v| v.as_str())
10543 .map(str::to_string)
10544 .or_else(|| Some(pa.namespace.clone()));
10545 let tier = payload
10546 .get("tier")
10547 .and_then(|v| v.as_str())
10548 .and_then(Tier::from_str)
10549 .unwrap_or(Tier::Mid);
10550 let tags: Vec<String> = payload
10551 .get("tags")
10552 .and_then(|v| v.as_array())
10553 .map(|arr| {
10554 arr.iter()
10555 .filter_map(|v| v.as_str().map(str::to_string))
10556 .collect()
10557 })
10558 .unwrap_or_default();
10559 let priority = i32::try_from(
10560 payload
10561 .get("priority")
10562 .and_then(|v| v.as_i64())
10563 .unwrap_or(5),
10564 )
10565 .unwrap_or(5);
10566 let confidence = payload
10567 .get(field_names::CONFIDENCE)
10568 .and_then(|v| v.as_f64())
10569 .unwrap_or(1.0);
10570 let agent_id = payload
10575 .get("agent_id")
10576 .and_then(|v| v.as_str())
10577 .map(str::to_string)
10578 .unwrap_or_else(|| pa.requested_by.clone());
10579 let metadata = payload
10580 .get("metadata")
10581 .cloned()
10582 .unwrap_or_else(|| serde_json::json!({}));
10583
10584 let input = crate::storage::reflect::ReflectInput {
10585 source_ids,
10586 title,
10587 content,
10588 namespace,
10589 tier,
10590 tags,
10591 priority,
10592 confidence,
10593 source: crate::validate::DEFAULT_NHI_SOURCE.to_string(),
10598 agent_id,
10599 metadata,
10600 };
10601 let outcome = crate::storage::reflect::reflect(conn, &input)
10602 .map_err(|e| anyhow::anyhow!("reflect execute failed: {e}"))?;
10603 Ok(Some(outcome.id))
10604}
10605
10606pub fn is_namespace_standard(conn: &Connection, id: &str) -> bool {
10608 conn.query_row(
10609 "SELECT COUNT(*) FROM namespace_meta WHERE standard_id = ?1",
10610 params![id],
10611 |r| r.get::<_, i64>(0),
10612 )
10613 .unwrap_or(0)
10614 > 0
10615}
10616
10617pub fn count_active_governance_rules(conn: &Connection) -> Result<usize> {
10623 let count: i64 = conn
10624 .query_row(
10625 "SELECT COUNT(*) FROM memories m
10626 INNER JOIN namespace_meta nm ON nm.standard_id = m.id
10627 WHERE json_extract(m.metadata, '$.governance') IS NOT NULL",
10628 [],
10629 |r| r.get(0),
10630 )
10631 .unwrap_or(0);
10632 Ok(usize::try_from(count.max(0)).unwrap_or(0))
10633}
10634
10635pub fn list_active_governance_policies(
10657 conn: &Connection,
10658) -> Result<Vec<(String, GovernancePolicy)>> {
10659 let mut stmt = conn.prepare(
10664 "SELECT nm.namespace, m.metadata
10665 FROM namespace_meta nm
10666 INNER JOIN memories m ON m.id = nm.standard_id
10667 WHERE json_extract(m.metadata, '$.governance') IS NOT NULL
10668 ORDER BY nm.namespace ASC",
10669 )?;
10670 let rows = stmt.query_map([], |r| {
10671 let ns: String = r.get(0)?;
10672 let meta_str: String = r.get(1)?;
10673 Ok((ns, meta_str))
10674 })?;
10675
10676 let mut out = Vec::new();
10677 for row in rows.flatten() {
10678 let (ns, meta_str) = row;
10679 let Ok(meta) = serde_json::from_str::<serde_json::Value>(&meta_str) else {
10681 continue;
10682 };
10683 match GovernancePolicy::from_metadata(&meta) {
10687 Some(Ok(policy)) => out.push((ns, policy)),
10688 _ => continue,
10689 }
10690 }
10691 Ok(out)
10692}
10693
10694pub fn count_subscriptions(conn: &Connection) -> Result<usize> {
10698 let count: i64 = conn
10699 .query_row("SELECT COUNT(*) FROM subscriptions", [], |r| r.get(0))
10700 .unwrap_or(0);
10701 Ok(usize::try_from(count.max(0)).unwrap_or(0))
10702}
10703
10704pub fn count_pending_actions_by_status(conn: &Connection, status: &str) -> Result<usize> {
10708 let count: i64 = conn
10709 .query_row(
10710 "SELECT COUNT(*) FROM pending_actions WHERE status = ?1",
10711 params![status],
10712 |r| r.get(0),
10713 )
10714 .unwrap_or(0);
10715 Ok(usize::try_from(count.max(0)).unwrap_or(0))
10716}
10717
10718pub fn sweep_pending_action_timeouts(
10738 conn: &Connection,
10739 global_default_secs: i64,
10740) -> Result<Vec<(String, String)>> {
10741 if global_default_secs <= 0 {
10751 return Ok(Vec::new());
10752 }
10753 let mut stmt = conn.prepare(
10754 "SELECT id, namespace FROM pending_actions
10755 WHERE status = 'pending'
10756 AND (julianday('now') - julianday(requested_at)) * 86400.0
10757 > COALESCE(default_timeout_seconds, ?1)",
10758 )?;
10759 let rows: Vec<(String, String)> = stmt
10760 .query_map(params![global_default_secs], |row| {
10761 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10762 })?
10763 .collect::<rusqlite::Result<Vec<_>>>()?;
10764 if rows.is_empty() {
10765 return Ok(Vec::new());
10766 }
10767
10768 let now = Utc::now().to_rfc3339();
10774 let tx_savepoint = conn.unchecked_transaction()?;
10775 {
10776 let mut update = tx_savepoint.prepare(
10777 "UPDATE pending_actions
10778 SET status = 'expired', expired_at = ?1
10779 WHERE id = ?2 AND status = 'pending'",
10780 )?;
10781 for (id, _) in &rows {
10782 update.execute(params![now, id])?;
10783 }
10784 }
10785 tx_savepoint.commit()?;
10786 for (id, _) in &rows {
10792 if let Ok(Some(pa)) = get_pending_action(conn, id) {
10793 emit_pending_action_event(conn, &pa, "pending_action.timed_out", None);
10794 }
10795 }
10796 Ok(rows)
10797}
10798
10799pub fn doctor_dim_violations(conn: &Connection) -> Result<Option<usize>> {
10831 let has_dim = conn
10832 .prepare("SELECT embedding_dim FROM memories LIMIT 0")
10833 .is_ok();
10834 if !has_dim {
10835 return Ok(None);
10836 }
10837 let n: i64 = conn
10841 .query_row(
10842 "WITH per_ns_modes AS (
10843 SELECT namespace, embedding_dim, COUNT(*) AS c
10844 FROM memories
10845 WHERE embedding IS NOT NULL AND embedding_dim IS NOT NULL
10846 GROUP BY namespace, embedding_dim
10847 ),
10848 ranked AS (
10849 SELECT namespace, embedding_dim,
10850 ROW_NUMBER() OVER (PARTITION BY namespace ORDER BY c DESC) AS rn
10851 FROM per_ns_modes
10852 ),
10853 modes AS (
10854 SELECT namespace, embedding_dim AS modal_dim
10855 FROM ranked WHERE rn = 1
10856 )
10857 SELECT COUNT(*)
10858 FROM memories m
10859 LEFT JOIN modes mo ON mo.namespace = m.namespace
10860 WHERE m.embedding IS NOT NULL
10861 AND (m.embedding_dim IS NULL
10862 OR (mo.modal_dim IS NOT NULL AND m.embedding_dim != mo.modal_dim))",
10863 [],
10864 |r| r.get(0),
10865 )
10866 .unwrap_or(0);
10867 Ok(Some(usize::try_from(n.max(0)).unwrap_or(0)))
10868}
10869
10870pub fn doctor_oldest_pending_age_secs(conn: &Connection) -> Result<Option<i64>> {
10878 let row: Option<String> = conn
10879 .query_row(
10880 "SELECT requested_at FROM pending_actions WHERE status = 'pending'
10881 ORDER BY requested_at ASC LIMIT 1",
10882 [],
10883 |r| r.get(0),
10884 )
10885 .ok();
10886 let Some(ts) = row else {
10887 return Ok(None);
10888 };
10889 let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&ts) else {
10890 return Ok(None);
10891 };
10892 let raw_age = (Utc::now() - parsed.with_timezone(&Utc)).num_seconds();
10901 let age = if raw_age < 0 {
10902 tracing::warn!(
10903 requested_at = %ts,
10904 raw_age_seconds = raw_age,
10905 "pending_actions row has future timestamp; clamping age to 0"
10906 );
10907 0
10908 } else {
10909 raw_age
10910 };
10911 Ok(Some(age))
10912}
10913
10914pub fn doctor_governance_coverage(conn: &Connection) -> Result<(usize, usize)> {
10922 let with_policy: i64 = conn
10923 .query_row(
10924 "SELECT COUNT(*) FROM memories m
10925 INNER JOIN namespace_meta nm ON nm.standard_id = m.id
10926 WHERE json_extract(m.metadata, '$.governance') IS NOT NULL",
10927 [],
10928 |r| r.get(0),
10929 )
10930 .unwrap_or(0);
10931 let total_meta: i64 = conn
10932 .query_row("SELECT COUNT(*) FROM namespace_meta", [], |r| r.get(0))
10933 .unwrap_or(0);
10934 let with = usize::try_from(with_policy.max(0)).unwrap_or(0);
10935 let total = usize::try_from(total_meta.max(0)).unwrap_or(0);
10936 Ok((with, total.saturating_sub(with)))
10937}
10938
10939pub fn doctor_governance_depth_distribution(conn: &Connection) -> Result<Vec<usize>> {
10951 const MAX_DEPTH: usize = 16;
10952 let mut stmt = conn.prepare("SELECT namespace, parent_namespace FROM namespace_meta")?;
10953 let rows = stmt.query_map([], |r| {
10954 Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
10955 })?;
10956 let parent_map: HashMap<String, Option<String>> = rows
10957 .filter_map(rusqlite::Result::ok)
10958 .collect::<HashMap<_, _>>();
10959 let mut hist = vec![0_usize; MAX_DEPTH + 1];
10960 for ns in parent_map.keys() {
10961 let mut depth = 0_usize;
10962 let mut cur = parent_map.get(ns).cloned().flatten();
10963 while let Some(p) = cur {
10964 depth += 1;
10965 if depth >= MAX_DEPTH {
10966 break;
10967 }
10968 cur = parent_map.get(&p).cloned().flatten();
10969 }
10970 let bucket = depth.min(MAX_DEPTH);
10971 hist[bucket] += 1;
10972 }
10973 Ok(hist)
10974}
10975
10976pub fn doctor_webhook_delivery_totals(conn: &Connection) -> Result<(u64, u64)> {
10984 let dispatched: i64 = conn
10985 .query_row(
10986 "SELECT COALESCE(SUM(dispatch_count), 0) FROM subscriptions",
10987 [],
10988 |r| r.get(0),
10989 )
10990 .unwrap_or(0);
10991 let failed: i64 = conn
10992 .query_row(
10993 "SELECT COALESCE(SUM(failure_count), 0) FROM subscriptions",
10994 [],
10995 |r| r.get(0),
10996 )
10997 .unwrap_or(0);
10998 Ok((
10999 u64::try_from(dispatched.max(0)).unwrap_or(0),
11000 u64::try_from(failed.max(0)).unwrap_or(0),
11001 ))
11002}
11003
11004#[derive(Debug, Clone)]
11019pub struct CapabilityExpansionRow {
11020 pub id: String,
11021 pub agent_id: Option<String>,
11022 pub event_type: String,
11023 pub requested_family: Option<String>,
11024 pub granted: bool,
11025 pub attestation_tier: Option<String>,
11026 pub timestamp: String,
11027}
11028
11029pub fn record_capability_expansion(
11041 conn: &Connection,
11042 agent_id: Option<&str>,
11043 family: &str,
11044 granted: bool,
11045 attestation_tier: Option<&str>,
11046) {
11047 let id = uuid::Uuid::new_v4().to_string();
11048 let now = Utc::now().to_rfc3339();
11049 let result = conn.execute(
11050 "INSERT INTO audit_log (id, agent_id, event_type, requested_family, \
11051 granted, attestation_tier, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11052 rusqlite::params![
11053 id,
11054 agent_id,
11055 "capability_expansion",
11056 family,
11057 i32::from(granted),
11058 attestation_tier,
11059 now,
11060 ],
11061 );
11062 if let Err(e) = result {
11063 tracing::warn!(
11064 "audit_log insert failed (capability_expansion / agent={:?} / family={}): {e}",
11065 agent_id,
11066 family,
11067 );
11068 }
11069}
11070
11071pub fn list_capability_expansions(
11074 conn: &Connection,
11075 limit: usize,
11076 agent_filter: Option<&str>,
11077) -> Result<Vec<CapabilityExpansionRow>> {
11078 let n = (limit.min(10_000)) as i64;
11079 let map_row = |r: &rusqlite::Row<'_>| -> rusqlite::Result<CapabilityExpansionRow> {
11080 Ok(CapabilityExpansionRow {
11081 id: r.get(0)?,
11082 agent_id: r.get(1)?,
11083 event_type: r.get(2)?,
11084 requested_family: r.get(3)?,
11085 granted: r.get::<_, i64>(4)? != 0,
11086 attestation_tier: r.get(5)?,
11087 timestamp: r.get(6)?,
11088 })
11089 };
11090 if let Some(a) = agent_filter {
11091 let mut stmt = conn.prepare(
11092 "SELECT id, agent_id, event_type, requested_family, granted, \
11093 attestation_tier, timestamp FROM audit_log \
11094 WHERE event_type = 'capability_expansion' AND agent_id = ?1 \
11095 ORDER BY timestamp DESC LIMIT ?2",
11096 )?;
11097 let rows = stmt.query_map(rusqlite::params![a, n], map_row)?;
11098 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
11099 } else {
11100 let mut stmt = conn.prepare(
11101 "SELECT id, agent_id, event_type, requested_family, granted, \
11102 attestation_tier, timestamp FROM audit_log \
11103 WHERE event_type = 'capability_expansion' \
11104 ORDER BY timestamp DESC LIMIT ?1",
11105 )?;
11106 let rows = stmt.query_map(rusqlite::params![n], map_row)?;
11107 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
11108 }
11109}
11110
11111pub fn doctor_max_sync_skew_secs(conn: &Connection) -> Result<Option<i64>> {
11112 let mut stmt = match conn.prepare(
11113 "SELECT last_seen_at, last_pulled_at FROM sync_state WHERE last_pulled_at IS NOT NULL",
11114 ) {
11115 Ok(s) => s,
11116 Err(_) => return Ok(None),
11117 };
11118 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
11119 let mut max_skew: Option<i64> = None;
11120 for row in rows {
11121 let Ok((seen, pulled)) = row else { continue };
11122 let Ok(s) = chrono::DateTime::parse_from_rfc3339(&seen) else {
11123 continue;
11124 };
11125 let Ok(p) = chrono::DateTime::parse_from_rfc3339(&pulled) else {
11126 continue;
11127 };
11128 let skew = (s.with_timezone(&Utc) - p.with_timezone(&Utc))
11129 .num_seconds()
11130 .abs();
11131 max_skew = Some(max_skew.map_or(skew, |m| m.max(skew)));
11132 }
11133 Ok(max_skew)
11134}
11135
11136pub struct ReflectionDepthRow {
11149 pub namespace: String,
11150 pub depth0: i64,
11151 pub depth1: i64,
11152 pub depth2: i64,
11153 pub depth3_plus: i64,
11154 pub avg_depth: f64,
11155 pub max_depth: i64,
11156 pub total: i64,
11157}
11158
11159pub fn doctor_reflection_depth_distribution(conn: &Connection) -> Result<Vec<ReflectionDepthRow>> {
11172 let mut stmt = conn.prepare(
11177 "SELECT
11178 namespace,
11179 SUM(CASE WHEN reflection_depth = 0 THEN 1 ELSE 0 END),
11180 SUM(CASE WHEN reflection_depth = 1 THEN 1 ELSE 0 END),
11181 SUM(CASE WHEN reflection_depth = 2 THEN 1 ELSE 0 END),
11182 SUM(CASE WHEN reflection_depth >= 3 THEN 1 ELSE 0 END),
11183 AVG(CAST(reflection_depth AS REAL)),
11184 MAX(reflection_depth),
11185 COUNT(*)
11186 FROM memories
11187 GROUP BY namespace
11188 HAVING MAX(reflection_depth) > 0
11189 ORDER BY namespace",
11190 )?;
11191 let rows = stmt.query_map([], |r| {
11192 Ok(ReflectionDepthRow {
11193 namespace: r.get(0)?,
11194 depth0: r.get(1)?,
11195 depth1: r.get(2)?,
11196 depth2: r.get(3)?,
11197 depth3_plus: r.get(4)?,
11198 avg_depth: r.get(5)?,
11199 max_depth: r.get(6)?,
11200 total: r.get(7)?,
11201 })
11202 })?;
11203 let mut out = Vec::new();
11204 for row in rows {
11205 out.push(row?);
11206 }
11207 Ok(out)
11208}
11209
11210pub fn doctor_reflection_depth_exceeded_count(
11226 conn: &Connection,
11227 since_rfc3339: &str,
11228) -> Result<i64> {
11229 let n: i64 = conn
11230 .query_row(
11231 "SELECT COUNT(*) FROM signed_events
11232 WHERE event_type = 'reflection.depth_exceeded'
11233 AND timestamp >= ?1",
11234 params![since_rfc3339],
11235 |r| r.get(0),
11236 )
11237 .unwrap_or(0);
11238 Ok(n)
11239}
11240
11241pub fn doctor_reflection_totals_by_namespace(
11253 conn: &Connection,
11254) -> Result<Vec<(String, i64, i64, i64)>> {
11255 let now = Utc::now();
11256 let last_day_cutoff = (now - chrono::Duration::hours(24)).to_rfc3339();
11257 let cutoff_7d = (now - chrono::Duration::days(7)).to_rfc3339();
11258
11259 let mut stmt = conn.prepare(
11260 "SELECT
11261 namespace,
11262 SUM(CASE WHEN created_at >= ?1 THEN 1 ELSE 0 END),
11263 SUM(CASE WHEN created_at >= ?2 THEN 1 ELSE 0 END),
11264 COUNT(*)
11265 FROM memories
11266 WHERE reflection_depth > 0
11267 GROUP BY namespace
11268 ORDER BY namespace",
11269 )?;
11270 let rows = stmt.query_map(params![last_day_cutoff, cutoff_7d], |r| {
11271 Ok((
11272 r.get::<_, String>(0)?,
11273 r.get::<_, i64>(1)?,
11274 r.get::<_, i64>(2)?,
11275 r.get::<_, i64>(3)?,
11276 ))
11277 })?;
11278 let mut out = Vec::new();
11279 for row in rows {
11280 out.push(row?);
11281 }
11282 Ok(out)
11283}
11284
11285#[cfg(test)]
11286mod tests {
11287 use super::*;
11288 use crate::models::{MID_TTL_EXTEND_SECS, Memory, SHORT_TTL_EXTEND_SECS, Tier};
11289
11290 fn test_db() -> Connection {
11291 open(std::path::Path::new(":memory:")).unwrap()
11292 }
11293
11294 fn insert_memory_at(conn: &Connection, id: &str, updated_at: &str) {
11299 conn.execute(
11300 "INSERT INTO memories (id, tier, namespace, title, content, created_at, updated_at) \
11301 VALUES (?1, 'mid', 'ns', ?1, 'content body', ?2, ?2)",
11302 params![id, updated_at],
11303 )
11304 .expect("insert memory row");
11305 }
11306
11307 #[test]
11308 fn memories_updated_since_sargable_split_none_and_some_paths() {
11309 let conn = test_db();
11315 let t1 = "2026-01-01T00:00:00+00:00";
11316 let t2 = "2026-01-02T00:00:00+00:00";
11317 let t3 = "2026-01-03T00:00:00+00:00";
11318 insert_memory_at(&conn, "b", t2);
11320 insert_memory_at(&conn, "c", t3);
11321 insert_memory_at(&conn, "a", t1);
11322
11323 let all = memories_updated_since(&conn, None, 100).expect("none path");
11325 let ids: Vec<&str> = all.iter().map(|m| m.id.as_str()).collect();
11326 assert_eq!(
11327 ids,
11328 vec!["a", "b", "c"],
11329 "None path: all rows ASC by updated_at"
11330 );
11331
11332 let after_t1 = memories_updated_since(&conn, Some(t1), 100).expect("some path");
11334 let ids: Vec<&str> = after_t1.iter().map(|m| m.id.as_str()).collect();
11335 assert_eq!(
11336 ids,
11337 vec!["b", "c"],
11338 "Some(t1): strict > excludes the boundary row"
11339 );
11340
11341 let after_t3 = memories_updated_since(&conn, Some(t3), 100).expect("some path empty");
11343 assert!(
11344 after_t3.is_empty(),
11345 "Some(t3): nothing strictly newer than the max"
11346 );
11347
11348 let one = memories_updated_since(&conn, Some(t1), 1).expect("some path limited");
11350 let ids: Vec<&str> = one.iter().map(|m| m.id.as_str()).collect();
11351 assert_eq!(
11352 ids,
11353 vec!["b"],
11354 "Some(t1) LIMIT 1: oldest row strictly after t1"
11355 );
11356 }
11357
11358 #[test]
11359 fn memories_updated_since_uses_updated_at_index() {
11360 let conn = test_db();
11364 let mut stmt = conn
11365 .prepare(
11366 "EXPLAIN QUERY PLAN \
11367 SELECT id FROM memories WHERE updated_at > ?1 \
11368 ORDER BY updated_at ASC LIMIT ?2",
11369 )
11370 .expect("prepare explain");
11371 let plan: String = stmt
11372 .query_map(params!["2026-01-01T00:00:00+00:00", 10_i64], |r| {
11373 r.get::<_, String>(3)
11374 })
11375 .expect("explain rows")
11376 .map(|r| r.expect("explain detail"))
11377 .collect::<Vec<_>>()
11378 .join(" | ");
11379 assert!(
11380 plan.contains("idx_memories_updated_at"),
11381 "sargable catchup query must use idx_memories_updated_at; plan was: {plan}"
11382 );
11383 }
11384
11385 #[test]
11386 fn perf_8_hierarchy_in_clause_cache_hits_on_repeat() {
11387 hierarchy_cache_clear_for_tests();
11393 let ns = Some("alphaone/team/alice");
11394 let (a, active_a) = hierarchy_in_clause(ns);
11395 let (b, active_b) = hierarchy_in_clause(ns);
11396 assert!(active_a && active_b);
11397 assert_eq!(
11398 a, b,
11399 "PERF-8: cached hierarchy_in_clause result drift on second lookup",
11400 );
11401 assert!(
11402 a.expect("non-None fragment")
11403 .contains("AND m.namespace IN ("),
11404 "PERF-8: fragment shape regressed",
11405 );
11406 }
11407
11408 #[test]
11409 fn perf_8_hierarchy_cache_handles_non_hierarchical_ns() {
11410 hierarchy_cache_clear_for_tests();
11414 let (frag, active) = hierarchy_in_clause(Some("global"));
11415 assert_eq!(frag, None);
11416 assert!(!active);
11417 }
11418
11419 #[test]
11420 fn perf_8_hierarchy_cache_bounded_under_pressure() {
11421 hierarchy_cache_clear_for_tests();
11424 for i in 0..(HIERARCHY_CACHE_MAX * 2) {
11425 let ns = format!("tenant{i}/sub");
11426 let _ = hierarchy_in_clause(Some(&ns));
11427 }
11428 let cache_len = hierarchy_cache().lock().unwrap().len();
11429 assert!(
11430 cache_len <= HIERARCHY_CACHE_MAX,
11431 "PERF-8: hierarchy cache grew unbounded: {cache_len} > {HIERARCHY_CACHE_MAX}",
11432 );
11433 }
11434
11435 #[test]
11446 fn get_many_batches_and_handles_empty_missing_and_chunked_inputs_981() {
11447 let conn = test_db();
11448 let m1 = make_memory("alpha", "ns/a", Tier::Long, 5);
11450 let m2 = make_memory("beta", "ns/b", Tier::Long, 5);
11451 let m3 = make_memory("gamma", "ns/c", Tier::Long, 5);
11452 insert(&conn, &m1).unwrap();
11453 insert(&conn, &m2).unwrap();
11454 insert(&conn, &m3).unwrap();
11455
11456 assert!(get_many(&conn, &[]).unwrap().is_empty());
11458
11459 let ids = vec![m1.id.clone(), m2.id.clone()];
11461 let got = get_many(&conn, &ids).unwrap();
11462 assert_eq!(got.len(), 2);
11463 assert!(got.contains_key(&m1.id));
11464 assert!(got.contains_key(&m2.id));
11465 assert!(!got.contains_key(&m3.id));
11466
11467 let mixed = vec![m1.id.clone(), "nope-not-a-real-id".to_string()];
11469 let got = get_many(&conn, &mixed).unwrap();
11470 assert_eq!(got.len(), 1);
11471 assert!(got.contains_key(&m1.id));
11472
11473 let reversed = vec![m3.id.clone(), m2.id.clone(), m1.id.clone()];
11475 let got = get_many(&conn, &reversed).unwrap();
11476 assert_eq!(got.len(), 3);
11477 for id in &reversed {
11478 assert!(got.contains_key(id), "id {id} missing from set-fetch");
11479 }
11480
11481 let mut bulk: Vec<Memory> = Vec::with_capacity(750);
11483 let mut bulk_ids: Vec<String> = Vec::with_capacity(750);
11484 for i in 0..750 {
11485 let m = make_memory(&format!("bulk-{i}"), "ns/bulk", Tier::Long, 1);
11486 insert(&conn, &m).unwrap();
11487 bulk_ids.push(m.id.clone());
11488 bulk.push(m);
11489 }
11490 let got = get_many(&conn, &bulk_ids).unwrap();
11491 assert_eq!(
11492 got.len(),
11493 750,
11494 "chunked fetch >500 must still return every row",
11495 );
11496 }
11497
11498 fn make_memory(title: &str, ns: &str, tier: Tier, priority: i32) -> Memory {
11499 let now = chrono::Utc::now().to_rfc3339();
11500 Memory {
11501 id: uuid::Uuid::new_v4().to_string(),
11502 tier: tier.clone(),
11503 namespace: ns.to_string(),
11504 title: title.to_string(),
11505 content: format!("Content for {title}"),
11506 tags: vec![],
11507 priority,
11508 confidence: 1.0,
11509 source: "test".to_string(),
11510 access_count: 0,
11511 created_at: now.clone(),
11512 updated_at: now,
11513 last_accessed_at: None,
11514 expires_at: tier
11515 .default_ttl_secs()
11516 .map(|s| (chrono::Utc::now() + chrono::Duration::seconds(s)).to_rfc3339()),
11517 metadata: serde_json::json!({}),
11518 reflection_depth: 0,
11519 memory_kind: crate::models::MemoryKind::Observation,
11520 entity_id: None,
11521 persona_version: None,
11522 citations: Vec::new(),
11523 source_uri: None,
11524 source_span: None,
11525 confidence_source: ConfidenceSource::CallerProvided,
11526 confidence_signals: None,
11527 confidence_decayed_at: None,
11528 version: 1,
11529 }
11530 }
11531
11532 fn mem_with_scope(ns: &str, scope: Option<&str>) -> Memory {
11533 let mut m = make_memory("scoped", ns, Tier::Long, 5);
11534 if let Some(s) = scope {
11535 let mut map = serde_json::Map::new();
11536 map.insert(
11537 crate::META_KEY_SCOPE.to_string(),
11538 serde_json::Value::String(s.to_string()),
11539 );
11540 m.metadata = serde_json::Value::Object(map);
11541 }
11542 m
11543 }
11544
11545 #[test]
11551 fn is_visible_scope_matrix_covers_every_arm() {
11552 let unfiltered = (None, None, None, None);
11554 assert!(super::is_visible(
11555 &mem_with_scope("acme/eng/web", Some("private")),
11556 &unfiltered
11557 ));
11558
11559 let prefixes = super::compute_visibility_prefixes(Some("acme/eng/web/team"));
11562 assert_eq!(
11563 prefixes,
11564 (
11565 Some("acme/eng/web/team".to_string()),
11566 Some("acme/eng/web".to_string()),
11567 Some("acme/eng".to_string()),
11568 Some("acme".to_string()),
11569 )
11570 );
11571
11572 assert!(super::is_visible(
11574 &mem_with_scope("zzz/other", Some("collective")),
11575 &prefixes
11576 ));
11577
11578 assert!(super::is_visible(
11580 &mem_with_scope("acme/eng/web/team", Some("private")),
11581 &prefixes
11582 ));
11583 assert!(!super::is_visible(
11584 &mem_with_scope("acme/eng/web", Some("private")),
11585 &prefixes
11586 ));
11587
11588 assert!(super::is_visible(
11590 &mem_with_scope("acme/eng/web/team", None),
11591 &prefixes
11592 ));
11593 assert!(!super::is_visible(
11594 &mem_with_scope("acme/other", None),
11595 &prefixes
11596 ));
11597
11598 assert!(super::is_visible(
11600 &mem_with_scope("acme/eng/web", Some("team")),
11601 &prefixes
11602 ));
11603 assert!(super::is_visible(
11604 &mem_with_scope("acme/eng/web/team/v2", Some("team")),
11605 &prefixes
11606 ));
11607 assert!(!super::is_visible(
11608 &mem_with_scope("acme/eng/api", Some("team")),
11609 &prefixes
11610 ));
11611
11612 assert!(super::is_visible(
11614 &mem_with_scope("acme/eng", Some("unit")),
11615 &prefixes
11616 ));
11617 assert!(!super::is_visible(
11618 &mem_with_scope("acme/sales", Some("unit")),
11619 &prefixes
11620 ));
11621
11622 assert!(super::is_visible(
11624 &mem_with_scope("acme", Some("org")),
11625 &prefixes
11626 ));
11627 assert!(!super::is_visible(
11628 &mem_with_scope("globex", Some("org")),
11629 &prefixes
11630 ));
11631
11632 let shallow = super::compute_visibility_prefixes(Some("acme"));
11635 assert_eq!(shallow.3, None);
11636 assert!(!super::is_visible(
11637 &mem_with_scope("acme", Some("org")),
11638 &shallow
11639 ));
11640
11641 assert!(!super::is_visible(
11643 &mem_with_scope("acme/eng/web/team", Some("definitely-not-a-scope")),
11644 &prefixes
11645 ));
11646
11647 assert_eq!(
11649 super::compute_visibility_prefixes(None),
11650 (None, None, None, None)
11651 );
11652 }
11653
11654 #[test]
11655 fn open_creates_schema() {
11656 let conn = test_db();
11657 let count: i64 = conn
11658 .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
11659 .unwrap();
11660 assert_eq!(count, 0);
11661 }
11662
11663 #[test]
11664 fn insert_and_get() {
11665 let conn = test_db();
11666 let mem = make_memory("Test insert", "test", Tier::Long, 5);
11667 let id = insert(&conn, &mem).unwrap();
11668 let got = get(&conn, &id).unwrap().unwrap();
11669 assert_eq!(got.title, "Test insert");
11670 assert_eq!(got.namespace, "test");
11671 assert_eq!(got.priority, 5);
11672 }
11673
11674 #[test]
11675 fn get_nonexistent() {
11676 let conn = test_db();
11677 let got = get(&conn, "nonexistent-id").unwrap();
11678 assert!(got.is_none());
11679 }
11680
11681 fn ttl_gap_secs(created_at: &str, expires_at: &str) -> i64 {
11688 let base = chrono::DateTime::parse_from_rfc3339(created_at).unwrap();
11689 let exp = chrono::DateTime::parse_from_rfc3339(expires_at).unwrap();
11690 (exp - base).num_seconds()
11691 }
11692
11693 #[test]
11694 fn insert_backfills_mid_expiry_when_none() {
11695 let conn = test_db();
11696 let mut mem = make_memory("mid none", "test", Tier::Mid, 5);
11697 mem.expires_at = None;
11698 let id = insert(&conn, &mem).unwrap();
11699 let got = get(&conn, &id).unwrap().unwrap();
11700 let exp = got.expires_at.expect("mid must not land immortal");
11701 assert_eq!(ttl_gap_secs(&got.created_at, &exp), crate::SECS_PER_WEEK);
11702 }
11703
11704 #[test]
11705 fn insert_backfills_short_expiry_when_none() {
11706 let conn = test_db();
11707 let mut mem = make_memory("short none", "test", Tier::Short, 5);
11708 mem.expires_at = None;
11709 let id = insert(&conn, &mem).unwrap();
11710 let got = get(&conn, &id).unwrap().unwrap();
11711 let exp = got.expires_at.expect("short must not land immortal");
11712 assert_eq!(
11713 ttl_gap_secs(&got.created_at, &exp),
11714 6 * crate::SECS_PER_HOUR
11715 );
11716 }
11717
11718 #[test]
11719 fn insert_leaves_long_expiry_none() {
11720 let conn = test_db();
11721 let mut mem = make_memory("long none", "test", Tier::Long, 5);
11722 mem.expires_at = None;
11723 let id = insert(&conn, &mem).unwrap();
11724 let got = get(&conn, &id).unwrap().unwrap();
11725 assert!(got.expires_at.is_none(), "long has no TTL — must stay NULL");
11726 }
11727
11728 #[test]
11729 fn insert_preserves_explicit_expiry() {
11730 let conn = test_db();
11731 let explicit = "2027-06-15T12:00:00+00:00".to_string();
11732 let mut mem = make_memory("mid explicit", "test", Tier::Mid, 5);
11733 mem.expires_at = Some(explicit.clone());
11734 let id = insert(&conn, &mem).unwrap();
11735 let got = get(&conn, &id).unwrap().unwrap();
11736 assert_eq!(got.expires_at, Some(explicit));
11737 }
11738
11739 #[test]
11740 fn insert_with_conflict_backfills_mid_expiry_when_none() {
11741 let conn = test_db();
11742 let mut mem = make_memory("conflict mid", "test", Tier::Mid, 5);
11743 mem.expires_at = None;
11744 let id = insert_with_conflict(&conn, &mem, ConflictMode::Merge).unwrap();
11745 let got = get(&conn, &id).unwrap().unwrap();
11746 let exp = got.expires_at.expect("mid must not land immortal");
11747 assert_eq!(ttl_gap_secs(&got.created_at, &exp), crate::SECS_PER_WEEK);
11748 }
11749
11750 #[test]
11751 fn insert_if_newer_backfills_mid_expiry_when_none() {
11752 let conn = test_db();
11753 let mut mem = make_memory("newer mid", "test", Tier::Mid, 5);
11754 mem.expires_at = None;
11755 let id = insert_if_newer(&conn, &mem).unwrap();
11756 let got = get(&conn, &id).unwrap().unwrap();
11757 let exp = got.expires_at.expect("mid must not land immortal");
11758 assert_eq!(ttl_gap_secs(&got.created_at, &exp), crate::SECS_PER_WEEK);
11759 }
11760
11761 #[test]
11762 fn consolidate_backfills_mid_expiry() {
11763 let conn = test_db();
11764 let a = make_memory("src a", "test", Tier::Mid, 5);
11765 let b = make_memory("src b", "test", Tier::Mid, 5);
11766 let id_a = insert(&conn, &a).unwrap();
11767 let id_b = insert(&conn, &b).unwrap();
11768 let new_id = consolidate(
11769 &conn,
11770 &[id_a, id_b],
11771 "merged",
11772 "summary body",
11773 "test",
11774 &Tier::Mid,
11775 "test",
11776 "agent-x",
11777 )
11778 .unwrap();
11779 let got = get(&conn, &new_id).unwrap().unwrap();
11780 let exp = got
11781 .expires_at
11782 .expect("consolidated mid must not land immortal");
11783 assert_eq!(ttl_gap_secs(&got.created_at, &exp), crate::SECS_PER_WEEK);
11784 }
11785
11786 #[test]
11787 fn update_partial_fields() {
11788 let conn = test_db();
11789 let mem = make_memory("Original", "test", Tier::Mid, 5);
11790 let id = insert(&conn, &mem).unwrap();
11791
11792 let (found, content_changed) = update(
11793 &conn,
11794 &id,
11795 Some("Updated Title"),
11796 None,
11797 None,
11798 None,
11799 None,
11800 Some(9),
11801 None,
11802 None,
11803 None,
11804 )
11805 .unwrap();
11806 assert!(found);
11807 assert!(content_changed); let got = get(&conn, &id).unwrap().unwrap();
11810 assert_eq!(got.title, "Updated Title");
11811 assert_eq!(got.priority, 9);
11812 assert_eq!(got.content, mem.content); }
11814
11815 #[test]
11816 fn update_content_changed_flag() {
11817 let conn = test_db();
11818 let mem = make_memory("Stable", "test", Tier::Mid, 5);
11819 let id = insert(&conn, &mem).unwrap();
11820
11821 let (found, content_changed) = update(
11823 &conn,
11824 &id,
11825 None,
11826 None,
11827 None,
11828 None,
11829 None,
11830 Some(8),
11831 None,
11832 None,
11833 None,
11834 )
11835 .unwrap();
11836 assert!(found);
11837 assert!(!content_changed);
11838
11839 let (found, content_changed) = update(
11841 &conn,
11842 &id,
11843 None,
11844 Some("New content"),
11845 None,
11846 None,
11847 None,
11848 None,
11849 None,
11850 None,
11851 None,
11852 )
11853 .unwrap();
11854 assert!(found);
11855 assert!(content_changed);
11856 }
11857
11858 #[test]
11859 fn update_nonexistent_returns_false() {
11860 let conn = test_db();
11861 let (found, _) = update(
11862 &conn,
11863 "bad-id",
11864 Some("New"),
11865 None,
11866 None,
11867 None,
11868 None,
11869 None,
11870 None,
11871 None,
11872 None,
11873 )
11874 .unwrap();
11875 assert!(!found);
11876 }
11877
11878 #[test]
11879 fn update_tier_downgrade_protection() {
11880 let conn = test_db();
11881 let mem = make_memory("Permanent", "test", Tier::Long, 9);
11883 let id = insert(&conn, &mem).unwrap();
11884
11885 let (found, _) = update(
11886 &conn,
11887 &id,
11888 None,
11889 None,
11890 Some(&Tier::Short),
11891 None,
11892 None,
11893 None,
11894 None,
11895 None,
11896 None,
11897 )
11898 .unwrap();
11899 assert!(found);
11900 let got = get(&conn, &id).unwrap().unwrap();
11901 assert_eq!(got.tier, Tier::Long); let mem2 = make_memory("Working", "test", Tier::Mid, 5);
11905 let id2 = insert(&conn, &mem2).unwrap();
11906
11907 let (found, _) = update(
11908 &conn,
11909 &id2,
11910 None,
11911 None,
11912 Some(&Tier::Short),
11913 None,
11914 None,
11915 None,
11916 None,
11917 None,
11918 None,
11919 )
11920 .unwrap();
11921 assert!(found);
11922 let got2 = get(&conn, &id2).unwrap().unwrap();
11923 assert_eq!(got2.tier, Tier::Mid); let (found, _) = update(
11927 &conn,
11928 &id2,
11929 None,
11930 None,
11931 Some(&Tier::Long),
11932 None,
11933 None,
11934 None,
11935 None,
11936 None,
11937 None,
11938 )
11939 .unwrap();
11940 assert!(found);
11941 let got3 = get(&conn, &id2).unwrap().unwrap();
11942 assert_eq!(got3.tier, Tier::Long); }
11944
11945 #[test]
11946 fn update_title_collision_returns_error() {
11947 let conn = test_db();
11948 let mem_a = make_memory("Alpha", "test", Tier::Mid, 5);
11949 let mem_b = make_memory("Beta", "test", Tier::Mid, 5);
11950 let id_a = insert(&conn, &mem_a).unwrap();
11951 let _id_b = insert(&conn, &mem_b).unwrap();
11952
11953 let result = update(
11955 &conn,
11956 &id_a,
11957 Some("Beta"),
11958 None,
11959 None,
11960 None,
11961 None,
11962 None,
11963 None,
11964 None,
11965 None,
11966 );
11967 assert!(result.is_err());
11968 let err = result.unwrap_err().to_string();
11969 assert!(err.contains("already exists in namespace"));
11970 }
11971
11972 #[test]
11973 fn delete_existing() {
11974 let conn = test_db();
11975 let mem = make_memory("To delete", "test", Tier::Short, 3);
11976 let id = insert(&conn, &mem).unwrap();
11977 assert!(delete(&conn, &id).unwrap());
11978 assert!(get(&conn, &id).unwrap().is_none());
11979 }
11980
11981 #[test]
11982 fn delete_nonexistent() {
11983 let conn = test_db();
11984 assert!(!delete(&conn, "bad-id").unwrap());
11985 }
11986
11987 #[test]
11988 fn list_with_namespace_filter() {
11989 let conn = test_db();
11990 insert(&conn, &make_memory("A", "ns1", Tier::Long, 5)).unwrap();
11991 insert(&conn, &make_memory("B", "ns2", Tier::Long, 5)).unwrap();
11992 insert(&conn, &make_memory("C", "ns1", Tier::Long, 5)).unwrap();
11993
11994 let results = list(
11995 &conn,
11996 Some("ns1"),
11997 None,
11998 100,
11999 0,
12000 None,
12001 None,
12002 None,
12003 None,
12004 None,
12005 )
12006 .unwrap();
12007 assert_eq!(results.len(), 2);
12008 }
12009
12010 #[test]
12011 fn list_with_tier_filter() {
12012 let conn = test_db();
12013 insert(&conn, &make_memory("Long", "test", Tier::Long, 5)).unwrap();
12014 insert(&conn, &make_memory("Mid", "test", Tier::Mid, 5)).unwrap();
12015
12016 let results = list(
12017 &conn,
12018 None,
12019 Some(&Tier::Long),
12020 100,
12021 0,
12022 None,
12023 None,
12024 None,
12025 None,
12026 None,
12027 )
12028 .unwrap();
12029 assert_eq!(results.len(), 1);
12030 assert_eq!(results[0].title, "Long");
12031 }
12032
12033 #[test]
12034 fn list_with_limit() {
12035 let conn = test_db();
12036 for i in 0..5 {
12037 insert(
12038 &conn,
12039 &make_memory(&format!("Mem {i}"), "test", Tier::Long, 5),
12040 )
12041 .unwrap();
12042 }
12043 let results = list(&conn, None, None, 3, 0, None, None, None, None, None).unwrap();
12044 assert_eq!(results.len(), 3);
12045 }
12046
12047 #[test]
12048 fn search_keyword_match() {
12049 let conn = test_db();
12050 insert(
12051 &conn,
12052 &make_memory("PostgreSQL config", "test", Tier::Long, 5),
12053 )
12054 .unwrap();
12055 insert(&conn, &make_memory("Redis cache", "test", Tier::Long, 5)).unwrap();
12056
12057 let results = search(
12058 &conn,
12059 "PostgreSQL",
12060 None,
12061 None,
12062 10,
12063 None,
12064 None,
12065 None,
12066 None,
12067 None,
12068 None,
12069 false,
12070 )
12071 .unwrap();
12072 assert_eq!(results.len(), 1);
12073 assert!(results[0].title.contains("PostgreSQL"));
12074 }
12075
12076 #[test]
12077 fn search_no_match() {
12078 let conn = test_db();
12079 insert(&conn, &make_memory("PostgreSQL", "test", Tier::Long, 5)).unwrap();
12080 let results = search(
12081 &conn,
12082 "nonexistent_term_xyz",
12083 None,
12084 None,
12085 10,
12086 None,
12087 None,
12088 None,
12089 None,
12090 None,
12091 None,
12092 false,
12093 )
12094 .unwrap();
12095 assert_eq!(results.len(), 0);
12096 }
12097
12098 #[test]
12099 fn recall_returns_scored() {
12100 let conn = test_db();
12101 insert(
12102 &conn,
12103 &make_memory("Rust programming language", "test", Tier::Long, 8),
12104 )
12105 .unwrap();
12106 insert(
12107 &conn,
12108 &make_memory("Python scripting", "test", Tier::Long, 5),
12109 )
12110 .unwrap();
12111
12112 let (results, _tokens) = recall(
12113 &conn,
12114 "Rust programming",
12115 None,
12116 10,
12117 None,
12118 None,
12119 None,
12120 SHORT_TTL_EXTEND_SECS,
12121 MID_TTL_EXTEND_SECS,
12122 None,
12123 None,
12124 false,
12125 None,
12126 )
12127 .unwrap();
12128 assert!(!results.is_empty());
12129 let (mem, score) = &results[0];
12131 assert!(mem.title.contains("Rust"));
12132 assert!(*score > 0.0);
12133 }
12134
12135 #[test]
12136 fn recall_empty_context() {
12137 let conn = test_db();
12138 insert(&conn, &make_memory("Test", "test", Tier::Long, 5)).unwrap();
12139 let results = recall(
12141 &conn,
12142 "",
12143 None,
12144 10,
12145 None,
12146 None,
12147 None,
12148 SHORT_TTL_EXTEND_SECS,
12149 MID_TTL_EXTEND_SECS,
12150 None,
12151 None,
12152 false,
12153 None,
12154 );
12155 assert!(results.is_ok() || results.is_err());
12157 }
12158
12159 #[test]
12160 fn touch_increments_access_count() {
12161 let conn = test_db();
12162 let mem = make_memory("Touchable", "test", Tier::Mid, 5);
12163 let id = insert(&conn, &mem).unwrap();
12164 assert_eq!(get(&conn, &id).unwrap().unwrap().access_count, 0);
12165
12166 touch(&conn, &id, SHORT_TTL_EXTEND_SECS, MID_TTL_EXTEND_SECS).unwrap();
12167 assert_eq!(get(&conn, &id).unwrap().unwrap().access_count, 1);
12168
12169 touch(&conn, &id, SHORT_TTL_EXTEND_SECS, MID_TTL_EXTEND_SECS).unwrap();
12170 assert_eq!(get(&conn, &id).unwrap().unwrap().access_count, 2);
12171 }
12172
12173 #[test]
12174 fn find_contradictions_similar_titles() {
12175 let conn = test_db();
12176 insert(
12177 &conn,
12178 &make_memory("Database is PostgreSQL", "infra", Tier::Long, 8),
12179 )
12180 .unwrap();
12181 insert(
12182 &conn,
12183 &make_memory("Database is MySQL", "infra", Tier::Long, 5),
12184 )
12185 .unwrap();
12186
12187 let contradictions = find_contradictions(&conn, "Database is PostgreSQL", "infra").unwrap();
12188 assert!(!contradictions.is_empty());
12189 }
12190
12191 #[test]
12201 fn find_contradictions_disjoint_topics_no_false_positives_1320() {
12202 let conn = test_db();
12203 insert(
12204 &conn,
12205 &make_memory("Tomatoes are red fruit", "v1-p5-disjoint", Tier::Long, 5),
12206 )
12207 .unwrap();
12208 insert(
12209 &conn,
12210 &make_memory(
12211 "Moon landing happened in 1969",
12212 "v1-p5-disjoint",
12213 Tier::Long,
12214 5,
12215 ),
12216 )
12217 .unwrap();
12218 insert(
12219 &conn,
12220 &make_memory(
12221 "Retrieval-augmented generation works by combining recall with synthesis",
12222 "v1-p5-disjoint",
12223 Tier::Long,
12224 5,
12225 ),
12226 )
12227 .unwrap();
12228
12229 let hits = find_contradictions(&conn, "Tomatoes are red fruit", "v1-p5-disjoint").unwrap();
12231 assert!(
12232 hits.iter().all(|m| m.title == "Tomatoes are red fruit"),
12233 "tomato seed leaked false positives: {:?}",
12234 hits.iter().map(|m| m.title.as_str()).collect::<Vec<_>>(),
12235 );
12236
12237 let hits =
12239 find_contradictions(&conn, "Moon landing happened in 1969", "v1-p5-disjoint").unwrap();
12240 assert!(
12241 hits.iter()
12242 .all(|m| m.title == "Moon landing happened in 1969"),
12243 "moon-landing seed leaked false positives: {:?}",
12244 hits.iter().map(|m| m.title.as_str()).collect::<Vec<_>>(),
12245 );
12246
12247 let hits = find_contradictions(
12249 &conn,
12250 "Retrieval-augmented generation works by combining recall with synthesis",
12251 "v1-p5-disjoint",
12252 )
12253 .unwrap();
12254 assert!(
12255 hits.iter().all(|m| m.title.starts_with("Retrieval")),
12256 "retrieval seed leaked false positives: {:?}",
12257 hits.iter().map(|m| m.title.as_str()).collect::<Vec<_>>(),
12258 );
12259 }
12260
12261 #[test]
12267 fn find_contradictions_pure_stopword_seed_returns_empty_1320() {
12268 let conn = test_db();
12269 insert(
12270 &conn,
12271 &make_memory(
12272 "The thing is the other thing",
12273 "v1-p5-stopword",
12274 Tier::Long,
12275 5,
12276 ),
12277 )
12278 .unwrap();
12279 let hits = find_contradictions(&conn, "the is a", "v1-p5-stopword").unwrap();
12280 assert!(
12281 hits.is_empty(),
12282 "pure-stopword seed pulled candidates: {:?}",
12283 hits.iter().map(|m| m.title.as_str()).collect::<Vec<_>>(),
12284 );
12285 }
12286
12287 #[test]
12294 fn find_contradictions_similar_titles_still_caught_1320() {
12295 let conn = test_db();
12296 insert(
12297 &conn,
12298 &make_memory("Database is PostgreSQL", "v1-p5-positive", Tier::Long, 8),
12299 )
12300 .unwrap();
12301 insert(
12302 &conn,
12303 &make_memory("Database is MySQL", "v1-p5-positive", Tier::Long, 5),
12304 )
12305 .unwrap();
12306 let hits = find_contradictions(&conn, "Database is PostgreSQL", "v1-p5-positive").unwrap();
12307 let titles: Vec<&str> = hits.iter().map(|m| m.title.as_str()).collect();
12308 assert!(
12309 titles.contains(&"Database is MySQL"),
12310 "similar-title detection regressed: {titles:?}",
12311 );
12312 }
12313
12314 #[test]
12315 fn contradiction_title_jaccard_floor_pinned_1320() {
12316 assert!(
12322 (CONTRADICTION_TITLE_JACCARD_FLOOR - 0.30).abs() < f32::EPSILON,
12323 "floor drifted: {CONTRADICTION_TITLE_JACCARD_FLOOR}",
12324 );
12325 }
12326
12327 #[test]
12328 fn contradiction_title_tokens_strips_stopwords_and_lowercases_1320() {
12329 let toks = contradiction_title_tokens("The Database Is PostgreSQL");
12330 assert!(toks.contains("database"));
12331 assert!(toks.contains("postgresql"));
12332 assert!(!toks.contains("the"));
12333 assert!(!toks.contains("is"));
12334 }
12335
12336 #[test]
12337 fn create_and_get_links() {
12338 let conn = test_db();
12339 let id1 = insert(&conn, &make_memory("Memory A", "test", Tier::Long, 5)).unwrap();
12340 let id2 = insert(&conn, &make_memory("Memory B", "test", Tier::Long, 5)).unwrap();
12341
12342 create_link(&conn, &id1, &id2, "related_to").unwrap();
12343 let links = get_links(&conn, &id1).unwrap();
12344 assert_eq!(links.len(), 1);
12345 assert_eq!(
12346 links[0].relation,
12347 crate::models::MemoryLinkRelation::RelatedTo
12348 );
12349 }
12350
12351 #[test]
12352 fn consolidate_merges_memories() {
12353 let conn = test_db();
12354 let id1 = insert(&conn, &make_memory("Part 1", "test", Tier::Mid, 5)).unwrap();
12355 let id2 = insert(&conn, &make_memory("Part 2", "test", Tier::Mid, 5)).unwrap();
12356
12357 let new_id = consolidate(
12358 &conn,
12359 &[id1.clone(), id2.clone()],
12360 "Combined",
12361 "Part 1 + Part 2",
12362 "test",
12363 &Tier::Long,
12364 "test",
12365 "test-consolidator",
12366 )
12367 .unwrap();
12368 assert!(get(&conn, &id1).unwrap().is_none());
12370 assert!(get(&conn, &id2).unwrap().is_none());
12371 let combined = get(&conn, &new_id).unwrap().unwrap();
12373 assert_eq!(combined.title, "Combined");
12374 assert_eq!(combined.tier, Tier::Long);
12375 }
12376
12377 #[test]
12378 fn stats_counts() {
12379 let conn = test_db();
12380 let path = std::path::Path::new(":memory:");
12381 insert(&conn, &make_memory("A", "ns1", Tier::Long, 5)).unwrap();
12382 insert(&conn, &make_memory("B", "ns1", Tier::Mid, 5)).unwrap();
12383 insert(&conn, &make_memory("C", "ns2", Tier::Short, 5)).unwrap();
12384
12385 let s = stats(&conn, path).unwrap();
12386 assert_eq!(s.total, 3);
12387 }
12388
12389 #[test]
12390 fn gc_removes_expired() {
12391 let conn = test_db();
12392 let mut mem = make_memory("Expired", "test", Tier::Short, 5);
12393 mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string()); insert(&conn, &mem).unwrap();
12395
12396 let removed = gc(&conn, false).unwrap();
12397 assert_eq!(removed, 1);
12398 }
12399
12400 #[test]
12401 fn gc_preserves_long_term() {
12402 let conn = test_db();
12403 insert(&conn, &make_memory("Permanent", "test", Tier::Long, 5)).unwrap();
12404 let removed = gc(&conn, false).unwrap();
12405 assert_eq!(removed, 0);
12406 }
12407
12408 #[test]
12409 fn gc_archives_before_delete() {
12410 let conn = test_db();
12411 let mut mem = make_memory("Archivable", "test", Tier::Short, 5);
12412 mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
12413 insert(&conn, &mem).unwrap();
12414
12415 let removed = gc(&conn, true).unwrap();
12416 assert_eq!(removed, 1);
12417
12418 let archived = list_archived(&conn, None, 10, 0).unwrap();
12420 assert_eq!(archived.len(), 1);
12421 assert_eq!(archived[0]["title"], "Archivable");
12422 assert_eq!(archived[0]["archive_reason"], "ttl_expired");
12423 }
12424
12425 #[test]
12426 fn restore_archived_memory() {
12427 let conn = test_db();
12432 let mut mem = make_memory("Restorable", "test", Tier::Short, 5);
12433 let original_expiry = "2020-01-01T00:00:00+00:00".to_string();
12434 mem.expires_at = Some(original_expiry.clone());
12435 let id = insert(&conn, &mem).unwrap();
12436
12437 gc(&conn, true).unwrap();
12438 assert!(get(&conn, &id).unwrap().is_none()); let restored = restore_archived(&conn, &id).unwrap();
12441 assert!(restored);
12442
12443 let got = get(&conn, &id).unwrap().unwrap();
12444 assert_eq!(got.title, "Restorable");
12445 assert_eq!(
12446 got.tier.as_str(),
12447 Tier::Short.as_str(),
12448 "G5: restore must preserve the original tier"
12449 );
12450 assert_eq!(
12451 got.expires_at,
12452 Some(original_expiry),
12453 "G5: restore must preserve the original expires_at"
12454 );
12455 }
12456
12457 #[test]
12458 fn purge_archive_removes_all() {
12459 let conn = test_db();
12460 let mut mem = make_memory("Purgeable", "test", Tier::Short, 5);
12461 mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
12462 insert(&conn, &mem).unwrap();
12463 gc(&conn, true).unwrap();
12464
12465 let purged = purge_archive(&conn, None).unwrap();
12466 assert_eq!(purged, 1);
12467 assert_eq!(list_archived(&conn, None, 10, 0).unwrap().len(), 0);
12468 }
12469
12470 #[test]
12471 fn purge_archive_rejects_negative_days() {
12472 let conn = test_db();
12473 let result = purge_archive(&conn, Some(-1));
12474 assert!(result.is_err());
12475 assert!(result.unwrap_err().to_string().contains("non-negative"));
12476 }
12477
12478 #[test]
12479 fn restore_rejects_active_id_collision() {
12480 let conn = test_db();
12481 let mut mem = make_memory("Collision Test", "test", Tier::Short, 5);
12482 mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
12483 let id = insert(&conn, &mem).unwrap();
12484
12485 gc(&conn, true).unwrap();
12487 assert!(get(&conn, &id).unwrap().is_none());
12488
12489 conn.execute(
12491 "INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at)
12492 VALUES (?1, 'long', 'test', 'Blocker Title', 'blocks restore', '[]', 5, 1.0, 'test', 0, datetime('now'), datetime('now'))",
12493 rusqlite::params![id],
12494 ).unwrap();
12495
12496 let result = restore_archived(&conn, &id);
12498 assert!(result.is_err());
12499 assert!(
12500 result
12501 .unwrap_err()
12502 .to_string()
12503 .contains("already exists in active table")
12504 );
12505 }
12506
12507 #[test]
12508 fn archive_stats_counts() {
12509 let conn = test_db();
12510 let mut m1 = make_memory("Stats A", "ns1", Tier::Short, 5);
12511 m1.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
12512 let mut m2 = make_memory("Stats B", "ns1", Tier::Short, 5);
12513 m2.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
12514 insert(&conn, &m1).unwrap();
12515 insert(&conn, &m2).unwrap();
12516 gc(&conn, true).unwrap();
12517
12518 let stats = archive_stats(&conn).unwrap();
12519 assert_eq!(stats["archived_total"], 2);
12520 }
12521
12522 #[test]
12523 fn archive_memory_moves_live_row_to_archive() {
12524 let conn = test_db();
12529 let mem = make_memory("Archive me", "s29", Tier::Long, 5);
12530 let id = insert(&conn, &mem).unwrap();
12531
12532 let moved = archive_memory(&conn, &id, Some("explicit")).unwrap();
12533 assert!(moved, "live row must be archived on first call");
12534 assert!(
12535 get(&conn, &id).unwrap().is_none(),
12536 "row must be removed from active table"
12537 );
12538
12539 let archived = list_archived(&conn, None, 10, 0).unwrap();
12540 assert_eq!(archived.len(), 1);
12541 assert_eq!(archived[0]["id"], id);
12542 assert_eq!(archived[0]["archive_reason"], "explicit");
12543
12544 let second = archive_memory(&conn, &id, Some("explicit")).unwrap();
12546 assert!(
12547 !second,
12548 "second archive call must report no-op (no live row)"
12549 );
12550 }
12551
12552 #[test]
12553 fn archive_memory_missing_id_returns_false() {
12554 let conn = test_db();
12557 let moved = archive_memory(&conn, "nonexistent-id", None).unwrap();
12558 assert!(!moved);
12559 }
12560
12561 #[test]
12562 fn archive_memory_default_reason_is_archive() {
12563 let conn = test_db();
12564 let mem = make_memory("Default reason", "s29", Tier::Long, 5);
12565 let id = insert(&conn, &mem).unwrap();
12566 assert!(archive_memory(&conn, &id, None).unwrap());
12567 let archived = list_archived(&conn, None, 10, 0).unwrap();
12568 assert_eq!(archived[0]["archive_reason"], "archive");
12569 }
12570
12571 #[test]
12572 fn export_all_and_links() {
12573 let conn = test_db();
12574 let id1 = insert(&conn, &make_memory("Export A", "test", Tier::Long, 5)).unwrap();
12575 let id2 = insert(&conn, &make_memory("Export B", "test", Tier::Long, 5)).unwrap();
12576 create_link(&conn, &id1, &id2, "supersedes").unwrap();
12577
12578 let mems = export_all(&conn).unwrap();
12579 assert_eq!(mems.len(), 2);
12580 let links = export_links(&conn).unwrap();
12581 assert_eq!(links.len(), 1);
12582 }
12583
12584 #[test]
12585 fn list_namespaces_counts() {
12586 let conn = test_db();
12587 insert(&conn, &make_memory("A", "alpha", Tier::Long, 5)).unwrap();
12588 insert(&conn, &make_memory("B", "alpha", Tier::Long, 5)).unwrap();
12589 insert(&conn, &make_memory("C", "beta", Tier::Long, 5)).unwrap();
12590
12591 let ns = list_namespaces(&conn).unwrap();
12592 assert_eq!(ns.len(), 2);
12593 }
12594
12595 #[test]
12596 fn taxonomy_flat_namespaces_only() {
12597 let conn = test_db();
12599 insert(&conn, &make_memory("A", "alpha", Tier::Long, 5)).unwrap();
12600 insert(&conn, &make_memory("B", "alpha", Tier::Long, 5)).unwrap();
12601 insert(&conn, &make_memory("C", "beta", Tier::Long, 5)).unwrap();
12602
12603 let tax = get_taxonomy(&conn, None, 8, 1000).unwrap();
12604 assert_eq!(tax.total_count, 3);
12605 assert!(!tax.truncated);
12606 assert_eq!(tax.tree.namespace, "");
12607 assert_eq!(tax.tree.subtree_count, 3);
12608 assert_eq!(tax.tree.count, 0); assert_eq!(tax.tree.children.len(), 2);
12610 let alpha = tax
12611 .tree
12612 .children
12613 .iter()
12614 .find(|c| c.name == "alpha")
12615 .unwrap();
12616 assert_eq!(alpha.count, 2);
12617 assert_eq!(alpha.subtree_count, 2);
12618 assert!(alpha.children.is_empty());
12619 let beta = tax.tree.children.iter().find(|c| c.name == "beta").unwrap();
12620 assert_eq!(beta.count, 1);
12621 }
12622
12623 #[test]
12624 fn taxonomy_hierarchical_tree() {
12625 let conn = test_db();
12627 insert(&conn, &make_memory("a", "alphaone", Tier::Long, 5)).unwrap();
12628 insert(&conn, &make_memory("b", "alphaone/eng", Tier::Long, 5)).unwrap();
12629 insert(
12630 &conn,
12631 &make_memory("c", "alphaone/eng/platform", Tier::Long, 5),
12632 )
12633 .unwrap();
12634 insert(
12635 &conn,
12636 &make_memory("d", "alphaone/eng/platform", Tier::Long, 5),
12637 )
12638 .unwrap();
12639 insert(&conn, &make_memory("e", "alphaone/sales", Tier::Long, 5)).unwrap();
12640
12641 let tax = get_taxonomy(&conn, None, 8, 1000).unwrap();
12642 assert_eq!(tax.total_count, 5);
12643 assert_eq!(tax.tree.subtree_count, 5);
12644 assert_eq!(tax.tree.children.len(), 1);
12645
12646 let alphaone = &tax.tree.children[0];
12647 assert_eq!(alphaone.name, "alphaone");
12648 assert_eq!(alphaone.namespace, "alphaone");
12649 assert_eq!(alphaone.count, 1); assert_eq!(alphaone.subtree_count, 5);
12651 assert_eq!(alphaone.children.len(), 2);
12652
12653 let eng = alphaone.children.iter().find(|c| c.name == "eng").unwrap();
12654 assert_eq!(eng.namespace, "alphaone/eng");
12655 assert_eq!(eng.count, 1);
12656 assert_eq!(eng.subtree_count, 3);
12657 let platform = &eng.children[0];
12658 assert_eq!(platform.name, "platform");
12659 assert_eq!(platform.namespace, "alphaone/eng/platform");
12660 assert_eq!(platform.count, 2);
12661 assert_eq!(platform.subtree_count, 2);
12662 assert!(platform.children.is_empty());
12663 }
12664
12665 #[test]
12666 fn taxonomy_prefix_scopes_subtree() {
12667 let conn = test_db();
12668 insert(&conn, &make_memory("a", "alphaone/eng", Tier::Long, 5)).unwrap();
12669 insert(
12670 &conn,
12671 &make_memory("b", "alphaone/eng/platform", Tier::Long, 5),
12672 )
12673 .unwrap();
12674 insert(&conn, &make_memory("c", "alphaone/sales", Tier::Long, 5)).unwrap();
12675 insert(&conn, &make_memory("d", "alphaone-sibling", Tier::Long, 5)).unwrap();
12677 insert(&conn, &make_memory("e", "other", Tier::Long, 5)).unwrap();
12678
12679 let tax = get_taxonomy(&conn, Some("alphaone/eng"), 8, 1000).unwrap();
12680 assert_eq!(tax.total_count, 2);
12681 assert_eq!(tax.tree.namespace, "alphaone/eng");
12682 assert_eq!(tax.tree.name, "eng");
12683 assert_eq!(tax.tree.count, 1);
12684 assert_eq!(tax.tree.subtree_count, 2);
12685 assert_eq!(tax.tree.children.len(), 1);
12686 assert_eq!(tax.tree.children[0].name, "platform");
12687 assert_eq!(tax.tree.children[0].count, 1);
12688 }
12689
12690 #[test]
12696 fn taxonomy_prefix_like_metacharacters_do_not_widen_match_l5() {
12697 let conn = test_db();
12698 insert(&conn, &make_memory("a", "a%/child", Tier::Long, 5)).unwrap();
12699 insert(&conn, &make_memory("b", "ax/child", Tier::Long, 5)).unwrap();
12700 insert(&conn, &make_memory("c", "a_/child", Tier::Long, 5)).unwrap();
12701
12702 let tax = get_taxonomy(&conn, Some("a%"), 8, 1000).unwrap();
12704 assert_eq!(
12705 tax.total_count, 1,
12706 "prefix 'a%' must not aggregate 'ax/...' or 'a_/...' subtrees"
12707 );
12708
12709 let tax = get_taxonomy(&conn, Some("a_"), 8, 1000).unwrap();
12711 assert_eq!(
12712 tax.total_count, 1,
12713 "prefix 'a_' must not aggregate single-char-wildcard siblings"
12714 );
12715
12716 let tax = get_taxonomy(&conn, Some("ax"), 8, 1000).unwrap();
12718 assert_eq!(tax.total_count, 1);
12719 }
12720
12721 #[test]
12722 fn taxonomy_depth_clamps_but_preserves_subtree_counts() {
12723 let conn = test_db();
12724 insert(
12725 &conn,
12726 &make_memory("a", "alphaone/eng/platform/db", Tier::Long, 5),
12727 )
12728 .unwrap();
12729 insert(
12730 &conn,
12731 &make_memory("b", "alphaone/eng/platform/api", Tier::Long, 5),
12732 )
12733 .unwrap();
12734
12735 let tax = get_taxonomy(&conn, None, 2, 1000).unwrap();
12736 assert_eq!(tax.total_count, 2);
12737 let alphaone = &tax.tree.children[0];
12738 let eng = &alphaone.children[0];
12739 assert!(eng.children.is_empty());
12743 assert_eq!(eng.subtree_count, 2);
12744 assert_eq!(eng.count, 0); }
12746
12747 #[test]
12748 fn taxonomy_excludes_expired_memories() {
12749 let conn = test_db();
12752 let mut alive = make_memory("alive", "alpha", Tier::Long, 5);
12753 let mut dead = make_memory("dead", "alpha", Tier::Short, 5);
12754 dead.expires_at = Some("2000-01-01T00:00:00Z".to_string());
12756 alive.expires_at = None;
12757 insert(&conn, &alive).unwrap();
12758 insert(&conn, &dead).unwrap();
12759
12760 let tax = get_taxonomy(&conn, None, 8, 1000).unwrap();
12761 assert_eq!(tax.total_count, 1);
12762 assert_eq!(tax.tree.children.len(), 1);
12763 assert_eq!(tax.tree.children[0].count, 1);
12764 }
12765
12766 #[test]
12767 fn taxonomy_truncates_at_limit_but_total_stays_honest() {
12768 let conn = test_db();
12769 for ns in ["aa", "bb", "cc", "dd", "ee"] {
12770 insert(&conn, &make_memory("m", ns, Tier::Long, 5)).unwrap();
12771 }
12772 let tax = get_taxonomy(&conn, None, 8, 2).unwrap();
12773 assert_eq!(tax.total_count, 5);
12776 assert!(tax.truncated);
12777 assert_eq!(tax.tree.children.len(), 2);
12778 }
12779
12780 #[test]
12781 fn forget_by_namespace() {
12782 let conn = test_db();
12783 insert(&conn, &make_memory("A", "delete-me", Tier::Long, 5)).unwrap();
12784 insert(&conn, &make_memory("B", "delete-me", Tier::Long, 5)).unwrap();
12785 insert(&conn, &make_memory("C", "keep", Tier::Long, 5)).unwrap();
12786
12787 let deleted = forget(&conn, Some("delete-me"), None, None, false).unwrap();
12788 assert_eq!(deleted, 2);
12789 let remaining = list(&conn, None, None, 100, 0, None, None, None, None, None).unwrap();
12790 assert_eq!(remaining.len(), 1);
12791 }
12792
12793 #[test]
12794 fn set_and_get_embedding() {
12795 let conn = test_db();
12796 let mem = make_memory("Embed test", "test", Tier::Long, 5);
12797 let id = insert(&conn, &mem).unwrap();
12798
12799 let emb = vec![0.1f32, 0.2, 0.3, 0.4];
12800 set_embedding(&conn, &id, &emb).unwrap();
12801
12802 let got = get_embedding(&conn, &id).unwrap().unwrap();
12803 assert_eq!(got.len(), 4);
12804 assert!((got[0] - 0.1).abs() < 1e-6);
12805 }
12806
12807 #[test]
12812 fn unembedded_batch_after_cursor_paginates_1595() {
12813 let conn = test_db();
12814 let mut ids: Vec<String> = (0..5)
12815 .map(|i| {
12816 insert(
12817 &conn,
12818 &make_memory(&format!("row-{i}"), "bf-1595", Tier::Long, 5),
12819 )
12820 .unwrap()
12821 })
12822 .collect();
12823 ids.sort();
12824
12825 let first = get_unembedded_ids_batch_after(&conn, None, 2).unwrap();
12826 assert_eq!(first.len(), 2);
12827 assert_eq!(first[0].0, ids[0], "scan starts at the smallest id");
12828 let cursor = first.last().unwrap().0.clone();
12829
12830 let rest = get_unembedded_ids_batch_after(&conn, Some(&cursor), 10).unwrap();
12831 assert_eq!(rest.len(), 3);
12832 assert!(
12833 rest.iter().all(|(id, _, _)| id.as_str() > cursor.as_str()),
12834 "every row must sort strictly after the cursor"
12835 );
12836
12837 set_embedding(&conn, &ids[0], &[0.1, 0.2]).unwrap();
12839 let after = get_unembedded_ids_batch_after(&conn, None, 10).unwrap();
12840 assert_eq!(after.len(), 4);
12841 assert!(after.iter().all(|(id, _, _)| id != &ids[0]));
12842 }
12843
12844 #[test]
12848 fn memory_texts_batch_namespace_and_cursor_1598() {
12849 let conn = test_db();
12850 let mut ns_a_ids: Vec<String> = (0..3)
12851 .map(|i| {
12852 insert(
12853 &conn,
12854 &make_memory(&format!("a-{i}"), "reembed-a", Tier::Long, 5),
12855 )
12856 .unwrap()
12857 })
12858 .collect();
12859 ns_a_ids.sort();
12860 for i in 0..2 {
12861 insert(
12862 &conn,
12863 &make_memory(&format!("b-{i}"), "reembed-b", Tier::Long, 5),
12864 )
12865 .unwrap();
12866 }
12867 set_embedding(&conn, &ns_a_ids[0], &[0.5, 0.5]).unwrap();
12870
12871 let all = get_memory_texts_batch(&conn, None, None, 100).unwrap();
12872 assert_eq!(all.len(), 5, "unfiltered scan sees every live row");
12873
12874 let ns_a = get_memory_texts_batch(&conn, Some("reembed-a"), None, 100).unwrap();
12875 assert_eq!(ns_a.len(), 3);
12876 assert_eq!(ns_a[0].0, ns_a_ids[0], "embedded row still scanned");
12877
12878 let first = get_memory_texts_batch(&conn, Some("reembed-a"), None, 1).unwrap();
12879 let cursor = first[0].0.clone();
12880 let rest = get_memory_texts_batch(&conn, Some("reembed-a"), Some(&cursor), 100).unwrap();
12881 assert_eq!(rest.len(), 2);
12882 assert!(rest.iter().all(|(id, _, _)| id.as_str() > cursor.as_str()));
12883 }
12884
12885 #[test]
12889 fn set_embeddings_batch_reembed_bypasses_dim_invariant_1598() {
12890 let mut conn = test_db();
12891 let id1 = insert(&conn, &make_memory("dim-est", "reembed-dim", Tier::Long, 5)).unwrap();
12892 let id2 = insert(&conn, &make_memory("dim-mig", "reembed-dim", Tier::Long, 5)).unwrap();
12893 set_embedding(&conn, &id1, &[0.1, 0.2, 0.3, 0.4]).unwrap();
12895
12896 let refused =
12898 set_embeddings_batch(&mut conn, &[(id2.clone(), vec![0.1_f32; 8])]).unwrap_err();
12899 assert!(
12900 refused.downcast_ref::<EmbeddingDimMismatch>().is_some(),
12901 "checked writer must refuse the dim change: {refused}"
12902 );
12903
12904 let entries = vec![
12906 (id1.clone(), vec![0.9_f32; 8]),
12907 (id2.clone(), vec![0.8_f32; 8]),
12908 ];
12909 let written = set_embeddings_batch_reembed(&mut conn, &entries).unwrap();
12910 assert_eq!(written, 2);
12911 assert_eq!(get_embedding(&conn, &id1).unwrap().unwrap().len(), 8);
12912 assert_eq!(get_embedding(&conn, &id2).unwrap().unwrap().len(), 8);
12913 assert_eq!(
12914 namespace_embedding_dim(&conn, "reembed-dim").unwrap(),
12915 Some(8),
12916 "namespace converges to the target dim"
12917 );
12918
12919 let n = set_embeddings_batch_reembed(
12921 &mut conn,
12922 &[("no-such-id".to_string(), vec![0.1_f32; 8])],
12923 )
12924 .unwrap();
12925 assert_eq!(n, 0);
12926 assert_eq!(set_embeddings_batch_reembed(&mut conn, &[]).unwrap(), 0);
12927 }
12928
12929 #[test]
12932 fn embedding_coverage_counts_1598() {
12933 let conn = test_db();
12934 let id_a = insert(&conn, &make_memory("c-a", "cov-a", Tier::Long, 5)).unwrap();
12935 insert(&conn, &make_memory("c-b", "cov-a", Tier::Long, 5)).unwrap();
12936 insert(&conn, &make_memory("c-c", "cov-b", Tier::Long, 5)).unwrap();
12937 set_embedding(&conn, &id_a, &[0.1, 0.2]).unwrap();
12938
12939 assert_eq!(embedding_coverage(&conn, None).unwrap(), (3, 1));
12940 assert_eq!(embedding_coverage(&conn, Some("cov-a")).unwrap(), (2, 1));
12941 assert_eq!(embedding_coverage(&conn, Some("cov-b")).unwrap(), (1, 0));
12942 assert_eq!(embedding_coverage(&conn, Some("cov-none")).unwrap(), (0, 0));
12943 }
12944
12945 #[test]
12948 fn distinct_embedding_dims_lists_mixed_1598() {
12949 let mut conn = test_db();
12950 let id_a = insert(&conn, &make_memory("d-a", "dims-a", Tier::Long, 5)).unwrap();
12951 let id_b = insert(&conn, &make_memory("d-b", "dims-b", Tier::Long, 5)).unwrap();
12952 let id_c = insert(&conn, &make_memory("d-c", "dims-b", Tier::Long, 5)).unwrap();
12953 set_embedding(&conn, &id_a, &[0.1, 0.2]).unwrap();
12954 set_embedding(&conn, &id_b, &[0.1; 8]).unwrap();
12955 set_embeddings_batch_reembed(&mut conn, &[(id_c, vec![0.2_f32; 4])]).unwrap();
12958
12959 assert_eq!(distinct_embedding_dims(&conn, None).unwrap(), vec![2, 4, 8]);
12960 assert_eq!(
12961 distinct_embedding_dims(&conn, Some("dims-b")).unwrap(),
12962 vec![4, 8]
12963 );
12964 assert!(
12965 distinct_embedding_dims(&conn, Some("dims-none"))
12966 .unwrap()
12967 .is_empty()
12968 );
12969 }
12970
12971 fn insert_with_embedding(
12974 conn: &Connection,
12975 title: &str,
12976 ns: &str,
12977 embedding: &[f32],
12978 ) -> String {
12979 let mem = make_memory(title, ns, Tier::Long, 5);
12980 let id = insert(conn, &mem).unwrap();
12981 set_embedding(conn, &id, embedding).unwrap();
12982 id
12983 }
12984
12985 #[test]
12986 fn check_duplicate_empty_db_returns_no_match() {
12987 let conn = test_db();
12988 let q = vec![1.0_f32, 0.0, 0.0];
12989 let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
12990 assert!(!r.is_duplicate);
12991 assert!(r.nearest.is_none());
12992 assert_eq!(r.candidates_scanned, 0);
12993 }
12994
12995 #[test]
12996 fn check_duplicate_finds_highest_cosine_match() {
12997 let conn = test_db();
12998 let id_a = insert_with_embedding(&conn, "alpha", "ns", &[1.0, 0.0, 0.0]);
13003 let _id_b = insert_with_embedding(&conn, "beta", "ns", &[0.7, 0.7, 0.0]);
13004 let _id_c = insert_with_embedding(&conn, "gamma", "ns", &[0.0, 1.0, 0.0]);
13005
13006 let q = vec![1.0_f32, 0.0, 0.0];
13007 let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
13008 let nearest = r.nearest.expect("expected a nearest match");
13009 assert_eq!(nearest.id, id_a);
13010 assert!(nearest.similarity > 0.99);
13011 assert_eq!(r.candidates_scanned, 3);
13012 assert!(r.is_duplicate);
13013 assert!((r.threshold - 0.85).abs() < 1e-6);
13014 }
13015
13016 #[test]
13017 fn check_duplicate_below_threshold_not_flagged_but_returns_nearest() {
13018 let conn = test_db();
13019 let id_b = insert_with_embedding(&conn, "beta", "ns", &[0.7, 0.7, 0.0]);
13020
13021 let q = vec![1.0_f32, 0.0, 0.0];
13023 let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
13024 let nearest = r
13025 .nearest
13026 .expect("nearest must surface even when below threshold");
13027 assert_eq!(nearest.id, id_b);
13028 assert!(!r.is_duplicate);
13029 }
13030
13031 #[test]
13032 fn check_duplicate_threshold_clamped_to_floor() {
13033 let conn = test_db();
13034 let _ = insert_with_embedding(&conn, "x", "ns", &[1.0, 0.0, 0.0]);
13038 let q = vec![0.0_f32, 1.0, 0.0]; let r = check_duplicate(&conn, &q, None, 0.0).unwrap();
13040 assert!((r.threshold - DUPLICATE_THRESHOLD_MIN).abs() < 1e-6);
13041 assert!(!r.is_duplicate);
13042 }
13043
13044 #[test]
13045 fn check_duplicate_namespace_filter_isolates_scan() {
13046 let conn = test_db();
13047 let _hit_in_other_ns = insert_with_embedding(&conn, "x", "other", &[1.0, 0.0, 0.0]);
13048 let id_target = insert_with_embedding(&conn, "y", "ns", &[0.6, 0.8, 0.0]);
13049
13050 let q = vec![1.0_f32, 0.0, 0.0];
13051 let r = check_duplicate(&conn, &q, Some("ns"), 0.85).unwrap();
13052 assert_eq!(r.candidates_scanned, 1);
13053 assert_eq!(r.nearest.expect("namespace filter ignored").id, id_target);
13054 }
13055
13056 #[test]
13057 fn check_duplicate_skips_expired_rows() {
13058 let conn = test_db();
13059 let mut mem = make_memory("expired", "ns", Tier::Short, 5);
13062 mem.expires_at = Some((chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339());
13063 let id = insert(&conn, &mem).unwrap();
13064 set_embedding(&conn, &id, &[1.0, 0.0, 0.0]).unwrap();
13065
13066 let q = vec![1.0_f32, 0.0, 0.0];
13067 let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
13068 assert_eq!(r.candidates_scanned, 0);
13069 assert!(r.nearest.is_none());
13070 }
13071
13072 #[test]
13073 fn check_duplicate_skips_unembedded_rows() {
13074 let conn = test_db();
13075 let id_embedded = insert_with_embedding(&conn, "with-emb", "ns", &[1.0, 0.0, 0.0]);
13078 let mem = make_memory("no-emb", "ns", Tier::Long, 5);
13079 let _ = insert(&conn, &mem).unwrap();
13080
13081 let q = vec![1.0_f32, 0.0, 0.0];
13082 let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
13083 assert_eq!(r.candidates_scanned, 1);
13084 assert_eq!(r.nearest.expect("embedded match").id, id_embedded);
13085 }
13086
13087 #[test]
13088 fn check_duplicate_skips_blob_with_non_multiple_of_4_length() {
13089 let conn = test_db();
13095 let mem = make_memory("malformed-blob", "ns", Tier::Long, 5);
13096 let id = insert(&conn, &mem).unwrap();
13097 conn.execute(
13100 "UPDATE memories SET embedding = ?1 WHERE id = ?2",
13101 params![&[0u8; 7][..], &id],
13102 )
13103 .unwrap();
13104
13105 let q = vec![1.0_f32, 0.0];
13106 let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
13107 assert_eq!(
13108 r.candidates_scanned, 0,
13109 "malformed blob must be skipped, not silently truncated"
13110 );
13111 assert!(r.nearest.is_none());
13112 }
13113
13114 #[test]
13115 fn check_duplicate_skips_blob_with_dimension_mismatch() {
13116 let conn = test_db();
13121 let _id = insert_with_embedding(&conn, "different-dim", "ns", &[1.0, 0.0, 0.0]);
13123
13124 let q = vec![1.0_f32, 0.0, 0.0, 0.0];
13126 let r = check_duplicate(&conn, &q, None, 0.85).unwrap();
13127 assert_eq!(
13128 r.candidates_scanned, 0,
13129 "dimension-mismatched candidate must be skipped"
13130 );
13131 assert!(r.nearest.is_none());
13132 }
13133
13134 #[test]
13135 fn get_unembedded_returns_memoryless() {
13136 let conn = test_db();
13137 let mem = make_memory("No embed", "test", Tier::Long, 5);
13138 insert(&conn, &mem).unwrap();
13139
13140 let unembedded = get_unembedded_ids(&conn).unwrap();
13141 assert_eq!(unembedded.len(), 1);
13142 }
13143
13144 #[test]
13145 fn health_check_passes() {
13146 let conn = test_db();
13147 assert!(health_check(&conn).unwrap());
13148 }
13149
13150 #[test]
13151 fn sanitize_fts_strips_operators_and_quotes() {
13152 let sanitized = sanitize_fts_query("test* \"injection\" (drop)", true);
13154 assert!(!sanitized.contains('*'));
13155 assert!(!sanitized.contains('('));
13156 assert!(!sanitized.contains(')'));
13157 let sanitized2 = sanitize_fts_query("hello AND world OR NOT NEAR test", true);
13159 assert!(sanitized2.contains("hello"));
13160 assert!(sanitized2.contains("world"));
13161 assert!(sanitized2.contains("test"));
13162 let sanitized3 = sanitize_fts_query("", true);
13164 assert_eq!(sanitized3, "\"_empty_\"");
13165 let sanitized4 = sanitize_fts_query("-secret +required", true);
13171 assert!(!sanitized4.contains('+'));
13172 assert!(sanitized4.contains("secret"));
13173 assert!(sanitized4.contains("required"));
13174 let sanitized5 = sanitize_fts_query("well-known", true);
13176 assert!(sanitized5.contains("well-known"));
13177 }
13178
13179 #[test]
13180 fn get_by_prefix_8char() {
13181 let conn = test_db();
13182 let mem = make_memory("Prefix test", "test", Tier::Long, 5);
13183 let id = insert(&conn, &mem).unwrap();
13184 let prefix = &id[..8];
13185 let got = get_by_prefix(&conn, prefix).unwrap().unwrap();
13186 assert_eq!(got.id, id);
13187 assert_eq!(got.title, "Prefix test");
13188 }
13189
13190 #[test]
13191 fn get_by_prefix_full_uuid() {
13192 let conn = test_db();
13193 let mem = make_memory("Full UUID prefix", "test", Tier::Long, 5);
13194 let id = insert(&conn, &mem).unwrap();
13195 let got = get_by_prefix(&conn, &id).unwrap().unwrap();
13197 assert_eq!(got.id, id);
13198 }
13199
13200 #[test]
13201 fn get_by_prefix_nonexistent() {
13202 let conn = test_db();
13203 let got = get_by_prefix(&conn, "ffffffff").unwrap();
13204 assert!(got.is_none());
13205 }
13206
13207 #[test]
13208 fn get_by_prefix_ambiguous() {
13209 let conn = test_db();
13210 let mut mem1 = make_memory("Ambig A", "test", Tier::Long, 5);
13212 mem1.id = "aaaa1111-0000-0000-0000-000000000001".to_string();
13213 insert(&conn, &mem1).unwrap();
13214 let mut mem2 = make_memory("Ambig B", "test2", Tier::Long, 5);
13215 mem2.id = "aaaa2222-0000-0000-0000-000000000002".to_string();
13216 insert(&conn, &mem2).unwrap();
13217 let result = get_by_prefix(&conn, "aaaa");
13218 assert!(result.is_err());
13219 let err_msg = result.unwrap_err().to_string();
13220 assert!(err_msg.contains("ambiguous"));
13221 assert!(err_msg.contains("2 matches"));
13222 assert!(
13224 err_msg.contains("aaaa1111-0000-0000-0000-000000000001"),
13225 "error should list matching IDs, got: {err_msg}"
13226 );
13227 assert!(err_msg.contains("aaaa2222-0000-0000-0000-000000000002"));
13228 }
13229
13230 #[test]
13231 fn resolve_id_exact_then_prefix() {
13232 let conn = test_db();
13233 let mem = make_memory("Resolve test", "test", Tier::Long, 5);
13234 let id = insert(&conn, &mem).unwrap();
13235 let got = resolve_id(&conn, &id).unwrap().unwrap();
13237 assert_eq!(got.id, id);
13238 let got2 = resolve_id(&conn, &id[..8]).unwrap().unwrap();
13240 assert_eq!(got2.id, id);
13241 let got3 = resolve_id(&conn, "zzzzzzzz").unwrap();
13243 assert!(got3.is_none());
13244 }
13245
13246 #[test]
13247 fn insert_if_newer_updates() {
13248 let conn = test_db();
13249 let mut mem = make_memory("Sync test", "test", Tier::Long, 5);
13250 let id = insert(&conn, &mem).unwrap();
13251
13252 mem.id = id.clone();
13253 mem.content = "Updated via sync".to_string();
13254 mem.updated_at = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
13255 let result_id = insert_if_newer(&conn, &mem).unwrap();
13256 assert_eq!(result_id, id);
13257
13258 let got = get(&conn, &id).unwrap().unwrap();
13259 assert_eq!(got.content, "Updated via sync");
13260 }
13261
13262 #[test]
13265 fn metadata_default_empty_object() {
13266 let conn = test_db();
13267 let mem = make_memory("Default metadata", "test", Tier::Long, 5);
13268 let id = insert(&conn, &mem).unwrap();
13269 let got = get(&conn, &id).unwrap().unwrap();
13270 assert_eq!(got.metadata, serde_json::json!({}));
13271 }
13272
13273 #[test]
13274 fn metadata_store_and_retrieve() {
13275 let conn = test_db();
13276 let mut mem = make_memory("With metadata", "test", Tier::Long, 5);
13277 mem.metadata = serde_json::json!({"agent_id": "claude-1", "session": 42});
13278 let id = insert(&conn, &mem).unwrap();
13279 let got = get(&conn, &id).unwrap().unwrap();
13280 assert_eq!(got.metadata["agent_id"], "claude-1");
13281 assert_eq!(got.metadata["session"], 42);
13282 }
13283
13284 #[test]
13285 fn metadata_roundtrip_nested_json() {
13286 let conn = test_db();
13287 let mut mem = make_memory("Nested metadata", "test", Tier::Long, 5);
13288 mem.metadata = serde_json::json!({
13289 "agent": {"type": "ai:claude", "version": "4.6"},
13290 "tags_extra": ["experimental"],
13291 "score": 0.95
13292 });
13293 let id = insert(&conn, &mem).unwrap();
13294 let got = get(&conn, &id).unwrap().unwrap();
13295 assert_eq!(got.metadata["agent"]["type"], "ai:claude");
13296 assert_eq!(got.metadata["tags_extra"][0], "experimental");
13297 assert!((got.metadata["score"].as_f64().unwrap() - 0.95).abs() < f64::EPSILON);
13298 }
13299
13300 #[test]
13301 fn metadata_preserved_on_update() {
13302 let conn = test_db();
13303 let mut mem = make_memory("Update metadata", "test", Tier::Long, 5);
13304 mem.metadata = serde_json::json!({"key": "original"});
13305 let id = insert(&conn, &mem).unwrap();
13306
13307 let (found, _) = update(
13309 &conn,
13310 &id,
13311 None,
13312 Some("new content"),
13313 None,
13314 None,
13315 None,
13316 None,
13317 None,
13318 None,
13319 None,
13320 )
13321 .unwrap();
13322 assert!(found);
13323 let got = get(&conn, &id).unwrap().unwrap();
13324 assert_eq!(got.metadata["key"], "original");
13325 assert_eq!(got.content, "new content");
13326
13327 let new_meta = serde_json::json!({"key": "updated", "extra": true});
13329 let (found, _) = update(
13330 &conn,
13331 &id,
13332 None,
13333 None,
13334 None,
13335 None,
13336 None,
13337 None,
13338 None,
13339 None,
13340 Some(&new_meta),
13341 )
13342 .unwrap();
13343 assert!(found);
13344 let got = get(&conn, &id).unwrap().unwrap();
13345 assert_eq!(got.metadata["key"], "updated");
13346 assert_eq!(got.metadata["extra"], true);
13347 }
13348
13349 #[test]
13350 fn metadata_preserved_on_upsert() {
13351 let conn = test_db();
13352 let mut mem = make_memory("Upsert meta", "test", Tier::Long, 5);
13353 mem.metadata = serde_json::json!({"version": 1});
13354 insert(&conn, &mem).unwrap();
13355
13356 let mut mem2 = make_memory("Upsert meta", "test", Tier::Long, 5);
13358 mem2.metadata = serde_json::json!({"version": 2});
13359 let id = insert(&conn, &mem2).unwrap();
13360 let got = get(&conn, &id).unwrap().unwrap();
13361 assert_eq!(got.metadata["version"], 2);
13362 }
13363
13364 #[test]
13365 fn metadata_in_list_and_search() {
13366 let conn = test_db();
13367 let mut mem = make_memory("Searchable metadata", "test", Tier::Long, 8);
13368 mem.metadata = serde_json::json!({"source_model": "opus"});
13369 insert(&conn, &mem).unwrap();
13370
13371 let results = list(
13372 &conn,
13373 Some("test"),
13374 None,
13375 10,
13376 0,
13377 None,
13378 None,
13379 None,
13380 None,
13381 None,
13382 )
13383 .unwrap();
13384 assert_eq!(results.len(), 1);
13385 assert_eq!(results[0].metadata["source_model"], "opus");
13386
13387 let results = search(
13388 &conn,
13389 "Searchable",
13390 Some("test"),
13391 None,
13392 10,
13393 None,
13394 None,
13395 None,
13396 None,
13397 None,
13398 None,
13399 false,
13400 )
13401 .unwrap();
13402 assert_eq!(results.len(), 1);
13403 assert_eq!(results[0].metadata["source_model"], "opus");
13404 }
13405
13406 #[test]
13407 fn metadata_in_recall() {
13408 let conn = test_db();
13409 let mut mem = make_memory("Recallable metadata", "test", Tier::Long, 8);
13410 mem.metadata = serde_json::json!({"context": "test-recall"});
13411 insert(&conn, &mem).unwrap();
13412
13413 let (results, _tokens) = recall(
13414 &conn,
13415 "Recallable",
13416 Some("test"),
13417 10,
13418 None,
13419 None,
13420 None,
13421 crate::SECS_PER_HOUR,
13422 crate::SECS_PER_DAY,
13423 None,
13424 None,
13425 false,
13426 None,
13427 )
13428 .unwrap();
13429 assert!(!results.is_empty());
13430 assert_eq!(results[0].0.metadata["context"], "test-recall");
13431 }
13432
13433 #[test]
13434 fn metadata_in_export_import() {
13435 let conn = test_db();
13436 let mut mem = make_memory("Export metadata", "test", Tier::Long, 5);
13437 mem.metadata = serde_json::json!({"exported": true});
13438 insert(&conn, &mem).unwrap();
13439
13440 let exported = export_all(&conn).unwrap();
13441 assert_eq!(exported.len(), 1);
13442 assert_eq!(exported[0].metadata["exported"], true);
13443
13444 let conn2 = test_db();
13446 insert(&conn2, &exported[0]).unwrap();
13447 let got = get(&conn2, &exported[0].id).unwrap().unwrap();
13448 assert_eq!(got.metadata["exported"], true);
13449 }
13450
13451 #[test]
13452 fn metadata_schema_migration() {
13453 let conn = test_db();
13456 let mem = make_memory("Migration test", "test", Tier::Long, 5);
13457 let id = insert(&conn, &mem).unwrap();
13458
13459 let metadata_str: String = conn
13461 .query_row(
13462 "SELECT metadata FROM memories WHERE id = ?1",
13463 params![id],
13464 |r| r.get(0),
13465 )
13466 .unwrap();
13467 assert_eq!(metadata_str, "{}");
13468 }
13469
13470 #[test]
13471 fn metadata_survives_archive_restore_cycle() {
13472 let conn = test_db();
13473 let mut mem = make_memory("Archivable", "test", Tier::Short, 5);
13474 mem.metadata = serde_json::json!({"origin": "archive-test"});
13475 mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
13477 let id = insert(&conn, &mem).unwrap();
13478
13479 let deleted = gc(&conn, true).unwrap();
13481 assert_eq!(deleted, 1);
13482
13483 let archived = list_archived(&conn, None, 10, 0).unwrap();
13485 assert_eq!(archived.len(), 1);
13486 assert_eq!(archived[0]["metadata"]["origin"], "archive-test");
13487
13488 let restored = restore_archived(&conn, &id).unwrap();
13490 assert!(restored);
13491 let got = get(&conn, &id).unwrap().unwrap();
13492 assert_eq!(got.metadata["origin"], "archive-test");
13493 }
13494
13495 #[test]
13496 fn metadata_in_insert_if_newer() {
13497 let conn = test_db();
13498 let mut mem = make_memory("Sync metadata", "test", Tier::Long, 5);
13499 mem.metadata = serde_json::json!({"version": 1});
13500 let id = insert(&conn, &mem).unwrap();
13501
13502 mem.id = id.clone();
13504 mem.metadata = serde_json::json!({"version": 2, "synced": true});
13505 mem.updated_at = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
13506 insert_if_newer(&conn, &mem).unwrap();
13507
13508 let got = get(&conn, &id).unwrap().unwrap();
13509 assert_eq!(got.metadata["version"], 2);
13510 assert_eq!(got.metadata["synced"], true);
13511
13512 mem.metadata = serde_json::json!({"version": 0, "stale": true});
13514 mem.updated_at = "2020-01-01T00:00:00+00:00".to_string();
13515 insert_if_newer(&conn, &mem).unwrap();
13516
13517 let got = get(&conn, &id).unwrap().unwrap();
13518 assert_eq!(got.metadata["version"], 2); assert!(got.metadata.get("stale").is_none());
13520 }
13521
13522 #[test]
13523 fn metadata_merged_in_consolidate() {
13524 let conn = test_db();
13525 let mut mem_a = make_memory("Consolidate A", "test", Tier::Long, 5);
13526 mem_a.metadata = serde_json::json!({"agent": "claude", "shared": "from_a"});
13527 let id_a = insert(&conn, &mem_a).unwrap();
13528
13529 let mut mem_b = make_memory("Consolidate B", "test", Tier::Long, 7);
13530 mem_b.metadata = serde_json::json!({"model": "opus", "shared": "from_b"});
13531 let id_b = insert(&conn, &mem_b).unwrap();
13532
13533 let new_id = consolidate(
13534 &conn,
13535 &[id_a, id_b],
13536 "Merged",
13537 "Combined content",
13538 "test",
13539 &Tier::Long,
13540 "consolidation",
13541 "test-consolidator",
13542 )
13543 .unwrap();
13544
13545 let got = get(&conn, &new_id).unwrap().unwrap();
13546 assert_eq!(got.metadata["agent"], "claude");
13548 assert_eq!(got.metadata["model"], "opus");
13549 assert_eq!(got.metadata["shared"], "from_b");
13550 }
13551
13552 #[test]
13553 fn metadata_consolidate_rejects_oversized_merge() {
13554 let conn = test_db();
13555 let mut mem_a = make_memory("Big meta A", "test", Tier::Long, 5);
13557 let big_val_a: serde_json::Map<String, serde_json::Value> = (0..500)
13558 .map(|i| {
13559 (
13560 format!("key_a_{i}"),
13561 serde_json::Value::String("x".repeat(60)),
13562 )
13563 })
13564 .collect();
13565 mem_a.metadata = serde_json::Value::Object(big_val_a);
13566 let id_a = insert(&conn, &mem_a).unwrap();
13567
13568 let mut mem_b = make_memory("Big meta B", "test", Tier::Long, 5);
13569 let big_val_b: serde_json::Map<String, serde_json::Value> = (0..500)
13570 .map(|i| {
13571 (
13572 format!("key_b_{i}"),
13573 serde_json::Value::String("x".repeat(60)),
13574 )
13575 })
13576 .collect();
13577 mem_b.metadata = serde_json::Value::Object(big_val_b);
13578 let id_b = insert(&conn, &mem_b).unwrap();
13579
13580 let result = consolidate(
13582 &conn,
13583 &[id_a, id_b],
13584 "Oversized merge",
13585 "Should fail",
13586 "test",
13587 &Tier::Long,
13588 "consolidation",
13589 "test-consolidator",
13590 );
13591 let err = result.expect_err("consolidate should fail for oversized merged metadata");
13592 let msg = err.to_string();
13593 assert!(
13594 msg.contains("merged metadata exceeds size limit"),
13595 "expected metadata size error, got: {msg}"
13596 );
13597 }
13598
13599 #[test]
13600 fn metadata_special_characters_roundtrip() {
13601 let conn = test_db();
13602 let mut mem = make_memory("Special chars metadata", "test", Tier::Long, 5);
13603 mem.metadata = serde_json::json!({
13604 "pipe": "a|b|c",
13605 "newline": "line1\nline2",
13606 "tab": "col1\tcol2",
13607 "backslash": "path\\to\\file",
13608 "unicode": "\u{1F600}\u{1F4A9}",
13609 "cjk": "\u{4e16}\u{754c}",
13610 "empty": "",
13611 "nested_special": {"inner|key": "val\nue"}
13612 });
13613 let id = insert(&conn, &mem).unwrap();
13614 let got = get(&conn, &id).unwrap().unwrap();
13615 assert_eq!(got.metadata["pipe"], "a|b|c");
13616 assert_eq!(got.metadata["newline"], "line1\nline2");
13617 assert_eq!(got.metadata["unicode"], "\u{1F600}\u{1F4A9}");
13618 assert_eq!(got.metadata["cjk"], "\u{4e16}\u{754c}");
13619 assert_eq!(got.metadata["nested_special"]["inner|key"], "val\nue");
13620 }
13621
13622 #[test]
13623 fn metadata_corrupt_column_falls_back_to_empty() {
13624 let conn = test_db();
13625 let mem = make_memory("Corrupt test", "test", Tier::Long, 5);
13626 let id = insert(&conn, &mem).unwrap();
13627
13628 conn.execute(
13630 "UPDATE memories SET metadata = 'NOT VALID JSON {{{{' WHERE id = ?1",
13631 params![id],
13632 )
13633 .unwrap();
13634
13635 let got = get(&conn, &id).unwrap().unwrap();
13637 assert_eq!(got.metadata, serde_json::json!({}));
13638 }
13639
13640 #[test]
13641 fn metadata_restore_resets_corrupt_archived_metadata() {
13642 let conn = test_db();
13643 let mut mem = make_memory("Corrupt archive", "test", Tier::Short, 5);
13644 mem.metadata = serde_json::json!({"valid": true});
13645 mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
13646 let id = insert(&conn, &mem).unwrap();
13647
13648 gc(&conn, true).unwrap();
13650
13651 conn.execute(
13653 "UPDATE archived_memories SET metadata = 'CORRUPT JSON' WHERE id = ?1",
13654 params![id],
13655 )
13656 .unwrap();
13657
13658 let restored = restore_archived(&conn, &id).unwrap();
13660 assert!(restored);
13661 let got = get(&conn, &id).unwrap().unwrap();
13662 assert_eq!(got.metadata, serde_json::json!({}));
13663 }
13664
13665 #[test]
13666 fn scope_index_exists_after_migration() {
13667 let conn = test_db();
13670 let has_col: bool = conn
13671 .prepare("SELECT scope_idx FROM memories LIMIT 0")
13672 .is_ok();
13673 assert!(has_col, "scope_idx generated column missing");
13674 let idx_exists: i64 = conn
13675 .query_row(
13676 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_memories_scope_idx'",
13677 [],
13678 |row| row.get(0),
13679 )
13680 .unwrap();
13681 assert_eq!(idx_exists, 1, "idx_memories_scope_idx missing");
13682 }
13683
13684 #[test]
13685 fn scope_index_used_for_direct_scope_filter() {
13686 let conn = test_db();
13699 for i in 0..200 {
13701 let scope = if i % 3 == 0 { "collective" } else { "private" };
13702 let mut mem = make_memory(&format!("row-{i}"), "test", Tier::Long, 5);
13703 mem.metadata = serde_json::json!({"scope": scope});
13704 insert(&conn, &mem).unwrap();
13705 }
13706 conn.execute("ANALYZE", []).unwrap();
13707 let plan: Vec<String> = conn
13708 .prepare("EXPLAIN QUERY PLAN SELECT id FROM memories WHERE scope_idx = ?1")
13709 .unwrap()
13710 .query_map(params!["collective"], |row| row.get::<_, String>(3))
13711 .unwrap()
13712 .collect::<rusqlite::Result<_>>()
13713 .unwrap();
13714 let joined = plan.join("\n");
13715 assert!(
13716 joined.contains("idx_memories_scope_idx"),
13717 "direct scope filter must use idx_memories_scope_idx; got:\n{joined}"
13718 );
13719 }
13720
13721 #[test]
13722 fn scope_idx_reflects_metadata_on_insert_and_update() {
13723 let conn = test_db();
13726 let mut mem = make_memory("scope-tracking", "test", Tier::Long, 5);
13727 mem.metadata = serde_json::json!({"scope": "team"});
13728 let id = insert(&conn, &mem).unwrap();
13729 let scope: String = conn
13730 .query_row(
13731 "SELECT scope_idx FROM memories WHERE id = ?1",
13732 params![id],
13733 |r| r.get(0),
13734 )
13735 .unwrap();
13736 assert_eq!(scope, "team");
13737
13738 let new_meta = serde_json::json!({"scope": "unit"});
13740 update(
13741 &conn,
13742 &id,
13743 None,
13744 None,
13745 None,
13746 None,
13747 None,
13748 None,
13749 None,
13750 None,
13751 Some(&new_meta),
13752 )
13753 .unwrap();
13754 let scope2: String = conn
13755 .query_row(
13756 "SELECT scope_idx FROM memories WHERE id = ?1",
13757 params![id],
13758 |r| r.get(0),
13759 )
13760 .unwrap();
13761 assert_eq!(scope2, "unit");
13762
13763 let mut bare = make_memory("no-scope-key", "test", Tier::Long, 5);
13765 bare.metadata = serde_json::json!({});
13766 let id2 = insert(&conn, &bare).unwrap();
13767 let scope3: String = conn
13768 .query_row(
13769 "SELECT scope_idx FROM memories WHERE id = ?1",
13770 params![id2],
13771 |r| r.get(0),
13772 )
13773 .unwrap();
13774 assert_eq!(scope3, "private");
13775 }
13776
13777 #[test]
13778 fn auto_purge_archive_respects_max_days() {
13779 let conn = test_db();
13780 let mut mem = make_memory("Purge test", "test", Tier::Short, 5);
13781 mem.expires_at = Some("2020-01-01T00:00:00+00:00".to_string());
13782 insert(&conn, &mem).unwrap();
13783 gc(&conn, true).unwrap();
13784
13785 let archived = list_archived(&conn, None, 10, 0).unwrap();
13787 assert_eq!(archived.len(), 1);
13788
13789 conn.execute(
13791 "UPDATE archived_memories SET archived_at = ?1",
13792 params![(chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339()],
13793 )
13794 .unwrap();
13795
13796 let purged = auto_purge_archive(&conn, None).unwrap();
13798 assert_eq!(purged, 0);
13799 assert_eq!(list_archived(&conn, None, 10, 0).unwrap().len(), 1);
13800
13801 let purged = auto_purge_archive(&conn, Some(0)).unwrap();
13803 assert_eq!(purged, 0);
13804
13805 let purged = auto_purge_archive(&conn, Some(90)).unwrap();
13807 assert_eq!(purged, 0);
13808
13809 let purged = auto_purge_archive(&conn, Some(7)).unwrap();
13811 assert_eq!(purged, 1);
13812 assert!(list_archived(&conn, None, 10, 0).unwrap().is_empty());
13813 }
13814
13815 fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
13820 let mut stmt = conn
13821 .prepare(&format!("PRAGMA table_info({table})"))
13822 .unwrap();
13823 let cols: Vec<String> = stmt
13824 .query_map([], |row| row.get::<_, String>(1))
13825 .unwrap()
13826 .filter_map(Result::ok)
13827 .collect();
13828 cols.iter().any(|c| c == column)
13829 }
13830
13831 fn index_exists(conn: &Connection, name: &str) -> bool {
13832 conn.query_row(
13833 "SELECT 1 FROM sqlite_master WHERE type='index' AND name=?1",
13834 params![name],
13835 |r| r.get::<_, i64>(0),
13836 )
13837 .is_ok()
13838 }
13839
13840 #[test]
13841 fn schema_v15_memory_links_has_temporal_columns() {
13842 let conn = test_db();
13843 assert!(column_exists(&conn, "memory_links", "valid_from"));
13844 assert!(column_exists(&conn, "memory_links", "valid_until"));
13845 assert!(column_exists(&conn, "memory_links", "observed_by"));
13846 assert!(column_exists(&conn, "memory_links", "signature"));
13847 }
13848
13849 #[test]
13850 fn schema_v15_memory_links_temporal_indexes_exist() {
13851 let conn = test_db();
13852 assert!(index_exists(&conn, "idx_links_temporal_src"));
13853 assert!(index_exists(&conn, "idx_links_temporal_tgt"));
13854 assert!(index_exists(&conn, "idx_links_relation"));
13855 }
13856
13857 #[test]
13858 fn schema_v15_entity_aliases_table_exists() {
13859 let conn = test_db();
13860 let count: i64 = conn
13861 .query_row("SELECT COUNT(*) FROM entity_aliases", [], |r| r.get(0))
13862 .unwrap();
13863 assert_eq!(count, 0);
13864 assert!(index_exists(&conn, "idx_entity_aliases_alias"));
13865 }
13866
13867 #[test]
13868 fn schema_v15_entity_aliases_primary_key_unique() {
13869 let conn = test_db();
13870 let now = chrono::Utc::now().to_rfc3339();
13871 conn.execute(
13872 "INSERT INTO entity_aliases (entity_id, alias, created_at) VALUES (?1, ?2, ?3)",
13873 params!["e1", "Alpha", &now],
13874 )
13875 .unwrap();
13876 let dup = conn.execute(
13877 "INSERT INTO entity_aliases (entity_id, alias, created_at) VALUES (?1, ?2, ?3)",
13878 params!["e1", "Alpha", &now],
13879 );
13880 assert!(dup.is_err(), "expected PK uniqueness violation");
13881 }
13882
13883 #[test]
13886 fn entity_register_creates_new_entity_with_aliases() {
13887 let conn = test_db();
13888 let aliases = vec!["pa".to_string(), "Project A".to_string()];
13889 let reg = entity_register(
13890 &conn,
13891 "Project Alpha",
13892 "projects/alpha",
13893 &aliases,
13894 &serde_json::json!({}),
13895 Some("test-agent"),
13896 )
13897 .unwrap();
13898 assert!(reg.created, "first registration must be created=true");
13899 assert_eq!(reg.canonical_name, "Project Alpha");
13900 assert_eq!(reg.namespace, "projects/alpha");
13901 assert_eq!(
13906 reg.aliases,
13907 vec![
13908 "Project A".to_string(),
13909 "Project Alpha".to_string(),
13910 "pa".to_string()
13911 ]
13912 );
13913
13914 let m = get(&conn, ®.entity_id).unwrap().unwrap();
13915 assert_eq!(m.title, "Project Alpha");
13916 assert_eq!(m.tier.rank(), Tier::Long.rank());
13917 assert!(m.tags.contains(&"entity".to_string()));
13918 assert_eq!(m.metadata["kind"], "entity");
13919 assert_eq!(m.metadata["agent_id"], "test-agent");
13920 }
13921
13922 #[test]
13923 fn entity_register_reuses_existing_and_merges_aliases() {
13924 let conn = test_db();
13925 let first = entity_register(
13926 &conn,
13927 "Project Alpha",
13928 "projects/alpha",
13929 &["pa".to_string()],
13930 &serde_json::json!({}),
13931 Some("a1"),
13932 )
13933 .unwrap();
13934 let second = entity_register(
13935 &conn,
13936 "Project Alpha",
13937 "projects/alpha",
13938 &["pa".to_string(), "alpha".to_string()],
13939 &serde_json::json!({}),
13940 Some("a2"),
13941 )
13942 .unwrap();
13943 assert!(first.created);
13944 assert!(!second.created, "second call must reuse the entity");
13945 assert_eq!(first.entity_id, second.entity_id);
13946 assert_eq!(
13950 second.aliases,
13951 vec![
13952 "Project Alpha".to_string(),
13953 "pa".to_string(),
13954 "alpha".to_string()
13955 ]
13956 );
13957 }
13958
13959 #[test]
13960 fn entity_register_errors_on_collision_with_non_entity_memory() {
13961 let conn = test_db();
13962 let mem = make_memory("Conflict", "projects/alpha", Tier::Long, 5);
13963 insert(&conn, &mem).unwrap();
13964 let err = entity_register(
13965 &conn,
13966 "Conflict",
13967 "projects/alpha",
13968 &[],
13969 &serde_json::json!({}),
13970 None,
13971 )
13972 .unwrap_err();
13973 let msg = format!("{err}");
13974 assert!(
13975 msg.contains("non-entity memory"),
13976 "expected collision error, got: {msg}"
13977 );
13978 }
13979
13980 #[test]
13981 fn entity_register_skips_blank_aliases() {
13982 let conn = test_db();
13983 let reg = entity_register(
13984 &conn,
13985 "Trim Test",
13986 "test",
13987 &[String::new(), " ".to_string(), "ok".to_string()],
13988 &serde_json::json!({}),
13989 None,
13990 )
13991 .unwrap();
13992 assert_eq!(reg.aliases, vec!["Trim Test".to_string(), "ok".to_string()]);
13994 }
13995
13996 #[test]
13997 fn entity_register_preserves_caller_metadata_keys() {
13998 let conn = test_db();
13999 let extra = serde_json::json!({"team": "platform", "kind": "ignored"});
14000 let reg = entity_register(&conn, "Service X", "svc", &[], &extra, None).unwrap();
14001 let m = get(&conn, ®.entity_id).unwrap().unwrap();
14002 assert_eq!(m.metadata["team"], "platform");
14003 assert_eq!(m.metadata["kind"], "entity");
14006 }
14007
14008 #[test]
14009 fn entity_get_by_alias_returns_record_with_full_alias_set() {
14010 let conn = test_db();
14011 let reg = entity_register(
14012 &conn,
14013 "Project Alpha",
14014 "projects/alpha",
14015 &["pa".to_string(), "alpha".to_string()],
14016 &serde_json::json!({}),
14017 None,
14018 )
14019 .unwrap();
14020 let got = entity_get_by_alias(&conn, "pa", None).unwrap().unwrap();
14021 assert_eq!(got.entity_id, reg.entity_id);
14022 assert_eq!(got.canonical_name, "Project Alpha");
14023 assert_eq!(got.namespace, "projects/alpha");
14024 assert_eq!(
14028 got.aliases,
14029 vec![
14030 "Project Alpha".to_string(),
14031 "alpha".to_string(),
14032 "pa".to_string()
14033 ]
14034 );
14035 }
14036
14037 #[test]
14038 fn entity_register_canonical_name_resolves_via_get_by_alias() {
14039 let conn = test_db();
14045 let reg = entity_register(
14046 &conn,
14047 "OnlyCanonical",
14048 "test",
14049 &[],
14050 &serde_json::json!({}),
14051 None,
14052 )
14053 .unwrap();
14054 assert!(reg.created);
14055 assert_eq!(
14056 reg.aliases,
14057 vec!["OnlyCanonical".to_string()],
14058 "canonical_name must be auto-inserted as an alias"
14059 );
14060 let got = entity_get_by_alias(&conn, "OnlyCanonical", Some("test"))
14061 .unwrap()
14062 .expect("canonical_name must resolve via entity_get_by_alias");
14063 assert_eq!(got.entity_id, reg.entity_id);
14064 assert_eq!(got.canonical_name, "OnlyCanonical");
14065 }
14066
14067 #[test]
14068 fn entity_get_by_alias_returns_none_for_unknown_alias() {
14069 let conn = test_db();
14070 let got = entity_get_by_alias(&conn, "missing", None).unwrap();
14071 assert!(got.is_none());
14072 }
14073
14074 #[test]
14075 fn entity_get_by_alias_filters_by_namespace() {
14076 let conn = test_db();
14077 entity_register(
14078 &conn,
14079 "Acme",
14080 "ns_a",
14081 &["a".to_string()],
14082 &serde_json::json!({}),
14083 None,
14084 )
14085 .unwrap();
14086 entity_register(
14087 &conn,
14088 "Acme Corp",
14089 "ns_b",
14090 &["a".to_string()],
14091 &serde_json::json!({}),
14092 None,
14093 )
14094 .unwrap();
14095 let in_a = entity_get_by_alias(&conn, "a", Some("ns_a"))
14096 .unwrap()
14097 .unwrap();
14098 assert_eq!(in_a.namespace, "ns_a");
14099 assert_eq!(in_a.canonical_name, "Acme");
14100 let in_b = entity_get_by_alias(&conn, "a", Some("ns_b"))
14101 .unwrap()
14102 .unwrap();
14103 assert_eq!(in_b.namespace, "ns_b");
14104 assert_eq!(in_b.canonical_name, "Acme Corp");
14105 }
14106
14107 #[test]
14108 fn entity_get_by_alias_without_namespace_picks_most_recent() {
14109 let conn = test_db();
14110 entity_register(
14112 &conn,
14113 "Older",
14114 "ns_old",
14115 &["dup".to_string()],
14116 &serde_json::json!({}),
14117 None,
14118 )
14119 .unwrap();
14120 std::thread::sleep(std::time::Duration::from_millis(5));
14122 entity_register(
14123 &conn,
14124 "Newer",
14125 "ns_new",
14126 &["dup".to_string()],
14127 &serde_json::json!({}),
14128 None,
14129 )
14130 .unwrap();
14131 let got = entity_get_by_alias(&conn, "dup", None).unwrap().unwrap();
14132 assert_eq!(got.canonical_name, "Newer");
14133 assert_eq!(got.namespace, "ns_new");
14134 }
14135
14136 #[test]
14137 fn entity_get_by_alias_ignores_non_entity_memory_with_matching_alias() {
14138 let conn = test_db();
14139 let mut mem = make_memory("Decoy", "test", Tier::Long, 5);
14143 mem.metadata = serde_json::json!({});
14144 let mid = insert(&conn, &mem).unwrap();
14145 let now = chrono::Utc::now().to_rfc3339();
14146 conn.execute(
14147 "INSERT INTO entity_aliases (entity_id, alias, created_at) VALUES (?1, ?2, ?3)",
14148 params![&mid, "decoy", &now],
14149 )
14150 .unwrap();
14151 let got = entity_get_by_alias(&conn, "decoy", None).unwrap();
14152 assert!(got.is_none(), "non-entity memories must not resolve");
14153 }
14154
14155 #[test]
14156 fn entity_register_idempotent_aliases_are_deduped() {
14157 let conn = test_db();
14158 let reg = entity_register(
14159 &conn,
14160 "Dedup",
14161 "test",
14162 &["x".to_string(), "x".to_string(), "y".to_string()],
14163 &serde_json::json!({}),
14164 None,
14165 )
14166 .unwrap();
14167 assert_eq!(reg.aliases.len(), 3);
14170 assert!(reg.aliases.contains(&"Dedup".to_string()));
14171 assert!(reg.aliases.contains(&"x".to_string()));
14172 assert!(reg.aliases.contains(&"y".to_string()));
14173 }
14174
14175 fn insert_link_at(
14180 conn: &Connection,
14181 source_id: &str,
14182 target_id: &str,
14183 relation: &str,
14184 valid_from: &str,
14185 ) {
14186 let now = chrono::Utc::now().to_rfc3339();
14187 conn.execute(
14188 "INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from) \
14189 VALUES (?1, ?2, ?3, ?4, ?5)",
14190 params![source_id, target_id, relation, now, valid_from],
14191 )
14192 .unwrap();
14193 }
14194
14195 #[test]
14196 fn create_link_populates_valid_from_for_new_rows() {
14197 let conn = test_db();
14198 let src = make_memory("kg-src", "test", Tier::Long, 5);
14199 let tgt = make_memory("kg-tgt", "test", Tier::Long, 5);
14200 insert(&conn, &src).unwrap();
14201 insert(&conn, &tgt).unwrap();
14202 create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
14203 let valid_from: Option<String> = conn
14204 .query_row(
14205 "SELECT valid_from FROM memory_links WHERE source_id = ?1",
14206 params![&src.id],
14207 |r| r.get(0),
14208 )
14209 .unwrap();
14210 assert!(
14211 valid_from.is_some(),
14212 "create_link must populate valid_from so kg_timeline can see new links"
14213 );
14214 }
14215
14216 #[test]
14218 fn schema_v23_memory_links_has_attest_level_column() {
14219 let conn = test_db();
14220 assert!(
14221 column_exists(&conn, "memory_links", "attest_level"),
14222 "v23 must add attest_level column to memory_links"
14223 );
14224 }
14225
14226 #[test]
14231 fn create_link_signed_without_keypair_is_unsigned() {
14232 let conn = test_db();
14233 let src = make_memory("h2-src-unsigned", "test", Tier::Long, 5);
14234 let tgt = make_memory("h2-tgt-unsigned", "test", Tier::Long, 5);
14235 insert(&conn, &src).unwrap();
14236 insert(&conn, &tgt).unwrap();
14237
14238 let level = create_link_signed(&conn, &src.id, &tgt.id, "related_to", None).unwrap();
14239 assert_eq!(level, "unsigned");
14240
14241 let (sig, attest): (Option<Vec<u8>>, Option<String>) = conn
14242 .query_row(
14243 "SELECT signature, attest_level FROM memory_links \
14244 WHERE source_id = ?1 AND target_id = ?2",
14245 params![&src.id, &tgt.id],
14246 |r| Ok((r.get(0)?, r.get(1)?)),
14247 )
14248 .unwrap();
14249 assert!(sig.is_none(), "no keypair → signature must be NULL");
14250 assert_eq!(attest.as_deref(), Some("unsigned"));
14251 }
14252
14253 #[test]
14258 fn create_link_signed_with_keypair_persists_valid_signature() {
14259 use crate::identity::{keypair, sign as link_sign};
14260 use ed25519_dalek::Verifier;
14261
14262 let conn = test_db();
14263 let src = make_memory("h2-src-signed", "test", Tier::Long, 5);
14264 let tgt = make_memory("h2-tgt-signed", "test", Tier::Long, 5);
14265 insert(&conn, &src).unwrap();
14266 insert(&conn, &tgt).unwrap();
14267
14268 let kp = keypair::generate("alice").unwrap();
14269 let level = create_link_signed(&conn, &src.id, &tgt.id, "supersedes", Some(&kp)).unwrap();
14270 assert_eq!(level, "self_signed");
14271
14272 let (sig, attest, valid_from): (Option<Vec<u8>>, Option<String>, Option<String>) = conn
14274 .query_row(
14275 "SELECT signature, attest_level, valid_from FROM memory_links \
14276 WHERE source_id = ?1 AND target_id = ?2",
14277 params![&src.id, &tgt.id],
14278 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
14279 )
14280 .unwrap();
14281 let sig_bytes = sig.expect("signature must be present when keypair is provided");
14282 assert_eq!(sig_bytes.len(), 64, "Ed25519 signature is 64 bytes");
14283 assert_eq!(attest.as_deref(), Some("self_signed"));
14284 let valid_from = valid_from.expect("valid_from must be set on the insert path");
14285
14286 let signable = link_sign::SignableLink {
14290 src_id: &src.id,
14291 dst_id: &tgt.id,
14292 relation: "supersedes",
14293 observed_by: Some(kp.agent_id.as_str()),
14294 valid_from: Some(valid_from.as_str()),
14295 valid_until: None,
14296 };
14297 let payload = link_sign::canonical_cbor(&signable).unwrap();
14298 let mut sig_arr = [0u8; 64];
14299 sig_arr.copy_from_slice(&sig_bytes);
14300 let sig_obj = ed25519_dalek::Signature::from_bytes(&sig_arr);
14301 kp.public
14302 .verify(&payload, &sig_obj)
14303 .expect("persisted signature must verify against the writer's public key");
14304 }
14305
14306 #[test]
14318 fn h6_create_link_signed_truncates_valid_from_to_microseconds() {
14319 use crate::identity::{keypair, sign as link_sign};
14320 use ed25519_dalek::Verifier;
14321
14322 let conn = test_db();
14323 let src = make_memory("h6-src", "test", Tier::Long, 5);
14324 let tgt = make_memory("h6-tgt", "test", Tier::Long, 5);
14325 insert(&conn, &src).unwrap();
14326 insert(&conn, &tgt).unwrap();
14327
14328 let kp = keypair::generate("alice").unwrap();
14329 let level = create_link_signed(&conn, &src.id, &tgt.id, "related_to", Some(&kp)).unwrap();
14330 assert_eq!(level, "self_signed");
14331
14332 let (sig, valid_from): (Option<Vec<u8>>, Option<String>) = conn
14333 .query_row(
14334 "SELECT signature, valid_from FROM memory_links \
14335 WHERE source_id = ?1 AND target_id = ?2",
14336 params![&src.id, &tgt.id],
14337 |r| Ok((r.get(0)?, r.get(1)?)),
14338 )
14339 .unwrap();
14340 let valid_from = valid_from.expect("valid_from set on signed insert path");
14341
14342 if let Some(dot) = valid_from.find('.') {
14347 let after = &valid_from[dot + 1..];
14348 let frac_len = after.chars().take_while(|c| c.is_ascii_digit()).count();
14349 assert!(
14350 frac_len <= 6,
14351 "H6 regression: valid_from has {frac_len}-digit fractional second; expected ≤ 6 (microseconds). Value: {valid_from}"
14352 );
14353 }
14354
14355 let sig_bytes = sig.expect("signature persisted");
14362 let signable = link_sign::SignableLink {
14363 src_id: &src.id,
14364 dst_id: &tgt.id,
14365 relation: "related_to",
14366 observed_by: Some(kp.agent_id.as_str()),
14367 valid_from: Some(valid_from.as_str()),
14368 valid_until: None,
14369 };
14370 let payload = link_sign::canonical_cbor(&signable).unwrap();
14371 let mut sig_arr = [0u8; 64];
14372 sig_arr.copy_from_slice(&sig_bytes);
14373 let sig_obj = ed25519_dalek::Signature::from_bytes(&sig_arr);
14374 kp.public.verify(&payload, &sig_obj).expect(
14375 "H6 regression: signature must verify against canonical CBOR \
14376 derived from the stored (microsecond-truncated) valid_from",
14377 );
14378 }
14379
14380 #[test]
14386 fn a3_validate_link_pre_create_refuses_reflection_cycle() {
14387 use crate::config::{
14388 PermissionsMode, lock_permissions_mode_for_test,
14389 override_active_permissions_mode_for_test,
14390 };
14391 let _gate = lock_permissions_mode_for_test();
14396 override_active_permissions_mode_for_test(PermissionsMode::Off);
14399
14400 let conn = test_db();
14401 let a = make_memory("a3-a", "ns", Tier::Long, 5);
14402 let b = make_memory("a3-b", "ns", Tier::Long, 5);
14403 let c = make_memory("a3-c", "ns", Tier::Long, 5);
14404 insert(&conn, &a).unwrap();
14405 insert(&conn, &b).unwrap();
14406 insert(&conn, &c).unwrap();
14407
14408 create_link(&conn, &a.id, &b.id, "reflects_on").unwrap();
14410 create_link(&conn, &b.id, &c.id, "reflects_on").unwrap();
14411
14412 let err = create_link(&conn, &c.id, &a.id, "reflects_on")
14414 .expect_err("cycle-closing reflects_on must be refused");
14415 let msg = err.to_string();
14416 assert!(
14417 msg.starts_with(LINK_CYCLE_ERR_PREFIX),
14418 "expected {LINK_CYCLE_ERR_PREFIX} prefix, got: {msg}"
14419 );
14420
14421 create_link(&conn, &c.id, &a.id, "related_to")
14424 .expect("related_to is not gated by the cycle check");
14425 }
14426
14427 #[test]
14432 fn a3_validate_link_pre_create_respects_governance_deny() {
14433 use crate::config::{
14434 PermissionsMode, lock_permissions_mode_for_test,
14435 override_active_permissions_mode_for_test,
14436 };
14437 use crate::permissions::{
14438 PermissionRule, RuleDecision, clear_active_permission_rules_for_test,
14439 set_active_permission_rules,
14440 };
14441 let _gate = lock_permissions_mode_for_test();
14442 override_active_permissions_mode_for_test(PermissionsMode::Enforce);
14443 clear_active_permission_rules_for_test();
14444 set_active_permission_rules(vec![PermissionRule {
14445 namespace_pattern: "a3-deny/**".to_string(),
14446 op: "memory_link".to_string(),
14447 agent_pattern: "*".to_string(),
14448 decision: RuleDecision::Deny,
14449 reason: Some("test: link denied by a3 rule".to_string()),
14450 }]);
14451
14452 let conn = test_db();
14453 let s = make_memory("a3-src", "a3-deny/scope", Tier::Long, 5);
14454 let t = make_memory("a3-tgt", "a3-deny/scope", Tier::Long, 5);
14455 insert(&conn, &s).unwrap();
14456 insert(&conn, &t).unwrap();
14457
14458 let err = create_link(&conn, &s.id, &t.id, "related_to")
14459 .expect_err("a Deny rule must refuse the link write");
14460 let msg = err.to_string();
14461 assert!(
14462 msg.starts_with(LINK_PERMISSION_DENIED_ERR_PREFIX),
14463 "expected {LINK_PERMISSION_DENIED_ERR_PREFIX} prefix, got: {msg}"
14464 );
14465
14466 clear_active_permission_rules_for_test();
14469 override_active_permissions_mode_for_test(PermissionsMode::Advisory);
14470 }
14471
14472 #[test]
14477 fn a3_create_link_inbound_peer_attested_bypasses_governance() {
14478 use crate::config::{
14479 PermissionsMode, lock_permissions_mode_for_test,
14480 override_active_permissions_mode_for_test,
14481 };
14482 use crate::permissions::{
14483 PermissionRule, RuleDecision, clear_active_permission_rules_for_test,
14484 set_active_permission_rules,
14485 };
14486 let _gate = lock_permissions_mode_for_test();
14487 override_active_permissions_mode_for_test(PermissionsMode::Enforce);
14488 clear_active_permission_rules_for_test();
14489 set_active_permission_rules(vec![PermissionRule {
14490 namespace_pattern: "**".to_string(),
14491 op: "memory_link".to_string(),
14492 agent_pattern: "*".to_string(),
14493 decision: RuleDecision::Deny,
14494 reason: Some("test: every link denied".to_string()),
14495 }]);
14496
14497 let conn = test_db();
14498 let s = make_memory("inbound-src", "a3-fed", Tier::Long, 5);
14499 let t = make_memory("inbound-tgt", "a3-fed", Tier::Long, 5);
14500 insert(&conn, &s).unwrap();
14501 insert(&conn, &t).unwrap();
14502
14503 let link = MemoryLink {
14513 source_id: s.id.clone(),
14514 target_id: t.id.clone(),
14515 relation: crate::models::MemoryLinkRelation::RelatedTo,
14516 created_at: chrono::Utc::now().to_rfc3339(),
14517 valid_from: None,
14518 valid_until: None,
14519 observed_by: Some("peer:remote".to_string()),
14520 signature: Some(vec![0xAB_u8; 64]),
14521 attest_level: None,
14522 };
14523
14524 create_link_inbound(&conn, &link, "peer_attested")
14526 .expect("peer_attested must bypass K9 governance");
14527
14528 let link2 = MemoryLink {
14530 source_id: t.id.clone(),
14531 target_id: s.id.clone(),
14532 relation: crate::models::MemoryLinkRelation::RelatedTo,
14533 created_at: chrono::Utc::now().to_rfc3339(),
14534 valid_from: None,
14535 valid_until: None,
14536 observed_by: Some("peer:remote".to_string()),
14537 signature: None,
14538 attest_level: None,
14539 };
14540 let err = create_link_inbound(&conn, &link2, "unsigned")
14541 .expect_err("unsigned inbound must NOT bypass governance");
14542 assert!(
14543 err.to_string()
14544 .starts_with(LINK_PERMISSION_DENIED_ERR_PREFIX)
14545 );
14546
14547 clear_active_permission_rules_for_test();
14548 override_active_permissions_mode_for_test(PermissionsMode::Advisory);
14549 }
14550
14551 #[test]
14555 fn a3_create_link_inbound_peer_attested_still_refuses_cycle() {
14556 use crate::config::{
14557 PermissionsMode, lock_permissions_mode_for_test,
14558 override_active_permissions_mode_for_test,
14559 };
14560 let _gate = lock_permissions_mode_for_test();
14561 override_active_permissions_mode_for_test(PermissionsMode::Off);
14562
14563 let conn = test_db();
14564 let a = make_memory("inbound-cycle-a", "ns", Tier::Long, 5);
14565 let b = make_memory("inbound-cycle-b", "ns", Tier::Long, 5);
14566 insert(&conn, &a).unwrap();
14567 insert(&conn, &b).unwrap();
14568 create_link(&conn, &a.id, &b.id, "reflects_on").unwrap();
14569
14570 let cycle_link = MemoryLink {
14571 source_id: b.id.clone(),
14572 target_id: a.id.clone(),
14573 relation: crate::models::MemoryLinkRelation::ReflectsOn,
14574 created_at: chrono::Utc::now().to_rfc3339(),
14575 valid_from: None,
14576 valid_until: None,
14577 observed_by: Some("peer:remote".to_string()),
14578 signature: None,
14579 attest_level: None,
14580 };
14581 let err = create_link_inbound(&conn, &cycle_link, "peer_attested")
14582 .expect_err("cycle check must run even on peer_attested inbound");
14583 assert!(err.to_string().starts_with(LINK_CYCLE_ERR_PREFIX));
14584 }
14585
14586 #[test]
14590 fn h6_truncate_to_microseconds_drops_nanos() {
14591 use chrono::{TimeZone, Timelike};
14592 let ns = Utc.with_ymd_and_hms(2026, 5, 10, 12, 34, 56).unwrap();
14593 let ns = ns.with_nanosecond(123_456_789).unwrap();
14594 let truncated = truncate_to_microseconds(ns);
14595 assert_eq!(truncated.nanosecond(), 123_456_000);
14597 let s = truncated.to_rfc3339();
14600 let dot = s.find('.').expect("fractional second present");
14601 let frac = &s[dot + 1..];
14602 let frac_len = frac.chars().take_while(|c| c.is_ascii_digit()).count();
14603 assert_eq!(frac_len, 6, "expected exactly 6-digit fractional; got: {s}");
14604 }
14605
14606 #[test]
14607 fn kg_timeline_returns_events_ordered_by_valid_from_ascending() {
14608 let conn = test_db();
14609 let src = make_memory("alpha", "kg/projects/alpha", Tier::Long, 5);
14610 let s1 = make_memory("kickoff", "kg/projects/alpha", Tier::Long, 5);
14611 let s2 = make_memory("design phase", "kg/projects/alpha", Tier::Long, 5);
14612 let s3 = make_memory("implementation", "kg/projects/alpha", Tier::Long, 5);
14613 insert(&conn, &src).unwrap();
14614 insert(&conn, &s1).unwrap();
14615 insert(&conn, &s2).unwrap();
14616 insert(&conn, &s3).unwrap();
14617
14618 insert_link_at(
14621 &conn,
14622 &src.id,
14623 &s2.id,
14624 "supersedes",
14625 "2026-02-03T00:00:00+00:00",
14626 );
14627 insert_link_at(
14628 &conn,
14629 &src.id,
14630 &s1.id,
14631 "related_to",
14632 "2026-01-15T00:00:00+00:00",
14633 );
14634 insert_link_at(
14635 &conn,
14636 &src.id,
14637 &s3.id,
14638 "supersedes",
14639 "2026-03-22T00:00:00+00:00",
14640 );
14641
14642 let events = kg_timeline(&conn, &src.id, None, None, None).unwrap();
14643 assert_eq!(events.len(), 3);
14644 assert_eq!(events[0].target_id, s1.id);
14645 assert_eq!(events[1].target_id, s2.id);
14646 assert_eq!(events[2].target_id, s3.id);
14647 assert_eq!(events[0].title, "kickoff");
14648 assert_eq!(events[1].relation, "supersedes");
14649 assert_eq!(events[0].target_namespace, "kg/projects/alpha");
14650 }
14651
14652 #[test]
14653 fn kg_timeline_filters_by_since_inclusive() {
14654 let conn = test_db();
14655 let src = make_memory("e", "ns", Tier::Long, 5);
14656 let t1 = make_memory("e1", "ns", Tier::Long, 5);
14657 let t2 = make_memory("e2", "ns", Tier::Long, 5);
14658 insert(&conn, &src).unwrap();
14659 insert(&conn, &t1).unwrap();
14660 insert(&conn, &t2).unwrap();
14661 insert_link_at(
14662 &conn,
14663 &src.id,
14664 &t1.id,
14665 "related_to",
14666 "2026-01-01T00:00:00+00:00",
14667 );
14668 insert_link_at(
14669 &conn,
14670 &src.id,
14671 &t2.id,
14672 "related_to",
14673 "2026-03-01T00:00:00+00:00",
14674 );
14675
14676 let events = kg_timeline(
14677 &conn,
14678 &src.id,
14679 Some("2026-02-01T00:00:00+00:00"),
14680 None,
14681 None,
14682 )
14683 .unwrap();
14684 assert_eq!(events.len(), 1);
14685 assert_eq!(events[0].target_id, t2.id);
14686
14687 let on_boundary = kg_timeline(
14689 &conn,
14690 &src.id,
14691 Some("2026-03-01T00:00:00+00:00"),
14692 None,
14693 None,
14694 )
14695 .unwrap();
14696 assert_eq!(on_boundary.len(), 1);
14697 }
14698
14699 #[test]
14700 fn kg_timeline_filters_by_until_inclusive() {
14701 let conn = test_db();
14702 let src = make_memory("e", "ns", Tier::Long, 5);
14703 let t1 = make_memory("e1", "ns", Tier::Long, 5);
14704 let t2 = make_memory("e2", "ns", Tier::Long, 5);
14705 insert(&conn, &src).unwrap();
14706 insert(&conn, &t1).unwrap();
14707 insert(&conn, &t2).unwrap();
14708 insert_link_at(
14709 &conn,
14710 &src.id,
14711 &t1.id,
14712 "related_to",
14713 "2026-01-01T00:00:00+00:00",
14714 );
14715 insert_link_at(
14716 &conn,
14717 &src.id,
14718 &t2.id,
14719 "related_to",
14720 "2026-03-01T00:00:00+00:00",
14721 );
14722
14723 let events = kg_timeline(
14724 &conn,
14725 &src.id,
14726 None,
14727 Some("2026-02-01T00:00:00+00:00"),
14728 None,
14729 )
14730 .unwrap();
14731 assert_eq!(events.len(), 1);
14732 assert_eq!(events[0].target_id, t1.id);
14733 }
14734
14735 #[test]
14736 fn kg_timeline_skips_links_with_null_valid_from() {
14737 let conn = test_db();
14738 let src = make_memory("s", "ns", Tier::Long, 5);
14739 let t1 = make_memory("t1", "ns", Tier::Long, 5);
14740 let t2 = make_memory("t2", "ns", Tier::Long, 5);
14741 insert(&conn, &src).unwrap();
14742 insert(&conn, &t1).unwrap();
14743 insert(&conn, &t2).unwrap();
14744 let now = chrono::Utc::now().to_rfc3339();
14747 conn.execute(
14750 "INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from) \
14751 VALUES (?1, ?2, 'related_to', ?3, NULL)",
14752 params![&src.id, &t1.id, &now],
14753 )
14754 .unwrap();
14755 insert_link_at(
14756 &conn,
14757 &src.id,
14758 &t2.id,
14759 "supersedes",
14760 "2026-01-01T00:00:00+00:00",
14761 );
14762
14763 let events = kg_timeline(&conn, &src.id, None, None, None).unwrap();
14764 assert_eq!(events.len(), 1);
14765 assert_eq!(events[0].target_id, t2.id);
14766 }
14767
14768 #[test]
14769 fn kg_timeline_excludes_links_where_source_is_target() {
14770 let conn = test_db();
14775 let entity = make_memory("entity", "ns", Tier::Long, 5);
14776 let other = make_memory("other", "ns", Tier::Long, 5);
14777 insert(&conn, &entity).unwrap();
14778 insert(&conn, &other).unwrap();
14779 insert_link_at(
14780 &conn,
14781 &other.id,
14782 &entity.id,
14783 "related_to",
14784 "2026-01-01T00:00:00+00:00",
14785 );
14786 let events = kg_timeline(&conn, &entity.id, None, None, None).unwrap();
14787 assert!(events.is_empty());
14788 }
14789
14790 #[test]
14791 fn kg_timeline_limit_clamped_to_max() {
14792 let conn = test_db();
14793 let src = make_memory("s", "ns", Tier::Long, 5);
14794 insert(&conn, &src).unwrap();
14795 for i in 0..5 {
14796 let t = make_memory(&format!("t{i}"), "ns", Tier::Long, 5);
14797 insert(&conn, &t).unwrap();
14798 insert_link_at(
14799 &conn,
14800 &src.id,
14801 &t.id,
14802 "related_to",
14803 &format!("2026-01-0{}T00:00:00+00:00", i + 1),
14804 );
14805 }
14806 let events = kg_timeline(&conn, &src.id, None, None, Some(usize::MAX)).unwrap();
14810 assert_eq!(events.len(), 5);
14811
14812 let one = kg_timeline(&conn, &src.id, None, None, Some(0)).unwrap();
14814 assert_eq!(one.len(), 1);
14815 }
14816
14817 #[test]
14818 fn kg_timeline_carries_observed_by_and_valid_until() {
14819 let conn = test_db();
14820 let src = make_memory("s", "ns", Tier::Long, 5);
14821 let t = make_memory("t", "ns", Tier::Long, 5);
14822 insert(&conn, &src).unwrap();
14823 insert(&conn, &t).unwrap();
14824 let now = chrono::Utc::now().to_rfc3339();
14825 conn.execute(
14826 "INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from, valid_until, observed_by) \
14827 VALUES (?1, ?2, 'supersedes', ?3, '2026-01-01T00:00:00+00:00', '2026-12-31T23:59:59+00:00', 'agent-pm-1')",
14828 params![&src.id, &t.id, &now],
14829 )
14830 .unwrap();
14831 let events = kg_timeline(&conn, &src.id, None, None, None).unwrap();
14832 assert_eq!(events.len(), 1);
14833 assert_eq!(events[0].observed_by.as_deref(), Some("agent-pm-1"));
14834 assert_eq!(
14835 events[0].valid_until.as_deref(),
14836 Some("2026-12-31T23:59:59+00:00")
14837 );
14838 }
14839
14840 #[test]
14841 fn kg_timeline_empty_for_unknown_source() {
14842 let conn = test_db();
14843 let events = kg_timeline(&conn, "nonexistent-id", None, None, None).unwrap();
14844 assert!(events.is_empty());
14845 }
14846
14847 #[test]
14850 fn invalidate_link_sets_valid_until_to_provided_timestamp() {
14851 let conn = test_db();
14852 let src = make_memory("inv-s", "test", Tier::Long, 5);
14853 let tgt = make_memory("inv-t", "test", Tier::Long, 5);
14854 insert(&conn, &src).unwrap();
14855 insert(&conn, &tgt).unwrap();
14856 create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
14857 let stamp = "2026-12-31T23:59:59+00:00";
14858 let res = invalidate_link(&conn, &src.id, &tgt.id, "related_to", Some(stamp))
14859 .unwrap()
14860 .expect("link must exist");
14861 assert_eq!(res.valid_until, stamp);
14862 assert!(res.previous_valid_until.is_none());
14863 let stored: Option<String> = conn
14864 .query_row(
14865 "SELECT valid_until FROM memory_links \
14866 WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3",
14867 params![&src.id, &tgt.id, "related_to"],
14868 |r| r.get(0),
14869 )
14870 .unwrap();
14871 assert_eq!(stored.as_deref(), Some(stamp));
14872 }
14873
14874 #[test]
14875 fn invalidate_link_defaults_to_now_when_no_timestamp_provided() {
14876 let conn = test_db();
14877 let src = make_memory("inv-s", "test", Tier::Long, 5);
14878 let tgt = make_memory("inv-t", "test", Tier::Long, 5);
14879 insert(&conn, &src).unwrap();
14880 insert(&conn, &tgt).unwrap();
14881 create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
14882 let res = invalidate_link(&conn, &src.id, &tgt.id, "related_to", None)
14883 .unwrap()
14884 .expect("link must exist");
14885 let parsed = chrono::DateTime::parse_from_rfc3339(&res.valid_until)
14889 .expect("default valid_until must be RFC3339");
14890 let now = chrono::Utc::now();
14891 let drift = now.signed_duration_since(parsed.with_timezone(&chrono::Utc));
14892 assert!(
14893 drift.num_seconds().abs() < 60,
14894 "default valid_until {} should be near now {now}",
14895 res.valid_until
14896 );
14897 }
14898
14899 #[test]
14900 fn invalidate_link_returns_none_for_unknown_triple() {
14901 let conn = test_db();
14902 let res = invalidate_link(&conn, "missing-src", "missing-tgt", "related_to", None).unwrap();
14904 assert!(res.is_none());
14905 }
14906
14907 #[test]
14908 fn invalidate_link_returns_none_when_relation_does_not_match() {
14909 let conn = test_db();
14911 let src = make_memory("inv-s", "test", Tier::Long, 5);
14912 let tgt = make_memory("inv-t", "test", Tier::Long, 5);
14913 insert(&conn, &src).unwrap();
14914 insert(&conn, &tgt).unwrap();
14915 create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
14916 let res = invalidate_link(&conn, &src.id, &tgt.id, "supersedes", None).unwrap();
14917 assert!(res.is_none(), "must not match across relation values");
14918 }
14919
14920 #[test]
14921 fn invalidate_link_overwrites_existing_valid_until_and_reports_prior() {
14922 let conn = test_db();
14923 let src = make_memory("inv-s", "test", Tier::Long, 5);
14924 let tgt = make_memory("inv-t", "test", Tier::Long, 5);
14925 insert(&conn, &src).unwrap();
14926 insert(&conn, &tgt).unwrap();
14927 create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
14928 let first = "2026-06-01T00:00:00+00:00";
14929 let second = "2026-12-01T00:00:00+00:00";
14930 let r1 = invalidate_link(&conn, &src.id, &tgt.id, "related_to", Some(first))
14931 .unwrap()
14932 .unwrap();
14933 assert!(r1.previous_valid_until.is_none());
14934 let r2 = invalidate_link(&conn, &src.id, &tgt.id, "related_to", Some(second))
14935 .unwrap()
14936 .unwrap();
14937 assert_eq!(r2.previous_valid_until.as_deref(), Some(first));
14938 assert_eq!(r2.valid_until, second);
14939 }
14940
14941 #[test]
14942 fn invalidate_link_distinguishes_relation_when_multiple_links_share_endpoints() {
14943 let conn = test_db();
14946 let src = make_memory("inv-s", "test", Tier::Long, 5);
14947 let tgt = make_memory("inv-t", "test", Tier::Long, 5);
14948 insert(&conn, &src).unwrap();
14949 insert(&conn, &tgt).unwrap();
14950 create_link(&conn, &src.id, &tgt.id, "related_to").unwrap();
14951 create_link(&conn, &src.id, &tgt.id, "supersedes").unwrap();
14952 let stamp = "2026-07-15T12:00:00+00:00";
14953 invalidate_link(&conn, &src.id, &tgt.id, "related_to", Some(stamp))
14954 .unwrap()
14955 .unwrap();
14956 let related: Option<String> = conn
14957 .query_row(
14958 "SELECT valid_until FROM memory_links \
14959 WHERE source_id = ?1 AND target_id = ?2 AND relation = 'related_to'",
14960 params![&src.id, &tgt.id],
14961 |r| r.get(0),
14962 )
14963 .unwrap();
14964 let supers: Option<String> = conn
14965 .query_row(
14966 "SELECT valid_until FROM memory_links \
14967 WHERE source_id = ?1 AND target_id = ?2 AND relation = 'supersedes'",
14968 params![&src.id, &tgt.id],
14969 |r| r.get(0),
14970 )
14971 .unwrap();
14972 assert_eq!(related.as_deref(), Some(stamp));
14973 assert!(
14974 supers.is_none(),
14975 "the sibling 'supersedes' link must remain valid"
14976 );
14977 }
14978
14979 #[test]
14980 fn invalidate_link_preserves_other_columns() {
14981 let conn = test_db();
14984 let src = make_memory("inv-s", "test", Tier::Long, 5);
14985 let tgt = make_memory("inv-t", "test", Tier::Long, 5);
14986 insert(&conn, &src).unwrap();
14987 insert(&conn, &tgt).unwrap();
14988 let now = chrono::Utc::now().to_rfc3339();
14989 conn.execute(
14990 "INSERT INTO memory_links \
14991 (source_id, target_id, relation, created_at, valid_from, observed_by) \
14992 VALUES (?1, ?2, 'related_to', ?3, '2026-01-01T00:00:00+00:00', 'agent-x')",
14993 params![&src.id, &tgt.id, &now],
14994 )
14995 .unwrap();
14996 invalidate_link(
14997 &conn,
14998 &src.id,
14999 &tgt.id,
15000 "related_to",
15001 Some("2026-12-31T23:59:59+00:00"),
15002 )
15003 .unwrap()
15004 .unwrap();
15005 let (vf, ob, ca): (Option<String>, Option<String>, String) = conn
15006 .query_row(
15007 "SELECT valid_from, observed_by, created_at FROM memory_links \
15008 WHERE source_id = ?1 AND target_id = ?2 AND relation = 'related_to'",
15009 params![&src.id, &tgt.id],
15010 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
15011 )
15012 .unwrap();
15013 assert_eq!(vf.as_deref(), Some("2026-01-01T00:00:00+00:00"));
15014 assert_eq!(ob.as_deref(), Some("agent-x"));
15015 assert_eq!(ca, now);
15016 }
15017
15018 #[test]
15019 fn kg_query_default_excludes_invalidated_edges() {
15020 let conn = test_db();
15024 let src = make_memory("inv-src", "ns", Tier::Long, 5);
15025 let live = make_memory("inv-live", "ns", Tier::Long, 5);
15026 let dead = make_memory("inv-dead", "ns", Tier::Long, 5);
15027 insert(&conn, &src).unwrap();
15028 insert(&conn, &live).unwrap();
15029 insert(&conn, &dead).unwrap();
15030 insert_link_full(&conn, &src.id, &live.id, "related_to", None, None, None);
15032 insert_link_full(
15034 &conn,
15035 &src.id,
15036 &dead.id,
15037 "supersedes",
15038 None,
15039 Some("2020-01-01T00:00:00+00:00"),
15040 None,
15041 );
15042
15043 let current = kg_query(&conn, &src.id, 1, None, None, None, false).unwrap();
15045 assert_eq!(current.len(), 1);
15046 assert_eq!(current[0].target_id, live.id);
15047
15048 let full = kg_query(&conn, &src.id, 1, None, None, None, true).unwrap();
15050 assert_eq!(full.len(), 2);
15051 }
15052
15053 #[test]
15054 fn default_for_managed_namespace_helper_yields_write_owner() {
15055 let policy = crate::models::GovernancePolicy::default_for_managed_namespace();
15065 assert_eq!(policy.core.write, crate::models::GovernanceLevel::Owner);
15066 assert_eq!(policy.core.promote, crate::models::GovernanceLevel::Any);
15067 assert_eq!(policy.core.delete, crate::models::GovernanceLevel::Owner);
15068 assert!(policy.core.inherit);
15069 }
15070
15071 #[test]
15072 fn namespace_set_standard_with_explicit_owner_policy_enforces_lock() {
15073 let conn = test_db();
15080 let mut standard = make_memory("std", "ns/locked", Tier::Long, 8);
15081 let policy =
15082 serde_json::to_value(crate::models::GovernancePolicy::default_for_managed_namespace())
15083 .unwrap();
15084 standard.metadata = serde_json::json!({"governance": policy});
15085 let standard_id = insert(&conn, &standard).unwrap();
15086 set_namespace_standard(&conn, "ns/locked", &standard_id, None).unwrap();
15087
15088 let resolved = resolve_governance_policy(&conn, "ns/locked")
15089 .expect("policy must resolve when explicitly set");
15090 assert_eq!(resolved.core.write, crate::models::GovernanceLevel::Owner);
15091 }
15092
15093 #[test]
15101 fn enforce_governance_inherits_owner_for_deep_child_owner_write() {
15102 use crate::config::{
15103 PermissionsMode, lock_permissions_mode_for_test,
15104 override_active_permissions_mode_for_test,
15105 };
15106 use crate::models::{
15107 ApproverType, CorePolicy, GovernanceDecision, GovernanceLevel, GovernancePolicy,
15108 GovernedAction, default_metadata,
15109 };
15110
15111 let _gate = lock_permissions_mode_for_test();
15112 override_active_permissions_mode_for_test(PermissionsMode::Enforce);
15113
15114 let conn = test_db();
15115
15116 let parent_ns = "f1/parent";
15118 let owner = "ai:alice";
15119 let policy = GovernancePolicy {
15120 core: CorePolicy {
15121 write: GovernanceLevel::Owner,
15122 promote: GovernanceLevel::Any,
15123 delete: GovernanceLevel::Owner,
15124 approver: ApproverType::Human,
15125 inherit: true,
15126 max_reflection_depth: None,
15127 },
15128 ..Default::default()
15129 };
15130
15131 let now = chrono::Utc::now().to_rfc3339();
15132 let mut metadata = default_metadata();
15133 if let Some(obj) = metadata.as_object_mut() {
15134 obj.insert(
15135 "agent_id".to_string(),
15136 serde_json::Value::String(owner.to_string()),
15137 );
15138 obj.insert(
15139 "governance".to_string(),
15140 serde_json::to_value(&policy).unwrap(),
15141 );
15142 }
15143 let standard = Memory {
15144 id: uuid::Uuid::new_v4().to_string(),
15145 tier: Tier::Long,
15146 namespace: format!("_standards-{parent_ns}"),
15147 title: "f1-standard".to_string(),
15148 content: "f1 policy".to_string(),
15149 tags: vec![],
15150 priority: 9,
15151 confidence: 1.0,
15152 source: "test".to_string(),
15153 access_count: 0,
15154 created_at: now.clone(),
15155 updated_at: now,
15156 last_accessed_at: None,
15157 expires_at: None,
15158 metadata,
15159 reflection_depth: 0,
15160 memory_kind: crate::models::MemoryKind::Observation,
15161 entity_id: None,
15162 persona_version: None,
15163 citations: Vec::new(),
15164 source_uri: None,
15165 source_span: None,
15166 confidence_source: ConfidenceSource::CallerProvided,
15167 confidence_signals: None,
15168 confidence_decayed_at: None,
15169 version: 1,
15170 };
15171 let standard_id = insert(&conn, &standard).unwrap();
15172 set_namespace_standard(&conn, parent_ns, &standard_id, None).unwrap();
15173
15174 let child_ns = "f1/parent/a/b/c";
15177 let payload = serde_json::json!({"title": "deep-child"});
15178
15179 let allow = enforce_governance(
15181 &conn,
15182 GovernedAction::Store,
15183 child_ns,
15184 owner,
15185 None,
15186 None,
15187 &payload,
15188 )
15189 .expect("enforce_governance must not error on inherited owner policy");
15190 assert!(
15191 matches!(allow, GovernanceDecision::Allow),
15192 "owner write at deep child must Allow when chain walk finds the parent's owner: got {allow:?}"
15193 );
15194
15195 let deny = enforce_governance(
15197 &conn,
15198 GovernedAction::Store,
15199 child_ns,
15200 "ai:eve",
15201 None,
15202 None,
15203 &payload,
15204 )
15205 .expect("enforce_governance must not error");
15206 match deny {
15207 GovernanceDecision::Deny(refusal) => {
15208 assert!(
15209 refusal.reason.contains("not the owner"),
15210 "non-owner deny should cite ownership mismatch, got: {refusal:?}"
15211 );
15212 assert_eq!(
15213 refusal.denied_level,
15214 GovernanceLevel::Owner,
15215 "owner-level refusal must carry GovernanceLevel::Owner; got {refusal:?}",
15216 );
15217 }
15218 other => panic!("expected Deny for non-owner, got {other:?}"),
15219 }
15220 }
15221
15222 #[test]
15238 fn enforce_governance_deep_child_with_inherit_false_still_resolves_via_walk() {
15239 use crate::config::{
15240 PermissionsMode, lock_permissions_mode_for_test,
15241 override_active_permissions_mode_for_test,
15242 };
15243 use crate::models::{
15244 ApproverType, CorePolicy, GovernanceDecision, GovernanceLevel, GovernancePolicy,
15245 GovernedAction, default_metadata,
15246 };
15247
15248 let _gate = lock_permissions_mode_for_test();
15249 override_active_permissions_mode_for_test(PermissionsMode::Enforce);
15250
15251 let conn = test_db();
15252
15253 let parent_ns = "f1nb/parent";
15261 let owner = "ai:alice";
15262 let policy = GovernancePolicy {
15263 core: CorePolicy {
15264 write: GovernanceLevel::Owner,
15265 promote: GovernanceLevel::Any,
15266 delete: GovernanceLevel::Owner,
15267 approver: ApproverType::Human,
15268 inherit: false,
15269 max_reflection_depth: None,
15270 },
15271 ..Default::default()
15272 };
15273 let now = chrono::Utc::now().to_rfc3339();
15274 let mut metadata = default_metadata();
15275 if let Some(obj) = metadata.as_object_mut() {
15276 obj.insert(
15277 "agent_id".to_string(),
15278 serde_json::Value::String(owner.to_string()),
15279 );
15280 obj.insert(
15281 "governance".to_string(),
15282 serde_json::to_value(&policy).unwrap(),
15283 );
15284 }
15285 let standard = Memory {
15286 id: uuid::Uuid::new_v4().to_string(),
15287 tier: Tier::Long,
15288 namespace: format!("_standards-{parent_ns}"),
15289 title: "f1nb-standard".to_string(),
15290 content: "policy".to_string(),
15291 tags: vec![],
15292 priority: 9,
15293 confidence: 1.0,
15294 source: "test".to_string(),
15295 access_count: 0,
15296 created_at: now.clone(),
15297 updated_at: now,
15298 last_accessed_at: None,
15299 expires_at: None,
15300 metadata,
15301 reflection_depth: 0,
15302 memory_kind: crate::models::MemoryKind::Observation,
15303 entity_id: None,
15304 persona_version: None,
15305 citations: Vec::new(),
15306 source_uri: None,
15307 source_span: None,
15308 confidence_source: ConfidenceSource::CallerProvided,
15309 confidence_signals: None,
15310 confidence_decayed_at: None,
15311 version: 1,
15312 };
15313 let standard_id = insert(&conn, &standard).unwrap();
15314 set_namespace_standard(&conn, parent_ns, &standard_id, None).unwrap();
15315
15316 let decision = enforce_governance(
15320 &conn,
15321 GovernedAction::Store,
15322 "f1nb/parent/x/y",
15323 owner,
15324 None,
15325 None,
15326 &serde_json::json!({}),
15327 )
15328 .unwrap();
15329 assert!(
15330 matches!(decision, GovernanceDecision::Allow),
15331 "owner write at deep child resolves via leaf-first walk: got {decision:?}"
15332 );
15333 }
15334
15335 #[test]
15336 fn find_paths_default_excludes_invalidated_edges() {
15337 let conn = test_db();
15341 let a = make_memory("fp-a", "ns", Tier::Long, 5);
15342 let b = make_memory("fp-b", "ns", Tier::Long, 5);
15343 let c = make_memory("fp-c", "ns", Tier::Long, 5);
15344 insert(&conn, &a).unwrap();
15345 insert(&conn, &b).unwrap();
15346 insert(&conn, &c).unwrap();
15347 insert_link_full(&conn, &a.id, &c.id, "related_to", None, None, None);
15349 insert_link_full(
15351 &conn,
15352 &a.id,
15353 &b.id,
15354 "supersedes",
15355 None,
15356 Some("2020-01-01T00:00:00+00:00"),
15357 None,
15358 );
15359 insert_link_full(&conn, &b.id, &c.id, "related_to", None, None, None);
15360
15361 let current = find_paths(&conn, &a.id, &c.id, Some(3), None, false).unwrap();
15363 assert_eq!(current.len(), 1);
15364 assert_eq!(current[0], vec![a.id.clone(), c.id.clone()]);
15365
15366 let full = find_paths(&conn, &a.id, &c.id, Some(3), None, true).unwrap();
15368 assert_eq!(full.len(), 2);
15369 }
15370
15371 fn insert_link_full(
15377 conn: &Connection,
15378 source_id: &str,
15379 target_id: &str,
15380 relation: &str,
15381 valid_from: Option<&str>,
15382 valid_until: Option<&str>,
15383 observed_by: Option<&str>,
15384 ) {
15385 let now = chrono::Utc::now().to_rfc3339();
15386 conn.execute(
15387 "INSERT INTO memory_links \
15388 (source_id, target_id, relation, created_at, valid_from, valid_until, observed_by) \
15389 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
15390 params![
15391 source_id,
15392 target_id,
15393 relation,
15394 now,
15395 valid_from,
15396 valid_until,
15397 observed_by
15398 ],
15399 )
15400 .unwrap();
15401 }
15402
15403 #[test]
15404 fn kg_query_returns_outbound_neighbors_at_depth_1() {
15405 let conn = test_db();
15406 let src = make_memory("alpha", "kg/projects/alpha", Tier::Long, 5);
15407 let n1 = make_memory("kickoff", "kg/projects/alpha", Tier::Long, 5);
15408 let n2 = make_memory("design", "kg/projects/alpha", Tier::Long, 5);
15409 insert(&conn, &src).unwrap();
15410 insert(&conn, &n1).unwrap();
15411 insert(&conn, &n2).unwrap();
15412 insert_link_full(
15413 &conn,
15414 &src.id,
15415 &n1.id,
15416 "related_to",
15417 Some("2026-01-15T00:00:00+00:00"),
15418 None,
15419 Some("agent-1"),
15420 );
15421 insert_link_full(
15422 &conn,
15423 &src.id,
15424 &n2.id,
15425 "supersedes",
15426 Some("2026-02-03T00:00:00+00:00"),
15427 None,
15428 Some("agent-2"),
15429 );
15430
15431 let nodes = kg_query(&conn, &src.id, 1, None, None, None, false).unwrap();
15432 assert_eq!(nodes.len(), 2);
15433 assert_eq!(nodes[0].target_id, n1.id);
15435 assert_eq!(nodes[1].target_id, n2.id);
15436 assert_eq!(nodes[0].title, "kickoff");
15437 assert_eq!(nodes[0].relation, "related_to");
15438 assert_eq!(nodes[0].observed_by.as_deref(), Some("agent-1"));
15439 assert_eq!(nodes[0].depth, 1);
15440 assert_eq!(nodes[0].path, format!("{}->{}", src.id, n1.id));
15441 assert_eq!(nodes[0].target_namespace, "kg/projects/alpha");
15442 }
15443
15444 #[test]
15445 fn kg_query_filters_by_valid_at_window() {
15446 let conn = test_db();
15447 let src = make_memory("e", "ns", Tier::Long, 5);
15448 let t1 = make_memory("e1", "ns", Tier::Long, 5);
15449 let t2 = make_memory("e2", "ns", Tier::Long, 5);
15450 insert(&conn, &src).unwrap();
15451 insert(&conn, &t1).unwrap();
15452 insert(&conn, &t2).unwrap();
15453 insert_link_full(
15455 &conn,
15456 &src.id,
15457 &t1.id,
15458 "related_to",
15459 Some("2026-01-01T00:00:00+00:00"),
15460 Some("2026-02-01T00:00:00+00:00"),
15461 None,
15462 );
15463 insert_link_full(
15464 &conn,
15465 &src.id,
15466 &t2.id,
15467 "related_to",
15468 Some("2026-03-01T00:00:00+00:00"),
15469 None,
15470 None,
15471 );
15472
15473 let n_jan = kg_query(
15475 &conn,
15476 &src.id,
15477 1,
15478 Some("2026-01-15T00:00:00+00:00"),
15479 None,
15480 None,
15481 false,
15482 )
15483 .unwrap();
15484 assert_eq!(n_jan.len(), 1);
15485 assert_eq!(n_jan[0].target_id, t1.id);
15486
15487 let n_feb = kg_query(
15490 &conn,
15491 &src.id,
15492 1,
15493 Some("2026-02-15T00:00:00+00:00"),
15494 None,
15495 None,
15496 false,
15497 )
15498 .unwrap();
15499 assert!(n_feb.is_empty());
15500
15501 let n_apr = kg_query(
15503 &conn,
15504 &src.id,
15505 1,
15506 Some("2026-04-01T00:00:00+00:00"),
15507 None,
15508 None,
15509 false,
15510 )
15511 .unwrap();
15512 assert_eq!(n_apr.len(), 1);
15513 assert_eq!(n_apr[0].target_id, t2.id);
15514 }
15515
15516 #[test]
15517 fn kg_query_skips_null_valid_from_when_valid_at_filter_active() {
15518 let conn = test_db();
15519 let src = make_memory("s", "ns", Tier::Long, 5);
15520 let t = make_memory("t", "ns", Tier::Long, 5);
15521 insert(&conn, &src).unwrap();
15522 insert(&conn, &t).unwrap();
15523 insert_link_full(&conn, &src.id, &t.id, "related_to", None, None, None);
15526
15527 let with_filter = kg_query(
15528 &conn,
15529 &src.id,
15530 1,
15531 Some("2026-01-15T00:00:00+00:00"),
15532 None,
15533 None,
15534 false,
15535 )
15536 .unwrap();
15537 assert!(with_filter.is_empty());
15538
15539 let without = kg_query(&conn, &src.id, 1, None, None, None, false).unwrap();
15541 assert_eq!(without.len(), 1);
15542 assert_eq!(without[0].target_id, t.id);
15543 }
15544
15545 #[test]
15546 fn kg_query_filters_by_allowed_agents() {
15547 let conn = test_db();
15548 let src = make_memory("s", "ns", Tier::Long, 5);
15549 let t1 = make_memory("t1", "ns", Tier::Long, 5);
15550 let t2 = make_memory("t2", "ns", Tier::Long, 5);
15551 let t3 = make_memory("t3", "ns", Tier::Long, 5);
15552 insert(&conn, &src).unwrap();
15553 insert(&conn, &t1).unwrap();
15554 insert(&conn, &t2).unwrap();
15555 insert(&conn, &t3).unwrap();
15556 insert_link_full(
15557 &conn,
15558 &src.id,
15559 &t1.id,
15560 "related_to",
15561 Some("2026-01-01T00:00:00+00:00"),
15562 None,
15563 Some("agent-a"),
15564 );
15565 insert_link_full(
15566 &conn,
15567 &src.id,
15568 &t2.id,
15569 "related_to",
15570 Some("2026-01-02T00:00:00+00:00"),
15571 None,
15572 Some("agent-b"),
15573 );
15574 insert_link_full(
15577 &conn,
15578 &src.id,
15579 &t3.id,
15580 "related_to",
15581 Some("2026-01-03T00:00:00+00:00"),
15582 None,
15583 None,
15584 );
15585
15586 let allow_a = vec!["agent-a".to_string()];
15587 let only_a = kg_query(&conn, &src.id, 1, None, Some(&allow_a), None, false).unwrap();
15588 assert_eq!(only_a.len(), 1);
15589 assert_eq!(only_a[0].target_id, t1.id);
15590
15591 let allow_both = vec!["agent-a".to_string(), "agent-b".to_string()];
15592 let both = kg_query(&conn, &src.id, 1, None, Some(&allow_both), None, false).unwrap();
15593 assert_eq!(both.len(), 2);
15594 }
15595
15596 #[test]
15597 fn kg_query_empty_allowed_agents_returns_zero_rows() {
15598 let conn = test_db();
15599 let src = make_memory("s", "ns", Tier::Long, 5);
15600 let t = make_memory("t", "ns", Tier::Long, 5);
15601 insert(&conn, &src).unwrap();
15602 insert(&conn, &t).unwrap();
15603 insert_link_full(
15604 &conn,
15605 &src.id,
15606 &t.id,
15607 "related_to",
15608 Some("2026-01-01T00:00:00+00:00"),
15609 None,
15610 Some("agent-a"),
15611 );
15612
15613 let unfiltered = kg_query(&conn, &src.id, 1, None, None, None, false).unwrap();
15615 assert_eq!(unfiltered.len(), 1);
15616
15617 let empty: Vec<String> = Vec::new();
15620 let none = kg_query(&conn, &src.id, 1, None, Some(&empty), None, false).unwrap();
15621 assert!(none.is_empty());
15622 }
15623
15624 #[test]
15625 fn kg_query_rejects_max_depth_zero() {
15626 let conn = test_db();
15627 let src = make_memory("s", "ns", Tier::Long, 5);
15628 insert(&conn, &src).unwrap();
15629 let err = kg_query(&conn, &src.id, 0, None, None, None, false).unwrap_err();
15630 assert!(err.to_string().contains("max_depth"));
15631 }
15632
15633 #[test]
15634 fn kg_query_rejects_unsupported_max_depth() {
15635 let conn = test_db();
15639 let src = make_memory("s", "ns", Tier::Long, 5);
15640 insert(&conn, &src).unwrap();
15641 let err = kg_query(
15642 &conn,
15643 &src.id,
15644 KG_QUERY_MAX_SUPPORTED_DEPTH + 1,
15645 None,
15646 None,
15647 None,
15648 false,
15649 )
15650 .unwrap_err();
15651 let msg = err.to_string();
15652 assert!(msg.contains(&format!("max_depth={}", KG_QUERY_MAX_SUPPORTED_DEPTH + 1)));
15653 assert!(msg.contains(&format!("supported depth={KG_QUERY_MAX_SUPPORTED_DEPTH}")));
15654 }
15655
15656 #[test]
15657 fn kg_query_traverses_multiple_hops() {
15658 let conn = test_db();
15661 let src = make_memory("src", "ns", Tier::Long, 5);
15662 let mid = make_memory("mid", "ns", Tier::Long, 5);
15663 let leaf = make_memory("leaf", "ns", Tier::Long, 5);
15664 insert(&conn, &src).unwrap();
15665 insert(&conn, &mid).unwrap();
15666 insert(&conn, &leaf).unwrap();
15667 insert_link_full(
15668 &conn,
15669 &src.id,
15670 &mid.id,
15671 "related_to",
15672 Some("2026-01-01T00:00:00+00:00"),
15673 None,
15674 Some("agent-x"),
15675 );
15676 insert_link_full(
15677 &conn,
15678 &mid.id,
15679 &leaf.id,
15680 "supersedes",
15681 Some("2026-01-02T00:00:00+00:00"),
15682 None,
15683 Some("agent-x"),
15684 );
15685
15686 let d1 = kg_query(&conn, &src.id, 1, None, None, None, false).unwrap();
15688 assert_eq!(d1.len(), 1);
15689 assert_eq!(d1[0].target_id, mid.id);
15690 assert_eq!(d1[0].depth, 1);
15691
15692 let d2 = kg_query(&conn, &src.id, 2, None, None, None, false).unwrap();
15694 assert_eq!(d2.len(), 2);
15695 assert_eq!(d2[0].target_id, mid.id);
15696 assert_eq!(d2[0].depth, 1);
15697 assert_eq!(d2[0].path, format!("{}->{}", src.id, mid.id));
15698 assert_eq!(d2[1].target_id, leaf.id);
15699 assert_eq!(d2[1].depth, 2);
15700 assert_eq!(d2[1].relation, "supersedes");
15701 assert_eq!(d2[1].path, format!("{}->{}->{}", src.id, mid.id, leaf.id));
15702 }
15703
15704 #[test]
15705 fn kg_query_multi_hop_respects_valid_at_per_hop() {
15706 let conn = test_db();
15711 let src = make_memory("s", "ns", Tier::Long, 5);
15712 let mid = make_memory("m", "ns", Tier::Long, 5);
15713 let leaf = make_memory("l", "ns", Tier::Long, 5);
15714 insert(&conn, &src).unwrap();
15715 insert(&conn, &mid).unwrap();
15716 insert(&conn, &leaf).unwrap();
15717 insert_link_full(
15718 &conn,
15719 &src.id,
15720 &mid.id,
15721 "related_to",
15722 Some("2026-01-01T00:00:00+00:00"),
15723 Some("2026-02-01T00:00:00+00:00"),
15724 None,
15725 );
15726 insert_link_full(
15727 &conn,
15728 &mid.id,
15729 &leaf.id,
15730 "related_to",
15731 Some("2026-04-01T00:00:00+00:00"),
15732 None,
15733 None,
15734 );
15735
15736 let mid_only = kg_query(
15737 &conn,
15738 &src.id,
15739 3,
15740 Some("2026-01-15T00:00:00+00:00"),
15741 None,
15742 None,
15743 false,
15744 )
15745 .unwrap();
15746 assert_eq!(mid_only.len(), 1);
15747 assert_eq!(mid_only[0].target_id, mid.id);
15748
15749 let neither = kg_query(
15750 &conn,
15751 &src.id,
15752 3,
15753 Some("2026-04-15T00:00:00+00:00"),
15754 None,
15755 None,
15756 false,
15757 )
15758 .unwrap();
15759 assert!(neither.is_empty());
15760 }
15761
15762 #[test]
15763 fn kg_query_detects_cycles() {
15764 let conn = test_db();
15768 let a = make_memory("a", "ns", Tier::Long, 5);
15769 let b = make_memory("b", "ns", Tier::Long, 5);
15770 let c = make_memory("c", "ns", Tier::Long, 5);
15771 insert(&conn, &a).unwrap();
15772 insert(&conn, &b).unwrap();
15773 insert(&conn, &c).unwrap();
15774 insert_link_full(
15775 &conn,
15776 &a.id,
15777 &b.id,
15778 "related_to",
15779 Some("2026-01-01T00:00:00+00:00"),
15780 None,
15781 None,
15782 );
15783 insert_link_full(
15784 &conn,
15785 &b.id,
15786 &c.id,
15787 "related_to",
15788 Some("2026-01-02T00:00:00+00:00"),
15789 None,
15790 None,
15791 );
15792 insert_link_full(
15793 &conn,
15794 &c.id,
15795 &a.id,
15796 "related_to",
15797 Some("2026-01-03T00:00:00+00:00"),
15798 None,
15799 None,
15800 );
15801
15802 let nodes = kg_query(&conn, &a.id, 5, None, None, None, false).unwrap();
15803 assert_eq!(nodes.len(), 2);
15809 assert_eq!(nodes[0].target_id, b.id);
15810 assert_eq!(nodes[0].depth, 1);
15811 assert_eq!(nodes[1].target_id, c.id);
15812 assert_eq!(nodes[1].depth, 2);
15813 }
15814
15815 #[test]
15816 fn kg_query_multi_hop_filters_by_allowed_agents_per_hop() {
15817 let conn = test_db();
15820 let src = make_memory("s", "ns", Tier::Long, 5);
15821 let mid = make_memory("m", "ns", Tier::Long, 5);
15822 let leaf = make_memory("l", "ns", Tier::Long, 5);
15823 insert(&conn, &src).unwrap();
15824 insert(&conn, &mid).unwrap();
15825 insert(&conn, &leaf).unwrap();
15826 insert_link_full(
15827 &conn,
15828 &src.id,
15829 &mid.id,
15830 "related_to",
15831 Some("2026-01-01T00:00:00+00:00"),
15832 None,
15833 Some("agent-a"),
15834 );
15835 insert_link_full(
15836 &conn,
15837 &mid.id,
15838 &leaf.id,
15839 "related_to",
15840 Some("2026-01-02T00:00:00+00:00"),
15841 None,
15842 Some("agent-b"),
15843 );
15844
15845 let allow_a = vec!["agent-a".to_string()];
15846 let only_first = kg_query(&conn, &src.id, 3, None, Some(&allow_a), None, false).unwrap();
15847 assert_eq!(only_first.len(), 1);
15848 assert_eq!(only_first[0].target_id, mid.id);
15849
15850 let allow_both = vec!["agent-a".to_string(), "agent-b".to_string()];
15851 let both = kg_query(&conn, &src.id, 3, None, Some(&allow_both), None, false).unwrap();
15852 assert_eq!(both.len(), 2);
15853 assert_eq!(both[1].target_id, leaf.id);
15854 assert_eq!(both[1].depth, 2);
15855 }
15856
15857 #[test]
15858 fn kg_query_limit_clamped_to_max() {
15859 let conn = test_db();
15860 let src = make_memory("s", "ns", Tier::Long, 5);
15861 insert(&conn, &src).unwrap();
15862 for i in 0..3 {
15863 let t = make_memory(&format!("t{i}"), "ns", Tier::Long, 5);
15864 insert(&conn, &t).unwrap();
15865 insert_link_full(
15866 &conn,
15867 &src.id,
15868 &t.id,
15869 "related_to",
15870 Some(&format!("2026-01-{:02}T00:00:00+00:00", i + 1)),
15871 None,
15872 None,
15873 );
15874 }
15875
15876 let all = kg_query(&conn, &src.id, 1, None, None, Some(usize::MAX), false).unwrap();
15879 assert_eq!(all.len(), 3);
15880
15881 let one = kg_query(&conn, &src.id, 1, None, None, Some(0), false).unwrap();
15883 assert_eq!(one.len(), 1);
15884 }
15885
15886 #[test]
15887 fn kg_query_empty_for_unknown_source() {
15888 let conn = test_db();
15889 let nodes = kg_query(&conn, "no-such-id", 1, None, None, None, false).unwrap();
15890 assert!(nodes.is_empty());
15891 }
15892
15893 #[test]
15894 fn schema_v15_existing_links_get_valid_from_backfilled() {
15895 let path = std::env::temp_dir().join(format!(
15902 "ai_memory_v15_backfill_{}.db",
15903 uuid::Uuid::new_v4()
15904 ));
15905 {
15906 let conn = open(&path).unwrap();
15907 let src = make_memory("src", "test", Tier::Long, 5);
15908 let tgt = make_memory("tgt", "test", Tier::Long, 5);
15909 insert(&conn, &src).unwrap();
15910 insert(&conn, &tgt).unwrap();
15911 conn.execute(
15914 "INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from) \
15915 VALUES (?1, ?2, 'related_to', ?3, NULL)",
15916 params![&src.id, &tgt.id, &chrono::Utc::now().to_rfc3339()],
15917 )
15918 .unwrap();
15919 conn.execute("DELETE FROM schema_version", []).unwrap();
15921 conn.execute("INSERT INTO schema_version (version) VALUES (14)", [])
15922 .unwrap();
15923 }
15924
15925 let conn2 = open(&path).unwrap();
15926 let backfilled: Option<String> = conn2
15927 .query_row("SELECT valid_from FROM memory_links LIMIT 1", [], |r| {
15928 r.get(0)
15929 })
15930 .unwrap();
15931 assert!(
15932 backfilled.is_some(),
15933 "expected valid_from to be backfilled, got NULL"
15934 );
15935 let _ = std::fs::remove_file(&path);
15936 }
15937
15938 #[test]
15939 fn namespace_prefix_query_index_available() {
15940 let conn = test_db();
15941 let result: Option<String> = conn
15947 .query_row(
15948 "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_memories_namespace'",
15949 [],
15950 |r| r.get(0),
15951 )
15952 .unwrap();
15953 assert_eq!(
15954 result,
15955 Some("idx_memories_namespace".to_string()),
15956 "idx_memories_namespace index should exist"
15957 );
15958
15959 let count: i64 = conn
15961 .query_row(
15962 "SELECT COUNT(*) FROM memories WHERE namespace LIKE 'test/%'",
15963 [],
15964 |r| r.get(0),
15965 )
15966 .unwrap();
15967 assert_eq!(count, 0);
15968 }
15969
15970 #[test]
15975 fn doctor_dim_violations_post_p2_returns_zero_on_fresh_db() {
15976 let conn = test_db();
15981 let result = doctor_dim_violations(&conn).unwrap();
15982 assert_eq!(result, Some(0));
15983 }
15984
15985 #[test]
15986 fn doctor_oldest_pending_age_secs_empty_queue() {
15987 let conn = test_db();
15988 let age = doctor_oldest_pending_age_secs(&conn).unwrap();
15989 assert_eq!(age, None);
15990 }
15991
15992 #[test]
15993 fn doctor_oldest_pending_age_secs_reports_age() {
15994 let conn = test_db();
15995 let one_hour_ago = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
15996 conn.execute(
15997 "INSERT INTO pending_actions (id, action_type, namespace, payload, requested_by, requested_at, status)
15998 VALUES ('p1', 'store', 'ns', '{}', 'agent', ?1, 'pending')",
15999 params![one_hour_ago],
16000 )
16001 .unwrap();
16002 let age = doctor_oldest_pending_age_secs(&conn).unwrap().unwrap();
16003 assert!((3500..=3700).contains(&age), "expected ~3600s, got {age}");
16005 }
16006
16007 #[test]
16008 fn doctor_governance_coverage_with_namespace_meta() {
16009 let conn = test_db();
16010 let (with, without) = doctor_governance_coverage(&conn).unwrap();
16012 assert_eq!((with, without), (0, 0));
16013 }
16014
16015 #[test]
16016 fn doctor_governance_depth_distribution_chains() {
16017 let conn = test_db();
16018 let now = Utc::now().to_rfc3339();
16020 conn.execute(
16021 "INSERT INTO namespace_meta (namespace, parent_namespace, updated_at) VALUES ('root', NULL, ?1)",
16022 params![now],
16023 ).unwrap();
16024 conn.execute(
16025 "INSERT INTO namespace_meta (namespace, parent_namespace, updated_at) VALUES ('a', 'root', ?1)",
16026 params![now],
16027 ).unwrap();
16028 conn.execute(
16029 "INSERT INTO namespace_meta (namespace, parent_namespace, updated_at) VALUES ('a/b', 'a', ?1)",
16030 params![now],
16031 ).unwrap();
16032 conn.execute(
16033 "INSERT INTO namespace_meta (namespace, parent_namespace, updated_at) VALUES ('a/b/c', 'a/b', ?1)",
16034 params![now],
16035 ).unwrap();
16036 let dist = doctor_governance_depth_distribution(&conn).unwrap();
16037 assert_eq!(dist[0], 1, "root has depth 0");
16038 assert_eq!(dist[1], 1, "a has depth 1");
16039 assert_eq!(dist[2], 1, "a/b has depth 2");
16040 assert_eq!(dist[3], 1, "a/b/c has depth 3");
16041 }
16042
16043 #[test]
16044 fn doctor_webhook_delivery_totals_empty() {
16045 let conn = test_db();
16046 let (dispatched, failed) = doctor_webhook_delivery_totals(&conn).unwrap();
16047 assert_eq!((dispatched, failed), (0, 0));
16048 }
16049
16050 #[test]
16051 fn doctor_max_sync_skew_secs_empty() {
16052 let conn = test_db();
16053 let skew = doctor_max_sync_skew_secs(&conn).unwrap();
16054 assert_eq!(skew, None);
16055 }
16056
16057 #[test]
16060 fn audit_log_record_and_list_grant_and_deny() {
16061 let conn = test_db();
16062 record_capability_expansion(&conn, Some("alice"), "graph", true, None);
16063 record_capability_expansion(&conn, Some("bob"), "power", false, None);
16064 let rows = list_capability_expansions(&conn, 50, None).unwrap();
16065 assert_eq!(rows.len(), 2);
16066 assert!(rows[0].timestamp >= rows[1].timestamp);
16068 let grant_row = rows
16069 .iter()
16070 .find(|r| r.agent_id.as_deref() == Some("alice"))
16071 .unwrap();
16072 assert!(grant_row.granted);
16073 assert_eq!(grant_row.requested_family.as_deref(), Some("graph"));
16074 let deny_row = rows
16075 .iter()
16076 .find(|r| r.agent_id.as_deref() == Some("bob"))
16077 .unwrap();
16078 assert!(!deny_row.granted);
16079 assert_eq!(deny_row.requested_family.as_deref(), Some("power"));
16080 }
16081
16082 #[test]
16083 fn audit_log_filter_by_agent() {
16084 let conn = test_db();
16085 record_capability_expansion(&conn, Some("alice"), "graph", true, None);
16086 record_capability_expansion(&conn, Some("bob"), "power", false, None);
16087 let alice = list_capability_expansions(&conn, 50, Some("alice")).unwrap();
16088 assert_eq!(alice.len(), 1);
16089 assert_eq!(alice[0].agent_id.as_deref(), Some("alice"));
16090 let none_match = list_capability_expansions(&conn, 50, Some("nobody")).unwrap();
16091 assert!(none_match.is_empty());
16092 }
16093
16094 #[test]
16095 fn audit_log_anonymous_caller() {
16096 let conn = test_db();
16097 record_capability_expansion(&conn, None, "core", true, None);
16098 let rows = list_capability_expansions(&conn, 50, None).unwrap();
16099 assert_eq!(rows.len(), 1);
16100 assert!(rows[0].agent_id.is_none());
16101 }
16102
16103 #[test]
16104 fn audit_log_migration_idempotent_on_re_open() {
16105 let p = tempfile::NamedTempFile::new().unwrap();
16108 let p = p.path().to_path_buf();
16109 let _ = open(&p).unwrap();
16110 let conn = open(&p).unwrap();
16111 let cnt: i64 = conn
16113 .query_row(
16114 "SELECT count(*) FROM sqlite_master WHERE name LIKE 'idx_audit_log_%'",
16115 [],
16116 |r| r.get(0),
16117 )
16118 .unwrap();
16119 assert_eq!(
16120 cnt, 3,
16121 "expected 3 audit_log indexes (agent_id, ts, event_type)"
16122 );
16123 }
16124
16125 fn insert_stale_pending(
16135 conn: &Connection,
16136 id: &str,
16137 namespace: &str,
16138 age_secs: i64,
16139 per_row_timeout: Option<i64>,
16140 ) {
16141 let requested_at = (chrono::Utc::now() - chrono::Duration::seconds(age_secs)).to_rfc3339();
16142 conn.execute(
16143 "INSERT INTO pending_actions
16144 (id, action_type, namespace, payload, requested_by, requested_at,
16145 status, default_timeout_seconds)
16146 VALUES (?1, 'store', ?2, '{}', 'tester', ?3, 'pending', ?4)",
16147 params![id, namespace, requested_at, per_row_timeout],
16148 )
16149 .unwrap();
16150 }
16151
16152 #[test]
16153 fn sweep_marks_stale_pending_row_expired() {
16154 let conn = test_db();
16155 insert_stale_pending(&conn, "stale-1", "ns/a", 7_200, None);
16157
16158 let expired = sweep_pending_action_timeouts(&conn, crate::SECS_PER_HOUR).unwrap();
16159 assert_eq!(expired.len(), 1, "expected exactly one expiry");
16160 assert_eq!(expired[0], ("stale-1".to_string(), "ns/a".to_string()));
16161
16162 let (status, expired_at): (String, Option<String>) = conn
16164 .query_row(
16165 "SELECT status, expired_at FROM pending_actions WHERE id = ?1",
16166 params!["stale-1"],
16167 |r| Ok((r.get(0)?, r.get(1)?)),
16168 )
16169 .unwrap();
16170 assert_eq!(status, "expired");
16171 assert!(
16172 expired_at.is_some(),
16173 "expired_at must be stamped by the sweeper"
16174 );
16175 }
16176
16177 #[test]
16178 fn sweep_leaves_fresh_pending_alone() {
16179 let conn = test_db();
16180 insert_stale_pending(&conn, "fresh-1", "ns/a", 30, None);
16182
16183 let expired = sweep_pending_action_timeouts(&conn, crate::SECS_PER_HOUR).unwrap();
16184 assert!(expired.is_empty());
16185 let status: String = conn
16186 .query_row(
16187 "SELECT status FROM pending_actions WHERE id = ?1",
16188 params!["fresh-1"],
16189 |r| r.get(0),
16190 )
16191 .unwrap();
16192 assert_eq!(status, "pending");
16193 }
16194
16195 #[test]
16196 fn sweep_per_row_timeout_overrides_global_default() {
16197 let conn = test_db();
16198 insert_stale_pending(&conn, "short-ttl", "ns/a", 300, Some(60));
16201 insert_stale_pending(&conn, "no-override", "ns/a", 300, None);
16204
16205 let expired = sweep_pending_action_timeouts(&conn, crate::SECS_PER_HOUR).unwrap();
16206 let ids: Vec<&String> = expired.iter().map(|(id, _)| id).collect();
16207 assert_eq!(ids, vec![&"short-ttl".to_string()]);
16208 }
16209
16210 #[test]
16211 fn sweep_skips_already_decided_rows() {
16212 let conn = test_db();
16213 let approved_at = (chrono::Utc::now() - chrono::Duration::seconds(7_200)).to_rfc3339();
16215 conn.execute(
16216 "INSERT INTO pending_actions
16217 (id, action_type, namespace, payload, requested_by, requested_at,
16218 status, decided_by, decided_at)
16219 VALUES ('approved-old', 'store', 'ns/a', '{}', 'alice', ?1,
16220 'approved', 'bob', ?1)",
16221 params![approved_at],
16222 )
16223 .unwrap();
16224
16225 let expired = sweep_pending_action_timeouts(&conn, 60).unwrap();
16226 assert!(expired.is_empty(), "non-pending rows must be ignored");
16227 let status: String = conn
16228 .query_row(
16229 "SELECT status FROM pending_actions WHERE id = 'approved-old'",
16230 [],
16231 |r| r.get(0),
16232 )
16233 .unwrap();
16234 assert_eq!(status, "approved", "decided row status preserved");
16235 }
16236
16237 #[test]
16238 fn sweep_disabled_when_global_default_non_positive() {
16239 let conn = test_db();
16240 insert_stale_pending(&conn, "stale-2", "ns/a", 7_200, None);
16242 let expired = sweep_pending_action_timeouts(&conn, 0).unwrap();
16245 assert!(expired.is_empty());
16246 let expired_neg = sweep_pending_action_timeouts(&conn, -1).unwrap();
16247 assert!(expired_neg.is_empty());
16248 }
16249
16250 #[test]
16251 fn sweep_empty_queue_is_silent_noop() {
16252 let conn = test_db();
16253 let expired = sweep_pending_action_timeouts(&conn, 60).unwrap();
16254 assert!(expired.is_empty());
16255 }
16256
16257 #[test]
16269 fn test_memories_tier_check_rejects_invalid() {
16270 let conn = test_db();
16271 let now = chrono::Utc::now().to_rfc3339();
16272 let err = conn.execute(
16273 "INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, metadata) \
16274 VALUES (?1, 'long-term', 'ns-ck', 'bad-tier', 'x', '[]', 5, 1.0, 'test', 0, ?2, ?2, '{}')",
16275 params!["m-bad-tier", now],
16276 ).unwrap_err();
16277 let msg = err.to_string();
16278 assert!(
16279 msg.contains("memories.tier must be one of"),
16280 "expected R1-M2 tier check, got: {msg}"
16281 );
16282 }
16283
16284 #[test]
16287 fn test_memories_priority_check_rejects_oob() {
16288 let conn = test_db();
16289 let now = chrono::Utc::now().to_rfc3339();
16290 let err = conn.execute(
16291 "INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, metadata) \
16292 VALUES (?1, 'mid', 'ns-ck', 'bad-prio', 'x', '[]', 11, 1.0, 'test', 0, ?2, ?2, '{}')",
16293 params!["m-bad-prio", now],
16294 ).unwrap_err();
16295 assert!(
16296 err.to_string()
16297 .contains("memories.priority must be between 1 and 10"),
16298 "expected R1-M2 priority check, got: {err}"
16299 );
16300 let err_low = conn.execute(
16302 "INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, metadata) \
16303 VALUES (?1, 'mid', 'ns-ck', 'bad-prio-low', 'x', '[]', 0, 1.0, 'test', 0, ?2, ?2, '{}')",
16304 params!["m-bad-prio-low", now],
16305 ).unwrap_err();
16306 assert!(err_low.to_string().contains("priority"));
16307 }
16308
16309 #[test]
16311 fn test_memories_confidence_check_rejects_oob() {
16312 let conn = test_db();
16313 let now = chrono::Utc::now().to_rfc3339();
16314 let err = conn.execute(
16315 "INSERT INTO memories (id, tier, namespace, title, content, tags, priority, confidence, source, access_count, created_at, updated_at, metadata) \
16316 VALUES (?1, 'mid', 'ns-ck', 'bad-conf', 'x', '[]', 5, 1.5, 'test', 0, ?2, ?2, '{}')",
16317 params!["m-bad-conf", now],
16318 ).unwrap_err();
16319 assert!(
16320 err.to_string().contains("memories.confidence"),
16321 "expected R1-M2 confidence check, got: {err}"
16322 );
16323 }
16324
16325 #[test]
16328 fn test_memory_links_relation_check_rejects_unknown() {
16329 let conn = test_db();
16330 let src = insert(&conn, &make_memory("rel-src", "ns-ck", Tier::Mid, 5)).unwrap();
16331 let tgt = insert(&conn, &make_memory("rel-tgt", "ns-ck", Tier::Mid, 5)).unwrap();
16332 let now = chrono::Utc::now().to_rfc3339();
16333 let err = conn
16334 .execute(
16335 "INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from) \
16336 VALUES (?1, ?2, 'follows', ?3, ?3)",
16337 params![src, tgt, now],
16338 )
16339 .unwrap_err();
16340 assert!(
16341 err.to_string()
16342 .contains("memory_links.relation must be one of"),
16343 "expected R1-M2 relation check, got: {err}"
16344 );
16345 }
16346
16347 #[test]
16350 fn test_memory_links_attest_level_check_rejects_unknown() {
16351 let conn = test_db();
16352 let src = insert(&conn, &make_memory("att-src", "ns-ck", Tier::Mid, 5)).unwrap();
16353 let tgt = insert(&conn, &make_memory("att-tgt", "ns-ck", Tier::Mid, 5)).unwrap();
16354 let now = chrono::Utc::now().to_rfc3339();
16355 conn.execute(
16357 "INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from, attest_level) \
16358 VALUES (?1, ?2, 'related_to', ?3, ?3, NULL)",
16359 params![src, tgt, now],
16360 )
16361 .expect("NULL attest_level must remain accepted");
16362 let err = conn.execute(
16364 "INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from, attest_level) \
16365 VALUES (?1, ?2, 'supersedes', ?3, ?3, 'totally-fake')",
16366 params![src, tgt, now],
16367 ).unwrap_err();
16368 assert!(err.to_string().contains("memory_links.attest_level"));
16369 }
16370
16371 #[test]
16374 fn test_insert_with_conflict_error_mode_refuses_duplicate() {
16375 let conn = test_db();
16376 let m1 = make_memory("dup-title", "ns-conflict", Tier::Mid, 5);
16377 let _id = insert_with_conflict(&conn, &m1, ConflictMode::Error).unwrap();
16378 let mut m2 = make_memory("dup-title", "ns-conflict", Tier::Mid, 7);
16379 m2.content = "second writer should be refused".to_string();
16380 let err = insert_with_conflict(&conn, &m2, ConflictMode::Error).unwrap_err();
16381 let conflict = err.downcast_ref::<ConflictError>();
16382 assert!(
16383 conflict.is_some(),
16384 "expected typed ConflictError, got: {err}"
16385 );
16386 let row = find_by_title_namespace(&conn, "dup-title", "ns-conflict")
16388 .unwrap()
16389 .expect("first row still present");
16390 let fetched = get(&conn, &row).unwrap().unwrap();
16391 assert_ne!(
16392 fetched.content, "second writer should be refused",
16393 "Error mode must not mutate the existing row"
16394 );
16395 }
16396
16397 #[test]
16400 fn test_insert_with_conflict_merge_mode_updates() {
16401 let conn = test_db();
16402 let m1 = make_memory("merge-title", "ns-merge", Tier::Mid, 5);
16403 let id_a = insert_with_conflict(&conn, &m1, ConflictMode::Merge).unwrap();
16404 let mut m2 = make_memory("merge-title", "ns-merge", Tier::Mid, 7);
16405 m2.content = "merged-content".to_string();
16406 let id_b = insert_with_conflict(&conn, &m2, ConflictMode::Merge).unwrap();
16407 assert_eq!(id_a, id_b, "merge mode returns the existing row id");
16408 let fetched = get(&conn, &id_a).unwrap().unwrap();
16409 assert_eq!(fetched.content, "merged-content");
16410 }
16411
16412 #[test]
16415 fn test_insert_with_conflict_version_keeps_both() {
16416 let conn = test_db();
16417 let m1 = make_memory("versioned", "ns-v", Tier::Mid, 5);
16418 let id_a = insert_with_conflict(&conn, &m1, ConflictMode::Version).unwrap();
16419 let mut m2 = make_memory("versioned", "ns-v", Tier::Mid, 5);
16420 m2.content = "second version content".to_string();
16421 let id_b = insert_with_conflict(&conn, &m2, ConflictMode::Version).unwrap();
16422 assert_ne!(id_a, id_b, "version mode produces a distinct row");
16423 let original_id = find_by_title_namespace(&conn, "versioned", "ns-v")
16425 .unwrap()
16426 .expect("original row");
16427 let versioned_id = find_by_title_namespace(&conn, "versioned (2)", "ns-v")
16428 .unwrap()
16429 .expect("versioned row");
16430 assert_eq!(original_id, id_a);
16431 assert_eq!(versioned_id, id_b);
16432 }
16433
16434 #[test]
16437 fn test_memory_link_relation_round_trips() {
16438 let conn = test_db();
16439 let src = insert(&conn, &make_memory("rt-src", "ns-rt", Tier::Mid, 5)).unwrap();
16440 let tgt = insert(&conn, &make_memory("rt-tgt", "ns-rt", Tier::Mid, 5)).unwrap();
16441 create_link(&conn, &src, &tgt, "supersedes").unwrap();
16442 let links = get_links(&conn, &src).unwrap();
16443 assert_eq!(links.len(), 1);
16444 assert_eq!(
16445 links[0].relation,
16446 crate::models::MemoryLinkRelation::Supersedes,
16447 "relation must round-trip as the typed Supersedes variant"
16448 );
16449 let wire = serde_json::to_string(&links[0]).unwrap();
16451 assert!(
16452 wire.contains("\"relation\":\"supersedes\""),
16453 "serde wire form must be the canonical lowercase snake_case \
16454 string; got {wire}"
16455 );
16456 }
16457
16458 fn count_signed_events(conn: &Connection, event_type: &str) -> usize {
16468 crate::signed_events::list_signed_events(conn, None, 1000, 0)
16469 .unwrap_or_default()
16470 .into_iter()
16471 .filter(|e| e.event_type == event_type)
16472 .count()
16473 }
16474
16475 #[test]
16481 fn test_execute_reflect_arm_succeeds_round_trip() {
16482 let conn = test_db();
16483 let src1 = make_memory("src-1", "ns/reflect", Tier::Mid, 5);
16485 let src2 = make_memory("src-2", "ns/reflect", Tier::Mid, 5);
16486 let src1_id = insert(&conn, &src1).unwrap();
16487 let src2_id = insert(&conn, &src2).unwrap();
16488
16489 let payload = serde_json::json!({
16491 "source_ids": [src1_id, src2_id],
16492 "title": "reflective synthesis",
16493 "content": "deep observation across sources",
16494 "namespace": "ns/reflect",
16495 "tier": Tier::Mid.as_str(),
16496 "tags": ["reflective"],
16497 "priority": 6,
16498 "confidence": 0.9,
16499 "agent_id": "alice",
16500 "proposed_depth": 1,
16501 });
16502 let pending_id = queue_pending_action(
16503 &conn,
16504 crate::models::GovernedAction::Reflect,
16505 "ns/reflect",
16506 None,
16507 "alice",
16508 &payload,
16509 )
16510 .unwrap();
16511 assert!(decide_pending_action(&conn, &pending_id, true, "approver").unwrap());
16513
16514 let result = execute_pending_action(&conn, &pending_id).expect("reflect execute ok");
16515 let new_id = result.expect("reflect must return the new reflection id");
16516 let mem = get(&conn, &new_id)
16517 .unwrap()
16518 .expect("reflection memory landed");
16519 assert_eq!(mem.title, "reflective synthesis");
16520 assert_eq!(mem.namespace, "ns/reflect");
16521 assert_eq!(mem.reflection_depth, 1, "depth = max(source depths) + 1");
16522 assert_eq!(mem.metadata["agent_id"], "alice");
16524 }
16525
16526 #[test]
16532 fn test_execute_refuses_payload_agent_id_mismatch() {
16533 let conn = test_db();
16534 let mut mem = make_memory("laundered store", "ns/launder", Tier::Mid, 5);
16535 mem.metadata = serde_json::json!({"agent_id": "bob"});
16539 let payload = serde_json::to_value(&mem).unwrap();
16540 let pending_id = queue_pending_action(
16541 &conn,
16542 crate::models::GovernedAction::Store,
16543 "ns/launder",
16544 None,
16545 "alice",
16546 &payload,
16547 )
16548 .unwrap();
16549 assert!(decide_pending_action(&conn, &pending_id, true, "approver").unwrap());
16550
16551 let err = execute_pending_action(&conn, &pending_id)
16552 .expect_err("execute MUST refuse laundered agent_id");
16553 let msg = format!("{err}");
16554 assert!(
16555 msg.contains("approver-on-behalf laundering refused"),
16556 "expected laundering-refusal message, got: {msg}"
16557 );
16558 let count: i64 = conn
16560 .query_row(
16561 "SELECT COUNT(*) FROM memories WHERE namespace = 'ns/launder'",
16562 [],
16563 |r| r.get(0),
16564 )
16565 .unwrap();
16566 assert_eq!(count, 0, "refused execute must not insert a memory");
16567 assert_eq!(
16569 count_signed_events(&conn, "pending_action.refused_agent_id_mismatch"),
16570 1,
16571 "refusal must append a signed_events row"
16572 );
16573 assert_eq!(count_signed_events(&conn, "pending_action.approved"), 0);
16575 }
16576
16577 #[test]
16581 fn test_approve_emits_signed_event() {
16582 let conn = test_db();
16583 let mem = make_memory("approved store", "ns/approve", Tier::Mid, 5);
16584 let payload = serde_json::to_value(&mem).unwrap();
16585 let pending_id = queue_pending_action(
16586 &conn,
16587 crate::models::GovernedAction::Store,
16588 "ns/approve",
16589 None,
16590 mem.metadata["agent_id"].as_str().unwrap_or("alice"),
16591 &payload,
16592 )
16593 .unwrap();
16594 assert!(decide_pending_action(&conn, &pending_id, true, "approver").unwrap());
16598 let _ = execute_pending_action(&conn, &pending_id).expect("execute ok");
16599 assert_eq!(
16600 count_signed_events(&conn, "pending_action.approved"),
16601 1,
16602 "approve+execute must append one audit row"
16603 );
16604 assert_eq!(count_signed_events(&conn, "pending_action.denied"), 0);
16606 assert_eq!(count_signed_events(&conn, "pending_action.timed_out"), 0);
16607 }
16608
16609 #[test]
16613 fn test_deny_emits_signed_event() {
16614 let conn = test_db();
16615 let payload = serde_json::json!({"title": "to-deny", "content": "x"});
16616 let pending_id = queue_pending_action(
16617 &conn,
16618 crate::models::GovernedAction::Store,
16619 "ns/deny",
16620 None,
16621 "alice",
16622 &payload,
16623 )
16624 .unwrap();
16625 let transitioned = decide_pending_action(&conn, &pending_id, false, "approver").unwrap();
16626 assert!(transitioned, "deny transition must succeed on pending row");
16627 assert_eq!(
16628 count_signed_events(&conn, "pending_action.denied"),
16629 1,
16630 "deny must append one audit row"
16631 );
16632 assert_eq!(count_signed_events(&conn, "pending_action.approved"), 0);
16634 assert_eq!(count_signed_events(&conn, "pending_action.timed_out"), 0);
16635 }
16636
16637 #[test]
16642 fn test_timeout_sweeper_emits_signed_event() {
16643 let conn = test_db();
16644 insert_stale_pending(&conn, "stale-a", "ns/x", 7_200, None);
16647 insert_stale_pending(&conn, "stale-b", "ns/y", 7_200, None);
16648 insert_stale_pending(&conn, "fresh-c", "ns/z", 30, None);
16649
16650 let expired = sweep_pending_action_timeouts(&conn, crate::SECS_PER_HOUR).unwrap();
16651 assert_eq!(expired.len(), 2, "two stale rows must expire");
16652 assert_eq!(
16653 count_signed_events(&conn, "pending_action.timed_out"),
16654 2,
16655 "one audit row per expired pending row"
16656 );
16657 let fresh_status: String = conn
16659 .query_row(
16660 "SELECT status FROM pending_actions WHERE id = 'fresh-c'",
16661 [],
16662 |r| r.get(0),
16663 )
16664 .unwrap();
16665 assert_eq!(fresh_status, "pending");
16666 }
16667
16668 fn count_signed_events_of_type(conn: &Connection, event_type: &str) -> i64 {
16676 conn.query_row(
16677 "SELECT COUNT(*) FROM signed_events WHERE event_type = ?1",
16678 params![event_type],
16679 |r| r.get(0),
16680 )
16681 .unwrap()
16682 }
16683
16684 #[test]
16685 fn test_memory_link_created_emits_signed_event_unsigned_path() {
16686 let conn = test_db();
16691 let src = make_memory("s4info2-src-u", "test", Tier::Long, 5);
16692 let tgt = make_memory("s4info2-tgt-u", "test", Tier::Long, 5);
16693 insert(&conn, &src).unwrap();
16694 insert(&conn, &tgt).unwrap();
16695
16696 let before = count_signed_events_of_type(&conn, "memory_link.created");
16697 create_link_signed(&conn, &src.id, &tgt.id, "related_to", None).unwrap();
16698 let after = count_signed_events_of_type(&conn, "memory_link.created");
16699 assert_eq!(after, before + 1, "unsigned create must emit one audit row");
16700
16701 let (attest, sig): (String, Option<Vec<u8>>) = conn
16703 .query_row(
16704 "SELECT attest_level, signature FROM signed_events \
16705 WHERE event_type = 'memory_link.created' \
16706 ORDER BY timestamp DESC LIMIT 1",
16707 [],
16708 |r| Ok((r.get(0)?, r.get(1)?)),
16709 )
16710 .unwrap();
16711 assert_eq!(attest, "unsigned");
16712 assert!(sig.is_none(), "unsigned create must emit NULL signature");
16713 }
16714
16715 #[test]
16716 fn test_memory_link_created_emits_signed_event_signed_path() {
16717 use crate::identity::{keypair, sign as link_sign};
16722
16723 let conn = test_db();
16724 let src = make_memory("s4info2-src-s", "test", Tier::Long, 5);
16725 let tgt = make_memory("s4info2-tgt-s", "test", Tier::Long, 5);
16726 insert(&conn, &src).unwrap();
16727 insert(&conn, &tgt).unwrap();
16728
16729 let kp = keypair::generate("alice").unwrap();
16730 create_link_signed(&conn, &src.id, &tgt.id, "supersedes", Some(&kp)).unwrap();
16731
16732 let (link_sig, valid_from): (Vec<u8>, String) = conn
16735 .query_row(
16736 "SELECT signature, valid_from FROM memory_links \
16737 WHERE source_id = ?1 AND target_id = ?2",
16738 params![&src.id, &tgt.id],
16739 |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, String>(1)?)),
16740 )
16741 .unwrap();
16742 let signable = link_sign::SignableLink {
16743 src_id: &src.id,
16744 dst_id: &tgt.id,
16745 relation: "supersedes",
16746 observed_by: Some(kp.agent_id.as_str()),
16747 valid_from: Some(valid_from.as_str()),
16748 valid_until: None,
16749 };
16750 let expected_hash = crate::signed_events::payload_hash(
16751 &link_sign::canonical_cbor(&signable).expect("cbor"),
16752 );
16753
16754 let (agent, attest, sig, payload): (String, String, Option<Vec<u8>>, Vec<u8>) = conn
16755 .query_row(
16756 "SELECT agent_id, attest_level, signature, payload_hash \
16757 FROM signed_events \
16758 WHERE event_type = 'memory_link.created' \
16759 ORDER BY timestamp DESC LIMIT 1",
16760 [],
16761 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
16762 )
16763 .unwrap();
16764 assert_eq!(agent, "alice");
16765 assert_eq!(attest, "self_signed");
16766 assert_eq!(
16767 sig.as_deref(),
16768 Some(link_sig.as_slice()),
16769 "audit row signature must mirror memory_links.signature byte-for-byte"
16770 );
16771 assert_eq!(
16772 payload, expected_hash,
16773 "audit row payload_hash must SHA-256 the canonical CBOR H2 signed over"
16774 );
16775 }
16776
16777 #[test]
16778 fn test_memory_link_created_emit_is_idempotent_on_replay() {
16779 let conn = test_db();
16785 let src = make_memory("s4info2-src-d", "test", Tier::Long, 5);
16786 let tgt = make_memory("s4info2-tgt-d", "test", Tier::Long, 5);
16787 insert(&conn, &src).unwrap();
16788 insert(&conn, &tgt).unwrap();
16789
16790 create_link_signed(&conn, &src.id, &tgt.id, "related_to", None).unwrap();
16791 let after_first = count_signed_events_of_type(&conn, "memory_link.created");
16792 create_link_signed(&conn, &src.id, &tgt.id, "related_to", None).unwrap();
16793 let after_second = count_signed_events_of_type(&conn, "memory_link.created");
16794 assert_eq!(
16795 after_second, after_first,
16796 "duplicate (src,dst,relation) replay must not emit a second audit row"
16797 );
16798 }
16799
16800 #[test]
16801 fn test_create_link_inbound_emits_signed_event() {
16802 let conn = test_db();
16805 let src = make_memory("s4info2-in-src", "test", Tier::Long, 5);
16806 let tgt = make_memory("s4info2-in-tgt", "test", Tier::Long, 5);
16807 insert(&conn, &src).unwrap();
16808 insert(&conn, &tgt).unwrap();
16809
16810 let now = chrono::Utc::now().to_rfc3339();
16811 let link = MemoryLink {
16812 source_id: src.id.clone(),
16813 target_id: tgt.id.clone(),
16814 relation: crate::models::MemoryLinkRelation::RelatedTo,
16815 created_at: now.clone(),
16816 signature: None,
16817 observed_by: Some("peer-bob".to_string()),
16818 valid_from: Some(now.clone()),
16819 valid_until: None,
16820 attest_level: None,
16821 };
16822 let before = count_signed_events_of_type(&conn, "memory_link.created");
16823 create_link_inbound(&conn, &link, "unsigned").unwrap();
16824 let after = count_signed_events_of_type(&conn, "memory_link.created");
16825 assert_eq!(after, before + 1);
16826
16827 let agent: String = conn
16828 .query_row(
16829 "SELECT agent_id FROM signed_events \
16830 WHERE event_type = 'memory_link.created' \
16831 ORDER BY timestamp DESC LIMIT 1",
16832 [],
16833 |r| r.get(0),
16834 )
16835 .unwrap();
16836 assert_eq!(
16837 agent, "peer-bob",
16838 "inbound emit must record the peer's claimed observed_by"
16839 );
16840 }
16841
16842 #[test]
16843 fn test_create_link_signed_emit_failure_does_not_roll_back() {
16844 let conn = test_db();
16849 let src = make_memory("s4info2-fail-src", "test", Tier::Long, 5);
16850 let tgt = make_memory("s4info2-fail-tgt", "test", Tier::Long, 5);
16851 insert(&conn, &src).unwrap();
16852 insert(&conn, &tgt).unwrap();
16853
16854 conn.execute("DROP TABLE signed_events", []).unwrap();
16856
16857 let result = create_link_signed(&conn, &src.id, &tgt.id, "related_to", None);
16858 assert!(
16859 result.is_ok(),
16860 "audit emit failure must not crater the link create: {result:?}"
16861 );
16862
16863 let count: i64 = conn
16865 .query_row(
16866 "SELECT COUNT(*) FROM memory_links \
16867 WHERE source_id = ?1 AND target_id = ?2",
16868 params![&src.id, &tgt.id],
16869 |r| r.get(0),
16870 )
16871 .unwrap();
16872 assert_eq!(
16873 count, 1,
16874 "link row must have committed despite audit failure"
16875 );
16876 }
16877
16878 #[test]
16890 fn l1_1_migration_backfill_sets_reflection_kind() {
16891 let conn = test_db();
16892 let now = chrono::Utc::now().to_rfc3339();
16893 let id = uuid::Uuid::new_v4().to_string();
16894 conn.execute(
16899 "INSERT INTO memories (id, tier, namespace, title, content, tags, priority, \
16900 confidence, source, access_count, created_at, updated_at, metadata, \
16901 reflection_depth, memory_kind) \
16902 VALUES (?1,'mid','ns','backfill-test','content','[]',5,1.0,'test',0,?2,?2,?3,0,'observation')",
16903 rusqlite::params![id, now, r#"{"type":"reflection"}"#],
16904 )
16905 .unwrap();
16906
16907 let before: String = conn
16909 .query_row(
16910 "SELECT memory_kind FROM memories WHERE id = ?1",
16911 [&id],
16912 |r| r.get(0),
16913 )
16914 .unwrap();
16915 assert_eq!(before, "observation");
16916
16917 conn.execute(
16919 "UPDATE memories SET memory_kind = 'reflection' \
16920 WHERE memory_kind = 'observation' \
16921 AND json_valid(metadata) \
16922 AND json_extract(metadata, '$.type') = 'reflection'",
16923 [],
16924 )
16925 .unwrap();
16926
16927 let after: String = conn
16928 .query_row(
16929 "SELECT memory_kind FROM memories WHERE id = ?1",
16930 [&id],
16931 |r| r.get(0),
16932 )
16933 .unwrap();
16934 assert_eq!(
16935 after, "reflection",
16936 "backfill must upgrade metadata.type=reflection rows to memory_kind=reflection"
16937 );
16938 }
16939
16940 #[test]
16943 fn l1_1_migration_backfill_leaves_non_reflection_rows_alone() {
16944 let conn = test_db();
16945 let now = chrono::Utc::now().to_rfc3339();
16946 let id = uuid::Uuid::new_v4().to_string();
16947 conn.execute(
16948 "INSERT INTO memories (id, tier, namespace, title, content, tags, priority, \
16949 confidence, source, access_count, created_at, updated_at, metadata, \
16950 reflection_depth, memory_kind) \
16951 VALUES (?1,'mid','ns','obs-test','content','[]',5,1.0,'test',0,?2,?2,'{}',0,'observation')",
16952 rusqlite::params![id, now],
16953 )
16954 .unwrap();
16955
16956 conn.execute(
16957 "UPDATE memories SET memory_kind = 'reflection' \
16958 WHERE memory_kind = 'observation' \
16959 AND json_valid(metadata) \
16960 AND json_extract(metadata, '$.type') = 'reflection'",
16961 [],
16962 )
16963 .unwrap();
16964
16965 let after: String = conn
16966 .query_row(
16967 "SELECT memory_kind FROM memories WHERE id = ?1",
16968 [&id],
16969 |r| r.get(0),
16970 )
16971 .unwrap();
16972 assert_eq!(
16973 after, "observation",
16974 "backfill must not change rows without metadata.type=reflection"
16975 );
16976 }
16977
16978 #[test]
16981 fn l1_1_memories_by_kind_returns_correct_subset() {
16982 let conn = test_db();
16983
16984 let obs = Memory {
16986 id: uuid::Uuid::new_v4().to_string(),
16987 tier: Tier::Long,
16988 namespace: "kind-ns".to_string(),
16989 title: "obs-memory".to_string(),
16990 content: "observation content".to_string(),
16991 tags: vec![],
16992 priority: 5,
16993 confidence: 1.0,
16994 source: "test".to_string(),
16995 access_count: 0,
16996 created_at: chrono::Utc::now().to_rfc3339(),
16997 updated_at: chrono::Utc::now().to_rfc3339(),
16998 last_accessed_at: None,
16999 expires_at: None,
17000 metadata: serde_json::json!({}),
17001 reflection_depth: 0,
17002 memory_kind: crate::models::MemoryKind::Observation,
17003 entity_id: None,
17004 persona_version: None,
17005 citations: Vec::new(),
17006 source_uri: None,
17007 source_span: None,
17008 confidence_source: ConfidenceSource::CallerProvided,
17009 confidence_signals: None,
17010 confidence_decayed_at: None,
17011 version: 1,
17012 };
17013 let ref_mem = Memory {
17014 id: uuid::Uuid::new_v4().to_string(),
17015 tier: Tier::Long,
17016 namespace: "kind-ns".to_string(),
17017 title: "ref-memory".to_string(),
17018 content: "reflection content".to_string(),
17019 tags: vec![],
17020 priority: 5,
17021 confidence: 1.0,
17022 source: "test".to_string(),
17023 access_count: 0,
17024 created_at: chrono::Utc::now().to_rfc3339(),
17025 updated_at: chrono::Utc::now().to_rfc3339(),
17026 last_accessed_at: None,
17027 expires_at: None,
17028 metadata: serde_json::json!({}),
17029 reflection_depth: 1,
17030 memory_kind: crate::models::MemoryKind::Reflection,
17031 entity_id: None,
17032 persona_version: None,
17033 citations: Vec::new(),
17034 source_uri: None,
17035 source_span: None,
17036 confidence_source: ConfidenceSource::CallerProvided,
17037 confidence_signals: None,
17038 confidence_decayed_at: None,
17039 version: 1,
17040 };
17041
17042 insert(&conn, &obs).unwrap();
17043 insert(&conn, &ref_mem).unwrap();
17044
17045 let obs_rows = memories_by_kind(&conn, &crate::models::MemoryKind::Observation).unwrap();
17046 let ref_rows = memories_by_kind(&conn, &crate::models::MemoryKind::Reflection).unwrap();
17047
17048 assert!(
17049 obs_rows
17050 .iter()
17051 .all(|m| m.memory_kind == crate::models::MemoryKind::Observation),
17052 "memories_by_kind(Observation) must return only Observation memories"
17053 );
17054 assert!(
17055 ref_rows
17056 .iter()
17057 .all(|m| m.memory_kind == crate::models::MemoryKind::Reflection),
17058 "memories_by_kind(Reflection) must return only Reflection memories"
17059 );
17060 assert!(
17062 obs_rows.iter().any(|m| m.title == "obs-memory"),
17063 "obs-memory must be in Observation results"
17064 );
17065 assert!(
17067 ref_rows.iter().any(|m| m.title == "ref-memory"),
17068 "ref-memory must be in Reflection results"
17069 );
17070 assert!(
17072 !ref_rows.iter().any(|m| m.title == "obs-memory"),
17073 "obs-memory must not appear in Reflection results"
17074 );
17075 }
17076
17077 #[test]
17080 fn l1_1_memory_kind_roundtrips_through_insert_get() {
17081 let conn = test_db();
17082 let mem = Memory {
17083 id: uuid::Uuid::new_v4().to_string(),
17084 tier: Tier::Long,
17085 namespace: "roundtrip-ns".to_string(),
17086 title: "kind-roundtrip".to_string(),
17087 content: "roundtrip content".to_string(),
17088 tags: vec![],
17089 priority: 5,
17090 confidence: 1.0,
17091 source: "test".to_string(),
17092 access_count: 0,
17093 created_at: chrono::Utc::now().to_rfc3339(),
17094 updated_at: chrono::Utc::now().to_rfc3339(),
17095 last_accessed_at: None,
17096 expires_at: None,
17097 metadata: serde_json::json!({}),
17098 reflection_depth: 1,
17099 memory_kind: crate::models::MemoryKind::Reflection,
17100 entity_id: None,
17101 persona_version: None,
17102 citations: Vec::new(),
17103 source_uri: None,
17104 source_span: None,
17105 confidence_source: ConfidenceSource::CallerProvided,
17106 confidence_signals: None,
17107 confidence_decayed_at: None,
17108 version: 1,
17109 };
17110 let id = insert(&conn, &mem).unwrap();
17111 let got = get(&conn, &id)
17112 .unwrap()
17113 .expect("inserted memory must be found");
17114 assert_eq!(
17115 got.memory_kind,
17116 crate::models::MemoryKind::Reflection,
17117 "memory_kind=Reflection must roundtrip through insert→get"
17118 );
17119 }
17120
17121 #[test]
17125 fn l1_1_upsert_preserves_reflection_kind() {
17126 let conn = test_db();
17127 let now = chrono::Utc::now().to_rfc3339();
17128 let id = uuid::Uuid::new_v4().to_string();
17129
17130 let mem_reflection = Memory {
17132 id: id.clone(),
17133 tier: Tier::Long,
17134 namespace: "sticky-ns".to_string(),
17135 title: "sticky-title".to_string(),
17136 content: "original content".to_string(),
17137 tags: vec![],
17138 priority: 5,
17139 confidence: 1.0,
17140 source: "test".to_string(),
17141 access_count: 0,
17142 created_at: now.clone(),
17143 updated_at: now.clone(),
17144 last_accessed_at: None,
17145 expires_at: None,
17146 metadata: serde_json::json!({}),
17147 reflection_depth: 1,
17148 memory_kind: crate::models::MemoryKind::Reflection,
17149 entity_id: None,
17150 persona_version: None,
17151 citations: Vec::new(),
17152 source_uri: None,
17153 source_span: None,
17154 confidence_source: ConfidenceSource::CallerProvided,
17155 confidence_signals: None,
17156 confidence_decayed_at: None,
17157 version: 1,
17158 };
17159 insert(&conn, &mem_reflection).unwrap();
17160
17161 let mem_obs = Memory {
17163 id: uuid::Uuid::new_v4().to_string(), tier: Tier::Long,
17165 namespace: "sticky-ns".to_string(),
17166 title: "sticky-title".to_string(),
17167 content: "updated content".to_string(),
17168 tags: vec![],
17169 priority: 6,
17170 confidence: 1.0,
17171 source: "test".to_string(),
17172 access_count: 0,
17173 created_at: now.clone(),
17174 updated_at: now,
17175 last_accessed_at: None,
17176 expires_at: None,
17177 metadata: serde_json::json!({}),
17178 reflection_depth: 0,
17179 memory_kind: crate::models::MemoryKind::Observation,
17180 entity_id: None,
17181 persona_version: None,
17182 citations: Vec::new(),
17183 source_uri: None,
17184 source_span: None,
17185 confidence_source: ConfidenceSource::CallerProvided,
17186 confidence_signals: None,
17187 confidence_decayed_at: None,
17188 version: 1,
17189 };
17190 insert(&conn, &mem_obs).unwrap();
17191
17192 let got = get(&conn, &id)
17194 .unwrap()
17195 .expect("original memory must still exist");
17196 assert_eq!(
17197 got.memory_kind,
17198 crate::models::MemoryKind::Reflection,
17199 "upsert with Observation must not overwrite an existing Reflection kind"
17200 );
17201 }
17202
17203 #[test]
17208 fn strongest_attest_returns_unsigned_for_isolate_source() {
17209 let conn = test_db();
17212 let lonely = make_memory("lonely", "test", Tier::Long, 5);
17213 insert(&conn, &lonely).unwrap();
17214 let got = strongest_attest_level_for_source(&conn, &lonely.id).unwrap();
17215 assert_eq!(got, "unsigned");
17216 }
17217
17218 #[test]
17219 fn strongest_attest_picks_self_signed_over_unsigned() {
17220 use crate::identity::keypair;
17221 let _gate = crate::config::lock_permissions_mode_for_test();
17227 let conn = test_db();
17228 let src = make_memory("attest-src", "test", Tier::Long, 5);
17229 let a = make_memory("attest-a", "test", Tier::Long, 5);
17230 let b = make_memory("attest-b", "test", Tier::Long, 5);
17231 insert(&conn, &src).unwrap();
17232 insert(&conn, &a).unwrap();
17233 insert(&conn, &b).unwrap();
17234 create_link_signed(&conn, &src.id, &a.id, "related_to", None).unwrap();
17236 let kp = keypair::generate("alice").unwrap();
17237 create_link_signed(&conn, &src.id, &b.id, "supersedes", Some(&kp)).unwrap();
17238 let got = strongest_attest_level_for_source(&conn, &src.id).unwrap();
17239 assert_eq!(got, "self_signed", "self_signed beats unsigned");
17240 }
17241
17242 #[test]
17243 fn strongest_attest_picks_peer_attested_over_self_signed() {
17244 let conn = test_db();
17249 let src = make_memory("attest-pa-src", "test", Tier::Long, 5);
17250 let a = make_memory("attest-pa-a", "test", Tier::Long, 5);
17251 let b = make_memory("attest-pa-b", "test", Tier::Long, 5);
17252 insert(&conn, &src).unwrap();
17253 insert(&conn, &a).unwrap();
17254 insert(&conn, &b).unwrap();
17255 let kp = crate::identity::keypair::generate("alice").unwrap();
17257 create_link_signed(&conn, &src.id, &a.id, "related_to", Some(&kp)).unwrap();
17258 let now = chrono::Utc::now().to_rfc3339();
17261 let sig = vec![0xAB_u8; 64];
17262 conn.execute(
17263 "INSERT INTO memory_links \
17264 (source_id, target_id, relation, created_at, valid_from, signature, attest_level, observed_by) \
17265 VALUES (?1, ?2, 'related_to', ?3, ?3, ?4, 'peer_attested', 'peer-bob')",
17266 params![&src.id, &b.id, &now, &sig],
17267 )
17268 .unwrap();
17269 let got = strongest_attest_level_for_source(&conn, &src.id).unwrap();
17270 assert_eq!(got, "peer_attested", "peer_attested beats self_signed");
17271 }
17272
17273 #[test]
17274 fn ck_trigger_refuses_self_signed_insert_without_signature() {
17275 let conn = test_db();
17281 let s = make_memory("ck-src", "test", Tier::Long, 5);
17282 let t = make_memory("ck-tgt", "test", Tier::Long, 5);
17283 insert(&conn, &s).unwrap();
17284 insert(&conn, &t).unwrap();
17285 let now = chrono::Utc::now().to_rfc3339();
17286 let res = conn.execute(
17287 "INSERT INTO memory_links \
17288 (source_id, target_id, relation, created_at, valid_from, signature, attest_level) \
17289 VALUES (?1, ?2, 'related_to', ?3, ?3, NULL, 'self_signed')",
17290 params![&s.id, &t.id, &now],
17291 );
17292 let err = res.expect_err("CHECK trigger must reject self_signed + NULL signature");
17293 let msg = format!("{err}");
17294 assert!(
17295 msg.contains("CHECK constraint failed")
17296 || msg.contains("attest_level")
17297 || msg.contains("64-byte signature"),
17298 "trigger error must name the failure mode, got: {msg}"
17299 );
17300 }
17301
17302 #[test]
17303 fn ck_trigger_refuses_self_signed_insert_with_wrong_length_signature() {
17304 let conn = test_db();
17308 let s = make_memory("ck-src-wlen", "test", Tier::Long, 5);
17309 let t = make_memory("ck-tgt-wlen", "test", Tier::Long, 5);
17310 insert(&conn, &s).unwrap();
17311 insert(&conn, &t).unwrap();
17312 let now = chrono::Utc::now().to_rfc3339();
17313 let res = conn.execute(
17314 "INSERT INTO memory_links \
17315 (source_id, target_id, relation, created_at, valid_from, signature, attest_level) \
17316 VALUES (?1, ?2, 'related_to', ?3, ?3, ?4, 'self_signed')",
17317 params![&s.id, &t.id, &now, &[0u8; 8][..]],
17318 );
17319 assert!(
17320 res.is_err(),
17321 "CHECK trigger must reject wrong-length signature"
17322 );
17323 }
17324
17325 #[test]
17326 fn ck_trigger_refuses_update_to_self_signed_without_signature() {
17327 let conn = test_db();
17331 let s = make_memory("ck-upd-src", "test", Tier::Long, 5);
17332 let t = make_memory("ck-upd-tgt", "test", Tier::Long, 5);
17333 insert(&conn, &s).unwrap();
17334 insert(&conn, &t).unwrap();
17335 create_link_signed(&conn, &s.id, &t.id, "related_to", None).unwrap();
17336 let res = conn.execute(
17337 "UPDATE memory_links SET attest_level = 'self_signed' \
17338 WHERE source_id = ?1 AND target_id = ?2",
17339 params![&s.id, &t.id],
17340 );
17341 assert!(
17342 res.is_err(),
17343 "CHECK trigger must reject UPDATE to self_signed with NULL signature"
17344 );
17345 }
17346
17347 #[test]
17348 fn ck_trigger_admits_unsigned_with_null_signature() {
17349 let conn = test_db();
17354 let s = make_memory("ck-unsigned-src", "test", Tier::Long, 5);
17355 let t = make_memory("ck-unsigned-tgt", "test", Tier::Long, 5);
17356 insert(&conn, &s).unwrap();
17357 insert(&conn, &t).unwrap();
17358 create_link_signed(&conn, &s.id, &t.id, "related_to", None)
17361 .expect("unsigned create must still succeed under the new CHECK trigger");
17362 }
17363
17364 #[test]
17369 fn agent_pubkey_none_before_bind_and_some_after() {
17370 let conn = test_db();
17371 register_agent(&conn, "ai:curator", "ai:generic", &[]).expect("register");
17372 assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), None);
17374
17375 let kp = crate::identity::keypair::generate("ai:curator").expect("generate");
17376 let b64 = kp.public_base64();
17377 bind_agent_pubkey(&conn, "ai:curator", &b64).expect("bind");
17378 assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), Some(b64));
17379 }
17380
17381 #[test]
17382 fn agent_pubkey_none_for_unregistered_agent() {
17383 let conn = test_db();
17384 assert_eq!(agent_pubkey(&conn, "ai:ghost").unwrap(), None);
17386 }
17387
17388 #[test]
17389 fn bind_agent_pubkey_rejects_unregistered_agent() {
17390 let conn = test_db();
17391 let err = bind_agent_pubkey(&conn, "ai:ghost", "AAAA").unwrap_err();
17392 assert!(
17393 err.to_string().contains("not registered"),
17394 "binding to an unregistered agent must be rejected; got: {err}",
17395 );
17396 }
17397
17398 #[test]
17399 fn bind_agent_pubkey_rotates_key_in_place() {
17400 let conn = test_db();
17401 register_agent(&conn, "ai:curator", "ai:generic", &[]).expect("register");
17402 let k1 = crate::identity::keypair::generate("ai:curator")
17403 .unwrap()
17404 .public_base64();
17405 let k2 = crate::identity::keypair::generate("ai:curator")
17406 .unwrap()
17407 .public_base64();
17408 assert_ne!(k1, k2, "two fresh keys differ");
17409 bind_agent_pubkey(&conn, "ai:curator", &k1).expect("bind k1");
17410 assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), Some(k1));
17411 bind_agent_pubkey(&conn, "ai:curator", &k2).expect("rotate to k2");
17413 assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), Some(k2));
17414 }
17415
17416 #[test]
17417 fn bind_agent_pubkey_preserves_registration_fields() {
17418 let conn = test_db();
17421 register_agent(
17422 &conn,
17423 "ai:curator",
17424 "ai:claude-opus",
17425 &["recall".to_string(), "write".to_string()],
17426 )
17427 .expect("register");
17428 let before = list_agents(&conn).expect("list before");
17429 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
17430 bind_agent_pubkey(&conn, "ai:curator", &kp.public_base64()).expect("bind");
17431 let after = list_agents(&conn).expect("list after");
17432
17433 let a_before = before
17434 .iter()
17435 .find(|a| a.agent_id == "ai:curator")
17436 .expect("present before");
17437 let a_after = after
17438 .iter()
17439 .find(|a| a.agent_id == "ai:curator")
17440 .expect("present after");
17441 assert_eq!(a_after.agent_type, a_before.agent_type);
17442 assert_eq!(a_after.capabilities, a_before.capabilities);
17443 assert_eq!(a_after.registered_at, a_before.registered_at);
17444 }
17445
17446 #[test]
17451 fn revoke_agent_pubkey_clears_bound_key() {
17452 let conn = test_db();
17453 register_agent(&conn, "ai:curator", "ai:generic", &[]).expect("register");
17454 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
17455 bind_agent_pubkey(&conn, "ai:curator", &kp.public_base64()).expect("bind");
17456 assert!(agent_pubkey(&conn, "ai:curator").unwrap().is_some());
17457 revoke_agent_pubkey(&conn, "ai:curator").expect("revoke");
17458 assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), None);
17459 }
17460
17461 #[test]
17462 fn revoke_agent_pubkey_is_idempotent_without_bound_key() {
17463 let conn = test_db();
17464 register_agent(&conn, "ai:curator", "ai:generic", &[]).expect("register");
17465 revoke_agent_pubkey(&conn, "ai:curator").expect("revoke unbound");
17467 assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), None);
17468 }
17469
17470 #[test]
17471 fn revoke_agent_pubkey_rejects_unregistered_agent() {
17472 let conn = test_db();
17473 let err = revoke_agent_pubkey(&conn, "ai:ghost").unwrap_err();
17474 assert!(
17475 err.to_string().contains("not registered"),
17476 "revoking an unregistered agent must be rejected; got: {err}",
17477 );
17478 }
17479
17480 #[test]
17481 fn revoke_agent_pubkey_preserves_registration_fields() {
17482 let conn = test_db();
17483 register_agent(
17484 &conn,
17485 "ai:curator",
17486 "ai:claude-opus",
17487 &["recall".to_string(), "write".to_string()],
17488 )
17489 .expect("register");
17490 let kp = crate::identity::keypair::generate("ai:curator").unwrap();
17491 bind_agent_pubkey(&conn, "ai:curator", &kp.public_base64()).expect("bind");
17492 revoke_agent_pubkey(&conn, "ai:curator").expect("revoke");
17493 let after = list_agents(&conn).expect("list after");
17494 let a = after
17495 .iter()
17496 .find(|a| a.agent_id == "ai:curator")
17497 .expect("present after revoke");
17498 assert_eq!(a.agent_type, "ai:claude-opus");
17499 assert_eq!(
17500 a.capabilities,
17501 vec!["recall".to_string(), "write".to_string()]
17502 );
17503 }
17504
17505 #[test]
17506 fn revoke_then_rebind_restores_attestable_key() {
17507 let conn = test_db();
17508 register_agent(&conn, "ai:curator", "ai:generic", &[]).expect("register");
17509 let k1 = crate::identity::keypair::generate("ai:curator")
17510 .unwrap()
17511 .public_base64();
17512 bind_agent_pubkey(&conn, "ai:curator", &k1).expect("bind k1");
17513 revoke_agent_pubkey(&conn, "ai:curator").expect("revoke");
17514 assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), None);
17515 let k2 = crate::identity::keypair::generate("ai:curator")
17516 .unwrap()
17517 .public_base64();
17518 bind_agent_pubkey(&conn, "ai:curator", &k2).expect("rebind k2");
17519 assert_eq!(agent_pubkey(&conn, "ai:curator").unwrap(), Some(k2));
17520 }
17521}