1use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::{Arc, Mutex};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use async_trait::async_trait;
22use rusqlite::{Connection, OptionalExtension, Transaction, params};
23
24use crate::identity_first::AgentIdentity;
25use crate::identity_first::agent_memory::{
26 AgentMemoryError, AgentMemoryForgetResult, AgentMemoryProvider, AgentMemoryRecallRequest,
27 AgentMemoryRecord, AuthoredWriteReceipt, NewAgentMemory, compact_whitespace,
28 decode_path_segment, encode_path_segment, new_memory_id, normalize_tags, read_markdown_records,
29 select_recall_records,
30};
31use crate::memory::taint::LlmWriteGate;
32
33pub use super::capabilities::{
37 DreamAuditVerdict, DreamRunAudit, EvidenceRefResolver, MemoryPanelStore, PanelRecordsPage,
38 PendingHarvest, PendingPromotion, PendingProposal, PersistedDreamRun, ScopeOverview,
39 StewardStore, TaintableStore,
40};
41
42use super::records::{
43 InjectionLogEntry, InjectionSurface, ManifestTier, MemoryAuthor, MemoryId, MemoryKind,
44 MemoryProvenance, MemoryScope, NewMemoryRecord, ProposalId, RecordMeta, RecordStatus,
45 TrustTier, UsageEvent, UsageStats, age_days, content_hash, validate_record_fields,
46};
47use super::staged::{
48 CommitReceipt, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, StageToken, StagedBatchKind,
49 StagedBatchView, StagedMemoryStore, StagedMutationBatch, StagedOp, StagedRecordView,
50 validate_batch,
51};
52
53pub const DEFAULT_SCOPE_FLOOR_RECORDS: usize = 4_000;
56pub const DEFAULT_SCOPE_FLOOR_BYTES: usize = 32 * 1024 * 1024;
57
58const STAGE_GC_MAX_AGE_MS: u64 = 24 * 60 * 60 * 1000;
61
62const SCHEMA_SQL: &str = "
63CREATE TABLE IF NOT EXISTS records (
64 memory_id TEXT PRIMARY KEY,
65 scope_kind TEXT NOT NULL,
66 scope_key TEXT NOT NULL,
67 kind TEXT NOT NULL,
68 title TEXT NOT NULL,
69 description TEXT NOT NULL DEFAULT '',
70 body TEXT NOT NULL,
71 tags TEXT NOT NULL DEFAULT '[]',
72 provenance TEXT NOT NULL,
73 trust TEXT NOT NULL,
74 status_kind TEXT NOT NULL,
75 status_detail TEXT,
76 supersedes TEXT,
77 derived_from TEXT NOT NULL DEFAULT '[]',
78 working_set_rank INTEGER,
79 rank_set_at_ms INTEGER,
80 content_hash TEXT NOT NULL,
81 created_at_ms INTEGER NOT NULL,
82 updated_at_ms INTEGER NOT NULL,
83 usage_stats TEXT NOT NULL DEFAULT '{}',
84 tombstoned_at_ms INTEGER,
85 -- §10.2 durable taint marker: 1 when the record landed quarantined or
86 -- descends from a record that did. Survives the tombstone that a
87 -- quarantine release applies to the origin (which erases the
88 -- `quarantined` status), so the transitive ceiling holds forever.
89 ever_quarantined INTEGER NOT NULL DEFAULT 0
90);
91CREATE INDEX IF NOT EXISTS records_scope_idx
92 ON records(scope_kind, scope_key, status_kind);
93CREATE INDEX IF NOT EXISTS records_scope_hash_idx
94 ON records(scope_kind, scope_key, content_hash);
95
96CREATE TABLE IF NOT EXISTS proposals (
97 proposal_id TEXT PRIMARY KEY,
98 scope_kind TEXT NOT NULL,
99 scope_key TEXT NOT NULL,
100 record TEXT NOT NULL,
101 author TEXT NOT NULL,
102 status TEXT NOT NULL DEFAULT 'pending',
103 created_at_ms INTEGER NOT NULL,
104 -- §10.1: quarantine decision captured AT PROPOSE TIME (the taint
105 -- tracker is in-memory and session-sticky; re-deriving at dream time
106 -- both under- and over-quarantines). NULL = clean at propose time.
107 taint TEXT
108);
109
110CREATE TABLE IF NOT EXISTS audit (
111 audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
112 stage_token TEXT NOT NULL,
113 op_index INTEGER NOT NULL,
114 op_kind TEXT NOT NULL,
115 memory_id TEXT,
116 detail TEXT NOT NULL,
117 applied_at_ms INTEGER NOT NULL
118);
119
120CREATE TABLE IF NOT EXISTS stage (
121 token TEXT PRIMARY KEY,
122 batch TEXT NOT NULL,
123 created_at_ms INTEGER NOT NULL
124);
125
126-- Injection ledger (§9.2): plain telemetry appends, deliberately outside
127-- the staged-batch path — rows here are observations about delivery, not
128-- record mutations. session_key is NULL for build-time assembly, where the
129-- session does not exist yet.
130CREATE TABLE IF NOT EXISTS injections (
131 injection_id INTEGER PRIMARY KEY AUTOINCREMENT,
132 record_id TEXT NOT NULL,
133 identity TEXT NOT NULL,
134 session_key TEXT,
135 surface TEXT NOT NULL,
136 at_ms INTEGER NOT NULL
137);
138CREATE INDEX IF NOT EXISTS injections_record_idx
139 ON injections(record_id, at_ms);
140
141-- Exit-interview queue (§8.5): identities recorded by the retire/delete
142-- hooks; the next dream harvests each pending row and marks it done.
143CREATE TABLE IF NOT EXISTS pending_harvests (
144 identity TEXT NOT NULL,
145 session_key TEXT,
146 cause TEXT NOT NULL,
147 retired_at_ms INTEGER NOT NULL,
148 status TEXT NOT NULL DEFAULT 'pending',
149 PRIMARY KEY (identity, retired_at_ms)
150);
151
152-- Quarantine-promotions awaiting operator approval through the gating
153-- flow (§10.2): gating pending_id → staged batch token. Only a gating
154-- approval commits the token; deny/timeout discards it.
155CREATE TABLE IF NOT EXISTS pending_promotions (
156 pending_id TEXT PRIMARY KEY,
157 stage_token TEXT NOT NULL,
158 record_id TEXT NOT NULL,
159 scope_kind TEXT NOT NULL,
160 scope_key TEXT NOT NULL,
161 rationale TEXT,
162 status TEXT NOT NULL DEFAULT 'pending',
163 created_at_ms INTEGER NOT NULL,
164 resolved_at_ms INTEGER
165);
166
167CREATE TABLE IF NOT EXISTS dream_runs (
168 run_id TEXT PRIMARY KEY,
169 partition_label TEXT NOT NULL DEFAULT 'realm',
170 started_at_ms INTEGER NOT NULL,
171 completed_at_ms INTEGER NOT NULL,
172 ops_committed INTEGER NOT NULL,
173 detail TEXT NOT NULL
174);
175CREATE INDEX IF NOT EXISTS dream_runs_completed
176 ON dream_runs(completed_at_ms DESC);
177
178CREATE TABLE IF NOT EXISTS dream_audit_verdicts (
179 run_id TEXT NOT NULL,
180 record_id TEXT NOT NULL,
181 verdict TEXT NOT NULL,
182 rationale TEXT NOT NULL,
183 created_at_ms INTEGER NOT NULL,
184 resolved_at_ms INTEGER,
185 resolution TEXT,
186 PRIMARY KEY (run_id, record_id)
187);
188CREATE INDEX IF NOT EXISTS dream_audit_verdicts_open
189 ON dream_audit_verdicts(record_id, resolved_at_ms);
190";
191
192const RECORD_COLUMNS: &str = "memory_id, scope_kind, scope_key, kind, title, description, body, \
193 tags, provenance, trust, status_kind, status_detail, supersedes, derived_from, \
194 working_set_rank, rank_set_at_ms, content_hash, created_at_ms, updated_at_ms, \
195 usage_stats, tombstoned_at_ms";
196
197const MOBKIT_MEMORY_DOMAIN: meerkat_sqlite::SchemaDomain = meerkat_sqlite::SchemaDomain {
207 name: "mobkit-memory",
208 migrations: &[
209 meerkat_sqlite::Migration {
210 version: 1,
211 name: "base-schema",
212 apply: migration_0001_base_schema,
213 },
214 meerkat_sqlite::Migration {
215 version: 2,
216 name: "quarantine-and-taint-columns",
217 apply: migration_0002_quarantine_and_taint_columns,
218 },
219 meerkat_sqlite::Migration {
220 version: 3,
221 name: "logical-identity-scope-keys",
222 apply: migration_0003_logical_identity_scope_keys,
223 },
224 ],
225 initialize_current: initialize_current_memory_schema,
226 allowed_existing_versions: &[2, 3],
232 released_predecessors: &[meerkat_sqlite::SchemaPredecessor {
233 version: 2,
234 verify: verify_released_0_8_10_memory_schema,
235 }],
236 owned_objects: &[
237 meerkat_sqlite::SchemaObject {
238 kind: meerkat_sqlite::SchemaObjectKind::Table,
239 name: "records",
240 },
241 meerkat_sqlite::SchemaObject {
242 kind: meerkat_sqlite::SchemaObjectKind::Index,
243 name: "records_scope_idx",
244 },
245 meerkat_sqlite::SchemaObject {
246 kind: meerkat_sqlite::SchemaObjectKind::Index,
247 name: "records_scope_hash_idx",
248 },
249 meerkat_sqlite::SchemaObject {
250 kind: meerkat_sqlite::SchemaObjectKind::Table,
251 name: "proposals",
252 },
253 meerkat_sqlite::SchemaObject {
254 kind: meerkat_sqlite::SchemaObjectKind::Table,
255 name: "audit",
256 },
257 meerkat_sqlite::SchemaObject {
258 kind: meerkat_sqlite::SchemaObjectKind::Table,
259 name: "stage",
260 },
261 meerkat_sqlite::SchemaObject {
262 kind: meerkat_sqlite::SchemaObjectKind::Table,
263 name: "injections",
264 },
265 meerkat_sqlite::SchemaObject {
266 kind: meerkat_sqlite::SchemaObjectKind::Index,
267 name: "injections_record_idx",
268 },
269 meerkat_sqlite::SchemaObject {
270 kind: meerkat_sqlite::SchemaObjectKind::Table,
271 name: "pending_harvests",
272 },
273 meerkat_sqlite::SchemaObject {
274 kind: meerkat_sqlite::SchemaObjectKind::Table,
275 name: "pending_promotions",
276 },
277 meerkat_sqlite::SchemaObject {
278 kind: meerkat_sqlite::SchemaObjectKind::Table,
279 name: "dream_runs",
280 },
281 meerkat_sqlite::SchemaObject {
282 kind: meerkat_sqlite::SchemaObjectKind::Index,
283 name: "dream_runs_completed",
284 },
285 meerkat_sqlite::SchemaObject {
286 kind: meerkat_sqlite::SchemaObjectKind::Table,
287 name: "dream_audit_verdicts",
288 },
289 meerkat_sqlite::SchemaObject {
290 kind: meerkat_sqlite::SchemaObjectKind::Index,
291 name: "dream_audit_verdicts_open",
292 },
293 ],
294 retired_objects: &[],
295};
296
297fn migration_0001_base_schema(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
298 tx.execute_batch(SCHEMA_SQL)
299}
300
301fn initialize_v2_memory_schema(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
305 migration_0001_base_schema(tx)?;
306 migration_0002_quarantine_and_taint_columns(tx)
307}
308
309fn initialize_current_memory_schema(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
310 initialize_v2_memory_schema(tx)?;
311 migration_0003_logical_identity_scope_keys(tx)
314}
315
316fn verify_released_0_8_10_memory_schema(conn: &Connection) -> Result<(), String> {
318 meerkat_sqlite::verify_released_schema_fingerprint(
319 conn,
320 &MOBKIT_MEMORY_DOMAIN,
321 MOBKIT_MEMORY_DOMAIN.owned_objects,
322 initialize_v2_memory_schema,
323 )
324}
325
326fn migration_0003_logical_identity_scope_keys(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
345 for (table, column, identity_scoped_only) in [
346 ("records", "scope_key", true),
347 ("proposals", "scope_key", true),
348 ("pending_promotions", "scope_key", true),
349 ("injections", "identity", false),
350 ] {
351 normalize_identity_keys(tx, table, column, identity_scoped_only)?;
352 }
353 normalize_staged_batch_scopes(tx)?;
358 let legacy: Vec<String> = collect_legacy_keys(tx, "pending_harvests", "identity", false)?;
359 for key in legacy {
360 let logical = crate::member_comms_id::logical_memory_identity(&key);
361 tx.execute(
362 "UPDATE OR IGNORE pending_harvests SET identity = ?1 WHERE identity = ?2",
363 rusqlite::params![logical, key],
364 )?;
365 tx.execute(
369 "DELETE FROM pending_harvests WHERE identity = ?1",
370 rusqlite::params![key],
371 )?;
372 }
373 Ok(())
374}
375
376fn normalize_identity_keys(
380 tx: &Transaction<'_>,
381 table: &str,
382 column: &str,
383 identity_scoped_only: bool,
384) -> Result<(), rusqlite::Error> {
385 let legacy = collect_legacy_keys(tx, table, column, identity_scoped_only)?;
386 for key in legacy {
387 let logical = crate::member_comms_id::logical_memory_identity(&key);
388 let filter = if identity_scoped_only {
389 " AND scope_kind = 'identity'"
390 } else {
391 ""
392 };
393 tx.execute(
394 &format!("UPDATE {table} SET {column} = ?1 WHERE {column} = ?2{filter}"),
395 rusqlite::params![logical, key],
396 )?;
397 }
398 Ok(())
399}
400
401fn normalize_staged_batch_scopes(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
412 let rows: Vec<(String, String)> = {
413 let mut stmt = tx.prepare("SELECT token, batch FROM stage")?;
414 stmt.query_map([], |row| {
415 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
416 })?
417 .collect::<Result<Vec<_>, _>>()?
418 };
419 for (token, batch_json) in rows {
420 let Ok(mut batch) = serde_json::from_str::<StagedMutationBatch>(&batch_json) else {
421 continue;
422 };
423 let mut changed = false;
424 for op in &mut batch.ops {
425 if let StagedOp::Create { scope, .. } = op
426 && let MemoryScope::Identity { identity, .. } = scope
427 {
428 let logical = crate::member_comms_id::logical_memory_identity(identity);
429 if *identity != logical {
430 *identity = logical;
431 changed = true;
432 }
433 }
434 }
435 if changed {
436 let serialized = serde_json::to_string(&batch)
437 .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?;
438 tx.execute(
439 "UPDATE stage SET batch = ?1 WHERE token = ?2",
440 rusqlite::params![serialized, token],
441 )?;
442 }
443 }
444 Ok(())
445}
446
447fn collect_legacy_keys(
450 tx: &Transaction<'_>,
451 table: &str,
452 column: &str,
453 identity_scoped_only: bool,
454) -> Result<Vec<String>, rusqlite::Error> {
455 let filter = if identity_scoped_only {
456 " WHERE scope_kind = 'identity'"
457 } else {
458 ""
459 };
460 let mut stmt = tx.prepare(&format!("SELECT DISTINCT {column} FROM {table}{filter}"))?;
461 let keys = stmt
462 .query_map([], |row| row.get::<_, String>(0))?
463 .collect::<Result<Vec<_>, _>>()?;
464 Ok(keys
465 .into_iter()
466 .filter(|key| crate::member_comms_id::logical_memory_identity(key) != *key)
467 .collect())
468}
469
470fn migration_0002_quarantine_and_taint_columns(
476 tx: &Transaction<'_>,
477) -> Result<(), rusqlite::Error> {
478 if add_column_if_absent(
479 tx,
480 "records",
481 "ever_quarantined",
482 "INTEGER NOT NULL DEFAULT 0",
483 )? {
484 tx.execute(
490 "UPDATE records SET ever_quarantined = 1 WHERE status_kind = 'quarantined'",
491 [],
492 )?;
493 tx.execute(
494 "UPDATE records SET ever_quarantined = 1 WHERE status_kind = 'tombstoned' \
495 AND memory_id IN (SELECT memory_id FROM audit \
496 WHERE detail LIKE '%\"quarantined\":\"%')",
497 [],
498 )?;
499 }
500 if add_column_if_absent(tx, "proposals", "taint", "TEXT")? {
501 tx.execute(
508 "UPDATE proposals SET taint = 'pre-migration proposal: propose-time \
509 taint fact unrecoverable' WHERE status IN ('pending', 'held')",
510 [],
511 )?;
512 }
513 Ok(())
514}
515
516fn add_column_if_absent(
520 tx: &Transaction<'_>,
521 table: &str,
522 column: &str,
523 ddl: &str,
524) -> Result<bool, rusqlite::Error> {
525 let mut stmt = tx.prepare(&format!("PRAGMA table_info({table})"))?;
526 let existing: Vec<String> = stmt
527 .query_map([], |row| row.get::<_, String>(1))?
528 .collect::<Result<_, _>>()?;
529 if existing.iter().any(|name| name == column) {
530 return Ok(false);
531 }
532 tx.execute(
533 &format!("ALTER TABLE {table} ADD COLUMN {column} {ddl}"),
534 [],
535 )?;
536 Ok(true)
537}
538
539#[derive(Clone)]
542pub struct SqliteAgentMemoryStore {
543 root: PathBuf,
544 scope_floor_records: usize,
545 scope_floor_bytes: usize,
546 connections: Arc<Mutex<HashMap<String, Arc<Mutex<Connection>>>>>,
547 llm_write_gate: Arc<Mutex<Option<Arc<dyn LlmWriteGate>>>>,
553 evidence_resolver: Arc<Mutex<Option<Arc<dyn EvidenceRefResolver>>>>,
558 event_sink: Arc<Mutex<Option<Arc<dyn crate::memory::events::MemoryEventSink>>>>,
561}
562
563impl SqliteAgentMemoryStore {
564 pub fn open(root: impl Into<PathBuf>) -> Result<Self, AgentMemoryError> {
565 let root = root.into();
566 if root.as_os_str().is_empty() {
567 return Err(AgentMemoryError::InvalidConfig(
568 "agent memory root path must not be empty".to_string(),
569 ));
570 }
571 fs::create_dir_all(&root).map_err(|err| AgentMemoryError::Io(err.to_string()))?;
572 Ok(Self {
573 root,
574 scope_floor_records: DEFAULT_SCOPE_FLOOR_RECORDS,
575 scope_floor_bytes: DEFAULT_SCOPE_FLOOR_BYTES,
576 connections: Arc::new(Mutex::new(HashMap::new())),
577 llm_write_gate: Arc::new(Mutex::new(None)),
578 evidence_resolver: Arc::new(Mutex::new(None)),
579 event_sink: Arc::new(Mutex::new(None)),
580 })
581 }
582
583 fn gate(&self) -> Option<Arc<dyn LlmWriteGate>> {
584 self.llm_write_gate
585 .lock()
586 .unwrap_or_else(std::sync::PoisonError::into_inner)
587 .clone()
588 }
589
590 fn resolver(&self) -> Option<Arc<dyn EvidenceRefResolver>> {
591 self.evidence_resolver
592 .lock()
593 .unwrap_or_else(std::sync::PoisonError::into_inner)
594 .clone()
595 }
596
597 fn events(&self) -> Option<Arc<dyn crate::memory::events::MemoryEventSink>> {
598 self.event_sink
599 .lock()
600 .unwrap_or_else(std::sync::PoisonError::into_inner)
601 .clone()
602 }
603
604 #[cfg(test)]
605 fn with_scope_floors(mut self, records: usize, bytes: usize) -> Self {
606 self.scope_floor_records = records;
607 self.scope_floor_bytes = bytes;
608 self
609 }
610
611 pub fn path_for_realm(&self, realm: &str) -> PathBuf {
614 self.root
615 .join(format!("{}.sqlite3", encode_path_segment(realm)))
616 }
617
618 fn realm_connection(&self, realm: &str) -> Result<Arc<Mutex<Connection>>, AgentMemoryError> {
619 let mut connections = self
620 .connections
621 .lock()
622 .unwrap_or_else(std::sync::PoisonError::into_inner);
623 if let Some(existing) = connections.get(realm) {
624 return Ok(existing.clone());
625 }
626 let mut conn = meerkat_sqlite::open(
627 &self.path_for_realm(realm),
628 meerkat_sqlite::ConnectionProfile::PRIMARY,
629 )
630 .map_err(sqlite_store_err)?;
631 meerkat_sqlite::apply_domain_migrations(&mut conn, &MOBKIT_MEMORY_DOMAIN)
632 .map_err(sqlite_store_err)?;
633 let now = now_ms();
634 conn.execute(
638 "DELETE FROM stage WHERE created_at_ms < ?1 AND token NOT IN \
639 (SELECT stage_token FROM pending_promotions WHERE status = 'pending')",
640 params![(now.saturating_sub(STAGE_GC_MAX_AGE_MS)) as i64],
641 )
642 .map_err(sql_err)?;
643 self.import_markdown_realm(&mut conn, realm)?;
644 let shared = Arc::new(Mutex::new(conn));
645 connections.insert(realm.to_string(), shared.clone());
646 Ok(shared)
647 }
648
649 fn import_markdown_realm(
663 &self,
664 conn: &mut Connection,
665 realm: &str,
666 ) -> Result<(), AgentMemoryError> {
667 let realm_dir = self.root.join(encode_path_segment(realm));
668 if !realm_dir.is_dir() {
669 return Ok(());
670 }
671 let entries =
672 fs::read_dir(&realm_dir).map_err(|err| AgentMemoryError::Io(err.to_string()))?;
673 let mut files: Vec<PathBuf> = entries
674 .filter_map(|entry| entry.ok().map(|e| e.path()))
675 .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
676 .collect();
677 files.sort();
678 for path in files {
679 match self.import_markdown_file(conn, realm, &path) {
680 Ok(()) => {}
681 Err(MarkdownImportError::Content(reason)) => {
682 tracing::warn!(
683 file = %path.display(),
684 reason,
685 "agent memory markdown import: file failed and was set aside as \
686 .import-failed (fix and rename back to .md to retry); the realm \
687 store stays open"
688 );
689 record_import_audit(conn, &path, 0, 1, std::slice::from_ref(&reason))?;
690 let mut failed_name = path.as_os_str().to_owned();
691 failed_name.push(".import-failed");
692 fs::rename(&path, PathBuf::from(failed_name))
693 .map_err(|err| AgentMemoryError::Io(err.to_string()))?;
694 }
695 Err(MarkdownImportError::Io(err)) => return Err(err),
696 }
697 }
698 Ok(())
699 }
700
701 fn import_markdown_file(
702 &self,
703 conn: &mut Connection,
704 realm: &str,
705 path: &Path,
706 ) -> Result<(), MarkdownImportError> {
707 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
708 return Ok(());
709 };
710 let identity_str = decode_path_segment(stem);
711 let identity = AgentIdentity::parse(&identity_str).map_err(|err| {
712 MarkdownImportError::Content(format!(
713 "'{}' does not decode to an agent identity: {err}",
714 path.display()
715 ))
716 })?;
717 let records = read_markdown_records(path).map_err(|err| match err {
718 AgentMemoryError::Io(_) => MarkdownImportError::Io(err),
719 other => MarkdownImportError::Content(other.to_string()),
720 })?;
721 let scope = MemoryScope::Identity {
722 realm: realm.to_string(),
723 identity: identity.as_str().to_string(),
724 };
725 let mut seen = std::collections::HashSet::new();
728 let mut ops = Vec::new();
729 let mut skip_reasons: Vec<String> = Vec::new();
730 for record in records {
731 if !seen.insert(record.memory_id.clone()) {
732 continue;
733 }
734 let exists: Option<i64> = conn
735 .query_row(
736 "SELECT 1 FROM records WHERE memory_id = ?1",
737 params![record.memory_id],
738 |row| row.get(0),
739 )
740 .optional()
741 .map_err(sql_err)
742 .map_err(MarkdownImportError::Io)?;
743 if exists.is_some() {
744 continue;
745 }
746 let mut skip = |record_id: &str, reason: String| {
750 tracing::warn!(
751 file = %path.display(),
752 memory_id = record_id,
753 reason,
754 "agent memory markdown import: record skipped"
755 );
756 skip_reasons.push(format!("{record_id}: {reason}"));
757 };
758 if let Err(reason) = validate_record_fields(&record.title, "", &record.body) {
759 skip(&record.memory_id, reason);
760 continue;
761 }
762 if let Some(class) = crate::memory::secrets::detect_record_secret(
763 &record.title,
764 "",
765 &record.body,
766 &record.tags,
767 ) {
768 skip(
769 &record.memory_id,
770 format!("matches the '{class}' secret pattern class (§10.4)"),
771 );
772 continue;
773 }
774 ops.push(StagedOp::Create {
775 id: Some(record.memory_id),
776 scope: scope.clone(),
777 record: NewMemoryRecord {
778 kind: MemoryKind::Fact,
779 title: record.title,
780 description: String::new(),
781 body: record.body,
782 tags: record.tags,
783 evidence: Vec::new(),
784 verification: None,
785 },
786 trust: TrustTier::AgentObserved,
787 derived_from: Vec::new(),
788 rationale: Some("markdown import".to_string()),
789 created_at_ms: Some(record.created_at_ms),
790 updated_at_ms: Some(record.updated_at_ms),
791 });
792 }
793 let imported = ops.len();
794 if !ops.is_empty() {
795 let batch = StagedMutationBatch {
796 kind: StagedBatchKind::FreshWrite,
797 realm: realm.to_string(),
798 author: MemoryAuthor::Agent {
799 identity: identity.as_str().to_string(),
800 },
801 ops,
802 };
803 let token = mint_token("import");
804 apply_batch_tx(conn, &batch, None, None, &token, now_ms()).map_err(|err| {
807 MarkdownImportError::Content(format!("batch validation failed: {err}"))
808 })?;
809 }
810 if !skip_reasons.is_empty() {
811 record_import_audit(conn, path, imported, skip_reasons.len(), &skip_reasons)
812 .map_err(MarkdownImportError::Io)?;
813 }
814 let mut imported_name = path.as_os_str().to_owned();
815 imported_name.push(".imported");
816 fs::rename(path, PathBuf::from(imported_name))
817 .map_err(|err| MarkdownImportError::Io(AgentMemoryError::Io(err.to_string())))?;
818 Ok(())
819 }
820
821 fn with_realm_conn<T>(
822 &self,
823 realm: &str,
824 f: impl FnOnce(&mut Connection) -> Result<T, AgentMemoryError>,
825 ) -> Result<T, AgentMemoryError> {
826 let _fence = meerkat_sqlite::OperationGuard::for_database(&self.path_for_realm(realm))
832 .map_err(sqlite_store_err)?;
833 let conn = self.realm_connection(realm)?;
834 let mut guard = conn
835 .lock()
836 .unwrap_or_else(std::sync::PoisonError::into_inner);
837 f(&mut guard)
838 }
839
840 fn recall_blocking(
841 &self,
842 request: AgentMemoryRecallRequest,
843 ) -> Result<Vec<AgentMemoryRecord>, AgentMemoryError> {
844 let scope = MemoryScope::Identity {
845 realm: request.realm.clone(),
846 identity: request.identity.as_str().to_string(),
847 };
848 let records =
849 self.with_realm_conn(&request.realm, |conn| active_scope_records(conn, &scope))?;
850 let projected = records.into_iter().map(project_record).collect();
851 Ok(select_recall_records(projected, &request))
852 }
853
854 fn remember_blocking(
855 &self,
856 realm: &str,
857 identity: &AgentIdentity,
858 memory: NewAgentMemory,
859 ) -> Result<AgentMemoryRecord, AgentMemoryError> {
860 let title = compact_whitespace(&memory.title);
861 let body = memory.body.trim().to_string();
862 validate_record_fields(&title, "", &body).map_err(AgentMemoryError::InvalidRecord)?;
863 let tags = normalize_tags(memory.tags)?;
864 let scope = MemoryScope::Identity {
865 realm: realm.to_string(),
866 identity: identity.as_str().to_string(),
867 };
868 let hash = content_hash(&title, &body);
869 let floor_records = self.scope_floor_records;
870 let floor_bytes = self.scope_floor_bytes;
871 let gate = self.gate();
872 let events = self.events();
873 self.with_realm_conn(realm, |conn| {
874 let existing: Option<MemoryRecordRow> = conn
877 .query_row(
878 &format!(
879 "SELECT {RECORD_COLUMNS} FROM records \
880 WHERE scope_kind = ?1 AND scope_key = ?2 AND content_hash = ?3 \
881 AND status_kind = 'active' \
882 ORDER BY created_at_ms ASC LIMIT 1"
883 ),
884 params![scope.kind_str(), scope.key(), hash],
885 row_to_record_row,
886 )
887 .optional()
888 .map_err(sql_err)?;
889 if let Some(row) = existing {
890 return Ok(project_record(row.into_record(scope.realm())?));
891 }
892 let batch = StagedMutationBatch {
893 kind: StagedBatchKind::FreshWrite,
894 realm: realm.to_string(),
895 author: MemoryAuthor::Application,
898 ops: vec![StagedOp::Create {
899 id: None,
900 scope: scope.clone(),
901 record: NewMemoryRecord {
902 kind: MemoryKind::Fact,
903 title,
904 description: String::new(),
905 body,
906 tags: tags.clone(),
907 evidence: Vec::new(),
908 verification: None,
909 },
910 trust: TrustTier::AgentObserved,
911 derived_from: Vec::new(),
912 rationale: None,
913 created_at_ms: None,
914 updated_at_ms: None,
915 }],
916 };
917 let receipt = apply_batch_tx(
918 conn,
919 &batch,
920 gate.as_deref(),
921 events.as_deref(),
922 &mint_token("direct"),
923 now_ms(),
924 )?;
925 warn_if_scope_floors_exceeded(conn, &scope, floor_records, floor_bytes)?;
926 let memory_id = receipt.memory_ids.first().cloned().ok_or_else(|| {
927 AgentMemoryError::Io("remember commit returned no record id".to_string())
928 })?;
929 let record = load_record(conn, scope.realm(), &memory_id)?.ok_or_else(|| {
930 AgentMemoryError::Io("remembered record vanished mid-commit".to_string())
931 })?;
932 Ok(project_record(record))
933 })
934 }
935
936 fn forget_blocking(
937 &self,
938 realm: &str,
939 identity: &AgentIdentity,
940 memory_id: &str,
941 ) -> Result<AgentMemoryForgetResult, AgentMemoryError> {
942 let memory_id = memory_id.trim().to_string();
943 if memory_id.is_empty() {
944 return Err(AgentMemoryError::InvalidRecord(
945 "memory_id must not be empty".to_string(),
946 ));
947 }
948 let scope = MemoryScope::Identity {
949 realm: realm.to_string(),
950 identity: identity.as_str().to_string(),
951 };
952 self.forget_in_scope_blocking(&scope, &memory_id, MemoryAuthor::Application)
953 }
954
955 fn forget_in_scope_blocking(
958 &self,
959 scope: &MemoryScope,
960 memory_id: &str,
961 author: MemoryAuthor,
962 ) -> Result<AgentMemoryForgetResult, AgentMemoryError> {
963 let memory_id = memory_id.to_string();
964 let gate = self.gate();
965 let events = self.events();
966 self.with_realm_conn(scope.realm(), |conn| {
967 let record = load_record(conn, scope.realm(), &memory_id)?;
968 let deletable = record.is_some_and(|record| {
969 record.scope == *scope && record.status != RecordStatus::Tombstoned
970 });
971 if !deletable {
972 return Ok(AgentMemoryForgetResult {
973 memory_id,
974 deleted: false,
975 });
976 }
977 let batch = StagedMutationBatch {
978 kind: StagedBatchKind::FreshWrite,
979 realm: scope.realm().to_string(),
980 author,
981 ops: vec![StagedOp::Tombstone {
982 id: memory_id.clone(),
983 rationale: None,
984 }],
985 };
986 apply_batch_tx(
987 conn,
988 &batch,
989 gate.as_deref(),
990 events.as_deref(),
991 &mint_token("direct"),
992 now_ms(),
993 )?;
994 Ok(AgentMemoryForgetResult {
995 memory_id,
996 deleted: true,
997 })
998 })
999 }
1000
1001 fn supersede_blocking(
1002 &self,
1003 scope: &MemoryScope,
1004 prior: &str,
1005 record: NewMemoryRecord,
1006 ) -> Result<MemoryId, AgentMemoryError> {
1007 self.supersede_with_author_blocking(scope, prior, record, MemoryAuthor::Application)
1008 .map(|receipt| receipt.memory_id)
1009 }
1010
1011 fn supersede_with_author_blocking(
1012 &self,
1013 scope: &MemoryScope,
1014 prior: &str,
1015 record: NewMemoryRecord,
1016 author: MemoryAuthor,
1017 ) -> Result<AuthoredWriteReceipt, AgentMemoryError> {
1018 let title = compact_whitespace(&record.title);
1019 let body = record.body.trim().to_string();
1020 validate_record_fields(&title, &record.description, &body)
1021 .map_err(AgentMemoryError::InvalidRecord)?;
1022 let tags = normalize_tags(record.tags)?;
1023 let realm = scope.realm().to_string();
1024 let expected_scope = scope.clone();
1025 let gate = self.gate();
1026 let events = self.events();
1027 self.with_realm_conn(&realm, |conn| {
1028 let existing = load_record(conn, &realm, prior)?.ok_or_else(|| {
1029 AgentMemoryError::InvalidRecord(format!("record '{prior}' does not exist"))
1030 })?;
1031 if existing.scope != expected_scope {
1032 return Err(AgentMemoryError::InvalidRecord(format!(
1033 "record '{prior}' does not belong to the requested scope"
1034 )));
1035 }
1036 let batch = StagedMutationBatch {
1037 kind: StagedBatchKind::FreshWrite,
1038 realm: realm.clone(),
1039 author,
1040 ops: vec![StagedOp::Supersede {
1041 id: None,
1042 prior: prior.to_string(),
1043 record: NewMemoryRecord {
1044 title,
1045 body,
1046 tags,
1047 ..record
1048 },
1049 trust: TrustTier::AgentObserved,
1050 derived_from: Vec::new(),
1051 rationale: None,
1052 }],
1053 };
1054 let receipt = apply_batch_tx(
1055 conn,
1056 &batch,
1057 gate.as_deref(),
1058 events.as_deref(),
1059 &mint_token("direct"),
1060 now_ms(),
1061 )?;
1062 let memory_id = receipt.memory_ids.first().cloned().ok_or_else(|| {
1063 AgentMemoryError::Io("supersede commit returned no record id".to_string())
1064 })?;
1065 let record = load_record(conn, &realm, &memory_id)?.ok_or_else(|| {
1066 AgentMemoryError::Io("superseding record vanished mid-commit".to_string())
1067 })?;
1068 Ok(AuthoredWriteReceipt {
1069 memory_id,
1070 status: record.status,
1071 })
1072 })
1073 }
1074
1075 fn remember_authored_blocking(
1077 &self,
1078 scope: &MemoryScope,
1079 record: NewMemoryRecord,
1080 author: MemoryAuthor,
1081 ) -> Result<AuthoredWriteReceipt, AgentMemoryError> {
1082 let title = compact_whitespace(&record.title);
1083 let body = record.body.trim().to_string();
1084 validate_record_fields(&title, &record.description, &body)
1085 .map_err(AgentMemoryError::InvalidRecord)?;
1086 let tags = normalize_tags(record.tags)?;
1087 let hash = content_hash(&title, &body);
1088 let realm = scope.realm().to_string();
1089 let scope = scope.clone();
1090 let floor_records = self.scope_floor_records;
1091 let floor_bytes = self.scope_floor_bytes;
1092 let gate = self.gate();
1093 let events = self.events();
1094 self.with_realm_conn(&realm, |conn| {
1095 let existing: Option<MemoryRecordRow> = conn
1098 .query_row(
1099 &format!(
1100 "SELECT {RECORD_COLUMNS} FROM records \
1101 WHERE scope_kind = ?1 AND scope_key = ?2 AND content_hash = ?3 \
1102 AND status_kind = 'active' \
1103 ORDER BY created_at_ms ASC LIMIT 1"
1104 ),
1105 params![scope.kind_str(), scope.key(), hash],
1106 row_to_record_row,
1107 )
1108 .optional()
1109 .map_err(sql_err)?;
1110 if let Some(row) = existing {
1111 let record = row.into_record(scope.realm())?;
1112 return Ok(AuthoredWriteReceipt {
1113 memory_id: record.id,
1114 status: record.status,
1115 });
1116 }
1117 let batch = StagedMutationBatch {
1118 kind: StagedBatchKind::FreshWrite,
1119 realm: realm.clone(),
1120 author,
1121 ops: vec![StagedOp::Create {
1122 id: None,
1123 scope: scope.clone(),
1124 record: NewMemoryRecord {
1125 title,
1126 body,
1127 tags,
1128 ..record
1129 },
1130 trust: TrustTier::AgentObserved,
1133 derived_from: Vec::new(),
1134 rationale: None,
1135 created_at_ms: None,
1136 updated_at_ms: None,
1137 }],
1138 };
1139 let receipt = apply_batch_tx(
1140 conn,
1141 &batch,
1142 gate.as_deref(),
1143 events.as_deref(),
1144 &mint_token("direct"),
1145 now_ms(),
1146 )?;
1147 warn_if_scope_floors_exceeded(conn, &scope, floor_records, floor_bytes)?;
1148 let memory_id = receipt.memory_ids.first().cloned().ok_or_else(|| {
1149 AgentMemoryError::Io("remember commit returned no record id".to_string())
1150 })?;
1151 let record = load_record(conn, scope.realm(), &memory_id)?.ok_or_else(|| {
1152 AgentMemoryError::Io("remembered record vanished mid-commit".to_string())
1153 })?;
1154 Ok(AuthoredWriteReceipt {
1155 memory_id,
1156 status: record.status,
1157 })
1158 })
1159 }
1160
1161 fn manifest_blocking(
1162 &self,
1163 scopes: &[MemoryScope],
1164 tier: ManifestTier,
1165 ) -> Result<Vec<RecordMeta>, AgentMemoryError> {
1166 let now = now_ms();
1167 let mut out = Vec::new();
1168 for scope in scopes {
1169 let metas =
1170 self.with_realm_conn(scope.realm(), |conn| scope_manifest(conn, scope, tier, now))?;
1171 out.extend(metas);
1172 }
1173 Ok(out)
1174 }
1175
1176 fn mark_usage_blocking(
1177 &self,
1178 ids: &[MemoryId],
1179 event: UsageEvent,
1180 ) -> Result<(), AgentMemoryError> {
1181 let now = now_ms();
1182 for realm in self.known_realms()? {
1183 self.with_realm_conn(&realm, |conn| {
1184 for id in ids {
1185 let usage_json: Option<String> = conn
1186 .query_row(
1187 "SELECT usage_stats FROM records WHERE memory_id = ?1",
1188 params![id],
1189 |row| row.get(0),
1190 )
1191 .optional()
1192 .map_err(sql_err)?;
1193 let Some(usage_json) = usage_json else {
1194 continue;
1195 };
1196 let mut usage: UsageStats =
1197 serde_json::from_str(&usage_json).unwrap_or_default();
1198 match event {
1199 UsageEvent::Injected => {
1200 usage.injected_count += 1;
1201 usage.last_injected_at_ms = Some(now);
1202 }
1203 UsageEvent::ExplicitRecall => {
1207 usage.explicit_recall_count += 1;
1208 usage.last_recalled_at_ms = Some(now);
1209 }
1210 UsageEvent::JudgedUseful => {
1211 usage.judged_useful_count += 1;
1212 usage.last_useful_at_ms = Some(now);
1213 }
1214 }
1215 conn.execute(
1216 "UPDATE records SET usage_stats = ?1 WHERE memory_id = ?2",
1217 params![json_string(&usage)?, id],
1218 )
1219 .map_err(sql_err)?;
1220 }
1221 Ok(())
1222 })?;
1223 }
1224 Ok(())
1225 }
1226
1227 fn log_injections_blocking(
1228 &self,
1229 realm: &str,
1230 entries: &[InjectionLogEntry],
1231 ) -> Result<(), AgentMemoryError> {
1232 if entries.is_empty() {
1233 return Ok(());
1234 }
1235 self.with_realm_conn(realm, |conn| {
1236 let mut stmt = conn
1237 .prepare(
1238 "INSERT INTO injections (record_id, identity, session_key, surface, at_ms) \
1239 VALUES (?1, ?2, ?3, ?4, ?5)",
1240 )
1241 .map_err(sql_err)?;
1242 for entry in entries {
1243 stmt.execute(params![
1244 entry.record_id,
1245 entry.identity,
1246 entry.session_key,
1247 entry.surface.as_str(),
1248 entry.at_ms as i64,
1249 ])
1250 .map_err(sql_err)?;
1251 }
1252 Ok(())
1253 })
1254 }
1255
1256 fn injection_log_blocking(
1257 &self,
1258 realm: &str,
1259 limit: usize,
1260 ) -> Result<Vec<InjectionLogEntry>, AgentMemoryError> {
1261 self.with_realm_conn(realm, |conn| {
1262 let mut stmt = conn
1263 .prepare(
1264 "SELECT record_id, identity, session_key, surface, at_ms FROM injections \
1265 ORDER BY injection_id DESC LIMIT ?1",
1266 )
1267 .map_err(sql_err)?;
1268 let rows = stmt
1269 .query_map(params![limit as i64], |row| {
1270 Ok((
1271 row.get::<_, String>(0)?,
1272 row.get::<_, String>(1)?,
1273 row.get::<_, Option<String>>(2)?,
1274 row.get::<_, String>(3)?,
1275 row.get::<_, i64>(4)?,
1276 ))
1277 })
1278 .map_err(sql_err)?;
1279 let mut entries = Vec::new();
1280 for row in rows {
1281 let (record_id, identity, session_key, surface, at_ms) = row.map_err(sql_err)?;
1282 let surface = InjectionSurface::parse(&surface).ok_or_else(|| {
1283 AgentMemoryError::Parse(format!("unknown injection surface '{surface}'"))
1284 })?;
1285 entries.push(InjectionLogEntry {
1286 record_id,
1287 identity,
1288 session_key,
1289 surface,
1290 at_ms: at_ms as u64,
1291 });
1292 }
1293 Ok(entries)
1294 })
1295 }
1296
1297 fn propose_blocking(
1298 &self,
1299 scope: &MemoryScope,
1300 record: NewMemoryRecord,
1301 author: MemoryAuthor,
1302 ) -> Result<ProposalId, AgentMemoryError> {
1303 validate_record_fields(&record.title, &record.description, &record.body)
1304 .map_err(AgentMemoryError::InvalidRecord)?;
1305 if let Some(class) = crate::memory::secrets::detect_record_secret(
1309 &record.title,
1310 &record.description,
1311 &record.body,
1312 &record.tags,
1313 ) {
1314 return Err(AgentMemoryError::InvalidRecord(
1315 crate::memory::staged::StagedBatchError::SecretDetected { op_index: 0, class }
1316 .to_string(),
1317 ));
1318 }
1319 let taint = self.gate().and_then(|gate| {
1326 gate.quarantine_reason(&author, StagedBatchKind::FreshWrite, &record.evidence)
1327 });
1328 if let Some(reason) = taint.as_deref() {
1329 tracing::warn!(
1330 realm = scope.realm(),
1331 author = ?author,
1332 reason,
1333 "agent memory: proposal from tainted context recorded as tainted; a plain \
1334 steward accept will downgrade to an operator gate"
1335 );
1336 }
1337 let proposal_id = mint_token("prop");
1338 self.with_realm_conn(scope.realm(), |conn| {
1339 conn.execute(
1340 "INSERT INTO proposals (proposal_id, scope_kind, scope_key, record, author, \
1341 status, created_at_ms, taint) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)",
1342 params![
1343 proposal_id,
1344 scope.kind_str(),
1345 scope.key(),
1346 json_string(&record)?,
1347 json_string(&author)?,
1348 now_ms() as i64,
1349 taint,
1350 ],
1351 )
1352 .map_err(sql_err)?;
1353 Ok(())
1354 })?;
1355 Ok(proposal_id)
1356 }
1357
1358 fn stage_blocking(&self, batch: StagedMutationBatch) -> Result<StageToken, AgentMemoryError> {
1359 let realm = batch.realm.clone();
1360 let resolver = self.resolver();
1361 self.with_realm_conn(&realm, |conn| {
1362 {
1363 let view = ConnBatchView {
1364 conn,
1365 realm: &batch.realm,
1366 };
1367 validate_batch(
1368 &batch,
1369 &view,
1370 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1371 now_ms(),
1372 )
1373 .map_err(|err| AgentMemoryError::InvalidRecord(err.to_string()))?;
1374 }
1375 check_verified_retier_evidence(conn, &batch, resolver.as_deref())?;
1376 let token = mint_token("stage");
1377 conn.execute(
1378 "INSERT INTO stage (token, batch, created_at_ms) VALUES (?1, ?2, ?3)",
1379 params![token, json_string(&batch)?, now_ms() as i64],
1380 )
1381 .map_err(sql_err)?;
1382 Ok(StageToken {
1383 realm: realm.clone(),
1384 token,
1385 })
1386 })
1387 }
1388
1389 fn commit_blocking(&self, token: StageToken) -> Result<CommitReceipt, AgentMemoryError> {
1390 let gate = self.gate();
1391 let resolver = self.resolver();
1392 let events = self.events();
1393 self.with_realm_conn(&token.realm, |conn| {
1394 let batch_json: Option<String> = conn
1395 .query_row(
1396 "SELECT batch FROM stage WHERE token = ?1",
1397 params![token.token],
1398 |row| row.get(0),
1399 )
1400 .optional()
1401 .map_err(sql_err)?;
1402 let Some(batch_json) = batch_json else {
1403 return Err(AgentMemoryError::InvalidRecord(format!(
1404 "unknown or expired stage token '{}'",
1405 token.token
1406 )));
1407 };
1408 let batch: StagedMutationBatch = serde_json::from_str(&batch_json)
1409 .map_err(|err| AgentMemoryError::Parse(err.to_string()))?;
1410 check_verified_retier_evidence(conn, &batch, resolver.as_deref())?;
1411 apply_batch_tx(
1412 conn,
1413 &batch,
1414 gate.as_deref(),
1415 events.as_deref(),
1416 &token.token,
1417 now_ms(),
1418 )
1419 })
1420 }
1421
1422 pub(crate) fn open_realm_ledgered(&self, realm: &str) -> Result<(), AgentMemoryError> {
1428 self.realm_connection(realm).map(|_| ())
1429 }
1430
1431 pub(crate) fn known_realms(&self) -> Result<Vec<String>, AgentMemoryError> {
1432 let entries =
1433 fs::read_dir(&self.root).map_err(|err| AgentMemoryError::Io(err.to_string()))?;
1434 let mut realms = Vec::new();
1435 for entry in entries.filter_map(Result::ok) {
1436 let path = entry.path();
1437 if path.extension().is_some_and(|ext| ext == "sqlite3")
1438 && let Some(stem) = path.file_stem().and_then(|stem| stem.to_str())
1439 {
1440 realms.push(decode_path_segment(stem));
1441 }
1442 }
1443 realms.sort();
1444 Ok(realms)
1445 }
1446}
1447
1448impl TaintableStore for SqliteAgentMemoryStore {
1454 fn set_llm_write_gate(&self, gate: Arc<dyn LlmWriteGate>) {
1455 *self
1456 .llm_write_gate
1457 .lock()
1458 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(gate);
1459 }
1460
1461 fn set_llm_write_gate_if_absent(&self, gate: Arc<dyn LlmWriteGate>) -> bool {
1462 let mut guard = self
1463 .llm_write_gate
1464 .lock()
1465 .unwrap_or_else(std::sync::PoisonError::into_inner);
1466 if guard.is_some() {
1467 return false;
1468 }
1469 *guard = Some(gate);
1470 true
1471 }
1472
1473 fn set_evidence_resolver(&self, resolver: Arc<dyn EvidenceRefResolver>) {
1474 *self
1475 .evidence_resolver
1476 .lock()
1477 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(resolver);
1478 }
1479
1480 fn set_event_sink(&self, sink: Arc<dyn crate::memory::events::MemoryEventSink>) {
1481 *self
1482 .event_sink
1483 .lock()
1484 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sink);
1485 }
1486
1487 fn set_event_sink_if_absent(
1488 &self,
1489 sink: Arc<dyn crate::memory::events::MemoryEventSink>,
1490 ) -> bool {
1491 let mut guard = self
1492 .event_sink
1493 .lock()
1494 .unwrap_or_else(std::sync::PoisonError::into_inner);
1495 if guard.is_some() {
1496 return false;
1497 }
1498 *guard = Some(sink);
1499 true
1500 }
1501}
1502
1503#[async_trait]
1504impl AgentMemoryProvider for SqliteAgentMemoryStore {
1505 async fn recall(
1506 &self,
1507 request: AgentMemoryRecallRequest,
1508 ) -> Result<Vec<AgentMemoryRecord>, AgentMemoryError> {
1509 let store = self.clone();
1510 run_blocking(move || store.recall_blocking(request)).await
1511 }
1512
1513 fn supports_remember(&self) -> bool {
1514 true
1515 }
1516
1517 async fn remember(
1518 &self,
1519 realm: &str,
1520 identity: &AgentIdentity,
1521 memory: NewAgentMemory,
1522 ) -> Result<AgentMemoryRecord, AgentMemoryError> {
1523 let store = self.clone();
1524 let realm = realm.to_string();
1525 let identity = identity.clone();
1526 run_blocking(move || store.remember_blocking(&realm, &identity, memory)).await
1527 }
1528
1529 fn supports_forget(&self) -> bool {
1530 true
1531 }
1532
1533 async fn forget(
1534 &self,
1535 realm: &str,
1536 identity: &AgentIdentity,
1537 memory_id: &str,
1538 ) -> Result<AgentMemoryForgetResult, AgentMemoryError> {
1539 let store = self.clone();
1540 let realm = realm.to_string();
1541 let identity = identity.clone();
1542 let memory_id = memory_id.to_string();
1543 run_blocking(move || store.forget_blocking(&realm, &identity, &memory_id)).await
1544 }
1545
1546 async fn manifest(
1547 &self,
1548 scopes: &[MemoryScope],
1549 tier: ManifestTier,
1550 ) -> Result<Vec<RecordMeta>, AgentMemoryError> {
1551 let store = self.clone();
1552 let scopes = scopes.to_vec();
1553 run_blocking(move || store.manifest_blocking(&scopes, tier)).await
1554 }
1555
1556 fn supports_manifest(&self) -> bool {
1557 true
1558 }
1559
1560 async fn supersede(
1561 &self,
1562 scope: &MemoryScope,
1563 prior: &str,
1564 record: NewMemoryRecord,
1565 ) -> Result<MemoryId, AgentMemoryError> {
1566 let store = self.clone();
1567 let scope = scope.clone();
1568 let prior = prior.to_string();
1569 run_blocking(move || store.supersede_blocking(&scope, &prior, record)).await
1570 }
1571
1572 fn supports_supersede(&self) -> bool {
1573 true
1574 }
1575
1576 async fn mark_usage(
1577 &self,
1578 ids: &[MemoryId],
1579 event: UsageEvent,
1580 ) -> Result<(), AgentMemoryError> {
1581 let store = self.clone();
1582 let ids = ids.to_vec();
1583 run_blocking(move || store.mark_usage_blocking(&ids, event)).await
1584 }
1585
1586 async fn log_injections(
1587 &self,
1588 realm: &str,
1589 entries: &[InjectionLogEntry],
1590 ) -> Result<(), AgentMemoryError> {
1591 let store = self.clone();
1592 let realm = realm.to_string();
1593 let entries = entries.to_vec();
1594 run_blocking(move || store.log_injections_blocking(&realm, &entries)).await
1595 }
1596
1597 async fn propose(
1598 &self,
1599 scope: &MemoryScope,
1600 record: NewMemoryRecord,
1601 author: MemoryAuthor,
1602 ) -> Result<ProposalId, AgentMemoryError> {
1603 let store = self.clone();
1604 let scope = scope.clone();
1605 run_blocking(move || store.propose_blocking(&scope, record, author)).await
1606 }
1607
1608 fn supports_propose(&self) -> bool {
1609 true
1610 }
1611
1612 async fn remember_authored(
1613 &self,
1614 scope: &MemoryScope,
1615 record: NewMemoryRecord,
1616 author: MemoryAuthor,
1617 ) -> Result<AuthoredWriteReceipt, AgentMemoryError> {
1618 let store = self.clone();
1619 let scope = scope.clone();
1620 run_blocking(move || store.remember_authored_blocking(&scope, record, author)).await
1621 }
1622
1623 async fn supersede_authored(
1624 &self,
1625 scope: &MemoryScope,
1626 prior: &str,
1627 record: NewMemoryRecord,
1628 author: MemoryAuthor,
1629 ) -> Result<AuthoredWriteReceipt, AgentMemoryError> {
1630 let store = self.clone();
1631 let scope = scope.clone();
1632 let prior = prior.to_string();
1633 run_blocking(move || store.supersede_with_author_blocking(&scope, &prior, record, author))
1634 .await
1635 }
1636
1637 async fn forget_authored(
1638 &self,
1639 scope: &MemoryScope,
1640 memory_id: &str,
1641 author: MemoryAuthor,
1642 ) -> Result<AgentMemoryForgetResult, AgentMemoryError> {
1643 let memory_id = memory_id.trim().to_string();
1644 if memory_id.is_empty() {
1645 return Err(AgentMemoryError::InvalidRecord(
1646 "memory_id must not be empty".to_string(),
1647 ));
1648 }
1649 let store = self.clone();
1650 let scope = scope.clone();
1651 run_blocking(move || store.forget_in_scope_blocking(&scope, &memory_id, author)).await
1652 }
1653
1654 fn supports_authored_writes(&self) -> bool {
1655 true
1656 }
1657
1658 fn as_taintable(&self) -> Option<Arc<dyn TaintableStore>> {
1659 Some(Arc::new(self.clone()))
1660 }
1661
1662 fn as_steward_store(&self) -> Option<Arc<dyn StewardStore>> {
1663 Some(Arc::new(self.clone()))
1664 }
1665
1666 fn as_memory_panel_store(&self) -> Option<Arc<dyn MemoryPanelStore>> {
1667 Some(Arc::new(self.clone()))
1668 }
1669
1670 fn as_selected_record_fetch(
1671 &self,
1672 ) -> Option<Arc<dyn crate::memory::selector::SelectedRecordFetch>> {
1673 Some(Arc::new(self.clone()))
1674 }
1675
1676 fn as_tombstone_source(&self) -> Option<Arc<dyn crate::memory::distiller::TombstoneSource>> {
1677 Some(Arc::new(self.clone()))
1678 }
1679}
1680
1681#[async_trait]
1682impl StagedMemoryStore for SqliteAgentMemoryStore {
1683 async fn stage(&self, batch: StagedMutationBatch) -> Result<StageToken, AgentMemoryError> {
1684 let store = self.clone();
1685 run_blocking(move || store.stage_blocking(batch)).await
1686 }
1687
1688 async fn commit(&self, token: StageToken) -> Result<CommitReceipt, AgentMemoryError> {
1689 let store = self.clone();
1690 run_blocking(move || store.commit_blocking(token)).await
1691 }
1692}
1693
1694#[async_trait]
1697impl StewardStore for SqliteAgentMemoryStore {
1698 fn scope_floors(&self) -> (usize, usize) {
1699 (self.scope_floor_records, self.scope_floor_bytes)
1700 }
1701
1702 async fn scope_overview(&self, realm: &str) -> Result<Vec<ScopeOverview>, AgentMemoryError> {
1703 let store = self.clone();
1704 let realm = realm.to_string();
1705 run_blocking(move || {
1706 store.with_realm_conn(&realm, |conn| {
1707 let mut stmt = conn
1708 .prepare(
1709 "SELECT scope_kind, scope_key, status_kind, COUNT(*), \
1710 COALESCE(SUM(LENGTH(body)), 0) FROM records \
1711 GROUP BY scope_kind, scope_key, status_kind",
1712 )
1713 .map_err(sql_err)?;
1714 let rows = stmt
1715 .query_map([], |row| {
1716 Ok((
1717 row.get::<_, String>(0)?,
1718 row.get::<_, String>(1)?,
1719 row.get::<_, String>(2)?,
1720 row.get::<_, i64>(3)?,
1721 row.get::<_, i64>(4)?,
1722 ))
1723 })
1724 .map_err(sql_err)?;
1725 let mut by_scope: HashMap<(String, String), ScopeOverview> = HashMap::new();
1726 for row in rows {
1727 let (scope_kind, scope_key, status_kind, count, bytes) =
1728 row.map_err(sql_err)?;
1729 let scope = scope_from_parts(&scope_kind, &scope_key, &realm)?;
1730 let entry =
1731 by_scope
1732 .entry((scope_kind, scope_key))
1733 .or_insert_with(|| ScopeOverview {
1734 scope,
1735 active: 0,
1736 quarantined: 0,
1737 superseded: 0,
1738 tombstoned: 0,
1739 body_bytes: 0,
1740 });
1741 match status_kind.as_str() {
1742 "active" => entry.active = count as u64,
1743 "quarantined" => entry.quarantined = count as u64,
1744 "superseded" => entry.superseded = count as u64,
1745 "tombstoned" => entry.tombstoned = count as u64,
1746 _ => {}
1747 }
1748 entry.body_bytes += bytes as u64;
1749 }
1750 let mut overview: Vec<ScopeOverview> = by_scope.into_values().collect();
1751 overview.sort_by(|a, b| a.scope.cmp(&b.scope));
1752 Ok(overview)
1753 })
1754 })
1755 .await
1756 }
1757
1758 async fn pending_proposals(
1759 &self,
1760 realm: &str,
1761 limit: usize,
1762 ) -> Result<Vec<PendingProposal>, AgentMemoryError> {
1763 let store = self.clone();
1764 let realm = realm.to_string();
1765 run_blocking(move || {
1766 store.with_realm_conn(&realm, |conn| {
1767 let mut stmt = conn
1768 .prepare(
1769 "SELECT proposal_id, scope_kind, scope_key, record, author, status, \
1770 created_at_ms, taint FROM proposals WHERE status IN ('pending', 'held') \
1771 ORDER BY created_at_ms ASC LIMIT ?1",
1772 )
1773 .map_err(sql_err)?;
1774 let rows = stmt
1775 .query_map(params![limit as i64], |row| {
1776 Ok((
1777 row.get::<_, String>(0)?,
1778 row.get::<_, String>(1)?,
1779 row.get::<_, String>(2)?,
1780 row.get::<_, String>(3)?,
1781 row.get::<_, String>(4)?,
1782 row.get::<_, String>(5)?,
1783 row.get::<_, i64>(6)?,
1784 row.get::<_, Option<String>>(7)?,
1785 ))
1786 })
1787 .map_err(sql_err)?;
1788 let mut proposals = Vec::new();
1789 for row in rows {
1790 let (
1791 proposal_id,
1792 scope_kind,
1793 scope_key,
1794 record,
1795 author,
1796 status,
1797 created,
1798 taint,
1799 ) = row.map_err(sql_err)?;
1800 proposals.push(PendingProposal {
1801 proposal_id,
1802 scope: scope_from_parts(&scope_kind, &scope_key, &realm)?,
1803 record: serde_json::from_str(&record)
1804 .map_err(|err| AgentMemoryError::Parse(err.to_string()))?,
1805 author: serde_json::from_str(&author)
1806 .map_err(|err| AgentMemoryError::Parse(err.to_string()))?,
1807 status,
1808 created_at_ms: created as u64,
1809 taint,
1810 });
1811 }
1812 Ok(proposals)
1813 })
1814 })
1815 .await
1816 }
1817
1818 async fn set_proposal_status(
1819 &self,
1820 realm: &str,
1821 proposal_id: &str,
1822 status: &str,
1823 ) -> Result<(), AgentMemoryError> {
1824 if !matches!(status, "accepted" | "rejected" | "held" | "pending") {
1825 return Err(AgentMemoryError::InvalidRecord(format!(
1826 "unknown proposal status '{status}'"
1827 )));
1828 }
1829 let store = self.clone();
1830 let realm = realm.to_string();
1831 let proposal_id = proposal_id.to_string();
1832 let status = status.to_string();
1833 run_blocking(move || {
1834 store.with_realm_conn(&realm, |conn| {
1835 let updated = conn
1836 .execute(
1837 "UPDATE proposals SET status = ?1 WHERE proposal_id = ?2",
1838 params![status, proposal_id],
1839 )
1840 .map_err(sql_err)?;
1841 if updated == 0 {
1842 return Err(AgentMemoryError::InvalidRecord(format!(
1843 "unknown proposal '{proposal_id}'"
1844 )));
1845 }
1846 Ok(())
1847 })
1848 })
1849 .await
1850 }
1851
1852 async fn quarantined_records(
1853 &self,
1854 realm: &str,
1855 limit: usize,
1856 ) -> Result<Vec<super::records::MemoryRecord>, AgentMemoryError> {
1857 let store = self.clone();
1858 let realm = realm.to_string();
1859 run_blocking(move || {
1860 store.with_realm_conn(&realm, |conn| {
1861 let mut stmt = conn
1862 .prepare(&format!(
1863 "SELECT {RECORD_COLUMNS} FROM records \
1864 WHERE status_kind = 'quarantined' \
1865 ORDER BY created_at_ms DESC LIMIT ?1"
1866 ))
1867 .map_err(sql_err)?;
1868 let rows = stmt
1869 .query_map(params![limit as i64], row_to_record_row)
1870 .map_err(sql_err)?;
1871 let mut records = Vec::new();
1872 for row in rows {
1873 records.push(row.map_err(sql_err)?.into_record(&realm)?);
1874 }
1875 Ok(records)
1876 })
1877 })
1878 .await
1879 }
1880
1881 async fn records_by_ids(
1882 &self,
1883 realm: &str,
1884 ids: &[String],
1885 ) -> Result<Vec<super::records::MemoryRecord>, AgentMemoryError> {
1886 let store = self.clone();
1887 let realm = realm.to_string();
1888 let ids = ids.to_vec();
1889 run_blocking(move || {
1890 store.with_realm_conn(&realm, |conn| {
1891 let mut records = Vec::new();
1892 for id in &ids {
1893 if let Some(record) = load_record(conn, &realm, id)? {
1894 records.push(record);
1895 }
1896 }
1897 Ok(records)
1898 })
1899 })
1900 .await
1901 }
1902
1903 async fn recent_records(
1904 &self,
1905 realm: &str,
1906 limit: usize,
1907 ) -> Result<Vec<super::records::MemoryRecord>, AgentMemoryError> {
1908 let store = self.clone();
1909 let realm = realm.to_string();
1910 run_blocking(move || {
1911 store.with_realm_conn(&realm, |conn| {
1912 let mut stmt = conn
1913 .prepare(&format!(
1914 "SELECT {RECORD_COLUMNS} FROM records \
1915 WHERE status_kind IN ('active', 'quarantined') \
1916 ORDER BY updated_at_ms DESC LIMIT ?1"
1917 ))
1918 .map_err(sql_err)?;
1919 let rows = stmt
1920 .query_map(params![limit as i64], row_to_record_row)
1921 .map_err(sql_err)?;
1922 let mut records = Vec::new();
1923 for row in rows {
1924 records.push(row.map_err(sql_err)?.into_record(&realm)?);
1925 }
1926 Ok(records)
1927 })
1928 })
1929 .await
1930 }
1931
1932 async fn injection_log(
1933 &self,
1934 realm: &str,
1935 limit: usize,
1936 ) -> Result<Vec<InjectionLogEntry>, AgentMemoryError> {
1937 let store = self.clone();
1938 let realm = realm.to_string();
1939 run_blocking(move || store.injection_log_blocking(&realm, limit)).await
1940 }
1941
1942 async fn record_pending_harvest(
1943 &self,
1944 realm: &str,
1945 identity: &str,
1946 session_key: Option<&str>,
1947 cause: &str,
1948 ) -> Result<(), AgentMemoryError> {
1949 let store = self.clone();
1950 let realm = realm.to_string();
1951 let identity = identity.to_string();
1952 let session_key = session_key.map(str::to_string);
1953 let cause = cause.to_string();
1954 run_blocking(move || {
1955 store.with_realm_conn(&realm, |conn| {
1956 conn.execute(
1957 "INSERT OR IGNORE INTO pending_harvests \
1958 (identity, session_key, cause, retired_at_ms, status) \
1959 VALUES (?1, ?2, ?3, ?4, 'pending')",
1960 params![identity, session_key, cause, now_ms() as i64],
1961 )
1962 .map_err(sql_err)?;
1963 Ok(())
1964 })
1965 })
1966 .await
1967 }
1968
1969 async fn pending_harvests(
1970 &self,
1971 realm: &str,
1972 limit: usize,
1973 ) -> Result<Vec<PendingHarvest>, AgentMemoryError> {
1974 let store = self.clone();
1975 let realm = realm.to_string();
1976 run_blocking(move || {
1977 store.with_realm_conn(&realm, |conn| {
1978 let mut stmt = conn
1979 .prepare(
1980 "SELECT identity, session_key, cause, retired_at_ms FROM \
1981 pending_harvests WHERE status = 'pending' \
1982 ORDER BY retired_at_ms ASC LIMIT ?1",
1983 )
1984 .map_err(sql_err)?;
1985 let rows = stmt
1986 .query_map(params![limit as i64], |row| {
1987 Ok(PendingHarvest {
1988 identity: row.get(0)?,
1989 session_key: row.get(1)?,
1990 cause: row.get(2)?,
1991 retired_at_ms: row.get::<_, i64>(3)? as u64,
1992 })
1993 })
1994 .map_err(sql_err)?;
1995 let mut harvests = Vec::new();
1996 for row in rows {
1997 harvests.push(row.map_err(sql_err)?);
1998 }
1999 Ok(harvests)
2000 })
2001 })
2002 .await
2003 }
2004
2005 async fn mark_harvest_complete(
2006 &self,
2007 realm: &str,
2008 identity: &str,
2009 retired_at_ms: u64,
2010 ) -> Result<(), AgentMemoryError> {
2011 let store = self.clone();
2012 let realm = realm.to_string();
2013 let identity = identity.to_string();
2014 run_blocking(move || {
2015 store.with_realm_conn(&realm, |conn| {
2016 conn.execute(
2017 "UPDATE pending_harvests SET status = 'harvested' \
2018 WHERE identity = ?1 AND retired_at_ms = ?2",
2019 params![identity, retired_at_ms as i64],
2020 )
2021 .map_err(sql_err)?;
2022 Ok(())
2023 })
2024 })
2025 .await
2026 }
2027
2028 async fn record_pending_promotion(
2029 &self,
2030 realm: &str,
2031 promotion: PendingPromotion,
2032 ) -> Result<(), AgentMemoryError> {
2033 let store = self.clone();
2034 let realm = realm.to_string();
2035 run_blocking(move || {
2036 store.with_realm_conn(&realm, |conn| {
2037 conn.execute(
2038 "INSERT INTO pending_promotions (pending_id, stage_token, record_id, \
2039 scope_kind, scope_key, rationale, status, created_at_ms) \
2040 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
2041 params![
2042 promotion.pending_id,
2043 promotion.stage_token,
2044 promotion.record_id,
2045 promotion.scope_kind,
2046 promotion.scope_key,
2047 promotion.rationale,
2048 promotion.status,
2049 promotion.created_at_ms as i64,
2050 ],
2051 )
2052 .map_err(sql_err)?;
2053 Ok(())
2054 })
2055 })
2056 .await
2057 }
2058
2059 async fn pending_promotion_by_id(
2060 &self,
2061 realm: &str,
2062 pending_id: &str,
2063 ) -> Result<Option<PendingPromotion>, AgentMemoryError> {
2064 let store = self.clone();
2065 let realm = realm.to_string();
2066 let pending_id = pending_id.to_string();
2067 run_blocking(move || {
2068 store.with_realm_conn(&realm, |conn| {
2069 conn.query_row(
2070 "SELECT pending_id, stage_token, record_id, scope_kind, scope_key, \
2071 rationale, status, created_at_ms FROM pending_promotions \
2072 WHERE pending_id = ?1 AND status = 'pending'",
2073 params![pending_id],
2074 |row| {
2075 Ok(PendingPromotion {
2076 pending_id: row.get(0)?,
2077 stage_token: row.get(1)?,
2078 record_id: row.get(2)?,
2079 scope_kind: row.get(3)?,
2080 scope_key: row.get(4)?,
2081 rationale: row.get(5)?,
2082 status: row.get(6)?,
2083 created_at_ms: row.get::<_, i64>(7)? as u64,
2084 })
2085 },
2086 )
2087 .optional()
2088 .map_err(sql_err)
2089 })
2090 })
2091 .await
2092 }
2093
2094 async fn pending_promotions(
2095 &self,
2096 realm: &str,
2097 ) -> Result<Vec<PendingPromotion>, AgentMemoryError> {
2098 let store = self.clone();
2099 let realm = realm.to_string();
2100 run_blocking(move || {
2101 store.with_realm_conn(&realm, |conn| {
2102 let mut stmt = conn
2103 .prepare(
2104 "SELECT pending_id, stage_token, record_id, scope_kind, scope_key, \
2105 rationale, status, created_at_ms FROM pending_promotions \
2106 WHERE status = 'pending' ORDER BY created_at_ms ASC",
2107 )
2108 .map_err(sql_err)?;
2109 let rows = stmt
2110 .query_map([], |row| {
2111 Ok(PendingPromotion {
2112 pending_id: row.get(0)?,
2113 stage_token: row.get(1)?,
2114 record_id: row.get(2)?,
2115 scope_kind: row.get(3)?,
2116 scope_key: row.get(4)?,
2117 rationale: row.get(5)?,
2118 status: row.get(6)?,
2119 created_at_ms: row.get::<_, i64>(7)? as u64,
2120 })
2121 })
2122 .map_err(sql_err)?;
2123 let mut promotions = Vec::new();
2124 for row in rows {
2125 promotions.push(row.map_err(sql_err)?);
2126 }
2127 Ok(promotions)
2128 })
2129 })
2130 .await
2131 }
2132
2133 async fn resolve_pending_promotion(
2134 &self,
2135 realm: &str,
2136 pending_id: &str,
2137 status: &str,
2138 ) -> Result<(), AgentMemoryError> {
2139 if !matches!(status, "committed" | "denied" | "expired") {
2140 return Err(AgentMemoryError::InvalidRecord(format!(
2141 "unknown promotion resolution '{status}'"
2142 )));
2143 }
2144 let store = self.clone();
2145 let realm = realm.to_string();
2146 let pending_id = pending_id.to_string();
2147 let status = status.to_string();
2148 run_blocking(move || {
2149 store.with_realm_conn(&realm, |conn| {
2150 conn.execute(
2151 "UPDATE pending_promotions SET status = ?1, resolved_at_ms = ?2 \
2152 WHERE pending_id = ?3",
2153 params![status, now_ms() as i64, pending_id],
2154 )
2155 .map_err(sql_err)?;
2156 Ok(())
2157 })
2158 })
2159 .await
2160 }
2161
2162 async fn rekey_pending_promotion(
2163 &self,
2164 realm: &str,
2165 old_pending_id: &str,
2166 new_pending_id: &str,
2167 ) -> Result<(), AgentMemoryError> {
2168 let store = self.clone();
2169 let realm = realm.to_string();
2170 let old_pending_id = old_pending_id.to_string();
2171 let new_pending_id = new_pending_id.to_string();
2172 run_blocking(move || {
2173 store.with_realm_conn(&realm, |conn| {
2174 conn.execute(
2175 "UPDATE pending_promotions SET pending_id = ?1 WHERE pending_id = ?2",
2176 params![new_pending_id, old_pending_id],
2177 )
2178 .map_err(sql_err)?;
2179 Ok(())
2180 })
2181 })
2182 .await
2183 }
2184
2185 async fn discard_stage(&self, token: StageToken) -> Result<(), AgentMemoryError> {
2186 let store = self.clone();
2187 run_blocking(move || {
2188 store.with_realm_conn(&token.realm, |conn| {
2189 conn.execute("DELETE FROM stage WHERE token = ?1", params![token.token])
2190 .map_err(sql_err)?;
2191 Ok(())
2192 })
2193 })
2194 .await
2195 }
2196
2197 async fn save_dream_run(
2198 &self,
2199 realm: &str,
2200 run: PersistedDreamRun,
2201 ) -> Result<(), AgentMemoryError> {
2202 let store = self.clone();
2203 let realm = realm.to_string();
2204 run_blocking(move || {
2205 store.with_realm_conn(&realm, |conn| {
2206 conn.execute(
2207 "INSERT OR REPLACE INTO dream_runs (run_id, partition_label, started_at_ms, completed_at_ms, ops_committed, detail) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2208 rusqlite::params![
2209 run.run_id,
2210 run.partition_label,
2211 run.started_at_ms,
2212 run.completed_at_ms,
2213 run.ops_committed,
2214 run.detail,
2215 ],
2216 )
2217 .map_err(sql_err)?;
2218 Ok(())
2219 })
2220 })
2221 .await
2222 }
2223
2224 async fn save_dream_audit_verdicts(
2225 &self,
2226 realm: &str,
2227 run_id: &str,
2228 verdicts: Vec<(String, String, String)>,
2229 ) -> Result<(), AgentMemoryError> {
2230 if verdicts.is_empty() {
2231 return Ok(());
2232 }
2233 let store = self.clone();
2234 let realm = realm.to_string();
2235 let run_id = run_id.to_string();
2236 let now = now_ms();
2237 run_blocking(move || {
2238 store.with_realm_conn(&realm, |conn| {
2239 for (record_id, verdict, rationale) in &verdicts {
2240 conn.execute(
2241 "INSERT OR REPLACE INTO dream_audit_verdicts (run_id, record_id, verdict, rationale, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5)",
2242 rusqlite::params![run_id, record_id, verdict, rationale, now],
2243 )
2244 .map_err(sql_err)?;
2245 }
2246 Ok(())
2247 })
2248 })
2249 .await
2250 }
2251}
2252
2253const DREAM_HISTORY_SCAN_ROWS: usize = 5_000;
2259const DREAM_HISTORY_ID_SAMPLE: usize = 12;
2260const DREAM_HISTORY_RATIONALE_SAMPLE: usize = 6;
2261
2262#[async_trait]
2263impl MemoryPanelStore for SqliteAgentMemoryStore {
2264 async fn panel_realms(&self) -> Result<Vec<String>, AgentMemoryError> {
2265 let store = self.clone();
2266 run_blocking(move || store.known_realms()).await
2267 }
2268
2269 async fn record_by_id(
2270 &self,
2271 realm: &str,
2272 memory_id: &str,
2273 ) -> Result<Option<super::records::MemoryRecord>, AgentMemoryError> {
2274 let store = self.clone();
2275 let realm = realm.to_string();
2276 let memory_id = memory_id.to_string();
2277 run_blocking(move || {
2278 store.with_realm_conn(&realm, |conn| load_record(conn, &realm, &memory_id))
2279 })
2280 .await
2281 }
2282
2283 async fn records_page(
2284 &self,
2285 realm: &str,
2286 scope_kind: Option<&str>,
2287 scope_key: Option<&str>,
2288 status_kind: Option<&str>,
2289 limit: usize,
2290 cursor: Option<(u64, String)>,
2291 ) -> Result<PanelRecordsPage, AgentMemoryError> {
2292 let store = self.clone();
2293 let realm = realm.to_string();
2294 let scope_kind = scope_kind.map(str::to_string);
2295 let scope_key = scope_key.map(str::to_string);
2296 let status_kind = status_kind.map(str::to_string);
2297 let limit = limit.max(1);
2298 run_blocking(move || {
2299 store.with_realm_conn(&realm, |conn| {
2300 let mut clauses: Vec<String> = Vec::new();
2301 let mut values: Vec<rusqlite::types::Value> = Vec::new();
2302 if let Some(kind) = &scope_kind {
2303 values.push(kind.clone().into());
2304 clauses.push(format!("scope_kind = ?{}", values.len()));
2305 }
2306 if let Some(key) = &scope_key {
2307 values.push(key.clone().into());
2308 clauses.push(format!("scope_key = ?{}", values.len()));
2309 }
2310 if let Some(status) = &status_kind {
2311 values.push(status.clone().into());
2312 clauses.push(format!("status_kind = ?{}", values.len()));
2313 }
2314 if let Some((after_ms, after_id)) = &cursor {
2315 values.push((*after_ms as i64).into());
2316 let ms_slot = values.len();
2317 values.push(after_id.clone().into());
2318 let id_slot = values.len();
2319 clauses.push(format!(
2320 "(updated_at_ms < ?{ms_slot} OR (updated_at_ms = ?{ms_slot} \
2321 AND memory_id < ?{id_slot}))"
2322 ));
2323 }
2324 let where_sql = if clauses.is_empty() {
2325 String::new()
2326 } else {
2327 format!("WHERE {}", clauses.join(" AND "))
2328 };
2329 values.push(((limit + 1) as i64).into());
2330 let sql = format!(
2331 "SELECT {RECORD_COLUMNS} FROM records {where_sql} \
2332 ORDER BY updated_at_ms DESC, memory_id DESC LIMIT ?{}",
2333 values.len()
2334 );
2335 let mut stmt = conn.prepare(&sql).map_err(sql_err)?;
2336 let rows = stmt
2337 .query_map(rusqlite::params_from_iter(values), row_to_record_row)
2338 .map_err(sql_err)?;
2339 let mut records = Vec::new();
2340 for row in rows {
2341 records.push(row.map_err(sql_err)?.into_record(&realm)?);
2342 }
2343 let next_cursor = if records.len() > limit {
2344 records.truncate(limit);
2345 records
2346 .last()
2347 .map(|record| (record.updated_at_ms, record.id.clone()))
2348 } else {
2349 None
2350 };
2351 Ok(PanelRecordsPage {
2352 records,
2353 next_cursor,
2354 })
2355 })
2356 })
2357 .await
2358 }
2359
2360 async fn supersede_chain(
2361 &self,
2362 realm: &str,
2363 memory_id: &str,
2364 max_len: usize,
2365 ) -> Result<Vec<super::records::MemoryRecord>, AgentMemoryError> {
2366 let store = self.clone();
2367 let realm = realm.to_string();
2368 let memory_id = memory_id.to_string();
2369 let max_len = max_len.max(1);
2370 run_blocking(move || {
2371 store.with_realm_conn(&realm, |conn| {
2372 let Some(origin) = load_record(conn, &realm, &memory_id)? else {
2373 return Ok(Vec::new());
2374 };
2375 let mut seen: std::collections::BTreeSet<String> =
2376 std::collections::BTreeSet::from([origin.id.clone()]);
2377 let mut ancestors: Vec<super::records::MemoryRecord> = Vec::new();
2378 let mut parent_id = origin.supersedes.clone();
2379 while let Some(id) = parent_id {
2380 if ancestors.len() + 1 >= max_len || !seen.insert(id.clone()) {
2381 break;
2382 }
2383 let Some(parent) = load_record(conn, &realm, &id)? else {
2384 break;
2385 };
2386 parent_id = parent.supersedes.clone();
2387 ancestors.push(parent);
2388 }
2389 ancestors.reverse();
2390 let mut chain = ancestors;
2391 chain.push(origin);
2392 loop {
2393 if chain.len() >= max_len {
2394 return Ok(chain);
2395 }
2396 let tip = chain.last().unwrap_or_else(|| unreachable!());
2397 let successor_id = match &tip.status {
2398 super::records::RecordStatus::Superseded { by } => Some(by.clone()),
2399 _ => None,
2400 };
2401 match successor_id {
2402 Some(id) => {
2403 if !seen.insert(id.clone()) {
2404 return Ok(chain);
2405 }
2406 let Some(successor) = load_record(conn, &realm, &id)? else {
2407 return Ok(chain);
2408 };
2409 chain.push(successor);
2410 }
2411 None => {
2412 let tip_id = tip.id.clone();
2414 let mut stmt = conn
2415 .prepare(&format!(
2416 "SELECT {RECORD_COLUMNS} FROM records \
2417 WHERE supersedes = ?1 ORDER BY created_at_ms ASC"
2418 ))
2419 .map_err(sql_err)?;
2420 let rows = stmt
2421 .query_map(params![tip_id], row_to_record_row)
2422 .map_err(sql_err)?;
2423 for row in rows {
2424 if chain.len() >= max_len {
2425 break;
2426 }
2427 let claimant = row.map_err(sql_err)?.into_record(&realm)?;
2428 if seen.insert(claimant.id.clone()) {
2429 chain.push(claimant);
2430 }
2431 }
2432 return Ok(chain);
2433 }
2434 }
2435 }
2436 })
2437 })
2438 .await
2439 }
2440
2441 async fn injection_log_for_record(
2442 &self,
2443 realm: &str,
2444 record_id: &str,
2445 limit: usize,
2446 ) -> Result<Vec<InjectionLogEntry>, AgentMemoryError> {
2447 let store = self.clone();
2448 let realm = realm.to_string();
2449 let record_id = record_id.to_string();
2450 run_blocking(move || {
2451 store.with_realm_conn(&realm, |conn| {
2452 let mut stmt = conn
2453 .prepare(
2454 "SELECT record_id, identity, session_key, surface, at_ms \
2455 FROM injections WHERE record_id = ?1 \
2456 ORDER BY at_ms DESC, injection_id DESC LIMIT ?2",
2457 )
2458 .map_err(sql_err)?;
2459 let rows = stmt
2460 .query_map(params![record_id, limit as i64], |row| {
2461 Ok((
2462 row.get::<_, String>(0)?,
2463 row.get::<_, String>(1)?,
2464 row.get::<_, Option<String>>(2)?,
2465 row.get::<_, String>(3)?,
2466 row.get::<_, i64>(4)?,
2467 ))
2468 })
2469 .map_err(sql_err)?;
2470 let mut entries = Vec::new();
2471 for row in rows {
2472 let (record_id, identity, session_key, surface, at_ms) =
2473 row.map_err(sql_err)?;
2474 let surface = InjectionSurface::parse(&surface).ok_or_else(|| {
2475 AgentMemoryError::Parse(format!("unknown injection surface '{surface}'"))
2476 })?;
2477 entries.push(InjectionLogEntry {
2478 record_id,
2479 identity,
2480 session_key,
2481 surface,
2482 at_ms: at_ms as u64,
2483 });
2484 }
2485 Ok(entries)
2486 })
2487 })
2488 .await
2489 }
2490
2491 async fn dream_runs(
2492 &self,
2493 realm: &str,
2494 limit: usize,
2495 ) -> Result<Vec<PersistedDreamRun>, AgentMemoryError> {
2496 let store = self.clone();
2497 let realm = realm.to_string();
2498 let limit = limit.max(1);
2499 run_blocking(move || {
2500 store.with_realm_conn(&realm, |conn| {
2501 let mut stmt = conn
2502 .prepare(
2503 "SELECT run_id, partition_label, started_at_ms, completed_at_ms, ops_committed, detail FROM dream_runs ORDER BY completed_at_ms DESC, run_id DESC LIMIT ?1",
2504 )
2505 .map_err(sql_err)?;
2506 let rows = stmt
2507 .query_map([limit as i64], |row| {
2508 Ok(PersistedDreamRun {
2509 run_id: row.get(0)?,
2510 partition_label: row.get(1)?,
2511 started_at_ms: row.get(2)?,
2512 completed_at_ms: row.get(3)?,
2513 ops_committed: row.get(4)?,
2514 detail: row.get(5)?,
2515 })
2516 })
2517 .map_err(sql_err)?
2518 .collect::<Result<Vec<_>, _>>()
2519 .map_err(sql_err)?;
2520 Ok(rows)
2521 })
2522 })
2523 .await
2524 }
2525
2526 async fn open_dream_audit_verdicts(
2527 &self,
2528 realm: &str,
2529 limit: usize,
2530 ) -> Result<Vec<DreamAuditVerdict>, AgentMemoryError> {
2531 let store = self.clone();
2532 let realm = realm.to_string();
2533 let limit = limit.max(1);
2534 run_blocking(move || {
2535 store.with_realm_conn(&realm, |conn| {
2536 let mut stmt = conn
2537 .prepare(
2538 "SELECT run_id, record_id, verdict, rationale, created_at_ms, resolved_at_ms, resolution FROM dream_audit_verdicts WHERE resolved_at_ms IS NULL ORDER BY created_at_ms DESC, record_id ASC LIMIT ?1",
2539 )
2540 .map_err(sql_err)?;
2541 let rows = stmt
2542 .query_map([limit as i64], |row| {
2543 Ok(DreamAuditVerdict {
2544 run_id: row.get(0)?,
2545 record_id: row.get(1)?,
2546 verdict: row.get(2)?,
2547 rationale: row.get(3)?,
2548 created_at_ms: row.get(4)?,
2549 resolved_at_ms: row.get(5)?,
2550 resolution: row.get(6)?,
2551 })
2552 })
2553 .map_err(sql_err)?
2554 .collect::<Result<Vec<_>, _>>()
2555 .map_err(sql_err)?;
2556 Ok(rows)
2557 })
2558 })
2559 .await
2560 }
2561
2562 async fn dream_history(
2563 &self,
2564 realm: &str,
2565 max_runs: usize,
2566 ) -> Result<Vec<DreamRunAudit>, AgentMemoryError> {
2567 let store = self.clone();
2568 let realm = realm.to_string();
2569 let max_runs = max_runs.max(1);
2570 run_blocking(move || {
2571 store.with_realm_conn(&realm, |conn| {
2572 let mut stmt = conn
2573 .prepare(
2574 "SELECT op_kind, memory_id, detail, applied_at_ms FROM audit \
2575 ORDER BY applied_at_ms DESC, audit_id DESC LIMIT ?1",
2576 )
2577 .map_err(sql_err)?;
2578 let rows = stmt
2579 .query_map(params![DREAM_HISTORY_SCAN_ROWS as i64], |row| {
2580 Ok((
2581 row.get::<_, String>(0)?,
2582 row.get::<_, Option<String>>(1)?,
2583 row.get::<_, String>(2)?,
2584 row.get::<_, i64>(3)?,
2585 ))
2586 })
2587 .map_err(sql_err)?;
2588 let mut order: Vec<String> = Vec::new();
2589 let mut runs: HashMap<String, DreamRunAudit> = HashMap::new();
2590 for row in rows {
2591 let (op_kind, memory_id, detail, applied_at_ms) = row.map_err(sql_err)?;
2592 let detail: serde_json::Value =
2593 serde_json::from_str(&detail).unwrap_or_default();
2594 let author = detail.get("author");
2595 let is_steward = author
2596 .and_then(|author| author.get("author"))
2597 .and_then(serde_json::Value::as_str)
2598 == Some("steward");
2599 if !is_steward {
2600 continue;
2601 }
2602 let Some(run_id) = author
2603 .and_then(|author| author.get("run_id"))
2604 .and_then(serde_json::Value::as_str)
2605 else {
2606 continue;
2607 };
2608 if !runs.contains_key(run_id) {
2609 if runs.len() >= max_runs {
2610 continue;
2611 }
2612 order.push(run_id.to_string());
2613 }
2614 let run = runs
2615 .entry(run_id.to_string())
2616 .or_insert_with(|| DreamRunAudit {
2617 run_id: run_id.to_string(),
2618 first_op_at_ms: applied_at_ms as u64,
2619 last_op_at_ms: applied_at_ms as u64,
2620 ..DreamRunAudit::default()
2621 });
2622 run.ops += 1;
2623 run.first_op_at_ms = run.first_op_at_ms.min(applied_at_ms as u64);
2624 run.last_op_at_ms = run.last_op_at_ms.max(applied_at_ms as u64);
2625 *run.op_kinds.entry(op_kind).or_insert(0) += 1;
2626 if !detail
2627 .get("quarantined")
2628 .map(serde_json::Value::is_null)
2629 .unwrap_or(true)
2630 {
2631 run.quarantined_ops += 1;
2632 }
2633 if let Some(memory_id) = memory_id
2634 && run.memory_ids.len() < DREAM_HISTORY_ID_SAMPLE
2635 {
2636 run.memory_ids.push(memory_id);
2637 }
2638 if let Some(rationale) =
2639 detail.get("rationale").and_then(serde_json::Value::as_str)
2640 && !rationale.is_empty()
2641 && run.rationales.len() < DREAM_HISTORY_RATIONALE_SAMPLE
2642 {
2643 run.rationales.push(rationale.to_string());
2644 }
2645 }
2646 Ok(order
2647 .into_iter()
2648 .filter_map(|run_id| runs.remove(&run_id))
2649 .collect())
2650 })
2651 })
2652 .await
2653 }
2654}
2655
2656impl SqliteAgentMemoryStore {
2657 pub async fn resolve_dream_audit_verdicts(
2665 &self,
2666 realm: &str,
2667 record_id: &str,
2668 resolution: &str,
2669 ) -> Result<usize, AgentMemoryError> {
2670 let store = self.clone();
2671 let realm = realm.to_string();
2672 let record_id = record_id.to_string();
2673 let resolution = resolution.to_string();
2674 let now = now_ms();
2675 run_blocking(move || {
2676 store.with_realm_conn(&realm, |conn| {
2677 let changed = conn
2678 .execute(
2679 "UPDATE dream_audit_verdicts SET resolved_at_ms = ?1, resolution = ?2 WHERE record_id = ?3 AND resolved_at_ms IS NULL",
2680 rusqlite::params![now, resolution, record_id],
2681 )
2682 .map_err(sql_err)?;
2683 Ok(changed)
2684 })
2685 })
2686 .await
2687 }
2688}
2689
2690#[async_trait]
2691impl crate::memory::distiller::TombstoneSource for SqliteAgentMemoryStore {
2692 async fn recent_tombstones(
2697 &self,
2698 scope: &MemoryScope,
2699 since_ms: u64,
2700 limit: usize,
2701 ) -> Result<Vec<crate::memory::distiller::TombstoneMeta>, AgentMemoryError> {
2702 let store = self.clone();
2703 let scope = scope.clone();
2704 run_blocking(move || {
2705 store.with_realm_conn(scope.realm(), |conn| {
2706 let mut statement = conn
2707 .prepare(
2708 "SELECT title, kind, tombstoned_at_ms FROM records \
2709 WHERE scope_kind = ?1 AND scope_key = ?2 \
2710 AND status_kind = 'tombstoned' AND tombstoned_at_ms >= ?3 \
2711 ORDER BY tombstoned_at_ms DESC LIMIT ?4",
2712 )
2713 .map_err(sql_err)?;
2714 let rows = statement
2715 .query_map(
2716 params![scope.kind_str(), scope.key(), since_ms as i64, limit as i64],
2717 |row| {
2718 Ok((
2719 row.get::<_, String>(0)?,
2720 row.get::<_, String>(1)?,
2721 row.get::<_, i64>(2)?,
2722 ))
2723 },
2724 )
2725 .map_err(sql_err)?;
2726 let mut tombstones = Vec::new();
2727 for row in rows {
2728 let (title, kind, tombstoned_at_ms) = row.map_err(sql_err)?;
2729 let kind = MemoryKind::parse(&kind).ok_or_else(|| {
2730 AgentMemoryError::Parse(format!("unknown record kind '{kind}'"))
2731 })?;
2732 tombstones.push(crate::memory::distiller::TombstoneMeta {
2733 title,
2734 kind,
2735 tombstoned_at_ms: tombstoned_at_ms as u64,
2736 });
2737 }
2738 Ok(tombstones)
2739 })
2740 })
2741 .await
2742 }
2743}
2744
2745#[async_trait]
2750impl crate::memory::selector::SelectedRecordFetch for SqliteAgentMemoryStore {
2751 async fn fetch_records(
2752 &self,
2753 scopes: &[MemoryScope],
2754 ids: &[String],
2755 ) -> Result<Vec<AgentMemoryRecord>, AgentMemoryError> {
2756 let store = self.clone();
2757 let scopes = scopes.to_vec();
2758 let ids = ids.to_vec();
2759 run_blocking(move || {
2760 let mut records = Vec::new();
2761 for id in &ids {
2762 for scope in &scopes {
2763 let found = store.with_realm_conn(scope.realm(), |conn| {
2764 load_record(conn, scope.realm(), id)
2765 })?;
2766 if let Some(record) = found
2767 && record.scope == *scope
2768 && matches!(record.status, RecordStatus::Active)
2769 {
2770 records.push(project_record(record));
2771 break;
2772 }
2773 }
2774 }
2775 Ok(records)
2776 })
2777 .await
2778 }
2779
2780 async fn fetch_records_annotated(
2781 &self,
2782 scopes: &[MemoryScope],
2783 ids: &[String],
2784 ) -> Result<Vec<crate::memory::selector::AnnotatedRecord>, AgentMemoryError> {
2785 let store = self.clone();
2786 let scopes = scopes.to_vec();
2787 let ids = ids.to_vec();
2788 run_blocking(move || {
2789 let mut records = Vec::new();
2790 for id in &ids {
2791 for scope in &scopes {
2792 let found = store.with_realm_conn(scope.realm(), |conn| {
2793 load_record(conn, scope.realm(), id)
2794 })?;
2795 if let Some(record) = found
2796 && record.scope == *scope
2797 && matches!(record.status, RecordStatus::Active)
2798 {
2799 let provenance = Some(crate::memory::selector::RecordProvenance {
2803 scope: record.scope.clone(),
2804 trust: record.trust,
2805 });
2806 records.push(crate::memory::selector::AnnotatedRecord {
2807 record: project_record(record),
2808 provenance,
2809 });
2810 break;
2811 }
2812 }
2813 }
2814 Ok(records)
2815 })
2816 .await
2817 }
2818}
2819
2820async fn run_blocking<T: Send + 'static>(
2823 f: impl FnOnce() -> Result<T, AgentMemoryError> + Send + 'static,
2824) -> Result<T, AgentMemoryError> {
2825 tokio::task::spawn_blocking(f)
2826 .await
2827 .map_err(|err| AgentMemoryError::Io(format!("agent memory task failed: {err}")))?
2828}
2829
2830fn check_verified_retier_evidence(
2836 conn: &Connection,
2837 batch: &StagedMutationBatch,
2838 resolver: Option<&dyn EvidenceRefResolver>,
2839) -> Result<(), AgentMemoryError> {
2840 let Some(resolver) = resolver else {
2841 return Ok(());
2842 };
2843 for (op_index, op) in batch.ops.iter().enumerate() {
2844 let StagedOp::Retier { id, trust, .. } = op else {
2845 continue;
2846 };
2847 if *trust != TrustTier::AgentVerified {
2848 continue;
2849 }
2850 let reject = |reason: String| {
2851 AgentMemoryError::InvalidRecord(
2852 super::staged::StagedBatchError::UnresolvableEvidence { op_index, reason }
2853 .to_string(),
2854 )
2855 };
2856 let provenance: Option<String> = conn
2857 .query_row(
2858 "SELECT provenance FROM records WHERE memory_id = ?1",
2859 params![id],
2860 |row| row.get(0),
2861 )
2862 .optional()
2863 .map_err(sql_err)?;
2864 let Some(provenance) = provenance else {
2865 continue;
2867 };
2868 let provenance: MemoryProvenance = serde_json::from_str(&provenance)
2869 .map_err(|err| AgentMemoryError::Parse(err.to_string()))?;
2870 let evidence = provenance
2871 .verification
2872 .as_ref()
2873 .map(|claim| claim.evidence.as_slice())
2874 .unwrap_or(&[]);
2875 if evidence.is_empty() {
2876 return Err(reject(
2877 "verification claim cites no evidence refs".to_string(),
2878 ));
2879 }
2880 for reference in evidence {
2881 resolver.resolves(reference).map_err(reject)?;
2882 }
2883 }
2884 Ok(())
2885}
2886
2887fn apply_batch_tx(
2898 conn: &mut Connection,
2899 batch: &StagedMutationBatch,
2900 gate: Option<&dyn LlmWriteGate>,
2901 events: Option<&dyn crate::memory::events::MemoryEventSink>,
2902 token: &str,
2903 now: u64,
2904) -> Result<CommitReceipt, AgentMemoryError> {
2905 let evidence: Vec<crate::memory::records::EvidenceRef> = batch
2906 .ops
2907 .iter()
2908 .flat_map(|op| match op {
2909 StagedOp::Create { record, .. } | StagedOp::Supersede { record, .. } => {
2910 record.evidence.clone()
2911 }
2912 _ => Vec::new(),
2913 })
2914 .collect();
2915 let quarantine =
2916 gate.and_then(|gate| gate.quarantine_reason(&batch.author, batch.kind, &evidence));
2917 if let Some(reason) = quarantine.as_deref() {
2918 tracing::warn!(
2919 realm = %batch.realm,
2920 author = ?batch.author,
2921 reason,
2922 "agent memory: LLM-authored write landing quarantined (write-only until review)"
2923 );
2924 if let Some(events) = events {
2925 events.emit(
2926 crate::memory::events::MemoryTimelineEvent::QuarantinedWrite {
2927 realm: batch.realm.clone(),
2928 author: format!("{:?}", batch.author),
2929 reason: reason.to_string(),
2930 },
2931 );
2932 }
2933 }
2934 let tx = conn.transaction().map_err(sql_err)?;
2935 {
2936 let view = ConnBatchView {
2937 conn: &tx,
2938 realm: &batch.realm,
2939 };
2940 validate_batch(batch, &view, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, now)
2941 .map_err(|err| AgentMemoryError::InvalidRecord(err.to_string()))?;
2942 }
2943 let mut memory_ids = Vec::with_capacity(batch.ops.len());
2944 for (op_index, op) in batch.ops.iter().enumerate() {
2945 let memory_id = apply_op(&tx, batch, op, quarantine.as_deref(), now)?;
2946 let detail = serde_json::json!({
2947 "op": op.kind_str(),
2948 "author": batch.author,
2949 "rationale": op_rationale(op),
2950 "quarantined": quarantine,
2951 });
2952 tx.execute(
2953 "INSERT INTO audit (stage_token, op_index, op_kind, memory_id, detail, \
2954 applied_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2955 params![
2956 token,
2957 op_index as i64,
2958 op.kind_str(),
2959 memory_id,
2960 detail.to_string(),
2961 now as i64,
2962 ],
2963 )
2964 .map_err(sql_err)?;
2965 memory_ids.push(memory_id);
2966 }
2967 tx.execute("DELETE FROM stage WHERE token = ?1", params![token])
2968 .map_err(sql_err)?;
2969 tx.commit().map_err(sql_err)?;
2970 Ok(CommitReceipt {
2971 token: token.to_string(),
2972 applied_ops: batch.ops.len(),
2973 memory_ids,
2974 })
2975}
2976
2977fn op_rationale(op: &StagedOp) -> Option<String> {
2978 match op {
2979 StagedOp::Create { rationale, .. }
2980 | StagedOp::Supersede { rationale, .. }
2981 | StagedOp::Tombstone { rationale, .. }
2982 | StagedOp::Retier { rationale, .. } => rationale.clone(),
2983 StagedOp::SetRank { .. } => None,
2984 }
2985}
2986
2987fn apply_op(
2988 conn: &Connection,
2989 batch: &StagedMutationBatch,
2990 op: &StagedOp,
2991 quarantine: Option<&str>,
2992 now: u64,
2993) -> Result<MemoryId, AgentMemoryError> {
2994 match op {
2995 StagedOp::Create {
2996 id,
2997 scope,
2998 record,
2999 trust,
3000 derived_from,
3001 created_at_ms,
3002 updated_at_ms,
3003 ..
3004 } => {
3005 let memory_id = id
3006 .clone()
3007 .unwrap_or_else(|| new_memory_id(&record.title, &record.body));
3008 insert_record(
3009 conn,
3010 &memory_id,
3011 scope,
3012 record,
3013 *trust,
3014 &batch.author,
3015 derived_from,
3016 None,
3017 None,
3018 None,
3019 quarantine,
3020 created_at_ms.unwrap_or(now),
3021 updated_at_ms.unwrap_or(now),
3022 )?;
3023 Ok(memory_id)
3024 }
3025 StagedOp::Supersede {
3026 id,
3027 prior,
3028 record,
3029 trust,
3030 derived_from,
3031 ..
3032 } => {
3033 let prior_row: (String, String, Option<i64>) = conn
3034 .query_row(
3035 "SELECT scope_kind, scope_key, working_set_rank \
3036 FROM records WHERE memory_id = ?1",
3037 params![prior],
3038 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
3039 )
3040 .map_err(sql_err)?;
3041 let scope = scope_from_parts(&prior_row.0, &prior_row.1, &batch.realm)?;
3042 let memory_id = id
3043 .clone()
3044 .unwrap_or_else(|| new_memory_id(&record.title, &record.body));
3045 insert_record(
3050 conn,
3051 &memory_id,
3052 &scope,
3053 record,
3054 *trust,
3055 &batch.author,
3056 derived_from,
3057 Some(prior.clone()),
3058 prior_row.2,
3059 None,
3060 quarantine,
3061 now,
3062 now,
3063 )?;
3064 if quarantine.is_none() {
3065 conn.execute(
3066 "UPDATE records SET status_kind = 'superseded', status_detail = ?1, \
3067 updated_at_ms = ?2 WHERE memory_id = ?3",
3068 params![memory_id, now as i64, prior],
3069 )
3070 .map_err(sql_err)?;
3071 } else {
3072 tracing::warn!(
3079 prior,
3080 successor = %memory_id,
3081 "agent memory: quarantined supersede leaves the prior record active \
3082 pending review"
3083 );
3084 }
3085 Ok(memory_id)
3086 }
3087 StagedOp::Tombstone { id, .. } => {
3088 conn.execute(
3089 "UPDATE records SET status_kind = 'tombstoned', status_detail = NULL, \
3090 tombstoned_at_ms = ?1, updated_at_ms = ?1 WHERE memory_id = ?2",
3091 params![now as i64, id],
3092 )
3093 .map_err(sql_err)?;
3094 Ok(id.clone())
3095 }
3096 StagedOp::Retier { id, trust, .. } => {
3097 conn.execute(
3098 "UPDATE records SET trust = ?1, updated_at_ms = ?2 WHERE memory_id = ?3",
3099 params![trust.as_str(), now as i64, id],
3100 )
3101 .map_err(sql_err)?;
3102 Ok(id.clone())
3103 }
3104 StagedOp::SetRank { id, rank } => {
3105 conn.execute(
3109 "UPDATE records SET working_set_rank = ?1, rank_set_at_ms = ?2 \
3110 WHERE memory_id = ?3",
3111 params![rank.map(|r| r as i64), now as i64, id],
3112 )
3113 .map_err(sql_err)?;
3114 Ok(id.clone())
3115 }
3116 }
3117}
3118
3119#[allow(clippy::too_many_arguments)]
3120fn insert_record(
3121 conn: &Connection,
3122 memory_id: &str,
3123 scope: &MemoryScope,
3124 record: &NewMemoryRecord,
3125 trust: TrustTier,
3126 author: &MemoryAuthor,
3127 derived_from: &[MemoryId],
3128 supersedes: Option<MemoryId>,
3129 working_set_rank: Option<i64>,
3130 rank_set_at_ms: Option<i64>,
3131 quarantine: Option<&str>,
3132 created_at_ms: u64,
3133 updated_at_ms: u64,
3134) -> Result<(), AgentMemoryError> {
3135 let tags = normalize_tags(record.tags.clone())?;
3136 let provenance = MemoryProvenance {
3137 evidence: record.evidence.clone(),
3138 author: author.clone(),
3139 profile: None,
3140 verification: record.verification.clone(),
3141 };
3142 let (status_kind, status_detail) = match quarantine {
3145 Some(reason) => ("quarantined", Some(reason)),
3146 None => ("active", None),
3147 };
3148 let ever_quarantined = quarantine.is_some() || {
3154 let mut ancestors: Vec<&str> = derived_from.iter().map(String::as_str).collect();
3155 if let Some(prior) = supersedes.as_deref() {
3156 ancestors.push(prior);
3157 }
3158 ancestors_reach_quarantine(conn, &ancestors)?
3159 };
3160 conn.execute(
3161 "INSERT INTO records (memory_id, scope_kind, scope_key, kind, title, description, \
3162 body, tags, provenance, trust, status_kind, status_detail, supersedes, derived_from, \
3163 working_set_rank, rank_set_at_ms, content_hash, created_at_ms, updated_at_ms, \
3164 usage_stats, tombstoned_at_ms, ever_quarantined) \
3165 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, \
3166 ?16, ?17, ?18, ?19, ?20, NULL, ?21)",
3167 params![
3168 memory_id,
3169 scope.kind_str(),
3170 scope.key(),
3171 record.kind.as_str(),
3172 record.title,
3173 record.description,
3174 record.body,
3175 json_string(&tags)?,
3176 json_string(&provenance)?,
3177 trust.as_str(),
3178 status_kind,
3179 status_detail,
3180 supersedes,
3181 json_string(&derived_from.to_vec())?,
3182 working_set_rank,
3183 rank_set_at_ms,
3184 content_hash(&record.title, &record.body),
3185 created_at_ms as i64,
3186 updated_at_ms as i64,
3187 json_string(&UsageStats::default())?,
3188 ever_quarantined,
3189 ],
3190 )
3191 .map_err(sql_err)?;
3192 Ok(())
3193}
3194
3195fn ancestors_reach_quarantine(
3198 conn: &Connection,
3199 ancestors: &[&str],
3200) -> Result<bool, AgentMemoryError> {
3201 if ancestors.is_empty() {
3202 return Ok(false);
3203 }
3204 let placeholders = (1..=ancestors.len())
3205 .map(|slot| format!("?{slot}"))
3206 .collect::<Vec<_>>()
3207 .join(", ");
3208 let sql = format!(
3209 "SELECT 1 FROM records WHERE memory_id IN ({placeholders}) \
3210 AND (ever_quarantined = 1 OR status_kind = 'quarantined') LIMIT 1"
3211 );
3212 let hit: Option<i64> = conn
3213 .query_row(&sql, rusqlite::params_from_iter(ancestors.iter()), |row| {
3214 row.get(0)
3215 })
3216 .optional()
3217 .map_err(sql_err)?;
3218 Ok(hit.is_some())
3219}
3220
3221struct ConnBatchView<'a> {
3225 conn: &'a Connection,
3226 realm: &'a str,
3227}
3228
3229impl StagedBatchView for ConnBatchView<'_> {
3230 fn record(&self, id: &str) -> Option<StagedRecordView> {
3231 self.conn
3232 .query_row(
3233 "SELECT scope_kind, scope_key, trust, status_kind, status_detail, supersedes, \
3234 derived_from, content_hash, provenance, ever_quarantined \
3235 FROM records WHERE memory_id = ?1",
3236 params![id],
3237 |row| {
3238 let scope_kind: String = row.get(0)?;
3239 let scope_key: String = row.get(1)?;
3240 let trust: String = row.get(2)?;
3241 let status_kind: String = row.get(3)?;
3242 let status_detail: Option<String> = row.get(4)?;
3243 let supersedes: Option<String> = row.get(5)?;
3244 let derived_from: String = row.get(6)?;
3245 let hash: String = row.get(7)?;
3246 let provenance: String = row.get(8)?;
3247 let ever_quarantined: bool = row.get(9)?;
3248 Ok((
3249 scope_kind,
3250 scope_key,
3251 trust,
3252 status_kind,
3253 status_detail,
3254 supersedes,
3255 derived_from,
3256 hash,
3257 provenance,
3258 ever_quarantined,
3259 ))
3260 },
3261 )
3262 .optional()
3263 .ok()
3264 .flatten()
3265 .and_then(
3266 |(
3267 scope_kind,
3268 scope_key,
3269 trust,
3270 status_kind,
3271 status_detail,
3272 supersedes,
3273 derived_from,
3274 hash,
3275 provenance,
3276 ever_quarantined,
3277 )| {
3278 let scope = scope_from_parts(&scope_kind, &scope_key, self.realm).ok()?;
3279 let provenance: MemoryProvenance = serde_json::from_str(&provenance).ok()?;
3280 Some(StagedRecordView {
3281 scope,
3282 trust: TrustTier::parse(&trust)?,
3283 status: status_from_parts(&status_kind, status_detail),
3284 supersedes,
3285 derived_from: serde_json::from_str(&derived_from).unwrap_or_default(),
3286 content_hash: hash,
3287 has_verification: provenance.verification.is_some(),
3288 ever_quarantined,
3289 })
3290 },
3291 )
3292 }
3293
3294 fn tombstoned_at_ms(&self, scope: &MemoryScope, hash: &str) -> Option<u64> {
3295 self.conn
3296 .query_row(
3297 "SELECT MAX(tombstoned_at_ms) FROM records WHERE scope_kind = ?1 \
3298 AND scope_key = ?2 AND content_hash = ?3 AND status_kind = 'tombstoned'",
3299 params![scope.kind_str(), scope.key(), hash],
3300 |row| row.get::<_, Option<i64>>(0),
3301 )
3302 .ok()
3303 .flatten()
3304 .map(|ms| ms as u64)
3305 }
3306}
3307
3308struct MemoryRecordRow {
3311 memory_id: String,
3312 scope_kind: String,
3313 scope_key: String,
3314 kind: String,
3315 title: String,
3316 description: String,
3317 body: String,
3318 tags: String,
3319 provenance: String,
3320 trust: String,
3321 status_kind: String,
3322 status_detail: Option<String>,
3323 supersedes: Option<String>,
3324 derived_from: String,
3325 working_set_rank: Option<i64>,
3326 created_at_ms: i64,
3327 updated_at_ms: i64,
3328 usage_stats: String,
3329}
3330
3331fn row_to_record_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryRecordRow> {
3332 Ok(MemoryRecordRow {
3333 memory_id: row.get(0)?,
3334 scope_kind: row.get(1)?,
3335 scope_key: row.get(2)?,
3336 kind: row.get(3)?,
3337 title: row.get(4)?,
3338 description: row.get(5)?,
3339 body: row.get(6)?,
3340 tags: row.get(7)?,
3341 provenance: row.get(8)?,
3342 trust: row.get(9)?,
3343 status_kind: row.get(10)?,
3344 status_detail: row.get(11)?,
3345 supersedes: row.get(12)?,
3346 derived_from: row.get(13)?,
3347 working_set_rank: row.get(14)?,
3348 created_at_ms: row.get(17)?,
3349 updated_at_ms: row.get(18)?,
3350 usage_stats: row.get(19)?,
3351 })
3352}
3353
3354impl MemoryRecordRow {
3355 fn into_record(self, realm: &str) -> Result<super::records::MemoryRecord, AgentMemoryError> {
3356 let scope = scope_from_parts(&self.scope_kind, &self.scope_key, realm)?;
3357 let provenance: MemoryProvenance = serde_json::from_str(&self.provenance)
3358 .map_err(|err| AgentMemoryError::Parse(err.to_string()))?;
3359 Ok(super::records::MemoryRecord {
3360 id: self.memory_id,
3361 scope,
3362 kind: MemoryKind::parse(&self.kind).ok_or_else(|| {
3363 AgentMemoryError::Parse(format!("unknown record kind '{}'", self.kind))
3364 })?,
3365 title: self.title,
3366 description: self.description,
3367 body: self.body,
3368 tags: serde_json::from_str(&self.tags).unwrap_or_default(),
3369 provenance,
3370 trust: TrustTier::parse(&self.trust).ok_or_else(|| {
3371 AgentMemoryError::Parse(format!("unknown trust tier '{}'", self.trust))
3372 })?,
3373 status: status_from_parts(&self.status_kind, self.status_detail),
3374 supersedes: self.supersedes,
3375 derived_from: serde_json::from_str(&self.derived_from).unwrap_or_default(),
3376 working_set_rank: self.working_set_rank.map(|rank| rank as u32),
3377 created_at_ms: self.created_at_ms as u64,
3378 updated_at_ms: self.updated_at_ms as u64,
3379 usage: serde_json::from_str(&self.usage_stats).unwrap_or_default(),
3380 })
3381 }
3382}
3383
3384fn status_from_parts(kind: &str, detail: Option<String>) -> RecordStatus {
3385 match kind {
3386 "superseded" => RecordStatus::Superseded {
3387 by: detail.unwrap_or_default(),
3388 },
3389 "quarantined" => RecordStatus::Quarantined {
3390 reason: detail.unwrap_or_default(),
3391 },
3392 "tombstoned" => RecordStatus::Tombstoned,
3393 _ => RecordStatus::Active,
3394 }
3395}
3396
3397fn scope_from_parts(kind: &str, key: &str, realm: &str) -> Result<MemoryScope, AgentMemoryError> {
3398 match kind {
3399 "identity" => Ok(MemoryScope::Identity {
3400 realm: realm.to_string(),
3401 identity: key.to_string(),
3402 }),
3403 "mob" => Ok(MemoryScope::Mob {
3404 realm: realm.to_string(),
3405 mob: key.to_string(),
3406 }),
3407 "operator" => Ok(MemoryScope::Operator {
3408 realm: realm.to_string(),
3409 operator: key.to_string(),
3410 }),
3411 "realm" => Ok(MemoryScope::Realm {
3412 realm: realm.to_string(),
3413 }),
3414 other => Err(AgentMemoryError::Parse(format!(
3415 "unknown scope kind '{other}'"
3416 ))),
3417 }
3418}
3419
3420fn active_scope_records(
3421 conn: &Connection,
3422 scope: &MemoryScope,
3423) -> Result<Vec<super::records::MemoryRecord>, AgentMemoryError> {
3424 let mut stmt = conn
3425 .prepare(&format!(
3426 "SELECT {RECORD_COLUMNS} FROM records WHERE scope_kind = ?1 AND scope_key = ?2 \
3427 AND status_kind = 'active'"
3428 ))
3429 .map_err(sql_err)?;
3430 let rows = stmt
3431 .query_map(params![scope.kind_str(), scope.key()], row_to_record_row)
3432 .map_err(sql_err)?;
3433 let mut records = Vec::new();
3434 for row in rows {
3435 records.push(row.map_err(sql_err)?.into_record(scope.realm())?);
3436 }
3437 Ok(records)
3438}
3439
3440fn load_record(
3441 conn: &Connection,
3442 realm: &str,
3443 memory_id: &str,
3444) -> Result<Option<super::records::MemoryRecord>, AgentMemoryError> {
3445 let row = conn
3446 .query_row(
3447 &format!("SELECT {RECORD_COLUMNS} FROM records WHERE memory_id = ?1"),
3448 params![memory_id],
3449 row_to_record_row,
3450 )
3451 .optional()
3452 .map_err(sql_err)?;
3453 row.map(|row| row.into_record(realm)).transpose()
3454}
3455
3456fn project_record(record: super::records::MemoryRecord) -> AgentMemoryRecord {
3460 AgentMemoryRecord {
3461 memory_id: record.id,
3462 title: record.title,
3463 body: record.body,
3464 tags: record.tags,
3465 created_at_ms: record.created_at_ms,
3466 updated_at_ms: record.updated_at_ms,
3467 }
3468}
3469
3470fn scope_manifest(
3474 conn: &Connection,
3475 scope: &MemoryScope,
3476 tier: ManifestTier,
3477 now: u64,
3478) -> Result<Vec<RecordMeta>, AgentMemoryError> {
3479 let to_meta = |row: &rusqlite::Row<'_>| -> rusqlite::Result<RecordMeta> {
3480 let kind: String = row.get(1)?;
3481 let updated_at: i64 = row.get(4)?;
3482 let rank: Option<i64> = row.get(5)?;
3483 Ok(RecordMeta {
3484 id: row.get(0)?,
3485 kind: MemoryKind::parse(&kind).unwrap_or(MemoryKind::Fact),
3486 title: row.get(2)?,
3487 description: row.get(3)?,
3488 age_days: age_days(updated_at as u64, now),
3489 rank: rank.map(|rank| rank as u32),
3490 })
3491 };
3492 const META_COLUMNS: &str =
3493 "memory_id, kind, title, description, updated_at_ms, working_set_rank";
3494 match tier {
3495 ManifestTier::Full => {
3496 let mut stmt = conn
3497 .prepare(&format!(
3498 "SELECT {META_COLUMNS} FROM records \
3499 WHERE scope_kind = ?1 AND scope_key = ?2 AND status_kind = 'active' \
3500 ORDER BY (working_set_rank IS NULL) ASC, working_set_rank ASC, \
3501 updated_at_ms DESC, created_at_ms DESC, rowid DESC"
3502 ))
3503 .map_err(sql_err)?;
3504 let rows = stmt
3505 .query_map(params![scope.kind_str(), scope.key()], to_meta)
3506 .map_err(sql_err)?;
3507 rows.collect::<Result<Vec<_>, _>>().map_err(sql_err)
3508 }
3509 ManifestTier::WorkingSet(k) => {
3510 let mut stmt = conn
3511 .prepare(&format!(
3512 "SELECT {META_COLUMNS} FROM records \
3513 WHERE scope_kind = ?1 AND scope_key = ?2 AND status_kind = 'active' \
3514 AND working_set_rank IS NOT NULL \
3515 ORDER BY working_set_rank ASC, updated_at_ms DESC, rowid DESC LIMIT ?3"
3516 ))
3517 .map_err(sql_err)?;
3518 let ranked = stmt
3519 .query_map(params![scope.kind_str(), scope.key(), k as i64], to_meta)
3520 .map_err(sql_err)?
3521 .collect::<Result<Vec<_>, _>>()
3522 .map_err(sql_err)?;
3523 let mut stmt = conn
3524 .prepare(&format!(
3525 "SELECT {META_COLUMNS} FROM records \
3526 WHERE scope_kind = ?1 AND scope_key = ?2 AND status_kind = 'active' \
3527 AND (working_set_rank IS NULL \
3528 OR updated_at_ms > COALESCE(rank_set_at_ms, 0)) \
3529 ORDER BY updated_at_ms DESC, created_at_ms DESC, rowid DESC LIMIT ?3"
3530 ))
3531 .map_err(sql_err)?;
3532 let recent = stmt
3533 .query_map(
3534 params![scope.kind_str(), scope.key(), (2 * k) as i64],
3535 to_meta,
3536 )
3537 .map_err(sql_err)?
3538 .collect::<Result<Vec<_>, _>>()
3539 .map_err(sql_err)?;
3540 let cap = 2 * k;
3541 let mut seen = std::collections::HashSet::new();
3542 let mut union = Vec::new();
3543 for meta in ranked.into_iter().chain(recent) {
3544 if union.len() >= cap {
3545 break;
3546 }
3547 if seen.insert(meta.id.clone()) {
3548 union.push(meta);
3549 }
3550 }
3551 Ok(union)
3552 }
3553 }
3554}
3555
3556fn warn_if_scope_floors_exceeded(
3560 conn: &Connection,
3561 scope: &MemoryScope,
3562 floor_records: usize,
3563 floor_bytes: usize,
3564) -> Result<(), AgentMemoryError> {
3565 let (count, bytes): (i64, Option<i64>) = conn
3566 .query_row(
3567 "SELECT COUNT(*), SUM(LENGTH(title) + LENGTH(description) + LENGTH(body)) \
3568 FROM records WHERE scope_kind = ?1 AND scope_key = ?2 \
3569 AND status_kind != 'tombstoned'",
3570 params![scope.kind_str(), scope.key()],
3571 |row| Ok((row.get(0)?, row.get(1)?)),
3572 )
3573 .map_err(sql_err)?;
3574 if let Some(reason) = scope_floor_warning(
3575 count as usize,
3576 bytes.unwrap_or(0) as usize,
3577 floor_records,
3578 floor_bytes,
3579 ) {
3580 tracing::warn!(
3581 realm = scope.realm(),
3582 scope_kind = scope.kind_str(),
3583 scope_key = scope.key(),
3584 "agent memory scope exceeds retention floor ({reason}); steward consolidation \
3585 needed — records are never evicted automatically"
3586 );
3587 }
3588 Ok(())
3589}
3590
3591fn scope_floor_warning(
3593 count: usize,
3594 bytes: usize,
3595 floor_records: usize,
3596 floor_bytes: usize,
3597) -> Option<String> {
3598 if count > floor_records {
3599 return Some(format!("{count} records > floor {floor_records}"));
3600 }
3601 if bytes > floor_bytes {
3602 return Some(format!("{bytes} bytes > floor {floor_bytes}"));
3603 }
3604 None
3605}
3606
3607enum MarkdownImportError {
3610 Content(String),
3611 Io(AgentMemoryError),
3612}
3613
3614fn record_import_audit(
3617 conn: &Connection,
3618 file: &Path,
3619 imported: usize,
3620 skipped: usize,
3621 reasons: &[String],
3622) -> Result<(), AgentMemoryError> {
3623 const MAX_AUDITED_REASONS: usize = 8;
3624 let detail = serde_json::json!({
3625 "op": "markdown_import",
3626 "file": file.display().to_string(),
3627 "imported": imported,
3628 "skipped": skipped,
3629 "skip_reasons": reasons.iter().take(MAX_AUDITED_REASONS).collect::<Vec<_>>(),
3630 });
3631 conn.execute(
3632 "INSERT INTO audit (stage_token, op_index, op_kind, memory_id, detail, applied_at_ms) \
3633 VALUES (?1, 0, 'import_summary', NULL, ?2, ?3)",
3634 params![
3635 mint_token("import-audit"),
3636 detail.to_string(),
3637 now_ms() as i64,
3638 ],
3639 )
3640 .map_err(sql_err)?;
3641 Ok(())
3642}
3643
3644fn json_string<T: serde::Serialize>(value: &T) -> Result<String, AgentMemoryError> {
3645 serde_json::to_string(value).map_err(|err| AgentMemoryError::Parse(err.to_string()))
3646}
3647
3648fn sql_err(err: rusqlite::Error) -> AgentMemoryError {
3649 AgentMemoryError::Io(err.to_string())
3650}
3651
3652fn sqlite_store_err(err: meerkat_sqlite::SqliteStoreError) -> AgentMemoryError {
3653 AgentMemoryError::Io(err.to_string())
3654}
3655
3656fn now_ms() -> u64 {
3657 SystemTime::now()
3658 .duration_since(UNIX_EPOCH)
3659 .map(|duration| duration.as_millis() as u64)
3660 .unwrap_or(0)
3661}
3662
3663fn mint_token(prefix: &str) -> String {
3664 static NEXT_TOKEN_SEQ: AtomicU64 = AtomicU64::new(0);
3665 let seq = NEXT_TOKEN_SEQ.fetch_add(1, Ordering::Relaxed);
3666 let nanos = SystemTime::now()
3667 .duration_since(UNIX_EPOCH)
3668 .map(|duration| duration.as_nanos())
3669 .unwrap_or(0);
3670 format!("{prefix}-{nanos}-{:x}-{seq:x}", std::process::id())
3671}
3672
3673#[cfg(test)]
3674#[allow(
3675 clippy::await_holding_lock,
3676 clippy::cloned_ref_to_slice_refs,
3677 clippy::expect_used,
3678 clippy::let_and_return,
3679 clippy::panic,
3680 clippy::unnecessary_to_owned
3681)]
3682mod tests {
3683 use super::*;
3684 use crate::identity_first::agent_memory::{AgentMemorySelection, MarkdownAgentMemoryStore};
3685 use std::error::Error;
3686
3687 fn identity() -> Result<AgentIdentity, Box<dyn Error>> {
3688 AgentIdentity::parse("identity:luka").map_err(|err| {
3689 std::io::Error::other(format!("test identity should parse: {err}")).into()
3690 })
3691 }
3692
3693 fn identity_scope(realm: &str) -> Result<MemoryScope, Box<dyn Error>> {
3694 Ok(MemoryScope::Identity {
3695 realm: realm.to_string(),
3696 identity: identity()?.as_str().to_string(),
3697 })
3698 }
3699
3700 fn new_memory(title: &str, body: &str) -> NewAgentMemory {
3701 NewAgentMemory {
3702 title: title.to_string(),
3703 body: body.to_string(),
3704 tags: Vec::new(),
3705 }
3706 }
3707
3708 fn recall_all(identity: AgentIdentity, realm: &str) -> AgentMemoryRecallRequest {
3709 AgentMemoryRecallRequest {
3710 identity,
3711 realm: realm.to_string(),
3712 query_text: None,
3713 query_terms: Vec::new(),
3714 selection: AgentMemorySelection::Always,
3715 max_entries: 64,
3716 }
3717 }
3718
3719 fn payload(title: &str, body: &str) -> NewMemoryRecord {
3720 NewMemoryRecord {
3721 kind: MemoryKind::Fact,
3722 title: title.to_string(),
3723 description: String::new(),
3724 body: body.to_string(),
3725 tags: Vec::new(),
3726 evidence: Vec::new(),
3727 verification: None,
3728 }
3729 }
3730
3731 #[tokio::test]
3732 async fn remember_dedups_exact_content_hash() -> Result<(), Box<dyn Error>> {
3733 let dir = tempfile::tempdir()?;
3734 let store = SqliteAgentMemoryStore::open(dir.path())?;
3735 let id = identity()?;
3736
3737 let first = store
3738 .remember("family", &id, new_memory("Same fact", "Same body"))
3739 .await?;
3740 let second = store
3741 .remember("family", &id, new_memory("Same fact", "Same body"))
3742 .await?;
3743 let third = store
3744 .remember("family", &id, new_memory("Other fact", "Other body"))
3745 .await?;
3746
3747 assert_eq!(
3748 first.memory_id, second.memory_id,
3749 "dedup must return the existing id"
3750 );
3751 assert_ne!(first.memory_id, third.memory_id);
3752 let records = store.recall(recall_all(id, "family")).await?;
3753 assert_eq!(records.len(), 2, "duplicate remember must not add a row");
3754 Ok(())
3755 }
3756
3757 #[tokio::test]
3758 async fn recall_scores_contextually_like_markdown_store() -> Result<(), Box<dyn Error>> {
3759 let dir = tempfile::tempdir()?;
3760 let store = SqliteAgentMemoryStore::open(dir.path())?;
3761 let id = identity()?;
3762 store
3763 .remember(
3764 "default",
3765 &id,
3766 NewAgentMemory {
3767 title: "Passport location".to_string(),
3768 body: "The passport is in the blue travel folder.".to_string(),
3769 tags: vec!["travel".to_string()],
3770 },
3771 )
3772 .await?;
3773 store
3774 .remember(
3775 "default",
3776 &id,
3777 new_memory("Unrelated", "Rust release checklist."),
3778 )
3779 .await?;
3780
3781 let matches = store
3782 .recall(AgentMemoryRecallRequest {
3783 identity: id,
3784 realm: "default".to_string(),
3785 query_text: Some("where did I put the passport".to_string()),
3786 query_terms: vec!["passport".to_string()],
3787 selection: AgentMemorySelection::Contextual,
3788 max_entries: 8,
3789 })
3790 .await?;
3791
3792 assert_eq!(matches.len(), 1);
3793 assert_eq!(matches[0].title, "Passport location");
3794 Ok(())
3795 }
3796
3797 #[tokio::test]
3798 async fn forget_tombstones_and_allows_deliberate_readd() -> Result<(), Box<dyn Error>> {
3799 let dir = tempfile::tempdir()?;
3800 let store = SqliteAgentMemoryStore::open(dir.path())?;
3801 let id = identity()?;
3802 let record = store
3803 .remember("family", &id, new_memory("Fact", "Body"))
3804 .await?;
3805
3806 let deleted = store.forget("family", &id, &record.memory_id).await?;
3807 assert!(deleted.deleted);
3808 assert!(
3809 store
3810 .recall(recall_all(id.clone(), "family"))
3811 .await?
3812 .is_empty()
3813 );
3814
3815 let again = store.forget("family", &id, &record.memory_id).await?;
3816 assert!(!again.deleted, "tombstoned record must not delete twice");
3817
3818 let readded = store
3822 .remember("family", &id, new_memory("Fact", "Body"))
3823 .await?;
3824 assert_ne!(readded.memory_id, record.memory_id);
3825 Ok(())
3826 }
3827
3828 #[tokio::test]
3829 async fn supersede_chains_and_inherits_rank() -> Result<(), Box<dyn Error>> {
3830 let dir = tempfile::tempdir()?;
3831 let store = SqliteAgentMemoryStore::open(dir.path())?;
3832 let id = identity()?;
3833 let scope = identity_scope("family")?;
3834 let prior = store
3835 .remember("family", &id, new_memory("DB host", "Use db-old.example."))
3836 .await?;
3837
3838 let token = store
3840 .stage(StagedMutationBatch {
3841 kind: StagedBatchKind::FreshWrite,
3842 realm: "family".to_string(),
3843 author: MemoryAuthor::Steward {
3844 run_id: "dream-1".to_string(),
3845 },
3846 ops: vec![StagedOp::SetRank {
3847 id: prior.memory_id.clone(),
3848 rank: Some(1),
3849 }],
3850 })
3851 .await?;
3852 store.commit(token).await?;
3853
3854 let new_id = store
3855 .supersede(
3856 &scope,
3857 &prior.memory_id,
3858 payload("DB host", "Use db-new.example."),
3859 )
3860 .await?;
3861 assert_ne!(new_id, prior.memory_id);
3862
3863 let records = store.recall(recall_all(id, "family")).await?;
3866 assert_eq!(records.len(), 1);
3867 assert_eq!(records[0].memory_id, new_id);
3868 assert!(records[0].body.contains("db-new"));
3869
3870 let manifest = store.manifest(&[scope.clone()], ManifestTier::Full).await?;
3871 assert_eq!(manifest.len(), 1);
3872 assert_eq!(manifest[0].id, new_id);
3873 assert_eq!(
3874 manifest[0].rank,
3875 Some(1),
3876 "supersede inherits the prior's rank"
3877 );
3878
3879 let conn = store.realm_connection("family")?;
3881 let guard = conn
3882 .lock()
3883 .unwrap_or_else(std::sync::PoisonError::into_inner);
3884 let (status_kind, by): (String, Option<String>) = guard.query_row(
3885 "SELECT status_kind, status_detail FROM records WHERE memory_id = ?1",
3886 params![prior.memory_id],
3887 |row| Ok((row.get(0)?, row.get(1)?)),
3888 )?;
3889 assert_eq!(status_kind, "superseded");
3890 assert_eq!(by.as_deref(), Some(new_id.as_str()));
3891 Ok(())
3892 }
3893
3894 #[tokio::test]
3895 async fn manifest_working_set_unions_ranked_and_recent() -> Result<(), Box<dyn Error>> {
3896 let dir = tempfile::tempdir()?;
3897 let store = SqliteAgentMemoryStore::open(dir.path())?;
3898 let id = identity()?;
3899 let scope = identity_scope("family")?;
3900 let mut ids = Vec::new();
3901 for i in 0..5 {
3902 let record = store
3903 .remember(
3904 "family",
3905 &id,
3906 new_memory(&format!("Fact {i}"), &format!("Body {i}")),
3907 )
3908 .await?;
3909 ids.push(record.memory_id);
3910 }
3911 let token = store
3914 .stage(StagedMutationBatch {
3915 kind: StagedBatchKind::FreshWrite,
3916 realm: "family".to_string(),
3917 author: MemoryAuthor::Steward {
3918 run_id: "dream-1".to_string(),
3919 },
3920 ops: (0..3)
3921 .map(|i| StagedOp::SetRank {
3922 id: ids[i].clone(),
3923 rank: Some(i as u32 + 1),
3924 })
3925 .collect(),
3926 })
3927 .await?;
3928 store.commit(token).await?;
3929
3930 let metas = store
3931 .manifest(&[scope.clone()], ManifestTier::WorkingSet(2))
3932 .await?;
3933 assert_eq!(metas.len(), 4);
3936 assert_eq!(metas[0].id, ids[0]);
3937 assert_eq!(metas[0].rank, Some(1));
3938 assert_eq!(metas[1].id, ids[1]);
3939 assert_eq!(metas[2].id, ids[4], "unranked slice is newest-first");
3940 assert_eq!(metas[3].id, ids[3]);
3941 assert!(
3942 !metas.iter().any(|meta| meta.id == ids[2]),
3943 "rank 3 is outside top-K and, being ranked and un-updated, outside the recent slice"
3944 );
3945
3946 let successor = store
3949 .supersede(&scope, &ids[2], payload("Fact 2", "Corrected body 2"))
3950 .await?;
3951 let metas = store
3952 .manifest(&[scope], ManifestTier::WorkingSet(2))
3953 .await?;
3954 assert!(
3955 metas.iter().any(|meta| meta.id == successor),
3956 "freshly superseded record must be selector-visible before the next dream: {metas:#?}"
3957 );
3958 Ok(())
3959 }
3960
3961 #[tokio::test]
3962 async fn staged_batch_without_commit_leaves_store_unchanged() -> Result<(), Box<dyn Error>> {
3963 let dir = tempfile::tempdir()?;
3964 let store = SqliteAgentMemoryStore::open(dir.path())?;
3965 let id = identity()?;
3966 let scope = identity_scope("family")?;
3967
3968 let token = store
3969 .stage(StagedMutationBatch {
3970 kind: StagedBatchKind::FreshWrite,
3971 realm: "family".to_string(),
3972 author: MemoryAuthor::Steward {
3973 run_id: "dream-crash".to_string(),
3974 },
3975 ops: vec![StagedOp::Create {
3976 id: None,
3977 scope: scope.clone(),
3978 record: payload("Staged fact", "Never committed"),
3979 trust: TrustTier::AgentObserved,
3980 derived_from: Vec::new(),
3981 rationale: None,
3982 created_at_ms: None,
3983 updated_at_ms: None,
3984 }],
3985 })
3986 .await?;
3987
3988 assert!(
3991 store
3992 .recall(recall_all(id.clone(), "family"))
3993 .await?
3994 .is_empty()
3995 );
3996 let reopened = SqliteAgentMemoryStore::open(dir.path())?;
3997 assert!(
3998 reopened
3999 .recall(recall_all(id.clone(), "family"))
4000 .await?
4001 .is_empty()
4002 );
4003
4004 let receipt = store.commit(token.clone()).await?;
4006 assert_eq!(receipt.applied_ops, 1);
4007 assert_eq!(store.recall(recall_all(id, "family")).await?.len(), 1);
4008 let replay = store.commit(token).await;
4009 assert!(matches!(replay, Err(AgentMemoryError::InvalidRecord(_))));
4010 Ok(())
4011 }
4012
4013 #[tokio::test]
4020 async fn pre_ledger_memory_file_is_refused_with_rows_preserved() -> Result<(), Box<dyn Error>> {
4021 let dir = tempfile::tempdir()?;
4022 let db_path = {
4023 let store = SqliteAgentMemoryStore::open(dir.path())?;
4024 store.path_for_realm("family")
4025 };
4026 {
4027 let conn = Connection::open(&db_path)?;
4028 conn.execute_batch(
4029 "CREATE TABLE records (
4030 memory_id TEXT PRIMARY KEY,
4031 scope_kind TEXT NOT NULL,
4032 scope_key TEXT NOT NULL,
4033 kind TEXT NOT NULL,
4034 title TEXT NOT NULL,
4035 description TEXT NOT NULL DEFAULT '',
4036 body TEXT NOT NULL,
4037 tags TEXT NOT NULL DEFAULT '[]',
4038 provenance TEXT NOT NULL,
4039 trust TEXT NOT NULL,
4040 status_kind TEXT NOT NULL,
4041 status_detail TEXT,
4042 supersedes TEXT,
4043 derived_from TEXT NOT NULL DEFAULT '[]',
4044 working_set_rank INTEGER,
4045 rank_set_at_ms INTEGER,
4046 content_hash TEXT NOT NULL,
4047 created_at_ms INTEGER NOT NULL,
4048 updated_at_ms INTEGER NOT NULL,
4049 usage_stats TEXT NOT NULL DEFAULT '{}',
4050 tombstoned_at_ms INTEGER
4051 );
4052 CREATE TABLE proposals (
4053 proposal_id TEXT PRIMARY KEY,
4054 scope_kind TEXT NOT NULL,
4055 scope_key TEXT NOT NULL,
4056 record TEXT NOT NULL,
4057 author TEXT NOT NULL,
4058 status TEXT NOT NULL DEFAULT 'pending',
4059 created_at_ms INTEGER NOT NULL
4060 );
4061 CREATE TABLE audit (
4062 audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
4063 stage_token TEXT NOT NULL,
4064 op_index INTEGER NOT NULL,
4065 op_kind TEXT NOT NULL,
4066 memory_id TEXT,
4067 detail TEXT NOT NULL,
4068 applied_at_ms INTEGER NOT NULL
4069 );",
4070 )?;
4071 let provenance = "{\"author\":{\"author\":\"application\"}}";
4072 let insert = |id: &str, status_kind: &str, detail: Option<&str>| {
4073 conn.execute(
4074 "INSERT INTO records (memory_id, scope_kind, scope_key, kind, title, \
4075 description, body, tags, provenance, trust, status_kind, status_detail, \
4076 supersedes, derived_from, content_hash, created_at_ms, updated_at_ms, \
4077 usage_stats) VALUES (?1, 'identity', 'identity:luka', 'fact', ?1, '', \
4078 'body', '[]', ?2, 'agent_observed', ?3, ?4, NULL, '[]', ?1, 1, 1, '{}')",
4079 params![id, provenance, status_kind, detail],
4080 )
4081 };
4082 insert("mem-clean", "active", None)?;
4083 insert("mem-quarantined", "quarantined", Some("tainted session"))?;
4084 insert("mem-tombstoned-was-quarantined", "tombstoned", None)?;
4085 insert("mem-tombstoned-clean", "tombstoned", None)?;
4086 conn.execute(
4087 "INSERT INTO audit (stage_token, op_index, op_kind, memory_id, detail, \
4088 applied_at_ms) VALUES ('direct-1', 0, 'create', \
4089 'mem-tombstoned-was-quarantined', \
4090 '{\"op\":\"create\",\"quarantined\":\"llm_writes=quarantined policy\"}', 1)",
4091 params![],
4092 )?;
4093 }
4094 let store = SqliteAgentMemoryStore::open(dir.path())?;
4095 assert!(
4098 store.pending_proposals("family", 4).await.is_err(),
4099 "first realm use over a pre-ledger file must refuse typed"
4100 );
4101 let probe = Connection::open(&db_path)?;
4102 let preserved: i64 =
4103 probe.query_row("SELECT COUNT(*) FROM records", [], |row| row.get(0))?;
4104 assert_eq!(preserved, 4, "the refusal must leave legacy rows untouched");
4105 assert_eq!(
4106 meerkat_sqlite::domain_version(&probe, "mobkit-memory")?,
4107 None,
4108 "a refused open must not stamp the ledger"
4109 );
4110 Ok(())
4111 }
4112
4113 #[tokio::test]
4120 async fn migration_folds_runtime_id_scopes_into_the_logical_identity()
4121 -> Result<(), Box<dyn Error>> {
4122 let dir = tempfile::tempdir()?;
4123 let db_path = {
4124 let store = SqliteAgentMemoryStore::open(dir.path())?;
4125 store.path_for_realm("default")
4126 };
4127 let gen0 = crate::member_comms_id::mob_member_id_str("rt:identity:parent-1:0").into_owned();
4128 let gen1 = crate::member_comms_id::mob_member_id_str("rt:identity:parent-1:1").into_owned();
4129 {
4130 let mut conn = Connection::open(&db_path)?;
4133 let tx = conn.transaction()?;
4134 initialize_v2_memory_schema(&tx)?;
4135 tx.execute_batch(
4136 "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
4137 INSERT INTO meerkat_schema (domain, version) VALUES ('mobkit-memory', 2);",
4138 )?;
4139 let provenance = "{\"author\":{\"author\":\"application\"}}";
4140 let insert_record = |id: &str, scope_key: &str| {
4141 tx.execute(
4142 "INSERT INTO records (memory_id, scope_kind, scope_key, kind, title, \
4143 description, body, tags, provenance, trust, status_kind, status_detail, \
4144 supersedes, derived_from, content_hash, created_at_ms, updated_at_ms, \
4145 usage_stats) VALUES (?1, 'identity', ?2, 'fact', ?1, '', 'body', '[]', \
4146 ?3, 'agent_observed', 'active', NULL, NULL, '[]', ?1, 1, 1, '{}')",
4147 params![id, scope_key, provenance],
4148 )
4149 };
4150 insert_record("mem-gen0", &gen0)?;
4151 insert_record("mem-gen1", &gen1)?;
4152 insert_record("mem-logical", "identity:parent-1")?;
4153 tx.execute(
4156 "INSERT INTO records (memory_id, scope_kind, scope_key, kind, title, \
4157 description, body, tags, provenance, trust, status_kind, status_detail, \
4158 supersedes, derived_from, content_hash, created_at_ms, updated_at_ms, \
4159 usage_stats) VALUES ('mem-mob', 'mob', ?1, 'fact', 'mob', '', 'body', '[]', \
4160 ?2, 'agent_observed', 'active', NULL, NULL, '[]', 'mem-mob', 1, 1, '{}')",
4161 params![gen0, provenance],
4162 )?;
4163 for (identity, at) in [
4166 (gen0.as_str(), 1),
4167 (gen0.as_str(), 2),
4168 ("identity:parent-1", 1),
4169 ] {
4170 tx.execute(
4171 "INSERT INTO pending_harvests (identity, session_key, cause, \
4172 retired_at_ms) VALUES (?1, NULL, 'retire', ?2)",
4173 params![identity, at],
4174 )?;
4175 }
4176 tx.execute(
4177 "INSERT INTO injections (record_id, identity, session_key, surface, at_ms) \
4178 VALUES ('mem-gen0', ?1, NULL, 'build', 1)",
4179 params![gen1],
4180 )?;
4181 let proposal_record = serde_json::to_string(&NewMemoryRecord {
4186 kind: MemoryKind::Fact,
4187 title: "proposed".to_string(),
4188 description: "proposed".to_string(),
4189 body: "proposed body".to_string(),
4190 tags: vec![],
4191 evidence: vec![],
4192 verification: None,
4193 })
4194 .expect("serialize proposal record");
4195 tx.execute(
4196 "INSERT INTO proposals (proposal_id, scope_kind, scope_key, record, author, \
4197 status, created_at_ms) VALUES ('prop-1', 'identity', ?1, ?2, ?3, 'pending', 1)",
4198 params![
4199 gen0,
4200 proposal_record,
4201 serde_json::to_string(&MemoryAuthor::Application)
4202 .expect("serialize proposal author")
4203 ],
4204 )?;
4205 let staged = StagedMutationBatch {
4209 realm: "default".to_string(),
4210 author: MemoryAuthor::Distiller {
4211 run_id: "run-legacy".to_string(),
4212 },
4213 kind: StagedBatchKind::FreshWrite,
4214 ops: vec![StagedOp::Create {
4215 id: None,
4216 scope: MemoryScope::Identity {
4217 realm: "default".to_string(),
4218 identity: gen0.clone(),
4219 },
4220 record: NewMemoryRecord {
4221 kind: MemoryKind::Fact,
4222 title: "staged".to_string(),
4223 description: "staged".to_string(),
4224 body: "staged body".to_string(),
4225 tags: vec![],
4226 evidence: vec![],
4227 verification: None,
4228 },
4229 trust: TrustTier::AgentObserved,
4230 derived_from: vec![],
4231 rationale: None,
4232 created_at_ms: None,
4233 updated_at_ms: None,
4234 }],
4235 };
4236 tx.execute(
4240 "INSERT INTO stage (token, batch, created_at_ms) VALUES ('stage-1', ?1, ?2)",
4241 params![
4242 serde_json::to_string(&staged).expect("serialize staged batch"),
4243 now_ms() as i64
4244 ],
4245 )?;
4246 tx.execute(
4247 "INSERT INTO pending_promotions (pending_id, stage_token, record_id, \
4248 scope_kind, scope_key, rationale, status, created_at_ms) VALUES \
4249 ('pending-1', 'stage-1', 'mem-gen0', 'identity', ?1, NULL, 'pending', 1)",
4250 params![gen0],
4251 )?;
4252 tx.commit()?;
4253 }
4254
4255 let store = SqliteAgentMemoryStore::open(dir.path())?;
4257 store.pending_proposals("default", 4).await?;
4259
4260 let probe = Connection::open(&db_path)?;
4261 assert_eq!(
4262 meerkat_sqlite::domain_version(&probe, "mobkit-memory")?,
4263 Some(3),
4264 "migration must stamp v3"
4265 );
4266 let logical_records: i64 = probe.query_row(
4267 "SELECT COUNT(*) FROM records WHERE scope_kind = 'identity' \
4268 AND scope_key = 'identity:parent-1'",
4269 [],
4270 |row| row.get(0),
4271 )?;
4272 assert_eq!(
4273 logical_records, 3,
4274 "both generations fold into the logical scope beside the existing row"
4275 );
4276 let legacy_records: i64 = probe.query_row(
4277 "SELECT COUNT(*) FROM records WHERE scope_kind = 'identity' \
4278 AND scope_key LIKE 'mk--%'",
4279 [],
4280 |row| row.get(0),
4281 )?;
4282 assert_eq!(
4283 legacy_records, 0,
4284 "no identity rows may stay runtime-id-keyed"
4285 );
4286 let mob_scope_key: String = probe.query_row(
4287 "SELECT scope_key FROM records WHERE memory_id = 'mem-mob'",
4288 [],
4289 |row| row.get(0),
4290 )?;
4291 assert_eq!(mob_scope_key, gen0, "mob-scope keys are not identity-space");
4292 let harvests: Vec<(String, i64)> = {
4293 let mut stmt = probe.prepare(
4294 "SELECT identity, retired_at_ms FROM pending_harvests ORDER BY retired_at_ms",
4295 )?;
4296 let rows = stmt
4297 .query_map([], |row| {
4298 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
4299 })?
4300 .collect::<Result<Vec<_>, _>>()?;
4301 rows
4302 };
4303 assert_eq!(
4304 harvests,
4305 vec![
4306 ("identity:parent-1".to_string(), 1),
4307 ("identity:parent-1".to_string(), 2)
4308 ],
4309 "harvest queue folds with PK collisions collapsed"
4310 );
4311 let injection_identity: String =
4312 probe.query_row("SELECT identity FROM injections", [], |row| row.get(0))?;
4313 assert_eq!(injection_identity, "identity:parent-1");
4314 let gen0_body: String = probe.query_row(
4316 "SELECT body FROM records WHERE memory_id = 'mem-gen0'",
4317 [],
4318 |row| row.get(0),
4319 )?;
4320 assert_eq!(gen0_body, "body");
4321 let (proposal_scope, proposal_record): (String, String) = probe.query_row(
4324 "SELECT scope_key, record FROM proposals WHERE proposal_id = 'prop-1'",
4325 [],
4326 |row| Ok((row.get(0)?, row.get(1)?)),
4327 )?;
4328 assert_eq!(proposal_scope, "identity:parent-1");
4329 assert!(
4330 proposal_record.contains("proposed body"),
4331 "{proposal_record}"
4332 );
4333 let promotion_scope: String = probe.query_row(
4335 "SELECT scope_key FROM pending_promotions WHERE pending_id = 'pending-1'",
4336 [],
4337 |row| row.get(0),
4338 )?;
4339 assert_eq!(promotion_scope, "identity:parent-1");
4340 let staged_json: String = probe.query_row(
4344 "SELECT batch FROM stage WHERE token = 'stage-1'",
4345 [],
4346 |row| row.get(0),
4347 )?;
4348 let staged: StagedMutationBatch = serde_json::from_str(&staged_json)?;
4349 match &staged.ops[0] {
4350 StagedOp::Create { scope, .. } => {
4351 assert_eq!(
4352 scope,
4353 &MemoryScope::Identity {
4354 realm: "default".to_string(),
4355 identity: "identity:parent-1".to_string(),
4356 },
4357 "the embedded Create scope must be normalized in place"
4358 );
4359 }
4360 other => panic!("seeded op must survive as Create, got {other:?}"),
4361 }
4362 drop(probe);
4363
4364 drop(store);
4367 let reopened = SqliteAgentMemoryStore::open(dir.path())?;
4368 reopened.pending_proposals("default", 4).await?;
4369 let probe = Connection::open(&db_path)?;
4370 assert_eq!(
4371 meerkat_sqlite::domain_version(&probe, "mobkit-memory")?,
4372 Some(3)
4373 );
4374 let logical_records: i64 = probe.query_row(
4375 "SELECT COUNT(*) FROM records WHERE scope_kind = 'identity' \
4376 AND scope_key = 'identity:parent-1'",
4377 [],
4378 |row| row.get(0),
4379 )?;
4380 assert_eq!(logical_records, 3, "reopen must not change folded state");
4381 Ok(())
4382 }
4383
4384 #[tokio::test]
4390 async fn pre_ledger_proposals_only_file_is_refused() -> Result<(), Box<dyn Error>> {
4391 let dir = tempfile::tempdir()?;
4392 let db_path = {
4393 let store = SqliteAgentMemoryStore::open(dir.path())?;
4394 store.path_for_realm("family")
4395 };
4396 {
4397 let conn = Connection::open(&db_path)?;
4398 conn.execute_batch(
4399 "CREATE TABLE proposals (
4400 proposal_id TEXT PRIMARY KEY,
4401 scope_kind TEXT NOT NULL,
4402 scope_key TEXT NOT NULL,
4403 record TEXT NOT NULL,
4404 author TEXT NOT NULL,
4405 status TEXT NOT NULL DEFAULT 'pending',
4406 created_at_ms INTEGER NOT NULL
4407 );",
4408 )?;
4409 let record = serde_json::to_string(&NewMemoryRecord {
4410 kind: MemoryKind::Fact,
4411 title: "Shared gotcha".to_string(),
4412 description: String::new(),
4413 body: "proposed before the taint column existed".to_string(),
4414 tags: Vec::new(),
4415 evidence: Vec::new(),
4416 verification: None,
4417 })?;
4418 let author = serde_json::to_string(&MemoryAuthor::Agent {
4419 identity: "identity:luka".to_string(),
4420 })?;
4421 let insert = |id: &str, status: &str| {
4422 conn.execute(
4423 "INSERT INTO proposals (proposal_id, scope_kind, scope_key, record, \
4424 author, status, created_at_ms) VALUES (?1, 'mob', 'mob:home', ?2, ?3, \
4425 ?4, 1)",
4426 params![id, record, author, status],
4427 )
4428 };
4429 insert("prop-pending", "pending")?;
4430 insert("prop-held", "held")?;
4431 insert("prop-accepted", "accepted")?;
4432 insert("prop-rejected", "rejected")?;
4433 }
4434 let store = SqliteAgentMemoryStore::open(dir.path())?;
4435 assert!(
4436 store.pending_proposals("family", 8).await.is_err(),
4437 "first realm use over a pre-ledger proposals table must refuse typed"
4438 );
4439 let probe = Connection::open(&db_path)?;
4440 let preserved: i64 =
4441 probe.query_row("SELECT COUNT(*) FROM proposals", [], |row| row.get(0))?;
4442 assert_eq!(preserved, 4, "the refusal must leave legacy rows untouched");
4443 assert_eq!(
4444 meerkat_sqlite::domain_version(&probe, "mobkit-memory")?,
4445 None,
4446 "a refused open must not stamp the ledger"
4447 );
4448 Ok(())
4449 }
4450
4451 #[tokio::test]
4454 async fn fresh_store_stamps_mobkit_memory_domain() -> Result<(), Box<dyn Error>> {
4455 let dir = tempfile::tempdir()?;
4456 let store = SqliteAgentMemoryStore::open(dir.path())?;
4457 let conn = store.realm_connection("family")?;
4458 let guard = conn
4459 .lock()
4460 .unwrap_or_else(std::sync::PoisonError::into_inner);
4461 assert_eq!(
4462 meerkat_sqlite::domain_version(&guard, "mobkit-memory")?,
4463 Some(3)
4464 );
4465 Ok(())
4466 }
4467
4468 #[tokio::test]
4476 async fn pre_ledger_records_and_proposals_file_is_refused() -> Result<(), Box<dyn Error>> {
4477 let dir = tempfile::tempdir()?;
4478 let db_path = {
4479 let store = SqliteAgentMemoryStore::open(dir.path())?;
4480 store.path_for_realm("family")
4481 };
4482 {
4483 let conn = Connection::open(&db_path)?;
4484 conn.execute_batch(
4485 "CREATE TABLE records (
4486 memory_id TEXT PRIMARY KEY,
4487 scope_kind TEXT NOT NULL,
4488 scope_key TEXT NOT NULL,
4489 kind TEXT NOT NULL,
4490 title TEXT NOT NULL,
4491 description TEXT NOT NULL DEFAULT '',
4492 body TEXT NOT NULL,
4493 tags TEXT NOT NULL DEFAULT '[]',
4494 provenance TEXT NOT NULL,
4495 trust TEXT NOT NULL,
4496 status_kind TEXT NOT NULL,
4497 status_detail TEXT,
4498 supersedes TEXT,
4499 derived_from TEXT NOT NULL DEFAULT '[]',
4500 working_set_rank INTEGER,
4501 rank_set_at_ms INTEGER,
4502 content_hash TEXT NOT NULL,
4503 created_at_ms INTEGER NOT NULL,
4504 updated_at_ms INTEGER NOT NULL,
4505 usage_stats TEXT NOT NULL DEFAULT '{}',
4506 tombstoned_at_ms INTEGER
4507 );
4508 CREATE TABLE proposals (
4509 proposal_id TEXT PRIMARY KEY,
4510 scope_kind TEXT NOT NULL,
4511 scope_key TEXT NOT NULL,
4512 record TEXT NOT NULL,
4513 author TEXT NOT NULL,
4514 status TEXT NOT NULL DEFAULT 'pending',
4515 created_at_ms INTEGER NOT NULL
4516 );",
4517 )?;
4518 conn.execute(
4519 "INSERT INTO records (memory_id, scope_kind, scope_key, kind, title, \
4520 description, body, tags, provenance, trust, status_kind, status_detail, \
4521 supersedes, derived_from, content_hash, created_at_ms, updated_at_ms, \
4522 usage_stats) VALUES ('mem-q', 'identity', 'identity:luka', 'fact', 'T', '', \
4523 'body', '[]', '{\"author\":{\"author\":\"application\"}}', 'agent_observed', \
4524 'quarantined', 'tainted', NULL, '[]', 'h1', 1, 1, '{}')",
4525 [],
4526 )?;
4527 for (id, status) in [
4528 ("prop-pending", "pending"),
4529 ("prop-held", "held"),
4530 ("prop-accepted", "accepted"),
4531 ] {
4532 conn.execute(
4533 "INSERT INTO proposals (proposal_id, scope_kind, scope_key, record, \
4534 author, status, created_at_ms) VALUES (?1, 'mob', 'mob:home', '{}', \
4535 '{}', ?2, 1)",
4536 params![id, status],
4537 )?;
4538 }
4539 }
4540 let store = SqliteAgentMemoryStore::open(dir.path())?;
4541 assert!(
4542 store.realm_connection("family").is_err(),
4543 "opening a pre-ledger realm connection must refuse typed"
4544 );
4545 let probe = Connection::open(&db_path)?;
4546 let records: i64 = probe.query_row("SELECT COUNT(*) FROM records", [], |row| row.get(0))?;
4547 assert_eq!(
4548 records, 1,
4549 "the refusal must leave legacy records untouched"
4550 );
4551 let proposals: i64 =
4552 probe.query_row("SELECT COUNT(*) FROM proposals", [], |row| row.get(0))?;
4553 assert_eq!(
4554 proposals, 3,
4555 "the refusal must leave legacy proposals untouched"
4556 );
4557 assert_eq!(
4558 meerkat_sqlite::domain_version(&probe, "mobkit-memory")?,
4559 None,
4560 "a refused open must not stamp the ledger"
4561 );
4562 Ok(())
4563 }
4564
4565 #[tokio::test]
4566 async fn stale_stage_tokens_gc_on_open() -> Result<(), Box<dyn Error>> {
4567 let dir = tempfile::tempdir()?;
4568 let store = SqliteAgentMemoryStore::open(dir.path())?;
4569 let stage_create = |title: &str| StagedMutationBatch {
4570 kind: StagedBatchKind::FreshWrite,
4571 realm: "family".to_string(),
4572 author: MemoryAuthor::Application,
4573 ops: vec![StagedOp::Create {
4574 id: None,
4575 scope: identity_scope("family").expect("scope"),
4576 record: payload(title, &format!("{title} body")),
4577 trust: TrustTier::AgentObserved,
4578 derived_from: Vec::new(),
4579 rationale: None,
4580 created_at_ms: None,
4581 updated_at_ms: None,
4582 }],
4583 };
4584 let ungated = store.stage(stage_create("Stale")).await?;
4585 let pending_gated = store.stage(stage_create("Gated pending")).await?;
4588 store
4589 .record_pending_promotion(
4590 "family",
4591 PendingPromotion {
4592 pending_id: "gate-pending".to_string(),
4593 stage_token: pending_gated.token.clone(),
4594 record_id: "mem-src-1".to_string(),
4595 scope_kind: "mob".to_string(),
4596 scope_key: "mob:home".to_string(),
4597 rationale: None,
4598 status: "pending".to_string(),
4599 created_at_ms: now_ms(),
4600 },
4601 )
4602 .await?;
4603 let resolved_gated = store.stage(stage_create("Gated resolved")).await?;
4606 store
4607 .record_pending_promotion(
4608 "family",
4609 PendingPromotion {
4610 pending_id: "gate-resolved".to_string(),
4611 stage_token: resolved_gated.token.clone(),
4612 record_id: "mem-src-2".to_string(),
4613 scope_kind: "mob".to_string(),
4614 scope_key: "mob:home".to_string(),
4615 rationale: None,
4616 status: "pending".to_string(),
4617 created_at_ms: now_ms(),
4618 },
4619 )
4620 .await?;
4621 store
4622 .resolve_pending_promotion("family", "gate-resolved", "denied")
4623 .await?;
4624 {
4626 let conn = store.realm_connection("family")?;
4627 let guard = conn
4628 .lock()
4629 .unwrap_or_else(std::sync::PoisonError::into_inner);
4630 guard.execute(
4631 "UPDATE stage SET created_at_ms = created_at_ms - ?1",
4632 params![(STAGE_GC_MAX_AGE_MS + 60_000) as i64],
4633 )?;
4634 }
4635 let reopened = SqliteAgentMemoryStore::open(dir.path())?;
4636 let result = reopened.commit(ungated).await;
4637 assert!(
4638 matches!(result, Err(AgentMemoryError::InvalidRecord(_))),
4639 "aged-out ungated stage token must be garbage-collected on open"
4640 );
4641 let result = reopened.commit(resolved_gated).await;
4642 assert!(
4643 matches!(result, Err(AgentMemoryError::InvalidRecord(_))),
4644 "a stage referenced only by a RESOLVED promotion must still be collected"
4645 );
4646 let receipt = reopened.commit(pending_gated).await.map_err(|err| {
4647 format!("a stage referenced by a pending gated promotion must survive GC: {err}")
4648 })?;
4649 assert_eq!(receipt.applied_ops, 1);
4650 Ok(())
4651 }
4652
4653 #[tokio::test]
4654 async fn stage_rejects_lattice_violations() -> Result<(), Box<dyn Error>> {
4655 let dir = tempfile::tempdir()?;
4656 let store = SqliteAgentMemoryStore::open(dir.path())?;
4657 let scope = identity_scope("family")?;
4658
4659 let above_ceiling = store
4661 .stage(StagedMutationBatch {
4662 kind: StagedBatchKind::FreshWrite,
4663 realm: "family".to_string(),
4664 author: MemoryAuthor::Agent {
4665 identity: identity()?.as_str().to_string(),
4666 },
4667 ops: vec![StagedOp::Create {
4668 id: None,
4669 scope: scope.clone(),
4670 record: payload("Fact", "Body"),
4671 trust: TrustTier::AgentVerified,
4672 derived_from: Vec::new(),
4673 rationale: None,
4674 created_at_ms: None,
4675 updated_at_ms: None,
4676 }],
4677 })
4678 .await;
4679 assert!(matches!(
4680 above_ceiling,
4681 Err(AgentMemoryError::InvalidRecord(_))
4682 ));
4683
4684 let operator_tier = store
4686 .stage(StagedMutationBatch {
4687 kind: StagedBatchKind::FreshWrite,
4688 realm: "family".to_string(),
4689 author: MemoryAuthor::Operator,
4690 ops: vec![StagedOp::Create {
4691 id: None,
4692 scope,
4693 record: payload("Fact", "Body"),
4694 trust: TrustTier::Operator,
4695 derived_from: Vec::new(),
4696 rationale: None,
4697 created_at_ms: None,
4698 updated_at_ms: None,
4699 }],
4700 })
4701 .await;
4702 assert!(matches!(
4703 operator_tier,
4704 Err(AgentMemoryError::InvalidRecord(_))
4705 ));
4706 Ok(())
4707 }
4708
4709 #[tokio::test]
4710 async fn transitive_taint_blocks_laundering_through_store() -> Result<(), Box<dyn Error>> {
4711 let dir = tempfile::tempdir()?;
4712 let store = SqliteAgentMemoryStore::open(dir.path())?;
4713 let scope = identity_scope("family")?;
4714
4715 let seed = store
4718 .stage(StagedMutationBatch {
4719 kind: StagedBatchKind::FreshWrite,
4720 realm: "family".to_string(),
4721 author: MemoryAuthor::Steward {
4722 run_id: "dream-1".to_string(),
4723 },
4724 ops: vec![
4725 StagedOp::Create {
4726 id: Some("mem-tainted".to_string()),
4727 scope: scope.clone(),
4728 record: payload("Web claim", "Untrusted web content"),
4729 trust: TrustTier::Untrusted,
4730 derived_from: Vec::new(),
4731 rationale: None,
4732 created_at_ms: None,
4733 updated_at_ms: None,
4734 },
4735 StagedOp::Create {
4736 id: Some("mem-merged".to_string()),
4737 scope: scope.clone(),
4738 record: {
4739 let mut merged = payload("Consolidated", "Merged content");
4740 merged.verification = Some(super::super::records::VerificationClaim {
4741 checked: "claims verification".to_string(),
4742 evidence: Vec::new(),
4743 });
4744 merged
4745 },
4746 trust: TrustTier::AgentObserved,
4747 derived_from: vec!["mem-tainted".to_string()],
4748 rationale: Some("consolidation".to_string()),
4749 created_at_ms: None,
4750 updated_at_ms: None,
4751 },
4752 ],
4753 })
4754 .await?;
4755 store.commit(seed).await?;
4756
4757 let launder = store
4758 .stage(StagedMutationBatch {
4759 kind: StagedBatchKind::FreshWrite,
4760 realm: "family".to_string(),
4761 author: MemoryAuthor::Steward {
4762 run_id: "dream-2".to_string(),
4763 },
4764 ops: vec![StagedOp::Retier {
4765 id: "mem-merged".to_string(),
4766 trust: TrustTier::AgentVerified,
4767 rationale: Some("launder attempt".to_string()),
4768 }],
4769 })
4770 .await;
4771 let err = match launder {
4772 Err(AgentMemoryError::InvalidRecord(message)) => message,
4773 other => return Err(format!("laundering must be rejected, got {other:?}").into()),
4774 };
4775 assert!(err.contains("untrusted/quarantined"), "{err}");
4776 Ok(())
4777 }
4778
4779 #[tokio::test]
4780 async fn markdown_import_preserves_ids_and_renames_file() -> Result<(), Box<dyn Error>> {
4781 let dir = tempfile::tempdir()?;
4782 let id = identity()?;
4783 let markdown = MarkdownAgentMemoryStore::open(dir.path())?;
4784 let first = markdown.remember(
4785 "family",
4786 &id,
4787 new_memory("Imported fact", "Body one with detail."),
4788 )?;
4789 let second = markdown.remember(
4790 "family",
4791 &id,
4792 NewAgentMemory {
4793 title: "Second fact".to_string(),
4794 body: "Body two with detail.".to_string(),
4795 tags: vec!["travel".to_string()],
4796 },
4797 )?;
4798 let md_path = markdown.path_for("family", &id);
4799
4800 let store = SqliteAgentMemoryStore::open(dir.path())?;
4801 let records = store.recall(recall_all(id.clone(), "family")).await?;
4802 let mut got: Vec<&str> = records.iter().map(|r| r.memory_id.as_str()).collect();
4803 got.sort_unstable();
4804 let mut want = [first.memory_id.as_str(), second.memory_id.as_str()];
4805 want.sort_unstable();
4806 assert_eq!(got, want, "import must preserve memory ids");
4807 let imported = records
4808 .iter()
4809 .find(|record| record.memory_id == second.memory_id)
4810 .ok_or("second record imported")?;
4811 assert_eq!(imported.tags, vec!["travel"]);
4812 assert_eq!(imported.created_at_ms, second.created_at_ms);
4813
4814 assert!(
4815 !md_path.exists(),
4816 "markdown file must be renamed after import"
4817 );
4818 let renamed = md_path.with_extension("md.imported");
4819 assert!(renamed.exists(), "markdown file must survive as .imported");
4820
4821 let conn = store.realm_connection("family")?;
4823 let guard = conn
4824 .lock()
4825 .unwrap_or_else(std::sync::PoisonError::into_inner);
4826 let audits: i64 = guard.query_row(
4827 "SELECT COUNT(*) FROM audit WHERE stage_token LIKE 'import-%'",
4828 [],
4829 |row| row.get(0),
4830 )?;
4831 assert_eq!(audits, 2);
4832 drop(guard);
4833
4834 let reopened = SqliteAgentMemoryStore::open(dir.path())?;
4836 assert_eq!(reopened.recall(recall_all(id, "family")).await?.len(), 2);
4837 Ok(())
4838 }
4839
4840 struct TaintedSessionGate;
4843
4844 impl crate::memory::taint::LlmWriteGate for TaintedSessionGate {
4845 fn quarantine_reason(
4846 &self,
4847 author: &MemoryAuthor,
4848 _kind: StagedBatchKind,
4849 evidence: &[crate::memory::records::EvidenceRef],
4850 ) -> Option<String> {
4851 if !author.is_llm() {
4852 return None;
4853 }
4854 evidence
4855 .iter()
4856 .any(|reference| reference.session_id == "tainted-sess")
4857 .then(|| "evidence cites a tainted session".to_string())
4858 }
4859 }
4860
4861 fn tainted_evidence() -> Vec<crate::memory::records::EvidenceRef> {
4862 vec![crate::memory::records::EvidenceRef {
4863 session_id: "tainted-sess".to_string(),
4864 generation: 0,
4865 revision: None,
4866 range: None,
4867 }]
4868 }
4869
4870 #[tokio::test]
4871 async fn release_then_retier_of_formerly_quarantined_origin_rejected()
4872 -> Result<(), Box<dyn Error>> {
4873 let dir = tempfile::tempdir()?;
4874 let store = SqliteAgentMemoryStore::open(dir.path())?;
4875 store.set_llm_write_gate(std::sync::Arc::new(TaintedSessionGate));
4876 let scope = identity_scope("family")?;
4877
4878 let mut record = payload("Quarantined origin", "possibly poisoned content");
4882 record.evidence = tainted_evidence();
4883 record.verification = Some(crate::memory::records::VerificationClaim {
4884 checked: "claims to have checked".to_string(),
4885 evidence: Vec::new(),
4886 });
4887 let receipt = store
4888 .remember_authored(
4889 &scope,
4890 record,
4891 MemoryAuthor::Agent {
4892 identity: identity()?.as_str().to_string(),
4893 },
4894 )
4895 .await?;
4896 assert!(matches!(receipt.status, RecordStatus::Quarantined { .. }));
4897 let origin = receipt.memory_id;
4898
4899 let mut copy_payload =
4902 payload("Quarantined origin", "possibly poisoned content (released)");
4903 copy_payload.verification = Some(crate::memory::records::VerificationClaim {
4904 checked: "claims to have checked".to_string(),
4905 evidence: Vec::new(),
4906 });
4907 let release = StagedMutationBatch {
4908 kind: StagedBatchKind::FreshWrite,
4909 realm: "family".to_string(),
4910 author: MemoryAuthor::Steward {
4911 run_id: "dream-1".to_string(),
4912 },
4913 ops: vec![
4914 StagedOp::Create {
4915 id: Some("mem-released-copy".to_string()),
4916 scope: scope.clone(),
4917 record: copy_payload,
4918 trust: TrustTier::AgentObserved,
4919 derived_from: vec![origin.clone()],
4920 rationale: Some("quarantine release".to_string()),
4921 created_at_ms: None,
4922 updated_at_ms: None,
4923 },
4924 StagedOp::Tombstone {
4925 id: origin.clone(),
4926 rationale: Some("superseded by quarantine release".to_string()),
4927 },
4928 ],
4929 };
4930 let token = store.stage(release).await?;
4931 store.commit(token).await?;
4932 let released = store
4933 .record_by_id("family", "mem-released-copy")
4934 .await?
4935 .ok_or("released copy exists")?;
4936 assert_eq!(released.status, RecordStatus::Active);
4937 let origin_record = store
4938 .record_by_id("family", &origin)
4939 .await?
4940 .ok_or("origin exists")?;
4941 assert_eq!(origin_record.status, RecordStatus::Tombstoned);
4942
4943 {
4946 let conn = store.realm_connection("family")?;
4947 let guard = conn
4948 .lock()
4949 .unwrap_or_else(std::sync::PoisonError::into_inner);
4950 let flags: Vec<(String, bool)> = {
4951 let mut stmt = guard.prepare(
4952 "SELECT memory_id, ever_quarantined FROM records ORDER BY memory_id",
4953 )?;
4954 let rows = stmt
4955 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
4956 .collect::<Result<Vec<_>, _>>()?;
4957 rows
4958 };
4959 for (memory_id, flag) in &flags {
4960 assert!(
4961 flag,
4962 "'{memory_id}' must carry ever_quarantined after the release"
4963 );
4964 }
4965 }
4966
4967 let retier = StagedMutationBatch {
4971 kind: StagedBatchKind::FreshWrite,
4972 realm: "family".to_string(),
4973 author: MemoryAuthor::Steward {
4974 run_id: "dream-2".to_string(),
4975 },
4976 ops: vec![StagedOp::Retier {
4977 id: "mem-released-copy".to_string(),
4978 trust: TrustTier::AgentVerified,
4979 rationale: Some("post-release launder attempt".to_string()),
4980 }],
4981 };
4982 let err = store.stage(retier).await.expect_err("ceiling must hold");
4983 assert!(
4984 err.to_string().contains("provenance chain reaches"),
4985 "{err}"
4986 );
4987 Ok(())
4988 }
4989
4990 #[tokio::test]
4991 async fn markdown_import_skips_bad_records_and_files_loudly() -> Result<(), Box<dyn Error>> {
4992 let dir = tempfile::tempdir()?;
4993 let id = identity()?;
4994 let markdown = MarkdownAgentMemoryStore::open(dir.path())?;
4995 let valid = markdown.remember("family", &id, new_memory("Valid fact", "Valid body."))?;
4996 let md_path = markdown.path_for("family", &id);
4997
4998 let oversized_title = "T".repeat(crate::memory::records::MAX_RECORD_TITLE_BYTES + 10);
5002 let mut content = fs::read_to_string(&md_path)?;
5003 content.push_str(&format!(
5004 "## {oversized_title}\n<!-- mobkit-agent-memory \
5005 {{\"memory_id\":\"mem-bad-title\",\"tags\":[],\"created_at_ms\":1,\
5006 \"updated_at_ms\":1}} -->\nSome body.\n<!-- /mobkit-agent-memory -->\n\n"
5007 ));
5008 content.push_str(
5009 "## Leaked credential\n<!-- mobkit-agent-memory \
5010 {\"memory_id\":\"mem-secret\",\"tags\":[],\"created_at_ms\":2,\
5011 \"updated_at_ms\":2} -->\nthe key was AKIAIOSFODNN7EXAMPLE\n\
5012 <!-- /mobkit-agent-memory -->\n\n",
5013 );
5014 fs::write(&md_path, content)?;
5015
5016 let junk_path = dir.path().join("family").join("not an identity.md");
5020 fs::write(&junk_path, "## Orphan\nnot a memory file\n")?;
5021
5022 let store = SqliteAgentMemoryStore::open(dir.path())?;
5023 let records = store.recall(recall_all(id.clone(), "family")).await?;
5024 assert_eq!(
5025 records
5026 .iter()
5027 .map(|record| record.memory_id.as_str())
5028 .collect::<Vec<_>>(),
5029 vec![valid.memory_id.as_str()],
5030 "only the valid record imports"
5031 );
5032 assert!(!md_path.exists(), "identity file renamed after import");
5033 assert!(md_path.with_extension("md.imported").exists());
5034 assert!(!junk_path.exists(), "junk file set aside");
5035 assert!(junk_path.with_extension("md.import-failed").exists());
5036
5037 let conn = store.realm_connection("family")?;
5039 let guard = conn
5040 .lock()
5041 .unwrap_or_else(std::sync::PoisonError::into_inner);
5042 let summaries: Vec<String> = {
5043 let mut stmt =
5044 guard.prepare("SELECT detail FROM audit WHERE op_kind = 'import_summary'")?;
5045 let rows = stmt
5046 .query_map([], |row| row.get(0))?
5047 .collect::<Result<Vec<_>, _>>()?;
5048 rows
5049 };
5050 assert_eq!(summaries.len(), 2, "one summary per skipping/failing file");
5051 let identity_summary = summaries
5052 .iter()
5053 .find(|detail| detail.contains("mem-bad-title"))
5054 .ok_or("identity-file summary present")?;
5055 assert!(
5056 identity_summary.contains("\"skipped\":2"),
5057 "{identity_summary}"
5058 );
5059 assert!(
5060 identity_summary.contains("secret pattern class"),
5061 "{identity_summary}"
5062 );
5063 assert!(
5064 !identity_summary.contains("AKIAIOSFODNN7EXAMPLE"),
5065 "audit must not echo the secret: {identity_summary}"
5066 );
5067 drop(guard);
5068
5069 let reopened = SqliteAgentMemoryStore::open(dir.path())?;
5071 assert_eq!(reopened.recall(recall_all(id, "family")).await?.len(), 1);
5072 Ok(())
5073 }
5074
5075 #[tokio::test]
5076 async fn propose_captures_taint_at_propose_time_and_refuses_secrets()
5077 -> Result<(), Box<dyn Error>> {
5078 let dir = tempfile::tempdir()?;
5079 let store = SqliteAgentMemoryStore::open(dir.path())?;
5080 store.set_llm_write_gate(std::sync::Arc::new(TaintedSessionGate));
5081 let mob = MemoryScope::Mob {
5082 realm: "family".to_string(),
5083 mob: "mob:home".to_string(),
5084 };
5085 let author = MemoryAuthor::Agent {
5086 identity: identity()?.as_str().to_string(),
5087 };
5088
5089 let mut tainted = payload("Shared gotcha", "from a poisoned session");
5091 tainted.evidence = tainted_evidence();
5092 let tainted_id = store.propose(&mob, tainted, author.clone()).await?;
5093 let clean_id = store
5095 .propose(
5096 &mob,
5097 payload("Clean gotcha", "from a clean session"),
5098 author.clone(),
5099 )
5100 .await?;
5101 let proposals = store.pending_proposals("family", 8).await?;
5102 let by_id: std::collections::HashMap<&str, &PendingProposal> = proposals
5103 .iter()
5104 .map(|proposal| (proposal.proposal_id.as_str(), proposal))
5105 .collect();
5106 let tainted_row = by_id.get(tainted_id.as_str()).ok_or("tainted present")?;
5107 assert!(
5108 tainted_row
5109 .taint
5110 .as_deref()
5111 .is_some_and(|reason| reason.contains("tainted")),
5112 "{:?}",
5113 tainted_row.taint
5114 );
5115 assert!(
5116 by_id
5117 .get(clean_id.as_str())
5118 .ok_or("clean present")?
5119 .taint
5120 .is_none()
5121 );
5122
5123 let err = store
5125 .propose(
5126 &mob,
5127 payload("Creds", "api_key = \"zXy1aB2cD3eF4gH5iJ6k\""),
5128 author,
5129 )
5130 .await
5131 .expect_err("secret-bearing proposal refused");
5132 let message = err.to_string();
5133 assert!(message.contains("credential-assignment"), "{message}");
5134 assert!(!message.contains("zXy1aB2cD3eF4gH5iJ6k"), "{message}");
5135 Ok(())
5136 }
5137
5138 #[tokio::test]
5139 async fn secret_bearing_writes_refused_at_store_seam() -> Result<(), Box<dyn Error>> {
5140 let dir = tempfile::tempdir()?;
5141 let store = SqliteAgentMemoryStore::open(dir.path())?;
5142 let id = identity()?;
5143 let err = store
5146 .remember(
5147 "family",
5148 &id,
5149 new_memory("AWS key", "found AKIAIOSFODNN7EXAMPLE in the logs"),
5150 )
5151 .await
5152 .expect_err("secret-bearing remember refused");
5153 let message = err.to_string();
5154 assert!(message.contains("aws-access-key-id"), "{message}");
5155 assert!(!message.contains("AKIAIOSFODNN7EXAMPLE"), "{message}");
5156
5157 store
5159 .remember(
5160 "family",
5161 &id,
5162 new_memory(
5163 "Key location",
5164 "The AWS key lives in the vault, path infra/aws.",
5165 ),
5166 )
5167 .await?;
5168 Ok(())
5169 }
5170
5171 #[tokio::test]
5172 async fn scope_floors_warn_but_never_evict() -> Result<(), Box<dyn Error>> {
5173 let dir = tempfile::tempdir()?;
5174 let store = SqliteAgentMemoryStore::open(dir.path())?.with_scope_floors(2, usize::MAX);
5175 let id = identity()?;
5176 for i in 0..4 {
5177 store
5178 .remember(
5179 "family",
5180 &id,
5181 new_memory(&format!("Fact {i}"), &format!("Body {i}")),
5182 )
5183 .await?;
5184 }
5185 let records = store.recall(recall_all(id, "family")).await?;
5186 assert_eq!(
5187 records.len(),
5188 4,
5189 "floors warn the steward; deterministic code never evicts"
5190 );
5191 Ok(())
5192 }
5193
5194 #[test]
5195 fn floor_warning_fires_above_either_floor() {
5196 assert!(scope_floor_warning(5, 0, 4, 100).is_some());
5197 assert!(scope_floor_warning(0, 101, 4, 100).is_some());
5198 assert!(scope_floor_warning(4, 100, 4, 100).is_none());
5199 }
5200
5201 #[tokio::test]
5202 async fn mark_usage_updates_counters() -> Result<(), Box<dyn Error>> {
5203 let dir = tempfile::tempdir()?;
5204 let store = SqliteAgentMemoryStore::open(dir.path())?;
5205 let id = identity()?;
5206 let record = store
5207 .remember("family", &id, new_memory("Fact", "Body"))
5208 .await?;
5209
5210 store
5211 .mark_usage(&[record.memory_id.clone()], UsageEvent::Injected)
5212 .await?;
5213 store
5214 .mark_usage(&[record.memory_id.clone()], UsageEvent::ExplicitRecall)
5215 .await?;
5216 store
5217 .mark_usage(&[record.memory_id.clone()], UsageEvent::ExplicitRecall)
5218 .await?;
5219 store
5220 .mark_usage(&[record.memory_id.clone()], UsageEvent::JudgedUseful)
5221 .await?;
5222
5223 let conn = store.realm_connection("family")?;
5224 let guard = conn
5225 .lock()
5226 .unwrap_or_else(std::sync::PoisonError::into_inner);
5227 let usage_json: String = guard.query_row(
5228 "SELECT usage_stats FROM records WHERE memory_id = ?1",
5229 params![record.memory_id],
5230 |row| row.get(0),
5231 )?;
5232 let usage: UsageStats = serde_json::from_str(&usage_json)?;
5233 assert_eq!(usage.injected_count, 1, "ambient injections only");
5234 assert_eq!(usage.explicit_recall_count, 2, "explicit pulls only");
5235 assert_eq!(usage.judged_useful_count, 1);
5236 assert!(usage.last_injected_at_ms.is_some());
5237 assert!(usage.last_recalled_at_ms.is_some());
5238 Ok(())
5239 }
5240
5241 #[tokio::test]
5242 async fn injection_ledger_appends_and_reads_newest_first() -> Result<(), Box<dyn Error>> {
5243 let dir = tempfile::tempdir()?;
5244 let store = SqliteAgentMemoryStore::open(dir.path())?;
5245 let id = identity()?;
5246 let record = store
5247 .remember("family", &id, new_memory("Fact", "Body"))
5248 .await?;
5249
5250 let build_entry = InjectionLogEntry {
5251 record_id: record.memory_id.clone(),
5252 identity: id.as_str().to_string(),
5253 session_key: None,
5254 surface: InjectionSurface::Build,
5255 at_ms: 100,
5256 };
5257 let turn_entry = InjectionLogEntry {
5258 record_id: record.memory_id.clone(),
5259 identity: id.as_str().to_string(),
5260 session_key: Some("session-1".to_string()),
5261 surface: InjectionSurface::Turn,
5262 at_ms: 200,
5263 };
5264 AgentMemoryProvider::log_injections(&store, "family", &[build_entry.clone()]).await?;
5265 AgentMemoryProvider::log_injections(&store, "family", &[turn_entry.clone()]).await?;
5266
5267 let entries = store.injection_log("family", 16).await?;
5268 assert_eq!(entries, vec![turn_entry, build_entry]);
5269
5270 let limited = store.injection_log("family", 1).await?;
5271 assert_eq!(limited.len(), 1);
5272 assert_eq!(limited[0].surface, InjectionSurface::Turn);
5273
5274 let other_realm = store.injection_log("other", 16).await?;
5275 assert!(other_realm.is_empty(), "ledger rows are realm-scoped");
5276 Ok(())
5277 }
5278
5279 #[tokio::test]
5280 async fn propose_queues_for_steward() -> Result<(), Box<dyn Error>> {
5281 let dir = tempfile::tempdir()?;
5282 let store = SqliteAgentMemoryStore::open(dir.path())?;
5283 let scope = MemoryScope::Mob {
5284 realm: "family".to_string(),
5285 mob: "mob:home".to_string(),
5286 };
5287 let proposal_id = store
5288 .propose(
5289 &scope,
5290 payload("Shared fact", "For the mob store"),
5291 MemoryAuthor::Agent {
5292 identity: identity()?.as_str().to_string(),
5293 },
5294 )
5295 .await?;
5296 assert!(proposal_id.starts_with("prop-"));
5297
5298 let conn = store.realm_connection("family")?;
5299 let guard = conn
5300 .lock()
5301 .unwrap_or_else(std::sync::PoisonError::into_inner);
5302 let (status, scope_kind): (String, String) = guard.query_row(
5303 "SELECT status, scope_kind FROM proposals WHERE proposal_id = ?1",
5304 params![proposal_id],
5305 |row| Ok((row.get(0)?, row.get(1)?)),
5306 )?;
5307 assert_eq!(status, "pending");
5308 assert_eq!(scope_kind, "mob");
5309
5310 let author_json: String = guard.query_row(
5311 "SELECT author FROM proposals WHERE proposal_id = ?1",
5312 params![proposal_id],
5313 |row| row.get(0),
5314 )?;
5315 let author: MemoryAuthor = serde_json::from_str(&author_json)?;
5316 assert_eq!(
5317 author,
5318 MemoryAuthor::Agent {
5319 identity: identity()?.as_str().to_string()
5320 },
5321 "proposals carry real authorship (§8.2)"
5322 );
5323 Ok(())
5324 }
5325
5326 struct AlwaysQuarantine;
5331
5332 impl LlmWriteGate for AlwaysQuarantine {
5333 fn quarantine_reason(
5334 &self,
5335 author: &MemoryAuthor,
5336 _kind: StagedBatchKind,
5337 _evidence: &[crate::memory::records::EvidenceRef],
5338 ) -> Option<String> {
5339 author
5340 .is_llm()
5341 .then(|| "session tainted by web tool 'web_search'".to_string())
5342 }
5343 }
5344
5345 fn agent_author() -> Result<MemoryAuthor, Box<dyn Error>> {
5346 Ok(MemoryAuthor::Agent {
5347 identity: identity()?.as_str().to_string(),
5348 })
5349 }
5350
5351 #[tokio::test]
5352 async fn gated_agent_write_lands_quarantined_and_stays_unreadable() -> Result<(), Box<dyn Error>>
5353 {
5354 let dir = tempfile::tempdir()?;
5355 let store = SqliteAgentMemoryStore::open(dir.path())?;
5356 store.set_llm_write_gate(Arc::new(AlwaysQuarantine));
5357 let id = identity()?;
5358 let scope = identity_scope("family")?;
5359
5360 let receipt = store
5361 .remember_authored(
5362 &scope,
5363 payload("Poisoned", "Attacker fact"),
5364 agent_author()?,
5365 )
5366 .await?;
5367 let RecordStatus::Quarantined { reason } = &receipt.status else {
5368 return Err(format!("expected quarantined status, got {:?}", receipt.status).into());
5369 };
5370 assert!(reason.contains("session tainted"), "{reason}");
5371
5372 assert!(
5375 store
5376 .recall(recall_all(id.clone(), "family"))
5377 .await?
5378 .is_empty(),
5379 "quarantined bodies must never reach recall"
5380 );
5381 assert!(
5382 store
5383 .manifest(&[scope.clone()], ManifestTier::Full)
5384 .await?
5385 .is_empty(),
5386 "quarantined records must never reach the manifest"
5387 );
5388
5389 let record = store
5392 .remember("family", &id, new_memory("App fact", "App body"))
5393 .await?;
5394 let records = store.recall(recall_all(id, "family")).await?;
5395 assert_eq!(records.len(), 1);
5396 assert_eq!(records[0].memory_id, record.memory_id);
5397 Ok(())
5398 }
5399
5400 #[tokio::test]
5401 async fn distiller_write_law_holds_at_the_store_seam() -> Result<(), Box<dyn Error>> {
5402 use crate::memory::taint::{ContentTrustConfig, SessionTaintTracker, TaintLlmWriteGate};
5403
5404 let dir = tempfile::tempdir()?;
5405 let store = SqliteAgentMemoryStore::open(dir.path())?;
5406 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
5407 store.set_llm_write_gate(Arc::new(TaintLlmWriteGate::new(
5408 Some(tracker.clone()),
5409 crate::identity_first::agent_memory::AgentMemoryLlmWrites::Observed,
5410 )));
5411 let scope = identity_scope("family")?;
5412 let author = MemoryAuthor::Distiller {
5413 run_id: "run-1".to_string(),
5414 };
5415 let with_evidence = |title: &str, session: &str| NewMemoryRecord {
5416 evidence: vec![crate::memory::records::EvidenceRef {
5417 session_id: session.to_string(),
5418 generation: 1,
5419 revision: None,
5420 range: Some((0, 3)),
5421 }],
5422 ..payload(title, "Distilled body")
5423 };
5424
5425 let receipt = store
5427 .remember_authored(
5428 &scope,
5429 with_evidence("Clean fact", "sess-clean"),
5430 author.clone(),
5431 )
5432 .await?;
5433 assert_eq!(receipt.status, RecordStatus::Active);
5434 let record = store.with_realm_conn(&"family".to_string(), |conn| {
5435 load_record(conn, "family", &receipt.memory_id)?
5436 .ok_or_else(|| AgentMemoryError::Io("record missing".to_string()))
5437 })?;
5438 assert_eq!(record.trust, TrustTier::AgentObserved);
5439 assert!(matches!(
5440 record.provenance.author,
5441 MemoryAuthor::Distiller { .. }
5442 ));
5443 assert_eq!(record.provenance.evidence.len(), 1);
5444 assert_eq!(record.provenance.evidence[0].range, Some((0, 3)));
5445
5446 tracker.note_current_session("identity:someone", "sess-dirty");
5449 tracker.observe_agent_event(
5450 "identity:someone",
5451 &meerkat_core::event::AgentEvent::ToolResultReceived {
5452 id: "t".to_string(),
5453 name: "web_fetch".to_string(),
5454 content: vec![],
5455 is_error: false,
5456 },
5457 );
5458 let receipt = store
5459 .remember_authored(
5460 &scope,
5461 with_evidence("Tainted fact", "sess-dirty"),
5462 author.clone(),
5463 )
5464 .await?;
5465 let RecordStatus::Quarantined { reason } = &receipt.status else {
5466 return Err(format!("expected quarantine, got {:?}", receipt.status).into());
5467 };
5468 assert!(reason.contains("evidence session tainted"), "{reason}");
5469
5470 tracker.mark_reset_boundary("sess-reset");
5472 let receipt = store
5473 .remember_authored(&scope, with_evidence("Reset fact", "sess-reset"), author)
5474 .await?;
5475 let RecordStatus::Quarantined { reason } = &receipt.status else {
5476 return Err(format!("expected quarantine, got {:?}", receipt.status).into());
5477 };
5478 assert!(reason.contains("reset boundary"), "{reason}");
5479 Ok(())
5480 }
5481
5482 #[tokio::test]
5483 async fn recent_tombstones_lists_scope_tombstones_newest_first() -> Result<(), Box<dyn Error>> {
5484 use crate::memory::distiller::TombstoneSource;
5485
5486 let dir = tempfile::tempdir()?;
5487 let store = SqliteAgentMemoryStore::open(dir.path())?;
5488 let id = identity()?;
5489 let scope = identity_scope("family")?;
5490 let kept = store
5491 .remember("family", &id, new_memory("Kept fact", "Body"))
5492 .await?;
5493 let dropped = store
5494 .remember("family", &id, new_memory("Phone number", "Body 2"))
5495 .await?;
5496 store.forget("family", &id, &dropped.memory_id).await?;
5497
5498 let tombstones = store.recent_tombstones(&scope, 0, 10).await?;
5499 assert_eq!(tombstones.len(), 1);
5500 assert_eq!(tombstones[0].title, "Phone number");
5501 assert!(tombstones[0].tombstoned_at_ms > 0);
5502 assert!(!tombstones.iter().any(|t| t.title == "Kept fact"));
5504 let future = tombstones[0].tombstoned_at_ms + 1;
5505 assert!(
5506 store
5507 .recent_tombstones(&scope, future, 10)
5508 .await?
5509 .is_empty()
5510 );
5511 let _ = kept;
5512 Ok(())
5513 }
5514
5515 #[tokio::test]
5516 async fn quarantined_supersede_leaves_prior_active() -> Result<(), Box<dyn Error>> {
5517 let dir = tempfile::tempdir()?;
5518 let store = SqliteAgentMemoryStore::open(dir.path())?;
5519 let id = identity()?;
5520 let scope = identity_scope("family")?;
5521 let prior = store
5522 .remember("family", &id, new_memory("DB host", "Use db-good.example."))
5523 .await?;
5524
5525 store.set_llm_write_gate(Arc::new(AlwaysQuarantine));
5526 let receipt = store
5527 .supersede_authored(
5528 &scope,
5529 &prior.memory_id,
5530 payload("DB host", "Use db-evil.example."),
5531 agent_author()?,
5532 )
5533 .await?;
5534 assert!(matches!(receipt.status, RecordStatus::Quarantined { .. }));
5535
5536 let records = store.recall(recall_all(id, "family")).await?;
5538 assert_eq!(records.len(), 1);
5539 assert_eq!(records[0].memory_id, prior.memory_id);
5540 assert!(records[0].body.contains("db-good"));
5541 Ok(())
5542 }
5543
5544 #[tokio::test]
5545 async fn gate_covers_staged_commits_not_just_direct_writes() -> Result<(), Box<dyn Error>> {
5546 let dir = tempfile::tempdir()?;
5547 let store = SqliteAgentMemoryStore::open(dir.path())?;
5548 store.set_llm_write_gate(Arc::new(AlwaysQuarantine));
5549 let id = identity()?;
5550 let scope = identity_scope("family")?;
5551
5552 let token = store
5553 .stage(StagedMutationBatch {
5554 kind: StagedBatchKind::FreshWrite,
5555 realm: "family".to_string(),
5556 author: agent_author()?,
5557 ops: vec![StagedOp::Create {
5558 id: None,
5559 scope,
5560 record: payload("Staged fact", "Via staged path"),
5561 trust: TrustTier::AgentObserved,
5562 derived_from: Vec::new(),
5563 rationale: None,
5564 created_at_ms: None,
5565 updated_at_ms: None,
5566 }],
5567 })
5568 .await?;
5569 store.commit(token).await?;
5570 assert!(
5571 store.recall(recall_all(id, "family")).await?.is_empty(),
5572 "the write gate must hold at the store seam for staged commits too"
5573 );
5574 Ok(())
5575 }
5576
5577 #[tokio::test]
5578 async fn ungated_authored_write_lands_active_with_agent_author() -> Result<(), Box<dyn Error>> {
5579 let dir = tempfile::tempdir()?;
5580 let store = SqliteAgentMemoryStore::open(dir.path())?;
5581 let id = identity()?;
5582 let scope = identity_scope("family")?;
5583
5584 let mut record = payload("Observed fact", "Seen in session");
5585 record.verification = Some(super::super::records::VerificationClaim {
5586 checked: "ran the smoke test and watched it pass".to_string(),
5587 evidence: Vec::new(),
5588 });
5589 let receipt = store
5590 .remember_authored(&scope, record, agent_author()?)
5591 .await?;
5592 assert_eq!(receipt.status, RecordStatus::Active);
5593
5594 let conn = store.realm_connection("family")?;
5597 let guard = conn
5598 .lock()
5599 .unwrap_or_else(std::sync::PoisonError::into_inner);
5600 let (trust, provenance_json): (String, String) = guard.query_row(
5601 "SELECT trust, provenance FROM records WHERE memory_id = ?1",
5602 params![receipt.memory_id],
5603 |row| Ok((row.get(0)?, row.get(1)?)),
5604 )?;
5605 assert_eq!(trust, "agent_observed");
5606 let provenance: MemoryProvenance = serde_json::from_str(&provenance_json)?;
5607 assert_eq!(provenance.author, agent_author()?);
5608 assert!(
5609 provenance
5610 .verification
5611 .as_ref()
5612 .is_some_and(|claim| claim.checked.contains("smoke test"))
5613 );
5614 drop(guard);
5615
5616 let records = store.recall(recall_all(id, "family")).await?;
5618 assert_eq!(records.len(), 1);
5619
5620 let scope = identity_scope("family")?;
5622 let result = store
5623 .forget_authored(&scope, &receipt.memory_id, agent_author()?)
5624 .await?;
5625 assert!(result.deleted);
5626 Ok(())
5627 }
5628
5629 #[tokio::test]
5630 async fn authored_update_rejects_cross_identity_scope() -> Result<(), Box<dyn Error>> {
5631 let dir = tempfile::tempdir()?;
5632 let store = SqliteAgentMemoryStore::open(dir.path())?;
5633 let id = identity()?;
5634 let prior = store
5635 .remember("family", &id, new_memory("Fact", "Body"))
5636 .await?;
5637
5638 let other_scope = MemoryScope::Identity {
5643 realm: "family".to_string(),
5644 identity: "identity:other".to_string(),
5645 };
5646 let cross = store
5647 .supersede_authored(
5648 &other_scope,
5649 &prior.memory_id,
5650 payload("Fact", "Hijacked body"),
5651 MemoryAuthor::Agent {
5652 identity: "identity:other".to_string(),
5653 },
5654 )
5655 .await;
5656 assert!(
5657 matches!(cross, Err(AgentMemoryError::InvalidRecord(_))),
5658 "cross-identity update must be rejected, got {cross:?}"
5659 );
5660 Ok(())
5661 }
5662
5663 #[tokio::test]
5664 async fn panel_records_page_paginates_and_filters() -> Result<(), Box<dyn Error>> {
5665 let dir = tempfile::tempdir()?;
5666 let store = SqliteAgentMemoryStore::open(dir.path())?;
5667 let scope = identity_scope("family")?;
5668 for index in 0..5 {
5669 store
5670 .remember_authored(
5671 &scope,
5672 payload(&format!("Fact {index}"), &format!("Body {index}")),
5673 MemoryAuthor::Operator,
5674 )
5675 .await?;
5676 }
5677
5678 let first = store
5681 .records_page("family", Some("identity"), None, None, 2, None)
5682 .await?;
5683 assert_eq!(first.records.len(), 2);
5684 let cursor = first.next_cursor.clone().expect("more pages");
5685 let second = store
5686 .records_page("family", Some("identity"), None, None, 2, Some(cursor))
5687 .await?;
5688 assert_eq!(second.records.len(), 2);
5689 let third_cursor = second.next_cursor.clone().expect("one more page");
5690 let third = store
5691 .records_page(
5692 "family",
5693 Some("identity"),
5694 None,
5695 None,
5696 2,
5697 Some(third_cursor),
5698 )
5699 .await?;
5700 assert_eq!(third.records.len(), 1);
5701 assert_eq!(third.next_cursor, None);
5702 let mut seen: Vec<String> = first
5703 .records
5704 .iter()
5705 .chain(second.records.iter())
5706 .chain(third.records.iter())
5707 .map(|record| record.id.clone())
5708 .collect();
5709 let total = seen.len();
5710 seen.dedup();
5711 assert_eq!(total, 5, "pages cover every record exactly once");
5712
5713 let quarantined = store
5715 .records_page("family", None, None, Some("quarantined"), 10, None)
5716 .await?;
5717 assert!(quarantined.records.is_empty());
5718 Ok(())
5719 }
5720
5721 #[tokio::test]
5722 async fn panel_supersede_chain_walks_both_directions() -> Result<(), Box<dyn Error>> {
5723 let dir = tempfile::tempdir()?;
5724 let store = SqliteAgentMemoryStore::open(dir.path())?;
5725 let scope = identity_scope("family")?;
5726 let root = store
5727 .remember_authored(&scope, payload("Fact", "v1"), MemoryAuthor::Operator)
5728 .await?;
5729 let mid = store
5730 .supersede_authored(
5731 &scope,
5732 &root.memory_id,
5733 payload("Fact", "v2"),
5734 MemoryAuthor::Operator,
5735 )
5736 .await?;
5737 let tip = store
5738 .supersede_authored(
5739 &scope,
5740 &mid.memory_id,
5741 payload("Fact", "v3"),
5742 MemoryAuthor::Operator,
5743 )
5744 .await?;
5745
5746 for entry in [&root.memory_id, &mid.memory_id, &tip.memory_id] {
5748 let chain = store.supersede_chain("family", entry, 16).await?;
5749 let ids: Vec<&str> = chain.iter().map(|record| record.id.as_str()).collect();
5750 assert_eq!(
5751 ids,
5752 [
5753 root.memory_id.as_str(),
5754 mid.memory_id.as_str(),
5755 tip.memory_id.as_str()
5756 ],
5757 "chain from {entry}"
5758 );
5759 }
5760 let bounded = store.supersede_chain("family", &root.memory_id, 2).await?;
5762 assert_eq!(bounded.len(), 2);
5763 Ok(())
5764 }
5765}