Skip to main content

meerkat_mobkit/memory/
sqlite_store.rs

1//! Bundled per-realm SQLite agent-memory store (§7.3).
2//!
3//! One database per realm at `<root>/<pct-encoded-realm>.sqlite3` — the same
4//! directory and encoding scheme the markdown store uses (deliberately NOT
5//! `<persistent_state>/memory/`, which belongs to meerkat's session semantic
6//! memory). WAL journaling, busy-timeout, plain B-tree lookups only: the
7//! bright-line ratchet (§12) forbids retrieval-index machinery here, and
8//! recall quality is the LLM Selector's job, not the store's.
9//!
10//! Every write path — including `remember`/`forget` and the markdown import
11//! — flows through the staged-batch validator and a single-transaction
12//! apply with one audit row per op (§8.5 crash semantics).
13
14use 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
33// The judgment-plane capability vocabulary lived here before the M4 de-weld;
34// re-exported so `sqlite_store::{EvidenceRefResolver, PendingPromotion, ...}`
35// paths keep resolving.
36pub 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
53/// Per-scope retention floors (§7.3): exceeded floors WARN the steward via
54/// tracing; deterministic code never evicts.
55pub const DEFAULT_SCOPE_FLOOR_RECORDS: usize = 4_000;
56pub const DEFAULT_SCOPE_FLOOR_BYTES: usize = 32 * 1024 * 1024;
57
58/// Staged-but-uncommitted batches older than this are garbage-collected on
59/// realm open — a dead producer leaves a token that is never applied.
60const 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
197/// The agent-memory store's schema domain in the per-realm-file migration
198/// ledger (`meerkat_schema`, one row per domain).
199///
200/// Migration 0001 is `SCHEMA_SQL` (all `CREATE ... IF NOT EXISTS`, so it
201/// converges a pre-ledger file without touching its existing tables);
202/// 0002 lifts the historical `ensure_column` probes and their backfills
203/// verbatim. The open-time stage GC and markdown import are open-time
204/// behaviors, not migrations — they keep running on every realm open in
205/// [`SqliteAgentMemoryStore::realm_connection`].
206const 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    // Version 2 is the mobkit 0.8.8 floor (SCHEMA_SQL already carried the
227    // quarantine/taint columns inline; v1 files are pre-floor and refused
228    // typed). Version 3 folds legacy runtime-id-keyed identity scopes into
229    // the logical identity (task #53) - data-only, so the v2 predecessor
230    // verifier is the CURRENT schema fingerprint.
231    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
301/// The released v2 (mobkit 0.8.8-0.8.10) schema shape, used as the frozen
302/// predecessor oracle. Migration 0003 is data-only, so this is byte-identical
303/// to the current schema.
304fn 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    // Data-only on a fresh file (no rows to fold); kept for the invariant
312    // that initialize_current composes every migration.
313    migration_0003_logical_identity_scope_keys(tx)
314}
315
316/// Frozen fingerprint verifier for allowed predecessor version 2.
317fn 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
326/// Migration 0003 (task #53): memory scope keys are LOGICAL identities.
327///
328/// Platform writers (the distiller's trigger-sink path foremost) keyed
329/// identity scopes by the mob-plane member id, the comms-safe roster
330/// encoding of a generated runtime alias (e.g.
331/// `mk--rt_cidentity_cparent-1_c0`), splitting each member's memory across
332/// per-incarnation scopes disjoint from the scope the SDK, injection, and
333/// recorder speak (`identity:parent-1`). Fold every identity-space key
334/// through the one normalization helper
335/// (`member_comms_id::logical_memory_identity`). Data-only: no DDL, so the
336/// v2 predecessor fingerprint stays the current schema.
337///
338/// Collision semantics: merged scopes may hold duplicate content
339/// (`records_scope_hash_idx` is non-unique; content-hash dedup is
340/// write-time-only). That is loss-free - rows keep distinct memory_ids and
341/// steward consolidation dedups. `pending_harvests` keys identity in its
342/// PRIMARY KEY, so folding uses OR IGNORE and collapses collided duplicates
343/// (two queue entries for one logical harvest become one).
344fn 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    // Proposals are covered by the key rewrite alone: the accept path
354    // hydrates the scope from the ROW's (scope_kind, scope_key) via
355    // scope_from_parts, and the serialized `record` is a NewMemoryRecord,
356    // which embeds no scope. Stage batches are NOT - see below.
357    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        // Only the collided leftovers (rows OR IGNORE could not move because
366        // the logical (identity, retired_at_ms) twin already exists) still
367        // carry the LEGACY key; drop exactly those.
368        tx.execute(
369            "DELETE FROM pending_harvests WHERE identity = ?1",
370            rusqlite::params![key],
371        )?;
372    }
373    Ok(())
374}
375
376/// Rewrite every non-logical identity key in `table.column` to its logical
377/// form. `identity_scoped_only` restricts to `scope_kind = 'identity'` rows
378/// (mob-scope keys are never identity-space).
379fn 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
401/// Stage rows embed the serialized [`StagedMutationBatch`], whose `Create`
402/// ops carry their target `MemoryScope` INLINE. Rewriting the key columns
403/// alone would let a surviving stage token re-create the legacy scope on
404/// apply - and stage tokens DO outlive boots: the open-time GC only prunes
405/// tokens older than [`STAGE_GC_MAX_AGE_MS`], and operator-gated promotions
406/// commit their token on approval, possibly days later. Normalize the
407/// embedded Identity scopes through the same helper. A batch that no longer
408/// deserializes is left untouched: the commit path parses the same JSON and
409/// fails identically, so an unreadable batch cannot reintroduce a legacy
410/// key.
411fn 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
447/// The distinct keys in `table.column` whose logical form differs (the
448/// decode/strip happens in Rust; SQLite cannot evaluate the codec).
449fn 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
470/// Column migrations for stores created before the columns joined
471/// SCHEMA_SQL (CREATE TABLE IF NOT EXISTS never alters). The `table_info`
472/// guards keep this convergent on files of any vintage: a fresh file whose
473/// 0001 already created the columns skips both the ALTERs and the
474/// backfills, exactly like the historical probes did.
475fn 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        // Backfill the durable §10.2 marker: currently-quarantined rows
485        // directly; tombstoned rows through their audit trail (the
486        // tombstone apply nulls status_detail, so the audit row's
487        // `"quarantined":"<reason>"` is the only remaining evidence
488        // that a row once landed quarantined).
489        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        // Conservative backfill (mirrors ever_quarantined above): the
502        // propose-time taint fact for pre-migration proposals lived only
503        // in the in-memory SessionTaintTracker and is unrecoverable, so
504        // still-live proposals route through the operator-gated
505        // promotion path instead of reading as clean. Terminal statuses
506        // (accepted/rejected) are never re-verdicted and stay untouched.
507        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
516/// `PRAGMA table_info` guard lifted from the historical `ensure_column`
517/// probe: adds the column when absent and reports whether it did (its
518/// backfill is owed only then).
519fn 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/// Bundled SQLite store. Cheap to clone; connections are cached per realm
540/// and shared across clones.
541#[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    /// §10.1 write-seam enforcement: consulted for every LLM-authored
548    /// create/supersede across ALL write paths (direct and staged commits),
549    /// so taint/posture quarantine holds for any caller — the Recorder
550    /// tool, staged batches, and future stages alike. Shared across clones
551    /// so wiring the gate once covers every handle.
552    llm_write_gate: Arc<Mutex<Option<Arc<dyn LlmWriteGate>>>>,
553    /// §10.2 P3 extension: evidence-ref resolvability for `agent_verified`
554    /// retiers. Optional like the write gate — the wiring that enables the
555    /// steward installs it; absent, the P2 claim-presence rule stands
556    /// alone. Shared across clones.
557    evidence_resolver: Arc<Mutex<Option<Arc<dyn EvidenceRefResolver>>>>,
558    /// §9.3 timeline sink for quarantined-write events. Shared across
559    /// clones; absent, the tracing warn is the only surface.
560    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    /// Same directory + percent-encoding scheme as
612    /// `MarkdownAgentMemoryStore::path_for`, one database per realm.
613    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        // Stage GC spares tokens referenced by a still-pending gated
635        // promotion (§10.2) — the operator's decision window outranks the
636        // dead-producer sweep; deny/timeout resolution discards them.
637        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    /// One-shot migration (§7.3): un-imported markdown files for this realm
650    /// are imported through the staged-commit path (ids and timestamps
651    /// preserved; kind=fact, trust=agent_observed, identity scope, agent
652    /// author with empty evidence) and renamed to `<file>.imported` —
653    /// user-inspectable data is never deleted.
654    ///
655    /// §7.3 invites hand edits, so content problems must never make the
656    /// realm store unopenable: an invalid record is skipped loudly (warn +
657    /// count in the import audit row) and the rest of the file imports; a
658    /// file that fails wholesale (bad identity stem, over the size cap,
659    /// residual batch-validation failure) is warned about, set aside as
660    /// `<file>.import-failed`, and the remaining files continue. Only real
661    /// I/O errors propagate into the open.
662    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        // Skip ids already present (idempotence if a rename previously
726        // failed) and dedup ids within the file (hand-edits happen).
727        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            // Pre-validate each record with the same deterministic checks
747            // the staged validator applies, so one bad hand-edited record
748            // skips loudly instead of failing the whole batch.
749            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            // Gate deliberately absent: the import migrates records the
805            // markdown store already accepted; it is not a new LLM write.
806            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        // Per-operation maintenance-fence guard: realm connections are
827        // cached for the store's lifetime, so the fence cannot ride the
828        // open — every operation takes its own shared guard instead, and
829        // offline maintenance drains in-flight guards before touching the
830        // file.
831        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            // Deterministic write guard (§7.3): an exact content-hash
875            // duplicate short-circuits to the existing id — no new row.
876            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                // RPC/SDK writes are application-principal writes (§7.2);
896                // the P1 Recorder threads real agent authorship.
897                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    /// Shared tombstone path for the wire `forget` (Application principal)
956    /// and the Recorder's `forget_authored` (Agent principal).
957    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    /// §8.2 Recorder create: agent-authored, gate-enforced, dedup-guarded.
1076    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            // Deterministic write guard (§7.3): an exact content-hash
1096            // duplicate short-circuits to the existing active record.
1097            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                    // §10.2: LLM writes enter at the ceiling; the staged
1131                    // validator rejects anything higher.
1132                    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                        // Counted apart from ambient injection (§9.2): a
1204                        // pull on purpose is a much stronger usefulness
1205                        // signal than a push that may have been ignored.
1206                        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        // §10.4 secret hygiene: proposals bypass the staged validator (the
1306        // row is not a record yet), so the write-seam refusal is applied
1307        // here directly.
1308        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        // §10.1: capture the quarantine decision AT PROPOSE TIME. The taint
1320        // tracker is in-memory and session-sticky; re-deriving when the
1321        // steward dreams would both under-quarantine (tracker restart,
1322        // reset boundary, eviction) and over-quarantine (identity tainted
1323        // later by an unrelated ingestion). The persisted fact makes the
1324        // steward's accept downgrade deterministic shell law.
1325        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    /// Force one realm's database through the normal ledgered open path
1423    /// (`realm_connection`: profile open, `meerkat_schema` migrations, stage
1424    /// GC, markdown import) without issuing any query. The M6 offline ledger
1425    /// baseline uses this to stamp existing realm files under the
1426    /// maintenance fence.
1427    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
1448// ---- provider trait implementations ----
1449
1450/// §10.1 firewall control surface. The gate/resolver/sink slots are shared
1451/// across clones (inner `Arc<Mutex<..>>`), so wiring once covers every
1452/// handle.
1453impl 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// ---- steward read/write surface (§8.5): StewardStore ----
1695
1696#[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
2253// ---- console Memory panel read surface (§9.3, P3b): MemoryPanelStore ----
2254
2255/// Bounds for [`MemoryPanelStore::dream_history`]: audit rows scanned
2256/// per call and per-run sample sizes. The panel is a summary surface, not a
2257/// full audit export.
2258const 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                            // Trailing claimants: visible, not walked.
2413                            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    /// Resolve every open audit verdict for `record_id` (the operator acted:
2658    /// superseded/retired/dismissed via the review queue).
2659    ///
2660    /// Deliberately NOT on [`StewardStore`]/[`MemoryPanelStore`]: no
2661    /// production caller exists yet (the operator review-queue mutation is
2662    /// a reserved seam); it joins the capability trait with its first
2663    /// consumer.
2664    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    /// Recent tombstones for the Distiller's pre-injected "never re-create
2693    /// these" list (§8.4). The mechanical backstop for exact recreation is
2694    /// the staged validator's content-hash check; this list closes the
2695    /// paraphrase gap at the prompt level.
2696    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/// Body fetch for selector-chosen ids (§8.3): a plain by-id read over the
2746/// composed scopes, wire-compat projected, returned in `ids` order. Only
2747/// active records in the requested scopes qualify — the selector judged a
2748/// manifest of exactly those.
2749#[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                        // The full MemoryRecord is in hand before projection
2800                        // strips it — carry scope + trust so injected bodies
2801                        // render their §7.2 labels.
2802                        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
2820// ---- blocking internals ----
2821
2822async 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
2830/// §10.2 P3 validator extension, enforced at the store seam (stage and
2831/// commit): every `Retier` to `agent_verified` requires the target record's
2832/// verification claim to cite at least one `EvidenceRef` that resolves
2833/// against the session store. No resolver wired ⇒ the P2 claim-presence
2834/// rule stands alone (wiring that enables the steward installs one).
2835fn 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            // Unknown record — validate_batch already rejects this.
2866            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
2887/// Validates (against the live transaction) and applies a batch atomically:
2888/// one SQLite transaction, one audit row per op (§8.5).
2889///
2890/// `gate` is the §10.1 LLM write gate: consulted once per batch (the
2891/// quarantine decision is a property of the author's session/posture and of
2892/// the batch's cited evidence, not of individual ops — a batch with any
2893/// tainted evidence quarantines wholesale, conservative direction) and
2894/// applied to every create/supersede in the batch. `None` only for the
2895/// markdown import, which migrates already-accepted records rather than
2896/// writing new LLM output.
2897fn 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            // §8.3 / §7.1: the superseding record inherits the prior's rank
3046            // until the next dream re-ranks. rank_set_at_ms stays NULL so
3047            // the successor also remains in the manifest's recent slice —
3048            // a fresh correction is selector-visible on the next assembly.
3049            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                // A quarantined supersede must not retire the active prior:
3073                // otherwise a tainted session could silently blank a good
3074                // record by "updating" it. The quarantined successor keeps
3075                // its `supersedes` lineage edge; the steward resolves the
3076                // fork at review (promote → prior superseded; tombstone →
3077                // lineage unchanged).
3078                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            // Rank is steward metadata: updated_at_ms is deliberately NOT
3106            // bumped, or every re-rank would flood the manifest's
3107            // "updated since last rank" recent slice.
3108            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    // §10.1: the gate's verdict lands as row status. Quarantined records are
3143    // write-only — every read surface filters on status_kind = 'active'.
3144    let (status_kind, status_detail) = match quarantine {
3145        Some(reason) => ("quarantined", Some(reason)),
3146        None => ("active", None),
3147    };
3148    // §10.2 durable taint: set when landing quarantined, inherited from any
3149    // direct ancestor (derivation source or superseded prior) that carries
3150    // it or currently sits quarantined. Materialized transitively at each
3151    // insert, so one level suffices; the validator's chain walk remains the
3152    // enforcement.
3153    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
3195/// One-level ancestor check backing the materialized `ever_quarantined`
3196/// inheritance in [`insert_record`].
3197fn 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
3221/// Validator view over a live connection/transaction. Rows in a realm DB
3222/// are realm-homogeneous by construction, so the view carries the realm to
3223/// reconstruct full scopes for the validator's realm-confinement checks.
3224struct 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
3308// ---- row mapping ----
3309
3310struct 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
3456/// Wire-compat projection: MemoryRecord → AgentMemoryRecord keeps
3457/// memory_id/title/body/tags/timestamps (§7.3 — recall stays
3458/// wire-compatible).
3459fn 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
3470/// §8.3 WorkingSet(k): top-K ranked (steward ordering) ∪ recent/unranked
3471/// slice (unranked, or updated since their last rank), newest first, the
3472/// union capped at 2*k. Full: every active record, ranked first.
3473fn 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
3556/// §7.3 retention floors: warn (never evict) when a scope outgrows its
3557/// record-count or byte floor — retention pressure is a dream input, not a
3558/// FIFO.
3559fn 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
3591/// Pure floor check, unit-tested separately from the tracing side effect.
3592fn 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
3607/// Markdown-import failure split: content problems are contained (skip the
3608/// file, keep the store open); I/O problems propagate into the open.
3609enum MarkdownImportError {
3610    Content(String),
3611    Io(AgentMemoryError),
3612}
3613
3614/// One summary audit row per markdown-import file with skips or a wholesale
3615/// failure: the durable, operator-visible counterpart of the tracing warns.
3616fn 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        // A deliberate non-LLM re-add of the same content passes the
3819        // tombstone-recreation guard (which targets LLM authors, §8.4) and
3820        // mints a fresh id.
3821        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        // Steward ranks the record, then the RPC update path supersedes it.
3839        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        // Only the successor is recallable (memory never argues with
3864        // itself), and it inherited the steward rank.
3865        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        // Chain is preserved on the row.
3880        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        // Rank the first three; ranking does not count as an update, so the
3912        // ranked records leave the recent/unranked slice.
3913        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        // top-2 ranked = ids[0], ids[1]; recent slice = the two unranked
3934        // (ids[4], ids[3] newest-first); union capped at 4.
3935        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        // A ranked record updated after its rank re-enters the recent slice
3947        // via supersede (rank inheritance keeps it selector-visible).
3948        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        // The producer "dies": no commit. Nothing is visible, in this
3989        // instance or a fresh one over the same directory.
3990        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        // Commit applies the batch and burns the token.
4005        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    /// A pre-ledger realm file (full historical DDL, no meerkat_schema row)
4014    /// is refused typed at first realm use with its rows left untouched and
4015    /// no ledger stamped: pre-ledger corpora are below the mobkit 0.8.8
4016    /// floor (`MOBKIT_MEMORY_DOMAIN` allows only version 2), and the 0.8.11
4017    /// reset retired silent pre-floor convergence. Until then this test
4018    /// pinned the `ever_quarantined` backfill that convergence ran.
4019    #[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        // Any realm operation opens the connection and runs the ledger
4096        // preflight, which must refuse the unledgered owned tables.
4097        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    /// Task #53 migration: a v2 realm file holding runtime-id-keyed identity
4114    /// scopes (the HomeCore shape - distiller output stranded under
4115    /// mk--rt_c... roster ids, one scope per respawn generation) folds into
4116    /// the logical identity scope on open, across every identity-keyed
4117    /// table, and stamps the ledger at v3. Already-logical rows and
4118    /// mob-scope rows are untouched.
4119    #[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            // Build the released v2 shape and stamp its ledger row, exactly
4131            // as a mobkit 0.8.8-0.8.10 binary left it.
4132            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            // A mob-scope row whose key must NEVER be rewritten even if it
4154            // looked identity-shaped.
4155            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            // Legacy pending harvests: two generations plus a logical twin
4164            // colliding on retired_at_ms=1 (must collapse, not error).
4165            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            // A pending proposal under the legacy key. Its serialized
4182            // `record` is a NewMemoryRecord (embeds NO scope - the accept
4183            // path re-derives scope from the row key), so the key rewrite
4184            // alone covers it.
4185            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            // A surviving stage token whose batch EMBEDS the legacy scope in
4206            // a Create op (the adversarial seam: tokens outlive boots inside
4207            // the 24h GC window, and gated promotions commit later).
4208            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            // A CURRENT timestamp: the open-time stage GC prunes tokens older
4237            // than STAGE_GC_MAX_AGE_MS, and this test is about a token that
4238            // legitimately survives the reopen.
4239            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        // Open through the store: the ledger applies migration 0003.
4256        let store = SqliteAgentMemoryStore::open(dir.path())?;
4257        // Any realm op runs the preflight + migrations.
4258        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        // Content preservation: folded rows keep their ids and bodies.
4315        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        // Proposals: key rewritten, serialized record untouched (it embeds
4322        // no scope; accept re-derives from the row key).
4323        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        // Pending promotion: key rewritten.
4334        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        // THE stage seam: the surviving token's EMBEDDED Create scope is
4341        // normalized, so a later gated commit cannot re-create the legacy
4342        // scope.
4343        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        // Idempotent reopen: a second open at v3 changes nothing and errors
4365        // nowhere (the ledger will not re-run the migration).
4366        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    /// A pre-ledger realm file holding ONLY a historical `proposals` table
4385    /// is refused the same way (the floor refusal is per owned object, not
4386    /// per complete schema). Until the 0.8.11 reset this test pinned the
4387    /// proposals `taint` conservative backfill that pre-floor convergence
4388    /// ran.
4389    #[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    /// A fresh realm database is stamped with the `mobkit-memory` ledger
4452    /// domain at its highest supported version.
4453    #[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    /// A pre-ledger, pre-`ever_quarantined`/`taint` file (records AND
4469    /// proposals, the fullest historical shape) is refused typed at first
4470    /// realm use, rows preserved, no ledger stamped. Until the 0.8.11 reset
4471    /// this test pinned the byte-for-byte taint sentinel that pre-floor
4472    /// convergence wrote; the sentinel string itself remains pinned by
4473    /// `migration_0002_quarantine_and_taint_columns`, which fresh
4474    /// `initialize_current` composition still executes.
4475    #[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        // A stage referenced by a still-PENDING gated promotion: the
4586        // operator's decision window outranks the dead-producer sweep.
4587        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        // A stage referenced by a RESOLVED promotion must NOT be exempt —
4604        // this pins the `status = 'pending'` filter in the GC query.
4605        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        // Age every stage row past the 24h GC horizon, then reopen.
4625        {
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        // Agent author above the LLM ceiling.
4660        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        // Operator tier is never staged-assignable, for any author.
4685        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        // Seed an untrusted record, merge it into a "fresh" consolidated
4716        // record, then try to retier the merge product upward.
4717        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        // Import audit trail exists (one audit row per imported record).
4822        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        // Reopening does not re-import (file renamed) and keeps counts.
4835        let reopened = SqliteAgentMemoryStore::open(dir.path())?;
4836        assert_eq!(reopened.recall(recall_all(id, "family")).await?.len(), 2);
4837        Ok(())
4838    }
4839
4840    /// Store-seam gate stand-in: quarantines LLM writes whose evidence
4841    /// cites the tainted session.
4842    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        // Agent write from a tainted session lands quarantined, carrying a
4879        // verification claim (so the later retier passes the claim check
4880        // and only the taint ceiling can stop it).
4881        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        // Steward release: create a copy derived from the origin, tombstone
4900        // the origin (exactly the dream's release group).
4901        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        // The durable taint marker persisted through the release: both the
4944        // tombstoned origin and the copy (inherited via derived_from).
4945        {
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        // §10.2 "capped forever": retiering the released copy to
4968        // agent_verified must be rejected even though the quarantined
4969        // origin is now tombstoned.
4970        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        // Hand-edits happen (§7.3 invites them): append one record with an
4999        // oversized title and one carrying a secret. Both must skip loudly;
5000        // the valid record must still import; the open must succeed.
5001        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        // A file whose stem is not an agent identity (whitespace never
5017        // validates) fails wholesale: set aside as .import-failed, never
5018        // taking the realm store down.
5019        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        // The skips are counted in import audit rows.
5038        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        // Reopen: no re-import attempts, store stays healthy.
5070        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        // Tainted at propose time: the fact is persisted on the row.
5090        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        // Clean propose: no taint.
5094        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        // §10.4: the proposal seam refuses secrets with the class named.
5124        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        // The wire remember path flows through the staged validator's
5144        // §10.4 chokepoint.
5145        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        // Clean writes pass.
5158        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    // ---- §10.1 write gate ----
5327
5328    /// Gate that quarantines every LLM-authored write (the
5329    /// `llm_writes = "quarantined"` posture / a permanently tainted session).
5330    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        // Quarantined records are write-only: recall and manifest (the
5373        // coordinator's two read surfaces) must never return them.
5374        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        // Non-LLM principals are not gated: the RPC remember path
5390        // (Application author) lands active through the same gate.
5391        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        // Clean evidence: lands Active, tier-ceilinged at AgentObserved.
5426        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        // Tainted evidence range: session-tainted ⇒ the write quarantines,
5447        // for the Distiller author (not just Agent authors).
5448        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        // Reset boundary: quarantines without any content taint (§8.4).
5471        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        // Active records never appear; a since_ms in the future filters out.
5503        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        // A tainted "update" must not blank the good record.
5537        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        // The verification is a CLAIM in provenance; the tier stays at the
5595        // LLM ceiling (§10.2).
5596        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        // Recall sees it (identity scope, active).
5617        let records = store.recall(recall_all(id, "family")).await?;
5618        assert_eq!(records.len(), 1);
5619
5620        // forget_authored tombstones it with agent authorship.
5621        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        // An agent may only supersede within its OWN identity scope: the
5639        // staged validator rejects the batch even when the caller lies
5640        // about the scope (single-lineage supersede stays with the record's
5641        // own writers, §8.2).
5642        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        // Keyset pagination: strictly-descending (updated_at_ms, id) with
5679        // no row repeated or skipped across pages.
5680        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        // Status filter.
5714        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        // The same chain comes back oldest-first from every entry point.
5747        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        // Bounded.
5761        let bounded = store.supersede_chain("family", &root.memory_id, 2).await?;
5762        assert_eq!(bounded.len(), 2);
5763        Ok(())
5764    }
5765}