Skip to main content

wm_memory/
store.rs

1//! LMDB-backed memory store.
2//!
3//! Each galaxy is an LMDB named database (sub-DB within the same file).
4//! Reads are zero-copy (mmap'd). Writes are batched.
5
6use lmdb::{
7    Cursor, Database, DatabaseFlags, Environment, EnvironmentFlags, RwTransaction, Transaction,
8    WriteFlags,
9};
10use std::collections::HashMap;
11use std::path::Path;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Arc, RwLock};
14use wm_core::{CoreError, Galaxy, Result};
15
16#[cfg(unix)]
17use std::os::unix::fs::PermissionsExt;
18
19use crate::episodic::EpisodicStore;
20use crate::indexes::IndexDbs;
21use crate::memory::{Memory, MemoryId, decode_embedding, encode_embedding};
22use crate::semantic::SemanticEncoder;
23
24/// Query filter for memories.
25#[derive(Debug, Clone, Default)]
26pub struct MemoryQuery {
27    /// Filter by tags (memory must contain ALL specified tags).
28    pub tags: Vec<String>,
29    /// Minimum importance (inclusive).
30    pub min_importance: Option<f32>,
31    /// Maximum importance (inclusive).
32    pub max_importance: Option<f32>,
33    /// Only memories created after this timestamp.
34    pub created_after: Option<chrono::DateTime<chrono::Utc>>,
35    /// Only memories created before this timestamp.
36    pub created_before: Option<chrono::DateTime<chrono::Utc>>,
37    /// Case-insensitive substring filter over content (literal match —
38    /// not tokenized or ranked; that is what the search engine is for).
39    pub content_substring: Option<String>,
40    /// Maximum number of results.
41    pub limit: usize,
42}
43
44impl MemoryQuery {
45    /// Create an empty query (matches all, limit 100).
46    #[must_use]
47    pub fn new() -> Self {
48        Self {
49            limit: 100,
50            ..Default::default()
51        }
52    }
53
54    /// Set tag filter.
55    #[must_use]
56    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
57        self.tags = tags;
58        self
59    }
60
61    /// Set importance range.
62    #[must_use]
63    pub const fn with_importance_range(mut self, min: f32, max: f32) -> Self {
64        self.min_importance = Some(min);
65        self.max_importance = Some(max);
66        self
67    }
68
69    /// Set temporal range.
70    #[must_use]
71    pub const fn with_time_range(
72        mut self,
73        after: chrono::DateTime<chrono::Utc>,
74        before: chrono::DateTime<chrono::Utc>,
75    ) -> Self {
76        self.created_after = Some(after);
77        self.created_before = Some(before);
78        self
79    }
80
81    /// One-sided temporal bound: only memories created at or after this
82    /// timestamp (`created_after` API passthrough).
83    #[must_use]
84    pub const fn with_created_after(mut self, after: chrono::DateTime<chrono::Utc>) -> Self {
85        self.created_after = Some(after);
86        self
87    }
88
89    /// One-sided temporal bound: only memories created at or before this
90    /// timestamp (`created_before` API passthrough).
91    #[must_use]
92    pub const fn with_created_before(mut self, before: chrono::DateTime<chrono::Utc>) -> Self {
93        self.created_before = Some(before);
94        self
95    }
96
97    /// Set limit.
98    #[must_use]
99    pub const fn with_limit(mut self, limit: usize) -> Self {
100        self.limit = limit;
101        self
102    }
103
104    /// Set a case-insensitive substring filter over content.
105    #[must_use]
106    pub fn with_content_substring(mut self, substring: impl Into<String>) -> Self {
107        self.content_substring = Some(substring.into().to_lowercase());
108        self
109    }
110
111    /// Check if a memory matches this query.
112    #[must_use]
113    pub fn matches(&self, mem: &Memory) -> bool {
114        // Tag filter: memory must contain all specified tags
115        if !self.tags.is_empty() {
116            for tag in &self.tags {
117                if !mem.metadata.tags.iter().any(|t| t == tag) {
118                    return false;
119                }
120            }
121        }
122
123        // Importance filter
124        if let Some(min) = self.min_importance {
125            if mem.metadata.importance < min {
126                return false;
127            }
128        }
129        if let Some(max) = self.max_importance {
130            if mem.metadata.importance > max {
131                return false;
132            }
133        }
134
135        // Temporal filter
136        if let Some(after) = self.created_after {
137            if mem.metadata.created_at < after {
138                return false;
139            }
140        }
141        if let Some(before) = self.created_before {
142            if mem.metadata.created_at > before {
143                return false;
144            }
145        }
146
147        // Substring filter (literal, case-insensitive — never ranked).
148        if let Some(sub) = &self.content_substring {
149            if !mem.content.to_lowercase().contains(sub) {
150                return false;
151            }
152        }
153
154        true
155    }
156}
157
158/// The LMDB environment containing all 14 galaxy sub-databases plus 4 index DBs.
159pub struct MemoryStore {
160    /// Path to the LMDB file
161    path: std::path::PathBuf,
162    /// LMDB environment (opened once, shared across threads)
163    env: Environment,
164    /// Cached handles to the 4 secondary index sub-databases
165    index_dbs: IndexDbs,
166    /// Semantic encoder for content-derived coordinates
167    semantic_encoder: SemanticEncoder,
168    /// Optional per-galaxy entry limit (DoS prevention)
169    max_entries_per_galaxy: Option<usize>,
170    /// Monotonic counter of successful mutations since this handle was
171    /// opened. Lets the dispatch pipeline cheaply detect actual store
172    /// writes (write-audit journal) without scanning galaxies.
173    mutation_count: AtomicU64,
174    /// Dedicated database for lossless v6 episodic records.
175    episodic_db: Database,
176    /// DUP_SORT term→id postings for bounded episodic search (v2 sidecar).
177    episodic_terms_v2_db: Database,
178    /// Content-hash → vector cache (v26 "Tier 2" idea, finally wired):
179    /// warm-start for re-ingest and re-runs. Keyed by the embedder
180    /// namespace + content hash, so switching models never serves stale
181    /// vectors.
182    embedding_cache_db: Database,
183    /// Per-memory revision chains (V8 S11c): append-only content-change
184    /// history, self-verifying (seq continuity + hash linkage + head
185    /// match). See [`crate::revision`].
186    revisions_db: Database,
187    /// Per-memory creation attestations (Track F Slice A, D5): one signed
188    /// record per created memory, keyed `att:{galaxy}:{memory_id}`.
189    /// See [`crate::attestation`].
190    attestations_db: Database,
191    /// Dedicated database for compressed cold-stored memories.
192    pub(crate) cold_storage_db: Database,
193    /// Per-session monotonic turn-sequence counters (H1, 2026-09-20).
194    /// Key = session id bytes, value = last allocated sequence (u64 BE).
195    /// Incremented inside the same write transaction as the turn record so
196    /// concurrent writers cannot reuse a sequence. Optional on read paths
197    /// (legacy stores keep opening strict; a writable open creates it).
198    session_sequences_db: Option<Database>,
199    /// Optional at-rest keyring DBI (Q39 slice A). `Some` when the store has
200    /// a keyring; read-only paths open it optionally and never create it.
201    keyring_db: Option<Database>,
202    /// Unlocked galaxy DEKs for writable at-rest stores (slice A verifies
203    /// them at open; record AEAD is slice B). `None` for plaintext stores.
204    at_rest: Option<crate::at_rest::AtRestState>,
205    /// Warm term-posting cache shared by episodic search views.
206    episodic_term_cache: std::sync::Arc<RwLock<HashMap<String, Vec<uuid::Uuid>>>>,
207    /// Optional embedder for episodic vector reranking.
208    episodic_embedder:
209        std::sync::OnceLock<Option<Arc<dyn crate::embedder::Embedder + Send + Sync>>>,
210    /// One-shot guard: rebuild the v2 episodic sidecar once per process.
211    episodic_sidecar_ensured: std::sync::OnceLock<()>,
212    /// Optional adaptive aliases for episodic key expansion.
213    episodic_aliases: std::sync::OnceLock<Option<crate::episodic_keys::AdaptiveAliases>>,
214    /// Optional vocabulary enrichment for episodic index-time term expansion.
215    episodic_enrichment: std::sync::OnceLock<Option<crate::enrichment::VocabularyEnrichment>>,
216}
217
218impl MemoryStore {
219    /// Probe whether another process holds the LMDB writer lock on this store.
220    ///
221    /// LMDB's write env-open falls back to a blocking *shared* lock when the
222    /// exclusive writer lock is held, then opens `data.mdb` for writing
223    /// anyway and wedges on its internal mutex — `wm grimoire`/`wm status`
224    /// hung forever against a live store until a SIGKILL (9.1.6). Callers
225    /// that need exclusive access probe first and fail loudly instead of
226    /// deadlocking.
227    ///
228    /// The probe is a non-blocking `fcntl(F_SETLK, F_WRLCK)` over the whole
229    /// `lock.mdb` (record locks overlap LMDB's byte-range writer lock), so
230    /// it never blocks and never mutates the store.
231    ///
232    /// Returns `Ok(())` when the writer lock is free, `Err(WouldBlock)`
233    /// when another process holds it.
234    #[cfg(unix)]
235    pub fn probe_write_lock(store_root: &Path) -> std::io::Result<()> {
236        use rustix::fs::{FlockOperation, fcntl_lock};
237        let lock_path = store_root.join("lmdb").join("lock.mdb");
238        let file = std::fs::OpenOptions::new()
239            .read(true)
240            .write(true)
241            .open(&lock_path)?;
242        match fcntl_lock(&file, FlockOperation::NonBlockingLockExclusive) {
243            Ok(()) => Ok(()),
244            Err(rustix::io::Errno::AGAIN | rustix::io::Errno::ACCESS) => Err(std::io::Error::new(
245                std::io::ErrorKind::WouldBlock,
246                "LMDB writer lock held by another process",
247            )),
248            Err(e) => Err(e.into()),
249        }
250    }
251
252    /// Non-unix: LMDB locking differs (LockFileEx on Windows); the probe is
253    /// best-effort there and reports the lock as free.
254    #[cfg(not(unix))]
255    pub fn probe_write_lock(_store_root: &Path) -> std::io::Result<()> {
256        Ok(())
257    }
258
259    /// Open or create an LMDB store at the given path.
260    ///
261    /// At-rest mode comes from the environment (`WM_AT_REST_MODE`, default
262    /// `off`) — see [`Self::open_with_at_rest`]. `off` is a provable no-op:
263    /// no keyring DBI is created and no key files are written. A
264    /// set-but-unrecognized `WM_AT_REST_MODE` value refuses the open
265    /// (fail-closed).
266    ///
267    /// On Unix, the store directory is created with mode 0o700 (owner-only
268    /// access) if it does not already exist. Existing directories are
269    /// left untouched.
270    pub fn open(path: impl AsRef<Path>, map_size: usize) -> Result<Self> {
271        Self::open_with_at_rest(path, map_size, &crate::at_rest::AtRestConfig::from_env()?)
272    }
273
274    /// Open or create an LMDB store with an explicit at-rest configuration.
275    ///
276    /// Q39 slice A: when the mode is `keyfile`/`passphrase`, the store's
277    /// keyring DBI is read (or initialized as `meta` + `rk:check` + 16
278    /// wrapped galaxy DEKs, all in one transaction) and the DEKs are
279    /// unwrapped at open. Records stay plaintext in slice A.
280    pub fn open_with_at_rest(
281        path: impl AsRef<Path>,
282        map_size: usize,
283        at_rest_config: &crate::at_rest::AtRestConfig,
284    ) -> Result<Self> {
285        let path = path.as_ref().to_path_buf();
286
287        // Ensure the directory exists with restrictive permissions.
288        std::fs::create_dir_all(&path)
289            .map_err(|e| CoreError::Memory(format!("Cannot create store dir: {e}")))?;
290        #[cfg(unix)]
291        {
292            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
293                .map_err(|e| CoreError::Memory(format!("Cannot set store dir permissions: {e}")))?;
294        }
295
296        let env = Environment::new()
297            .set_map_size(map_size)
298            .set_max_dbs(64)
299            .open(&path)
300            .map_err(|e| CoreError::Memory(format!("LMDB open failed: {e}")))?;
301
302        // Create all 14 galaxy sub-databases
303        for galaxy in Galaxy::all() {
304            let db = env
305                .create_db(Some(galaxy.db_name()), DatabaseFlags::default())
306                .map_err(|e| {
307                    CoreError::Memory(format!(
308                        "LMDB create_db failed for {}: {e}",
309                        galaxy.db_name()
310                    ))
311                })?;
312            let _ = db;
313        }
314
315        // Create 4 secondary index sub-databases
316        for (name, flags) in crate::indexes::INDEX_DBS {
317            let db = env
318                .create_db(Some(name), *flags)
319                .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for {name}: {e}")))?;
320            let _ = db;
321        }
322
323        let index_dbs = IndexDbs::open(&env)?;
324        let episodic_db = env
325            .create_db(Some("episodic_records"), DatabaseFlags::default())
326            .map_err(|e| {
327                CoreError::Memory(format!("LMDB create_db failed for episodic_records: {e}"))
328            })?;
329        // v2 sidecar: DUP_SORT postings (term -> set of record ids). Append of
330        // a record touches only the (term, id) pairs it introduces instead of
331        // rewriting whole posting lists, so ingest cost stays O(new records)
332        // as the store grows. The v1 msgpack-Vec database is retained unused
333        // on legacy stores; v2 is rebuilt from the authoritative records when
334        // found empty.
335        let episodic_terms_v2_db = env
336            .create_db(Some("episodic_terms_v2"), DatabaseFlags::DUP_SORT)
337            .map_err(|e| {
338                CoreError::Memory(format!("LMDB create_db failed for episodic_terms_v2: {e}"))
339            })?;
340        let embedding_cache_db = env
341            .create_db(Some("embedding_cache"), DatabaseFlags::default())
342            .map_err(|e| {
343                CoreError::Memory(format!("LMDB create_db failed for embedding_cache: {e}"))
344            })?;
345        let revisions_db = env
346            .create_db(Some("revisions"), DatabaseFlags::default())
347            .map_err(|e| CoreError::Memory(format!("LMDB create_db failed for revisions: {e}")))?;
348        let attestations_db = env
349            .create_db(
350                Some(crate::attestation::ATTESTATIONS_DB),
351                DatabaseFlags::default(),
352            )
353            .map_err(|e| {
354                CoreError::Memory(format!("LMDB create_db failed for attestations: {e}"))
355            })?;
356        let cold_storage_db = env
357            .create_db(Some("cold_storage"), DatabaseFlags::default())
358            .map_err(|e| {
359                CoreError::Memory(format!("LMDB create_db failed for cold_storage: {e}"))
360            })?;
361        // H1 (2026-09-20): per-session turn-sequence counters. Created on
362        // every writable open (legacy stores repair on first write open);
363        // deliberately not required schema on read paths.
364        let session_sequences_db = env
365            .create_db(Some("session_sequences"), DatabaseFlags::default())
366            .map_err(|e| {
367                CoreError::Memory(format!("LMDB create_db failed for session_sequences: {e}"))
368            })?;
369        let (keyring_db, at_rest) = crate::at_rest::open_at_rest(&env, &path, at_rest_config)?;
370        Ok(Self {
371            path,
372            env,
373            index_dbs,
374            semantic_encoder: SemanticEncoder::new(),
375            max_entries_per_galaxy: None,
376            mutation_count: AtomicU64::new(0),
377            episodic_db,
378            episodic_terms_v2_db,
379            embedding_cache_db,
380            revisions_db,
381            attestations_db,
382            cold_storage_db,
383            session_sequences_db: Some(session_sequences_db),
384            keyring_db,
385            at_rest,
386            episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
387            episodic_embedder: std::sync::OnceLock::new(),
388            episodic_sidecar_ensured: std::sync::OnceLock::new(),
389            episodic_aliases: std::sync::OnceLock::new(),
390            episodic_enrichment: std::sync::OnceLock::new(),
391        })
392    }
393
394    /// At-rest disclosure status (Q39 slice A): keyring meta only — the RK is
395    /// never resolved here, and nothing is created or written. `Absent` means
396    /// plaintext pass-through.
397    #[must_use]
398    pub fn at_rest_status(&self) -> crate::at_rest::AtRestStatus {
399        if let Some(state) = &self.at_rest {
400            return crate::at_rest::AtRestStatus::Present(state.status());
401        }
402        match &self.keyring_db {
403            None => crate::at_rest::AtRestStatus::Absent,
404            Some(db) => crate::at_rest::read_status(&self.env, *db, &self.path),
405        }
406    }
407
408    /// Unlocked keyring state for a writable at-rest store (slice B seam);
409    /// `None` for plaintext-pass-through stores and all read paths.
410    #[must_use]
411    pub const fn at_rest_state(&self) -> Option<&crate::at_rest::AtRestState> {
412        self.at_rest.as_ref()
413    }
414
415    /// Keyring DBI handle when the store has one (`None` for plaintext
416    /// pass-through stores and on read paths where the DBI is absent).
417    /// Slice-B migration reads/writes its ledger through this handle.
418    #[must_use]
419    pub const fn keyring_db(&self) -> Option<Database> {
420        self.keyring_db
421    }
422
423    /// Galaxy DEK for record AEAD, when the store runs with an unlocked
424    /// at-rest keyring (Q39 slice B).
425    pub(crate) fn record_cipher(&self, galaxy: Galaxy) -> Option<&[u8; 32]> {
426        self.at_rest
427            .as_ref()
428            .and_then(|state| state.galaxy_dek(galaxy.db_name()))
429    }
430
431    /// Encode a memory for storage: msgpack, sealed under the galaxy DEK
432    /// when the store has one. Keyring-absent stores are byte-identical to
433    /// the pre-slice-B codec.
434    pub(crate) fn encode_record_value(&self, galaxy: Galaxy, memory: &Memory) -> Result<Vec<u8>> {
435        let plaintext = rmp_serde::to_vec_named(memory)
436            .map_err(|e| CoreError::Memory(format!("serialize failed: {e}")))?;
437        let Some(key) = self.record_cipher(galaxy) else {
438            return Ok(plaintext);
439        };
440        crate::codec::seal_record(
441            &plaintext,
442            key,
443            galaxy.db_name(),
444            memory.metadata.id.as_bytes(),
445            memory.metadata.version,
446        )
447        .map_err(|e| CoreError::Memory(format!("at-rest seal failed: {e}")))
448    }
449
450    /// Decode a stored record value: sealed values open under the galaxy
451    /// DEK (failing closed without one), plaintext values follow the legacy
452    /// codec path.
453    pub(crate) fn decode_record_value(
454        &self,
455        galaxy: Galaxy,
456        key_bytes: &[u8],
457        value: &[u8],
458    ) -> Result<Memory> {
459        if crate::codec::is_sealed_record(value) {
460            let Some(dek) = self.record_cipher(galaxy) else {
461                return Err(CoreError::Memory(format!(
462                    "sealed record in {} but no at-rest key is loaded (WM_AT_REST_MODE off?)",
463                    galaxy.db_name()
464                )));
465            };
466            let record_id: [u8; 16] = key_bytes
467                .try_into()
468                .map_err(|_| CoreError::Memory("sealed record key is not a 16-byte id".into()))?;
469            let opened = crate::codec::open_record(value, dek, galaxy.db_name(), &record_id)
470                .map_err(|e| CoreError::Memory(format!("at-rest open failed: {e}")))?;
471            return crate::codec::decode(&opened)
472                .map_err(|e| CoreError::Memory(format!("deserialize failed: {e}")));
473        }
474        crate::codec::decode(value)
475            .map_err(|e| CoreError::Memory(format!("deserialize failed: {e}")))
476    }
477
478    /// Bounded env open for inspection paths (9.1.6).
479    ///
480    /// LMDB env opens can block forever: against a live writer the
481    /// exclusive-lock fallback wedges on an internal mutex, and a crashed
482    /// server can leave the lock file's in-file mutex locked so even
483    /// read-only opens hang. Inspection callers (status, doctor, grimoire)
484    /// must never hang — run the open on a worker thread and give up after
485    /// `timeout`, returning `Ok(None)` so the caller degrades loudly
486    /// instead of deadlocking.
487    pub fn open_readonly_bounded(
488        path: impl AsRef<Path>,
489        timeout: std::time::Duration,
490    ) -> Result<Option<Self>> {
491        let path = path.as_ref().to_path_buf();
492        let (tx, rx) = std::sync::mpsc::channel();
493        std::thread::spawn(move || {
494            let result = Self::open_readonly(&path);
495            let _ = tx.send(result);
496        });
497        match rx.recv_timeout(timeout) {
498            Ok(result) => result.map(Some),
499            Err(_) => Ok(None),
500        }
501    }
502
503    /// Bounded writable env open for exclusive-access paths (9.1.6).
504    /// See [`Self::open_readonly_bounded`] for the wedge rationale; write
505    /// paths bail with an actionable message on timeout instead of hanging.
506    pub fn open_default_bounded(
507        path: impl AsRef<Path>,
508        timeout: std::time::Duration,
509    ) -> Result<Option<Self>> {
510        let path = path.as_ref().to_path_buf();
511        let (tx, rx) = std::sync::mpsc::channel();
512        std::thread::spawn(move || {
513            let result = Self::open_default(&path);
514            let _ = tx.send(result);
515        });
516        match rx.recv_timeout(timeout) {
517            Ok(result) => result.map(Some),
518            Err(_) => Ok(None),
519        }
520    }
521
522    /// Default LMDB map size for this platform (`WM_DEFAULT_MAP_SIZE`
523    /// overrides). See [`Self::open_default`] for the platform rationale.
524    #[must_use]
525    pub fn default_map_size() -> usize {
526        let platform_default = if cfg!(windows) {
527            256 * 1024 * 1024
528        } else {
529            4 * 1024 * 1024 * 1024
530        };
531        std::env::var("WM_DEFAULT_MAP_SIZE")
532            .ok()
533            .and_then(|v| v.parse::<usize>().ok())
534            .filter(|&v| v > 0)
535            .unwrap_or(platform_default)
536    }
537
538    /// Open with the default map size.
539    ///
540    /// 4 GB on Unix: LMDB truncates the data file sparsely (ftruncate), so
541    /// reservation costs nothing until pages are written. On Windows NTFS
542    /// materializes the file at full map size immediately — a 4 GB default
543    /// would allocate 4 GB on disk per store the moment it opens — so the
544    /// Windows default is smaller; pass an explicit size to `open()` for
545    /// large stores. (Auto-grow on MapFull is a planned follow-up.)
546    pub fn open_default(path: impl AsRef<Path>) -> Result<Self> {
547        // Deployment knob: override the platform default explicitly (bytes).
548        // CI uses this on Windows, where NTFS materializes the map file at
549        // full size and hundreds of parallel test stores would exhaust the
550        // runner disk even at the 256MB Windows default.
551        let size = Self::default_map_size();
552        Self::open(path, size)
553    }
554
555    /// Open an existing LMDB store without creating a directory, database, or
556    /// writable LMDB environment. This is the preservation boundary used by
557    /// read-only evaluator servers: an incomplete or incompatible store must
558    /// fail closed for the caller to investigate, never be initialized or
559    /// repaired in place.
560    pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
561        let path = path.as_ref().to_path_buf();
562        if !path.is_dir() {
563            return Err(CoreError::Memory(format!(
564                "Read-only LMDB store directory does not exist: {}",
565                path.display()
566            )));
567        }
568        if !path.join("data.mdb").is_file() {
569            return Err(CoreError::Memory(format!(
570                "Read-only LMDB store is missing data.mdb: {}",
571                path.display()
572            )));
573        }
574
575        let env = Environment::new()
576            .set_max_dbs(32)
577            .set_flags(EnvironmentFlags::READ_ONLY)
578            .open(&path)
579            .map_err(|e| CoreError::Memory(format!("Read-only LMDB open failed: {e}")))?;
580
581        let index_dbs = IndexDbs::open(&env)?;
582        let open_named = |name: &str| {
583            env.open_db(Some(name)).map_err(|e| {
584                CoreError::Memory(format!("Read-only LMDB missing database {name}: {e}"))
585            })
586        };
587        let episodic_db = open_named("episodic_records")?;
588        let episodic_terms_v2_db = open_named("episodic_terms_v2")?;
589        let embedding_cache_db = open_named("embedding_cache")?;
590        let revisions_db = open_named("revisions")?;
591        let attestations_db = open_named(crate::attestation::ATTESTATIONS_DB)?;
592        let cold_storage_db = open_named("cold_storage")?;
593        // H1 counters are optional on read paths (like the keyring): legacy
594        // stores keep opening strict; the DBI is created by writable opens.
595        let session_sequences_db = env.open_db(Some("session_sequences")).ok();
596        // The at-rest keyring is optional on read paths: opened when present,
597        // never created, and its RK is never resolved here (status only).
598        let keyring_db = crate::at_rest::open_keyring_optional(&env)?;
599
600        Ok(Self {
601            path,
602            env,
603            index_dbs,
604            semantic_encoder: SemanticEncoder::new(),
605            max_entries_per_galaxy: None,
606            mutation_count: AtomicU64::new(0),
607            episodic_db,
608            episodic_terms_v2_db,
609            embedding_cache_db,
610            revisions_db,
611            attestations_db,
612            cold_storage_db,
613            session_sequences_db,
614            keyring_db,
615            at_rest: None,
616            episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
617            episodic_embedder: std::sync::OnceLock::new(),
618            episodic_sidecar_ensured: std::sync::OnceLock::new(),
619            episodic_aliases: std::sync::OnceLock::new(),
620            episodic_enrichment: std::sync::OnceLock::new(),
621        })
622    }
623
624    /// Named databases `open()` creates and `open_readonly()` requires,
625    /// beyond the galaxy and secondary-index sets.
626    const NAMED_DBIS: [&'static str; 6] = [
627        "episodic_records",
628        "episodic_terms_v2",
629        "embedding_cache",
630        "revisions",
631        crate::attestation::ATTESTATIONS_DB,
632        "cold_storage",
633    ];
634
635    /// Open an existing store for inspection without taking any lock
636    /// (`MDB_NOLOCK | MDB_RDONLY`), 9.1.6.
637    ///
638    /// Read-only env opens still block forever in two real situations:
639    /// a live writer holds the exclusive lock (lmdb-master falls back to a
640    /// blocking shared-lock wait), and a crashed server can leave the lock
641    /// file's in-file mutex wedged so every open hangs. Inspection paths
642    /// (status, doctor, grimoire) never need the lock file — no locks, no
643    /// reader slots, no mutex — just an mmap read of the store. The store
644    /// must exist and be schema-complete (same strict refusal as
645    /// [`Self::open_readonly`]); torn-meta-page reads are theoretically
646    /// possible mid-write and acceptable for display counts.
647    pub fn open_inspection(path: impl AsRef<Path>) -> Result<Self> {
648        let path = path.as_ref().to_path_buf();
649        if !path.is_dir() {
650            return Err(CoreError::Memory(format!(
651                "Read-only LMDB store directory does not exist: {}",
652                path.display()
653            )));
654        }
655        if !path.join("data.mdb").is_file() {
656            return Err(CoreError::Memory(format!(
657                "Read-only LMDB store is missing data.mdb: {}",
658                path.display()
659            )));
660        }
661
662        let env = Environment::new()
663            .set_max_dbs(32)
664            .set_flags(EnvironmentFlags::READ_ONLY | EnvironmentFlags::NO_LOCK)
665            .open(&path)
666            .map_err(|e| CoreError::Memory(format!("Inspection LMDB open failed: {e}")))?;
667
668        let index_dbs = IndexDbs::open(&env)?;
669        let open_named = |name: &str| {
670            env.open_db(Some(name)).map_err(|e| {
671                CoreError::Memory(format!("Inspection LMDB missing database {name}: {e}"))
672            })
673        };
674        let episodic_db = open_named("episodic_records")?;
675        let episodic_terms_v2_db = open_named("episodic_terms_v2")?;
676        let embedding_cache_db = open_named("embedding_cache")?;
677        let revisions_db = open_named("revisions")?;
678        let attestations_db = open_named(crate::attestation::ATTESTATIONS_DB)?;
679        let cold_storage_db = open_named("cold_storage")?;
680        // H1 counters are optional here too: inspection must never require a
681        // schema a legacy store may lack.
682        let session_sequences_db = env.open_db(Some("session_sequences")).ok();
683        // The at-rest keyring is optional on read paths: opened when present,
684        // never created, and its RK is never resolved here (status only).
685        let keyring_db = crate::at_rest::open_keyring_optional(&env)?;
686
687        Ok(Self {
688            path,
689            env,
690            index_dbs,
691            semantic_encoder: SemanticEncoder::new(),
692            max_entries_per_galaxy: None,
693            mutation_count: AtomicU64::new(0),
694            episodic_db,
695            episodic_terms_v2_db,
696            embedding_cache_db,
697            revisions_db,
698            attestations_db,
699            cold_storage_db,
700            session_sequences_db,
701            keyring_db,
702            at_rest: None,
703            episodic_term_cache: std::sync::Arc::new(RwLock::new(HashMap::new())),
704            episodic_embedder: std::sync::OnceLock::new(),
705            episodic_sidecar_ensured: std::sync::OnceLock::new(),
706            episodic_aliases: std::sync::OnceLock::new(),
707            episodic_enrichment: std::sync::OnceLock::new(),
708        })
709    }
710
711    /// Complete a store's schema in place: create any galaxy, index, or named
712    /// database this build expects but an older store lacks, then let the
713    /// caller reopen normally. Returns the database names that were missing.
714    ///
715    /// Restores from older builds can be byte-exact yet not openable (found
716    /// 2026-09-14: a 9.0.0 backup lacked `cold_storage`). This is the only
717    /// in-place repair path; `open_readonly` deliberately stays strict so
718    /// preservation callers see an incomplete store instead of a silent fix.
719    ///
720    /// The at-rest `keyring` DBI is **not** required schema (Q39 slice A
721    /// decision): legacy stores and strict reads stay working, and this
722    /// function neither creates nor repairs a keyring.
723    pub fn ensure_schema(path: impl AsRef<Path>) -> Result<Vec<String>> {
724        let path = path.as_ref().to_path_buf();
725        if !path.is_dir() {
726            return Err(CoreError::Memory(format!(
727                "Store directory does not exist: {}",
728                path.display()
729            )));
730        }
731        let expected = || {
732            Galaxy::all()
733                .into_iter()
734                .map(|galaxy| galaxy.db_name().to_string())
735                .chain(
736                    crate::indexes::INDEX_DBS
737                        .iter()
738                        .map(|(name, _)| (*name).to_string()),
739                )
740                .chain(Self::NAMED_DBIS.iter().map(|name| (*name).to_string()))
741        };
742        let missing: Vec<String> = {
743            let env = Environment::new()
744                .set_max_dbs(64)
745                .set_flags(EnvironmentFlags::READ_ONLY)
746                .open(&path)
747                .map_err(|e| CoreError::Memory(format!("Read-only LMDB open failed: {e}")))?;
748            expected()
749                .filter(|name| env.open_db(Some(name.as_str())).is_err())
750                .collect()
751        };
752        if missing.is_empty() {
753            return Ok(missing);
754        }
755        // A writable open creates every missing galaxy, index, and named
756        // database. Drop it immediately; the caller reopens as usual.
757        let store = Self::open_default(&path)?;
758        drop(store);
759        Ok(missing)
760    }
761
762    /// Set a per-galaxy entry limit for DoS prevention.
763    ///
764    /// When set, `put` will reject writes that would exceed the limit.
765    /// This prevents a single galaxy from exhausting the LMDB map.
766    #[must_use]
767    pub const fn with_entry_limit(mut self, limit: usize) -> Self {
768        self.max_entries_per_galaxy = Some(limit);
769        self
770    }
771
772    /// Path to the LMDB file.
773    pub fn path(&self) -> &Path {
774        &self.path
775    }
776
777    /// Get the LMDB environment handle.
778    pub const fn env(&self) -> &Environment {
779        &self.env
780    }
781
782    /// Monotonic counter of successful mutations since this handle was
783    /// opened (puts, deletes, clears, raw writes). Used by the dispatch
784    /// pipeline's write-audit journal to detect actual store writes.
785    pub fn mutation_count(&self) -> u64 {
786        self.mutation_count.load(Ordering::Relaxed)
787    }
788
789    /// Get the cached index database handles.
790    pub const fn index_dbs(&self) -> &IndexDbs {
791        &self.index_dbs
792    }
793
794    /// Get the semantic encoder.
795    pub const fn semantic_encoder(&self) -> &SemanticEncoder {
796        &self.semantic_encoder
797    }
798
799    /// Read-only health probe for the derived episodic sidecar (H2,
800    /// 2026-09-19 review): `(authoritative indexable record count, sidecar
801    /// empty?)`.
802    ///
803    /// `count > 0 && sidecar_empty` is the signature of a failed or
804    /// never-run sidecar rebuild — the raw lane is canonical, the term
805    /// postings are a reconstructible view. Callers (doctor) grade that
806    /// DEGRADED instead of reporting a healthy store. Private /
807    /// model-excluded records deliberately have no postings, so only
808    /// indexable records count. Unlike [`Self::episodic`], this never
809    /// triggers the once-per-process rebuild and never writes.
810    pub fn episodic_sidecar_health(&self) -> Result<(u64, bool)> {
811        let view = EpisodicStore::new(
812            &self.env,
813            self.episodic_db,
814            self.episodic_terms_v2_db,
815            self.episodic_term_cache.clone(),
816            &self.mutation_count,
817        );
818        let empty = view.sidecar_is_empty()?;
819        if !empty {
820            return Ok((view.record_count()?, false));
821        }
822        let indexable = view
823            .scan(None, usize::MAX)?
824            .iter()
825            .filter(|record| !record.is_private && !record.model_exclude)
826            .count() as u64;
827        Ok((indexable, true))
828    }
829
830    /// Authoritative episodic record count without triggering the
831    /// once-per-process sidecar rebuild (read-only; for inspection paths
832    /// such as `wm doctor`, which must diagnose, not silently repair).
833    pub fn episodic_record_count(&self) -> Result<u64> {
834        let view = EpisodicStore::new(
835            &self.env,
836            self.episodic_db,
837            self.episodic_terms_v2_db,
838            self.episodic_term_cache.clone(),
839            &self.mutation_count,
840        );
841        view.record_count()
842    }
843
844    /// Rebuild the episodic DUP_SORT sidecar once per process when it is
845    /// empty while authoritative records exist (legacy v1 stores and
846    /// lost-sidecar recovery). Raw records are never modified; a failed
847    /// rebuild leaves search on its raw-scan fallback.
848    fn ensure_episodic_sidecar(&self) {
849        if self.episodic_sidecar_ensured.get().is_some() {
850            return;
851        }
852        let _ = self.episodic_sidecar_ensured.set(());
853        let view = EpisodicStore::new(
854            &self.env,
855            self.episodic_db,
856            self.episodic_terms_v2_db,
857            self.episodic_term_cache.clone(),
858            &self.mutation_count,
859        );
860        let needs_rebuild = matches!(
861            (view.sidecar_is_empty(), view.record_count()),
862            (Ok(true), Ok(n)) if n > 0
863        );
864        if needs_rebuild {
865            match view.rebuild_sidecar() {
866                Ok(n) => tracing::info!("episodic sidecar rebuilt from {n} records"),
867                Err(e) => {
868                    tracing::warn!("episodic sidecar rebuild failed: {e}");
869                }
870            }
871        }
872    }
873
874    /// Open the v6 lossless episodic record view.
875    #[must_use]
876    pub fn episodic(&self) -> EpisodicStore<'_> {
877        self.ensure_episodic_sidecar();
878        let mut store = EpisodicStore::new(
879            &self.env,
880            self.episodic_db,
881            self.episodic_terms_v2_db,
882            self.episodic_term_cache.clone(),
883            &self.mutation_count,
884        );
885        if let Some(Some(embedder)) = self.episodic_embedder.get() {
886            store = store.with_embedder(embedder.clone());
887        }
888        if let Some(Some(aliases)) = self.episodic_aliases.get() {
889            store = store.with_adaptive_aliases(aliases.clone());
890        }
891        if let Some(Some(enrichment)) = self.episodic_enrichment.get() {
892            store = store.with_enrichment(enrichment.clone());
893        }
894        store
895    }
896
897    /// Attach an embedder for episodic vector reranking.
898    pub fn set_episodic_embedder(
899        &self,
900        embedder: Arc<dyn crate::embedder::Embedder + Send + Sync>,
901    ) {
902        let _ = self.episodic_embedder.set(Some(embedder));
903    }
904
905    /// Attach adaptive aliases for episodic key expansion.
906    pub fn set_episodic_aliases(&self, aliases: crate::episodic_keys::AdaptiveAliases) {
907        let _ = self.episodic_aliases.set(Some(aliases));
908    }
909
910    /// Attach vocabulary enrichment for episodic index-time term expansion.
911    pub fn set_episodic_enrichment(&self, enrichment: crate::enrichment::VocabularyEnrichment) {
912        let _ = self.episodic_enrichment.set(Some(enrichment));
913    }
914
915    /// Get a named database handle for a galaxy.
916    pub fn galaxy_db(&self, galaxy: Galaxy) -> Result<Database> {
917        self.env.open_db(Some(galaxy.db_name())).map_err(|e| {
918            CoreError::Memory(format!("LMDB open_db failed for {}: {e}", galaxy.db_name()))
919        })
920    }
921
922    // ── Memory CRUD ───────────────────────────────────────────────────
923
924    /// Store a memory in the given galaxy. Keyed by memory.metadata.id.
925    /// Also updates all secondary indexes.
926    ///
927    /// Returns a clear error if the per-galaxy entry limit is exceeded
928    /// or if the LMDB map is full.
929    pub fn put(&self, galaxy: Galaxy, memory: &Memory) -> Result<()> {
930        // Check per-galaxy entry limit (DoS prevention)
931        if let Some(limit) = self.max_entries_per_galaxy {
932            let current = self.count(galaxy)?;
933            if current >= limit {
934                return Err(CoreError::Memory(format!(
935                    "galaxy {} entry limit reached ({current}/{limit}), write rejected",
936                    galaxy.db_name()
937                )));
938            }
939        }
940
941        let mut tx = self
942            .env
943            .begin_rw_txn()
944            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
945        self.put_in_txn(&mut tx, galaxy, memory)?;
946        tx.commit()
947            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
948        self.mutation_count.fetch_add(1, Ordering::Relaxed);
949        Ok(())
950    }
951
952    /// Body of [`Self::put`] inside a caller-owned write transaction: record
953    /// upsert plus secondary-index maintenance. Split out for
954    /// [`Self::put_session_turn`], which allocates the turn's sequence in the
955    /// same transaction and therefore cannot call `put` itself. Dropping the
956    /// transaction without commit aborts it (early returns here abort).
957    fn put_in_txn(&self, tx: &mut RwTransaction, galaxy: Galaxy, memory: &Memory) -> Result<()> {
958        let db = self.galaxy_db(galaxy)?;
959        let key = memory.metadata.id.as_bytes();
960        let val = self.encode_record_value(galaxy, memory)?;
961
962        // Overwrite semantics: capture the previous record (if any) so its
963        // index entries can be removed before the new ones are added.
964        // Otherwise stale tags, importance values, timestamps, and content
965        // hashes stay queryable after updates.
966        let existing = tx
967            .get(db, key)
968            .ok()
969            .and_then(|bytes| self.decode_record_value(galaxy, key, bytes).ok());
970
971        match tx.put(db, key, &val, lmdb::WriteFlags::default()) {
972            Ok(()) => {}
973            Err(lmdb::Error::MapFull) => {
974                return Err(CoreError::Memory(format!(
975                    "LMDB map full: galaxy {}, consider growing map size or pruning old memories",
976                    galaxy.db_name()
977                )));
978            }
979            Err(e) => {
980                return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
981            }
982        }
983        if let Some(existing) = existing {
984            self.index_dbs.remove(tx, galaxy, &existing)?;
985        }
986        self.index_dbs.add(tx, galaxy, memory)?;
987        Ok(())
988    }
989
990    /// Atomically allocate the next per-session turn sequence **and** store
991    /// the turn in one LMDB write transaction (H1, 2026-09-20 review).
992    ///
993    /// The review reproduced duplicate sequences under concurrent writers:
994    /// allocation was read-count-then-write (`load_turns().len() + 1`),
995    /// outside the serialization boundary. The counter lives in the
996    /// `session_sequences` DBI and is incremented inside the record's
997    /// transaction, so N concurrent writers get exactly 1..=N unique,
998    /// contiguous sequences with no burned numbers (a crash before commit
999    /// rolls back both the counter and the record).
1000    ///
1001    /// `build` runs with the allocated sequence while the transaction is
1002    /// open and returns the record to store — this keeps the sequence inside
1003    /// the record's own content truthful (the turn schema carries it)
1004    /// without a second write transaction.
1005    ///
1006    /// Returns `(sequence, stored_memory)`.
1007    pub fn put_session_turn<F>(&self, session_id: &str, build: F) -> Result<(u64, Memory)>
1008    where
1009        F: FnOnce(u64) -> Memory,
1010    {
1011        let seq_db = self.session_sequences_db.ok_or_else(|| {
1012            CoreError::Memory(
1013                "session_sequences DBI missing (legacy store opened read-only); \
1014                 a writable open repairs it via ensure_schema"
1015                    .into(),
1016            )
1017        })?;
1018        if let Some(limit) = self.max_entries_per_galaxy {
1019            let current = self.count(Galaxy::Sessions)?;
1020            if current >= limit {
1021                return Err(CoreError::Memory(format!(
1022                    "galaxy {} entry limit reached ({current}/{limit}), write rejected",
1023                    Galaxy::Sessions.db_name()
1024                )));
1025            }
1026        }
1027
1028        let mut tx = self
1029            .env
1030            .begin_rw_txn()
1031            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1032        let key: &[u8] = session_id.as_bytes();
1033        // A malformed counter is an error, never a silent reset to 0 —
1034        // resetting would re-issue sequences the session already used.
1035        let current = match tx.get(seq_db, &key) {
1036            Ok(bytes) => {
1037                let arr: [u8; 8] = <[u8; 8]>::try_from(bytes).map_err(|_| {
1038                    CoreError::Memory(format!(
1039                        "session_sequences value for {session_id} is malformed \
1040                         ({} bytes, want 8)",
1041                        bytes.len()
1042                    ))
1043                })?;
1044                u64::from_be_bytes(arr)
1045            }
1046            Err(lmdb::Error::NotFound) => 0,
1047            Err(e) => {
1048                return Err(CoreError::Memory(format!(
1049                    "LMDB get failed (session_sequences): {e}"
1050                )));
1051            }
1052        };
1053        let next = current
1054            .checked_add(1)
1055            .ok_or_else(|| CoreError::Memory("session sequence overflow (u64)".into()))?;
1056        tx.put(
1057            seq_db,
1058            &key,
1059            &next.to_be_bytes(),
1060            lmdb::WriteFlags::default(),
1061        )
1062        .map_err(|e| CoreError::Memory(format!("LMDB put failed (session_sequences): {e}")))?;
1063
1064        let memory = build(next);
1065        self.put_in_txn(&mut tx, Galaxy::Sessions, &memory)?;
1066        tx.commit()
1067            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1068        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1069        Ok((next, memory))
1070    }
1071
1072    /// Last allocated turn sequence for a session, read from the counter
1073    /// (no scan). `None` when the session has no allocated sequence yet, or
1074    /// when the store predates the `session_sequences` DBI.
1075    pub fn last_session_sequence(&self, session_id: &str) -> Result<Option<u64>> {
1076        let Some(seq_db) = self.session_sequences_db else {
1077            return Ok(None);
1078        };
1079        let tx = self
1080            .env
1081            .begin_ro_txn()
1082            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1083        let key: &[u8] = session_id.as_bytes();
1084        match tx.get(seq_db, &key) {
1085            Ok(bytes) => {
1086                let arr: [u8; 8] = <[u8; 8]>::try_from(bytes).map_err(|_| {
1087                    CoreError::Memory(format!(
1088                        "session_sequences value for {session_id} is malformed \
1089                         ({} bytes, want 8)",
1090                        bytes.len()
1091                    ))
1092                })?;
1093                Ok(Some(u64::from_be_bytes(arr)))
1094            }
1095            Err(lmdb::Error::NotFound) => Ok(None),
1096            Err(e) => Err(CoreError::Memory(format!(
1097                "LMDB get failed (session_sequences): {e}"
1098            ))),
1099        }
1100    }
1101
1102    /// Retrieve a memory by ID from the given galaxy.
1103    pub fn get(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<Option<Memory>> {
1104        let db = self.galaxy_db(galaxy)?;
1105        let key = id.as_bytes();
1106
1107        let tx = self
1108            .env
1109            .begin_ro_txn()
1110            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1111        let result = tx.get(db, key);
1112        match result {
1113            Ok(bytes) => {
1114                let memory = self.decode_record_value(galaxy, key, bytes)?;
1115                tx.commit()
1116                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1117                Ok(Some(memory))
1118            }
1119            Err(lmdb::Error::NotFound) => {
1120                // ReadOnly transactions don't strictly need commit, but it's good practice
1121                tx.commit()
1122                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1123                Ok(None)
1124            }
1125            Err(e) => Err(CoreError::Memory(format!("LMDB get failed: {e}"))),
1126        }
1127    }
1128
1129    /// Retrieve a memory by ID searching across all memory galaxies (S9 cross-galaxy traversal).
1130    ///
1131    /// Cross-galaxy associations and edges reference galaxy-blind UUIDs.
1132    /// This searches through all memory galaxies in canonical order and returns
1133    /// the first matching (Galaxy, Memory) pair, or None if not found.
1134    pub fn find_across_galaxies(&self, id: uuid::Uuid) -> Result<Option<(Galaxy, Memory)>> {
1135        for galaxy in Galaxy::memory_galaxies() {
1136            if let Some(mem) = self.get(galaxy, id)? {
1137                return Ok(Some((galaxy, mem)));
1138            }
1139        }
1140        Ok(None)
1141    }
1142
1143    /// Delete a memory by ID from the given galaxy. Returns true if a key was removed.
1144    /// Also removes all secondary index entries for the memory.
1145    pub fn delete(&self, galaxy: Galaxy, id: uuid::Uuid) -> Result<bool> {
1146        let db = self.galaxy_db(galaxy)?;
1147        let key = id.as_bytes();
1148
1149        let mut tx = self
1150            .env
1151            .begin_rw_txn()
1152            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1153
1154        // Check if key exists and deserialize for index cleanup
1155        let exists = tx.get(db, key).is_ok();
1156        if exists {
1157            // Read memory to get index values for cleanup
1158            if let Ok(bytes) = tx.get(db, key) {
1159                if let Ok(memory) = self.decode_record_value(galaxy, key, bytes) {
1160                    let _ = self.index_dbs.remove(&mut tx, galaxy, &memory);
1161                }
1162            }
1163            tx.del(db, key, None)
1164                .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
1165        }
1166        tx.commit()
1167            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1168        if exists {
1169            self.mutation_count.fetch_add(1, Ordering::Relaxed);
1170        }
1171        Ok(exists)
1172    }
1173
1174    /// Scan up to `limit` memories from the given galaxy (unordered by LMDB page layout).
1175    pub fn scan(&self, galaxy: Galaxy, limit: usize) -> Result<Vec<Memory>> {
1176        let db = self.galaxy_db(galaxy)?;
1177        let tx = self
1178            .env
1179            .begin_ro_txn()
1180            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1181
1182        let mut cursor = tx
1183            .open_ro_cursor(db)
1184            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1185
1186        let mut memories = Vec::with_capacity(limit.min(256));
1187        for (i, (key, val)) in cursor.iter().enumerate() {
1188            if memories.len() >= limit {
1189                break;
1190            }
1191            match self.decode_record_value(galaxy, key, val) {
1192                Ok(memory) => memories.push(memory),
1193                Err(e) => {
1194                    tracing::warn!(
1195                        "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
1196                        galaxy
1197                    );
1198                }
1199            }
1200        }
1201
1202        drop(cursor);
1203        tx.commit()
1204            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1205        Ok(memories)
1206    }
1207
1208    /// Scan every memory in the galaxy (unordered by LMDB page layout).
1209    ///
1210    /// Used by maintenance tooling (e.g. index rebuild). The full galaxy is
1211    /// materialized in memory — prefer [`Self::scan`] for bounded reads.
1212    pub fn scan_all(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
1213        self.scan_all_impl(galaxy, false)
1214    }
1215
1216    /// Maintenance scan that refuses to omit an undecodable source record.
1217    /// Use before replacing derived indexes; a tolerant scan is not a complete
1218    /// authoritative snapshot when any record fails decoding.
1219    pub fn scan_all_strict(&self, galaxy: Galaxy) -> Result<Vec<Memory>> {
1220        self.scan_all_impl(galaxy, true)
1221    }
1222
1223    fn scan_all_impl(&self, galaxy: Galaxy, strict: bool) -> Result<Vec<Memory>> {
1224        let db = self.galaxy_db(galaxy)?;
1225        let tx = self
1226            .env
1227            .begin_ro_txn()
1228            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1229
1230        let mut cursor = tx
1231            .open_ro_cursor(db)
1232            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1233
1234        let mut memories = Vec::new();
1235        for (i, (key, val)) in cursor.iter().enumerate() {
1236            match self.decode_record_value(galaxy, key, val) {
1237                Ok(memory) => memories.push(memory),
1238                Err(e) => {
1239                    if strict {
1240                        return Err(CoreError::Memory(format!(
1241                            "refusing incomplete scan of {}: record {i} cannot be decoded: {e}",
1242                            galaxy.db_name()
1243                        )));
1244                    }
1245                    tracing::warn!(
1246                        "Skipping corrupted entry at index {i} in galaxy {:?}: {e}",
1247                        galaxy
1248                    );
1249                }
1250            }
1251        }
1252
1253        drop(cursor);
1254        tx.commit()
1255            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1256        Ok(memories)
1257    }
1258
1259    /// Count entries in a galaxy.
1260    pub fn count(&self, galaxy: Galaxy) -> Result<usize> {
1261        let db = self.galaxy_db(galaxy)?;
1262        let tx = self
1263            .env
1264            .begin_ro_txn()
1265            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1266        let mut cursor = tx
1267            .open_ro_cursor(db)
1268            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1269        let count = cursor.iter().count();
1270        drop(cursor);
1271        tx.commit()
1272            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1273        Ok(count)
1274    }
1275
1276    /// Count entries in a galaxy carrying a tag, using the tag index (no
1277    /// record decoding). `wm status` uses this to report logical sessions
1278    /// (records tagged `start`) instead of every turn/checkpoint record
1279    /// stored in the Sessions galaxy.
1280    pub fn count_by_tag(&self, galaxy: Galaxy, tag: &str) -> Result<usize> {
1281        let tx = self
1282            .env
1283            .begin_ro_txn()
1284            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1285        let ids = self.index_dbs.find_by_tag(&tx, galaxy, tag)?;
1286        tx.commit()
1287            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1288        Ok(ids.len())
1289    }
1290
1291    /// Clear all memories from a galaxy in a single transaction.
1292    /// Returns the number of entries cleared.
1293    /// Also removes all secondary index entries.
1294    pub fn clear_galaxy(&self, galaxy: Galaxy) -> Result<usize> {
1295        let db = self.galaxy_db(galaxy)?;
1296
1297        let mut tx = self
1298            .env
1299            .begin_rw_txn()
1300            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1301
1302        let mut cursor = tx
1303            .open_ro_cursor(db)
1304            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
1305
1306        let mut count = 0usize;
1307        let keys_to_delete: Vec<(Vec<u8>, Memory)> = cursor
1308            .iter()
1309            .filter_map(|(key, val)| {
1310                if let Ok(memory) = self.decode_record_value(galaxy, key, val) {
1311                    Some((key.to_vec(), memory))
1312                } else {
1313                    None
1314                }
1315            })
1316            .collect();
1317
1318        drop(cursor);
1319
1320        for (key, memory) in &keys_to_delete {
1321            let _ = self.index_dbs.remove(&mut tx, galaxy, memory);
1322            tx.del(db, &key, None)
1323                .map_err(|e| CoreError::Memory(format!("LMDB del failed: {e}")))?;
1324            count += 1;
1325        }
1326
1327        tx.commit()
1328            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1329        self.mutation_count
1330            .fetch_add(count as u64, Ordering::Relaxed);
1331        Ok(count)
1332    }
1333
1334    /// Put multiple memories into a galaxy in a single transaction.
1335    /// Returns the number of memories written.
1336    pub fn batch_put(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<usize> {
1337        if memories.is_empty() {
1338            return Ok(0);
1339        }
1340
1341        let db = self.galaxy_db(galaxy)?;
1342
1343        let mut tx = self
1344            .env
1345            .begin_rw_txn()
1346            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1347
1348        let mut count = 0usize;
1349        for memory in memories {
1350            let key = memory.metadata.id.as_bytes();
1351            let val = self.encode_record_value(galaxy, memory)?;
1352            match tx.put(db, key, &val, WriteFlags::default()) {
1353                Ok(()) => {}
1354                Err(lmdb::Error::MapFull) => {
1355                    tx.abort();
1356                    return Err(CoreError::Memory(format!(
1357                        "LMDB map full: galaxy {}, consider growing map size",
1358                        galaxy.db_name()
1359                    )));
1360                }
1361                Err(e) => {
1362                    tx.abort();
1363                    return Err(CoreError::Memory(format!("LMDB put failed: {e}")));
1364                }
1365            }
1366            self.index_dbs.add(&mut tx, galaxy, memory)?;
1367            count += 1;
1368        }
1369
1370        tx.commit()
1371            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1372        self.mutation_count
1373            .fetch_add(count as u64, Ordering::Relaxed);
1374        Ok(count)
1375    }
1376
1377    /// Get raw key-value bytes (for advanced use cases).
1378    pub fn get_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<Option<Vec<u8>>> {
1379        let db = self.galaxy_db(galaxy)?;
1380        let tx = self
1381            .env
1382            .begin_ro_txn()
1383            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1384        match tx.get(db, &key) {
1385            Ok(bytes) => {
1386                let data = bytes.to_vec();
1387                tx.commit()
1388                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1389                Ok(Some(data))
1390            }
1391            Err(lmdb::Error::NotFound) => {
1392                tx.commit()
1393                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1394                Ok(None)
1395            }
1396            Err(e) => Err(CoreError::Memory(format!("LMDB get_raw failed: {e}"))),
1397        }
1398    }
1399
1400    /// Put raw key-value bytes (for advanced use cases).
1401    pub fn put_raw(&self, galaxy: Galaxy, key: &[u8], val: &[u8]) -> Result<()> {
1402        let db = self.galaxy_db(galaxy)?;
1403        let mut tx = self
1404            .env
1405            .begin_rw_txn()
1406            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1407        tx.put(db, &key, &val, lmdb::WriteFlags::default())
1408            .map_err(|e| CoreError::Memory(format!("LMDB put_raw failed: {e}")))?;
1409        tx.commit()
1410            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1411        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1412        Ok(())
1413    }
1414
1415    /// Delete raw key-value bytes (for advanced use cases).
1416    /// Returns true if a key was removed.
1417    pub fn delete_raw(&self, galaxy: Galaxy, key: &[u8]) -> Result<bool> {
1418        let db = self.galaxy_db(galaxy)?;
1419        let mut tx = self
1420            .env
1421            .begin_rw_txn()
1422            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1423        let deleted = tx.del(db, &key, None).is_ok();
1424        tx.commit()
1425            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1426        if deleted {
1427            self.mutation_count.fetch_add(1, Ordering::Relaxed);
1428        }
1429        Ok(deleted)
1430    }
1431
1432    /// Batch multiple raw key-value writes in a single LMDB transaction.
1433    /// All writes succeed or fail atomically.
1434    pub fn put_raw_batch(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
1435        self.put_raw_batch_impl(galaxy, entries)?;
1436        self.mutation_count
1437            .fetch_add(entries.len() as u64, Ordering::Relaxed);
1438        Ok(())
1439    }
1440
1441    /// Batch multiple raw key-value writes without advancing the mutation
1442    /// counter.
1443    ///
1444    /// For governance bookkeeping (karma chain, write-audit journal): these
1445    /// writes are metadata *about* dispatches, not memory mutations. Letting
1446    /// them tick the counter attributes a whole batch flush to whichever
1447    /// dispatch happens to be in flight when the threshold trips — the
1448    /// 2026-08-28 restore-drill false-positive class ("read-only" tools
1449    /// flagged with the previous batch's size as their write delta).
1450    pub fn put_raw_batch_untracked(
1451        &self,
1452        galaxy: Galaxy,
1453        entries: &[(&[u8], &[u8])],
1454    ) -> Result<()> {
1455        self.put_raw_batch_impl(galaxy, entries)
1456    }
1457
1458    fn put_raw_batch_impl(&self, galaxy: Galaxy, entries: &[(&[u8], &[u8])]) -> Result<()> {
1459        let db = self.galaxy_db(galaxy)?;
1460        let mut tx = self
1461            .env
1462            .begin_rw_txn()
1463            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1464        for (key, val) in entries {
1465            tx.put(db, key, val, WriteFlags::default())
1466                .map_err(|e| CoreError::Memory(format!("LMDB put_raw_batch failed: {e}")))?;
1467        }
1468        tx.commit()
1469            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1470        Ok(())
1471    }
1472
1473    // ── Content-hash Deduplication ────────────────────────────────────
1474
1475    /// Check if a memory with the same content hash already exists in the galaxy.
1476    /// Uses the content_hash index for O(1) lookup.
1477    /// Returns the existing memory's ID if found.
1478    pub fn find_by_content_hash(&self, galaxy: Galaxy, hash: &str) -> Result<Option<uuid::Uuid>> {
1479        let tx = self
1480            .env
1481            .begin_ro_txn()
1482            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1483        let result = self.index_dbs.find_by_content_hash(&tx, galaxy, hash)?;
1484        tx.commit()
1485            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1486        Ok(result)
1487    }
1488
1489    /// Scan-based content hash lookup (O(n) fallback, used for testing index correctness).
1490    pub fn find_by_content_hash_scan(
1491        &self,
1492        galaxy: Galaxy,
1493        hash: &str,
1494    ) -> Result<Option<uuid::Uuid>> {
1495        let memories = self.scan(galaxy, 10_000)?;
1496        for mem in memories {
1497            if mem.metadata.content_hash == hash {
1498                return Ok(Some(mem.metadata.id));
1499            }
1500        }
1501        Ok(None)
1502    }
1503
1504    /// Store a memory with content-hash deduplication.
1505    /// If a memory with the same content already exists in the galaxy,
1506    /// returns the existing memory's ID without creating a duplicate.
1507    pub fn put_dedup(&self, galaxy: Galaxy, memory: &Memory) -> Result<uuid::Uuid> {
1508        if let Some(existing_id) =
1509            self.find_by_content_hash(galaxy, &memory.metadata.content_hash)?
1510        {
1511            return Ok(existing_id);
1512        }
1513        let id = memory.metadata.id;
1514        self.put(galaxy, memory)?;
1515        Ok(id)
1516    }
1517
1518    // ── Write Batching ─────────────────────────────────────────────────
1519
1520    /// Store multiple memories in a single LMDB transaction (batch write).
1521    /// All writes and index updates succeed or fail atomically.
1522    pub fn put_batch(&self, galaxy: Galaxy, memories: &[Memory]) -> Result<()> {
1523        let db = self.galaxy_db(galaxy)?;
1524        let mut tx = self
1525            .env
1526            .begin_rw_txn()
1527            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1528
1529        for memory in memories {
1530            let key = memory.metadata.id.as_bytes();
1531            let val = self.encode_record_value(galaxy, memory)?;
1532            tx.put(db, key, &val, WriteFlags::default())
1533                .map_err(|e| CoreError::Memory(format!("LMDB put_batch failed: {e}")))?;
1534            self.index_dbs.add(&mut tx, galaxy, memory)?;
1535        }
1536
1537        tx.commit()
1538            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1539        self.mutation_count
1540            .fetch_add(memories.len() as u64, Ordering::Relaxed);
1541        Ok(())
1542    }
1543
1544    // ── Query API ──────────────────────────────────────────────────────
1545
1546    /// Query memories in a galaxy with filtering.
1547    /// Uses secondary indexes when the query is a pure single-dimension filter
1548    /// (single tag, importance range, or time range with no other filters).
1549    /// Falls back to scan for complex multi-dimensional queries.
1550    pub fn query(&self, galaxy: Galaxy, query: &MemoryQuery) -> Result<Vec<Memory>> {
1551        // Try indexed fast paths for single-dimension queries. A substring
1552        // filter forces the full scan — the indexes cannot evaluate it.
1553        if query.content_substring.is_none()
1554            && query.tags.len() == 1
1555            && query.min_importance.is_none()
1556            && query.max_importance.is_none()
1557            && query.created_after.is_none()
1558            && query.created_before.is_none()
1559        {
1560            return self.query_by_tag_indexed(galaxy, &query.tags[0], query.limit);
1561        }
1562
1563        if query.content_substring.is_none()
1564            && query.tags.is_empty()
1565            && let Some(min) = query.min_importance
1566            && let Some(max) = query.max_importance
1567            && query.created_after.is_none()
1568            && query.created_before.is_none()
1569        {
1570            return self.query_by_importance_indexed(galaxy, min, max, query.limit);
1571        }
1572
1573        if query.content_substring.is_none()
1574            && query.tags.is_empty()
1575            && query.min_importance.is_none()
1576            && query.max_importance.is_none()
1577            && let Some(after) = query.created_after
1578            && let Some(before) = query.created_before
1579        {
1580            return self.query_by_time_indexed(galaxy, after, before, query.limit);
1581        }
1582
1583        // Fallback: full scan with in-memory filter
1584        let memories = self.scan(galaxy, 10_000)?;
1585        let mut results = Vec::new();
1586        for mem in memories {
1587            if query.matches(&mem) {
1588                results.push(mem);
1589                if results.len() >= query.limit {
1590                    break;
1591                }
1592            }
1593        }
1594        Ok(results)
1595    }
1596
1597    /// Tag-based indexed query → memories with the given tag.
1598    fn query_by_tag_indexed(&self, galaxy: Galaxy, tag: &str, limit: usize) -> Result<Vec<Memory>> {
1599        let db = self.galaxy_db(galaxy)?;
1600        let tx = self
1601            .env
1602            .begin_ro_txn()
1603            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1604        let ids = self.index_dbs.find_by_tag(&tx, galaxy, tag)?;
1605        let mut results = Vec::new();
1606        for id in &ids {
1607            if results.len() >= limit {
1608                break;
1609            }
1610            if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1611                if let Ok(mem) = self.decode_record_value(galaxy, id.as_bytes(), bytes) {
1612                    results.push(mem);
1613                }
1614            }
1615        }
1616        tx.commit()
1617            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1618        Ok(results)
1619    }
1620
1621    /// Importance-range indexed query → memories with importance in [min, max].
1622    fn query_by_importance_indexed(
1623        &self,
1624        galaxy: Galaxy,
1625        min: f32,
1626        max: f32,
1627        limit: usize,
1628    ) -> Result<Vec<Memory>> {
1629        let db = self.galaxy_db(galaxy)?;
1630        let tx = self
1631            .env
1632            .begin_ro_txn()
1633            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1634        let ids = self
1635            .index_dbs
1636            .find_by_importance_range(&tx, galaxy, min, max)?;
1637        let mut results = Vec::new();
1638        for id in &ids {
1639            if results.len() >= limit {
1640                break;
1641            }
1642            if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1643                if let Ok(mem) = self.decode_record_value(galaxy, id.as_bytes(), bytes) {
1644                    results.push(mem);
1645                }
1646            }
1647        }
1648        tx.commit()
1649            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1650        Ok(results)
1651    }
1652
1653    /// Time-range indexed query → memories created in [after, before].
1654    fn query_by_time_indexed(
1655        &self,
1656        galaxy: Galaxy,
1657        after: chrono::DateTime<chrono::Utc>,
1658        before: chrono::DateTime<chrono::Utc>,
1659        limit: usize,
1660    ) -> Result<Vec<Memory>> {
1661        let db = self.galaxy_db(galaxy)?;
1662        let tx = self
1663            .env
1664            .begin_ro_txn()
1665            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1666        let ids = self
1667            .index_dbs
1668            .find_by_time_range(&tx, galaxy, after, before)?;
1669        let mut results = Vec::new();
1670        for id in &ids {
1671            if results.len() >= limit {
1672                break;
1673            }
1674            if let Ok(bytes) = tx.get(db, id.as_bytes()) {
1675                if let Ok(mem) = self.decode_record_value(galaxy, id.as_bytes(), bytes) {
1676                    results.push(mem);
1677                }
1678            }
1679        }
1680        tx.commit()
1681            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1682        Ok(results)
1683    }
1684
1685    // ── Semantic Coordinate Encoding ─────────────────────────────────────
1686
1687    /// Store a memory with semantically-derived 5D coordinates.
1688    ///
1689    /// Replaces the SHA-256 hash-based `Coordinate5D::encode()` with
1690    /// anchor-based TF projection. The memory's `coord5d` field is updated
1691    /// with semantically meaningful x/y/z values before storage.
1692    pub fn put_semantic(&self, galaxy: Galaxy, memory: &mut Memory) -> Result<()> {
1693        let temporal_weight = memory.metadata.coord5d.w;
1694        let importance = memory.metadata.importance;
1695        memory.metadata.coord5d =
1696            self.semantic_encoder
1697                .encode_coordinate(&memory.content, temporal_weight, importance);
1698        self.put(galaxy, memory)
1699    }
1700
1701    /// Find memories in a galaxy with content semantically similar to the query text.
1702    ///
1703    /// Encodes the query text into a 5D coordinate and scans the galaxy,
1704    /// returning memories sorted by semantic distance (nearest first).
1705    pub fn find_similar(
1706        &self,
1707        galaxy: Galaxy,
1708        query_text: &str,
1709        limit: usize,
1710    ) -> Result<Vec<(Memory, f32)>> {
1711        let query_coord = self
1712            .semantic_encoder
1713            .encode_coordinate(query_text, 0.5, 0.5);
1714        let memories = self.scan(galaxy, 10_000)?;
1715        let mut results: Vec<(Memory, f32)> = memories
1716            .into_iter()
1717            .map(|m| {
1718                let dist = query_coord.semantic_distance_to(&m.metadata.coord5d);
1719                (m, dist)
1720            })
1721            .collect();
1722        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1723        results.truncate(limit);
1724        Ok(results)
1725    }
1726
1727    // ── Embedding Storage ──────────────────────────────────────────────
1728
1729    /// Store an embedding vector for a memory in the Embeddings galaxy.
1730    /// Keyed by the memory's UUID.
1731    pub fn put_embedding(&self, memory_id: uuid::Uuid, embedding: &[f32]) -> Result<()> {
1732        let db = self.galaxy_db(Galaxy::Embeddings)?;
1733        let key = memory_id.as_bytes();
1734        let val = encode_embedding(embedding);
1735
1736        let mut tx = self
1737            .env
1738            .begin_rw_txn()
1739            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1740        tx.put(db, key, &val, WriteFlags::default())
1741            .map_err(|e| CoreError::Memory(format!("LMDB put_embedding failed: {e}")))?;
1742        tx.commit()
1743            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1744        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1745        Ok(())
1746    }
1747
1748    /// Retrieve an embedding vector for a memory from the Embeddings galaxy.
1749    pub fn get_embedding(&self, memory_id: uuid::Uuid) -> Result<Option<Vec<f32>>> {
1750        let db = self.galaxy_db(Galaxy::Embeddings)?;
1751        let key = memory_id.as_bytes();
1752
1753        let tx = self
1754            .env
1755            .begin_ro_txn()
1756            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1757        match tx.get(db, key) {
1758            Ok(bytes) => {
1759                let embedding = decode_embedding(bytes);
1760                tx.commit()
1761                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1762                Ok(Some(embedding))
1763            }
1764            Err(lmdb::Error::NotFound) => {
1765                tx.commit()
1766                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1767                Ok(None)
1768            }
1769            Err(e) => Err(CoreError::Memory(format!("LMDB get_embedding failed: {e}"))),
1770        }
1771    }
1772
1773    /// Delete an embedding vector from the Embeddings galaxy.
1774    pub fn delete_embedding(&self, memory_id: uuid::Uuid) -> Result<bool> {
1775        let db = self.galaxy_db(Galaxy::Embeddings)?;
1776        let key = memory_id.as_bytes();
1777
1778        let mut tx = self
1779            .env
1780            .begin_rw_txn()
1781            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1782        let exists = tx.get(db, key).is_ok();
1783        if exists {
1784            tx.del(db, key, None)
1785                .map_err(|e| CoreError::Memory(format!("LMDB del_embedding failed: {e}")))?;
1786        }
1787        tx.commit()
1788            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1789        if exists {
1790            self.mutation_count.fetch_add(1, Ordering::Relaxed);
1791        }
1792        Ok(exists)
1793    }
1794
1795    // ── Embedding cache (content-hash → vector) ────────────────────────
1796
1797    /// Store a cached embedding under a caller-computed cache key
1798    /// (embedder namespace + content hash). Vectors for the same content
1799    /// differ across models, so the key must carry the namespace.
1800    pub fn put_embedding_cache(&self, cache_key: &str, embedding: &[f32]) -> Result<()> {
1801        let mut tx = self
1802            .env
1803            .begin_rw_txn()
1804            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1805        tx.put(
1806            self.embedding_cache_db,
1807            &cache_key.as_bytes().to_vec(),
1808            &encode_embedding(embedding),
1809            WriteFlags::default(),
1810        )
1811        .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1812        tx.commit()
1813            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1814        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1815        Ok(())
1816    }
1817
1818    /// Batched cache write: one transaction for the whole ingest chunk.
1819    pub fn put_embedding_cache_batch(&self, entries: &[(String, Vec<f32>)]) -> Result<()> {
1820        if entries.is_empty() {
1821            return Ok(());
1822        }
1823        let mut tx = self
1824            .env
1825            .begin_rw_txn()
1826            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1827        for (key, embedding) in entries {
1828            tx.put(
1829                self.embedding_cache_db,
1830                &key.as_bytes().to_vec(),
1831                &encode_embedding(embedding),
1832                WriteFlags::default(),
1833            )
1834            .map_err(|e| CoreError::Memory(format!("LMDB put_embedding_cache failed: {e}")))?;
1835        }
1836        tx.commit()
1837            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1838        self.mutation_count
1839            .fetch_add(entries.len() as u64, Ordering::Relaxed);
1840        Ok(())
1841    }
1842
1843    /// Look up a cached embedding. `Ok(None)` = miss; the caller embeds.
1844    pub fn get_embedding_cache(&self, cache_key: &str) -> Result<Option<Vec<f32>>> {
1845        let tx = self
1846            .env
1847            .begin_ro_txn()
1848            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1849        match tx.get(self.embedding_cache_db, &cache_key.as_bytes().to_vec()) {
1850            Ok(bytes) => {
1851                let embedding = decode_embedding(bytes);
1852                tx.commit()
1853                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1854                Ok(Some(embedding))
1855            }
1856            Err(lmdb::Error::NotFound) => {
1857                tx.commit()
1858                    .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1859                Ok(None)
1860            }
1861            Err(e) => Err(CoreError::Memory(format!(
1862                "LMDB get_embedding_cache failed: {e}"
1863            ))),
1864        }
1865    }
1866
1867    /// Batched lookup: one read transaction for the whole ingest chunk.
1868    /// Result aligns 1:1 with `keys`.
1869    pub fn get_embedding_cache_batch(&self, keys: &[String]) -> Result<Vec<Option<Vec<f32>>>> {
1870        let tx = self
1871            .env
1872            .begin_ro_txn()
1873            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1874        let mut out = Vec::with_capacity(keys.len());
1875        for key in keys {
1876            out.push(
1877                tx.get(self.embedding_cache_db, &key.as_bytes().to_vec())
1878                    .ok()
1879                    .map(decode_embedding),
1880            );
1881        }
1882        tx.commit()
1883            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1884        Ok(out)
1885    }
1886
1887    /// Number of cached vectors (doctor / honesty surfaces).
1888    pub fn embedding_cache_count(&self) -> Result<u64> {
1889        let tx = self
1890            .env
1891            .begin_ro_txn()
1892            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1893        let mut cursor = tx
1894            .open_ro_cursor(self.embedding_cache_db)
1895            .map_err(|e| CoreError::Memory(format!("LMDB cursor embedding_cache failed: {e}")))?;
1896        let mut count = 0u64;
1897        for _ in cursor.iter() {
1898            count += 1;
1899        }
1900        Ok(count)
1901    }
1902
1903    // ── Revision chain (V8 S11c) ────────────────────────────────────────
1904
1905    /// Append one content revision to a memory's chain. Seq is derived
1906    /// from the current chain tail (append-only by convention); the write
1907    /// bumps the store mutation counter so dispatch windows attribute it.
1908    pub fn record_revision(
1909        &self,
1910        galaxy: Galaxy,
1911        id: MemoryId,
1912        old_hash: &str,
1913        new_hash: &str,
1914        actor: crate::revision::RevisionActor,
1915    ) -> Result<crate::revision::MemoryRevision> {
1916        let seq = self.revisions(galaxy, id)?.len() as u32;
1917        let entry = crate::revision::MemoryRevision {
1918            seq,
1919            timestamp: wm_core::time::now_unix_secs(),
1920            old_hash: old_hash.to_string(),
1921            new_hash: new_hash.to_string(),
1922            actor_session: actor.session,
1923            actor_user: actor.user,
1924            actor_compartment: actor.compartment,
1925        };
1926        let key = crate::revision::revision_key(galaxy, id, seq);
1927        let val = serde_json::to_vec(&entry)
1928            .map_err(|e| CoreError::Memory(format!("revision serialize failed: {e}")))?;
1929        let mut tx = self
1930            .env
1931            .begin_rw_txn()
1932            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
1933        tx.put(self.revisions_db, &key, &val, WriteFlags::default())
1934            .map_err(|e| CoreError::Memory(format!("LMDB put revision failed: {e}")))?;
1935        tx.commit()
1936            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1937        self.mutation_count.fetch_add(1, Ordering::Relaxed);
1938        Ok(entry)
1939    }
1940
1941    /// Full revision chain for a memory, ordered by seq. Empty for
1942    /// memories never content-updated (or pre-S11c).
1943    pub fn revisions(
1944        &self,
1945        galaxy: Galaxy,
1946        id: MemoryId,
1947    ) -> Result<Vec<crate::revision::MemoryRevision>> {
1948        // Cursor-op constants from lmdb.h (frozen LMDB ABI): the `lmdb`
1949        // crate's `iter_from` panics on a SetRange miss, and a miss is a
1950        // normal state here (a memory with no revisions yet sorts after
1951        // every existing key), so the cursor is driven manually.
1952        const MDB_GET_CURRENT: u32 = 4;
1953        const MDB_NEXT: u32 = 8;
1954        const MDB_SET_RANGE: u32 = 17;
1955        let prefix = crate::revision::revision_prefix(galaxy, id);
1956        let tx = self
1957            .env
1958            .begin_ro_txn()
1959            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
1960        let cursor = tx
1961            .open_ro_cursor(self.revisions_db)
1962            .map_err(|e| CoreError::Memory(format!("LMDB cursor revisions failed: {e}")))?;
1963        let mut out = Vec::new();
1964        if cursor.get(Some(&prefix), None, MDB_SET_RANGE).is_ok() {
1965            while let Ok((key, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
1966                // `key` is None only for ops that return no key — GET_CURRENT
1967                // after a positioned read always carries one.
1968                if !key.is_some_and(|k| k.starts_with(&prefix)) {
1969                    break;
1970                }
1971                let entry: crate::revision::MemoryRevision = serde_json::from_slice(val)
1972                    .map_err(|e| CoreError::Memory(format!("revision deserialize failed: {e}")))?;
1973                out.push(entry);
1974                // Bounded walk — corrupt data can never spin this loop.
1975                if out.len() >= 10_000 || cursor.get(None, None, MDB_NEXT).is_err() {
1976                    break;
1977                }
1978            }
1979        }
1980        drop(cursor);
1981        tx.commit()
1982            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
1983        Ok(out)
1984    }
1985
1986    /// Walk one memory's revision chain and grade it against the memory's
1987    /// current content hash (seq continuity, hash linkage, head match).
1988    pub fn verify_revision_chain(
1989        &self,
1990        galaxy: Galaxy,
1991        id: MemoryId,
1992        current_hash: &str,
1993    ) -> Result<crate::revision::RevisionChainReport> {
1994        let entries = self.revisions(galaxy, id)?;
1995        Ok(crate::revision::verify_chain(&entries, current_hash))
1996    }
1997
1998    // ── Record attestations (Track F Slice A, D5) ───────────────────────
1999
2000    /// Record one creation attestation for a memory. Upsert by key
2001    /// (`att:{galaxy}:{memory_id}`): re-attestation overwrites, which is
2002    /// safe because attestation covers the *creation* event and memory ids
2003    /// are unique per create. The write bumps the mutation counter like
2004    /// every other store mutation.
2005    pub fn record_attestation(
2006        &self,
2007        galaxy: Galaxy,
2008        id: MemoryId,
2009        entry: &crate::attestation::RecordAttestation,
2010    ) -> Result<()> {
2011        let key = crate::attestation::attestation_key(galaxy, id);
2012        let val = serde_json::to_vec(entry)
2013            .map_err(|e| CoreError::Memory(format!("attestation serialize failed: {e}")))?;
2014        let mut tx = self
2015            .env
2016            .begin_rw_txn()
2017            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
2018        tx.put(self.attestations_db, &key, &val, WriteFlags::default())
2019            .map_err(|e| CoreError::Memory(format!("LMDB put attestation failed: {e}")))?;
2020        tx.commit()
2021            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2022        self.mutation_count.fetch_add(1, Ordering::Relaxed);
2023        Ok(())
2024    }
2025
2026    /// This memory's creation attestation, if the creating dispatch signed
2027    /// one (key-available creates only — absence is honest, not an error).
2028    pub fn attestation(
2029        &self,
2030        galaxy: Galaxy,
2031        id: MemoryId,
2032    ) -> Result<Option<crate::attestation::RecordAttestation>> {
2033        let key = crate::attestation::attestation_key(galaxy, id);
2034        let tx = self
2035            .env
2036            .begin_ro_txn()
2037            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2038        let out =
2039            match tx.get(self.attestations_db, &key) {
2040                Ok(val) => Some(serde_json::from_slice(val).map_err(|e| {
2041                    CoreError::Memory(format!("attestation deserialize failed: {e}"))
2042                })?),
2043                Err(lmdb::Error::NotFound) => None,
2044                Err(e) => {
2045                    return Err(CoreError::Memory(format!(
2046                        "LMDB get attestation failed: {e}"
2047                    )));
2048                }
2049            };
2050        drop(tx);
2051        Ok(out)
2052    }
2053
2054    /// Every attestation in the store (for `wm anchor`). Bounded walk —
2055    /// corrupt data can never spin this loop.
2056    pub fn scan_attestations(&self) -> Result<Vec<crate::attestation::RecordAttestation>> {
2057        const MDB_GET_CURRENT: u32 = 4;
2058        const MDB_NEXT: u32 = 8;
2059        const MDB_FIRST: u32 = 9;
2060        let tx = self
2061            .env
2062            .begin_ro_txn()
2063            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2064        let cursor = tx
2065            .open_ro_cursor(self.attestations_db)
2066            .map_err(|e| CoreError::Memory(format!("LMDB cursor attestations failed: {e}")))?;
2067        let mut out = Vec::new();
2068        if cursor.get(None, None, MDB_FIRST).is_ok() {
2069            while let Ok((_, val)) = cursor.get(None, None, MDB_GET_CURRENT) {
2070                let entry: crate::attestation::RecordAttestation = serde_json::from_slice(val)
2071                    .map_err(|e| {
2072                        CoreError::Memory(format!("attestation deserialize failed: {e}"))
2073                    })?;
2074                out.push(entry);
2075                // Bounded walk — corrupt data can never spin this loop.
2076                if out.len() >= 1_000_000 || cursor.get(None, None, MDB_NEXT).is_err() {
2077                    break;
2078                }
2079            }
2080        }
2081        drop(cursor);
2082        tx.commit()
2083            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2084        Ok(out)
2085    }
2086
2087    /// Grade one memory's attestation: presence, signature validity, and
2088    /// whether the attested hash still matches the live content hash.
2089    /// A content *update* after creation flips `matches_head` to false by
2090    /// design — updates are covered by the revisions chain, not by
2091    /// re-attestation.
2092    pub fn verify_attestation(
2093        &self,
2094        galaxy: Galaxy,
2095        id: MemoryId,
2096    ) -> Result<crate::attestation::AttestationReport> {
2097        use crate::attestation::AttestationReport;
2098        let Some(att) = self.attestation(galaxy, id)? else {
2099            return Ok(AttestationReport {
2100                attested: false,
2101                signature_valid: false,
2102                matches_head: false,
2103                memory_present: self.get(galaxy, id)?.is_some(),
2104                breaks: vec!["no attestation recorded for this memory".to_string()],
2105            });
2106        };
2107        let mut breaks = Vec::new();
2108        let signature_valid = crate::attestation::verify_attestation(&att);
2109        if !signature_valid {
2110            breaks.push("signature does not verify against recorded pubkey".to_string());
2111        }
2112        let (matches_head, memory_present) = if let Some(memory) = self.get(galaxy, id)? {
2113            let matches = memory.metadata.content_hash == att.record_hash;
2114            if !matches {
2115                breaks.push(
2116                    "attested record_hash != live content_hash (memory updated after attestation)"
2117                        .to_string(),
2118                );
2119            }
2120            (matches, true)
2121        } else {
2122            breaks.push("attested memory id not present in galaxy".to_string());
2123            (false, false)
2124        };
2125        Ok(AttestationReport {
2126            attested: true,
2127            signature_valid,
2128            matches_head,
2129            memory_present,
2130            breaks,
2131        })
2132    }
2133
2134    /// Grade every attestation in the store (for `wm anchor`). Entries
2135    /// whose galaxy/id no longer parse are reported as broken — never
2136    /// skipped silently.
2137    pub fn attestation_sweep(
2138        &self,
2139    ) -> Result<
2140        Vec<(
2141            crate::attestation::RecordAttestation,
2142            crate::attestation::AttestationReport,
2143        )>,
2144    > {
2145        use crate::attestation::AttestationReport;
2146        let mut out = Vec::new();
2147        for att in self.scan_attestations()? {
2148            let parsed = match (
2149                Galaxy::from_db_name(&att.galaxy),
2150                uuid::Uuid::parse_str(&att.memory_id),
2151            ) {
2152                (Some(galaxy), Ok(id)) => Some((galaxy, id)),
2153                _ => None,
2154            };
2155            match parsed {
2156                Some((galaxy, id)) => out.push((att, self.verify_attestation(galaxy, id)?)),
2157                None => out.push((
2158                    att,
2159                    AttestationReport {
2160                        attested: true,
2161                        signature_valid: false,
2162                        matches_head: false,
2163                        memory_present: false,
2164                        breaks: vec![
2165                            "attestation row has unparseable galaxy or memory id".to_string(),
2166                        ],
2167                    },
2168                )),
2169            }
2170        }
2171        Ok(out)
2172    }
2173
2174    // ── Non-Destructive Phagic Cold-Storage (the project's sacred rule) ───────
2175
2176    /// Store a compressed cold record in the cold_storage DBI.
2177    pub fn put_cold_record(&self, record: &crate::cold_storage::ColdRecord) -> Result<()> {
2178        let key = record.id.as_bytes();
2179        let val = rmp_serde::to_vec_named(record)
2180            .map_err(|e| CoreError::Memory(format!("Cold record serialization failed: {e}")))?;
2181        let mut tx = self
2182            .env
2183            .begin_rw_txn()
2184            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
2185        tx.put(self.cold_storage_db, key, &val, WriteFlags::default())
2186            .map_err(|e| CoreError::Memory(format!("LMDB put cold_storage failed: {e}")))?;
2187        tx.commit()
2188            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2189        self.mutation_count.fetch_add(1, Ordering::Relaxed);
2190        Ok(())
2191    }
2192
2193    /// Retrieve a compressed cold record by memory id.
2194    pub fn get_cold_record(&self, id: MemoryId) -> Result<Option<crate::cold_storage::ColdRecord>> {
2195        let key = id.as_bytes();
2196        let tx = self
2197            .env
2198            .begin_ro_txn()
2199            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2200        match tx.get(self.cold_storage_db, key) {
2201            Ok(bytes) => {
2202                let record: crate::cold_storage::ColdRecord = rmp_serde::from_slice(bytes)
2203                    .map_err(|e| {
2204                        CoreError::Memory(format!("Cold record deserialization failed: {e}"))
2205                    })?;
2206                Ok(Some(record))
2207            }
2208            Err(lmdb::Error::NotFound) => Ok(None),
2209            Err(e) => Err(CoreError::Memory(format!(
2210                "LMDB get cold_storage failed: {e}"
2211            ))),
2212        }
2213    }
2214
2215    /// Delete a cold record from the cold storage DBI (used when thawing back to hot).
2216    pub fn delete_cold_record(&self, id: MemoryId) -> Result<bool> {
2217        let key = id.as_bytes();
2218        let mut tx = self
2219            .env
2220            .begin_rw_txn()
2221            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
2222        let deleted = match tx.del(self.cold_storage_db, key, None) {
2223            Ok(()) => true,
2224            Err(lmdb::Error::NotFound) => false,
2225            Err(e) => {
2226                return Err(CoreError::Memory(format!(
2227                    "LMDB del cold_storage failed: {e}"
2228                )));
2229            }
2230        };
2231        tx.commit()
2232            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
2233        if deleted {
2234            self.mutation_count.fetch_add(1, Ordering::Relaxed);
2235        }
2236        Ok(deleted)
2237    }
2238
2239    /// Count cold records in the cold storage database, optionally filtered by galaxy.
2240    pub fn count_cold(&self, galaxy: Option<Galaxy>) -> Result<usize> {
2241        let tx = self
2242            .env
2243            .begin_ro_txn()
2244            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2245        let mut cursor = tx
2246            .open_ro_cursor(self.cold_storage_db)
2247            .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2248        let mut count = 0;
2249        for (_key, val) in cursor.iter() {
2250            if let Some(target_g) = galaxy {
2251                let record: crate::cold_storage::ColdRecord =
2252                    rmp_serde::from_slice(val).map_err(|e| {
2253                        CoreError::Memory(format!("Cold record deserialization failed: {e}"))
2254                    })?;
2255                if record.galaxy == target_g {
2256                    count += 1;
2257                }
2258            } else {
2259                count += 1;
2260            }
2261        }
2262        Ok(count)
2263    }
2264
2265    /// List cold records (summaries) with optional galaxy filter and limit.
2266    pub fn list_cold_records(
2267        &self,
2268        galaxy: Option<Galaxy>,
2269        limit: usize,
2270    ) -> Result<Vec<crate::cold_storage::ColdRecordSummary>> {
2271        let query = crate::cold_storage::ColdQuery {
2272            galaxy,
2273            limit: if limit == 0 { 100 } else { limit },
2274            ..Default::default()
2275        };
2276        self.query_cold_records(&query)
2277    }
2278
2279    /// Query cold records matching a `ColdQuery` filter.
2280    pub fn query_cold_records(
2281        &self,
2282        query: &crate::cold_storage::ColdQuery,
2283    ) -> Result<Vec<crate::cold_storage::ColdRecordSummary>> {
2284        let tx = self
2285            .env
2286            .begin_ro_txn()
2287            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2288        let mut cursor = tx
2289            .open_ro_cursor(self.cold_storage_db)
2290            .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2291        let mut results = Vec::new();
2292        let limit = if query.limit == 0 {
2293            usize::MAX
2294        } else {
2295            query.limit
2296        };
2297
2298        for (_key, val) in cursor.iter() {
2299            let record: crate::cold_storage::ColdRecord =
2300                rmp_serde::from_slice(val).map_err(|e| {
2301                    CoreError::Memory(format!("Cold record deserialization failed: {e}"))
2302                })?;
2303            let summary = record.summary();
2304            if query.matches(&summary) {
2305                results.push(summary);
2306                if results.len() >= limit {
2307                    break;
2308                }
2309            }
2310        }
2311        Ok(results)
2312    }
2313
2314    /// Bounded, identity-bound cold discovery.
2315    ///
2316    /// Scans at most `max_scan` cold records (LMDB `cold_storage` DBI),
2317    /// filters by galaxy when given, decompresses each candidate, verifies
2318    /// the id/galaxy/content-hash chain, applies visibility (private never
2319    /// surfaces; superseded/non-current records are skipped), and returns
2320    /// up to `limit` full cold records whose content or tags contain every
2321    /// query term (case-insensitive). Nothing is thawed or mutated.
2322    pub fn find_cold_matching(
2323        &self,
2324        terms: &[String],
2325        galaxy: Option<Galaxy>,
2326        limit: usize,
2327        max_scan: usize,
2328    ) -> Result<crate::cold_storage::ColdDiscoveryOutcome> {
2329        self.find_cold_matching_eligible(terms, galaxy, limit, max_scan, |_| true)
2330    }
2331
2332    /// Apply caller eligibility to verified payloads before consuming result
2333    /// capacity. Rejected matches still consume the bounded scan budget.
2334    pub fn find_cold_matching_eligible(
2335        &self,
2336        terms: &[String],
2337        galaxy: Option<Galaxy>,
2338        limit: usize,
2339        max_scan: usize,
2340        eligible: impl Fn(&Memory) -> bool,
2341    ) -> Result<crate::cold_storage::ColdDiscoveryOutcome> {
2342        use crate::cold_storage::ColdDiscoveryStop;
2343        let mut out = crate::cold_storage::ColdDiscoveryOutcome::default();
2344        if terms.is_empty() || limit == 0 || max_scan == 0 {
2345            return Ok(out);
2346        }
2347        let tx = self
2348            .env
2349            .begin_ro_txn()
2350            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
2351        let mut cursor = tx
2352            .open_ro_cursor(self.cold_storage_db)
2353            .map_err(|e| CoreError::Memory(format!("LMDB open_ro_cursor failed: {e}")))?;
2354        let mut iter = cursor.iter();
2355        loop {
2356            if out.scanned >= max_scan {
2357                out.stop_reason = ColdDiscoveryStop::ScanLimit;
2358                break;
2359            }
2360            if out.records.len() >= limit {
2361                out.stop_reason = ColdDiscoveryStop::ResultLimit;
2362                break;
2363            }
2364            let Some((key, val)) = iter.next() else {
2365                out.stop_reason = ColdDiscoveryStop::Exhausted;
2366                break;
2367            };
2368            out.scanned += 1;
2369            let record: crate::cold_storage::ColdRecord = if let Ok(r) = rmp_serde::from_slice(val)
2370            {
2371                r
2372            } else {
2373                out.integrity_rejected += 1;
2374                continue;
2375            };
2376            if let Some(g) = galaxy {
2377                if record.galaxy != g {
2378                    continue;
2379                }
2380            }
2381            out.candidates += 1;
2382            let mem = if let Ok(m) = record.decompress() {
2383                m
2384            } else {
2385                out.integrity_rejected += 1;
2386                continue;
2387            };
2388            if mem.metadata.is_private {
2389                out.private_skipped += 1;
2390                continue;
2391            }
2392            if !mem.metadata.validity.is_current() {
2393                out.non_current_skipped += 1;
2394                continue;
2395            }
2396            let integrity_ok = key == record.id.as_bytes()
2397                && mem.metadata.id == record.id
2398                && mem.metadata.galaxy == record.galaxy
2399                && mem.metadata.content_hash == record.content_hash
2400                && crate::content_hash(&mem.content) == record.content_hash;
2401            if !integrity_ok {
2402                out.integrity_rejected += 1;
2403                continue;
2404            }
2405            let haystack = format!(
2406                "{} {}",
2407                mem.content.to_lowercase(),
2408                mem.metadata.tags.join(" ").to_lowercase()
2409            );
2410            if !terms.iter().all(|t| haystack.contains(t.as_str())) {
2411                continue;
2412            }
2413            out.matched += 1;
2414            if !eligible(&mem) {
2415                out.eligibility_skipped += 1;
2416                continue;
2417            }
2418            out.records.push(record);
2419        }
2420        Ok(out)
2421    }
2422
2423    /// Freeze an active hot memory into the compressed cold archive.
2424    ///
2425    /// Non-destructive: preserves complete metadata, vector clocks, content, embeddings,
2426    /// and provenance. The memory transitions to `Tier::Archival`, is stored in `cold_storage_db`,
2427    /// is deindexed from Tantivy (if `search` provided), and is removed from the active hot galaxy.
2428    pub fn freeze_to_cold(
2429        &self,
2430        search: Option<&crate::SearchEngine>,
2431        memory_id: MemoryId,
2432        distance: f32,
2433        factors: crate::cold_storage::OuterRimFactors,
2434        digest_id: Option<MemoryId>,
2435        notes: Option<String>,
2436        codec: crate::cold_storage::CompressionCodec,
2437    ) -> Result<crate::cold_storage::ColdRecord> {
2438        let (galaxy, mut mem) = self.find_across_galaxies(memory_id)?.ok_or_else(|| {
2439            CoreError::NotFound(format!("Memory {memory_id} not found in hot store"))
2440        })?;
2441
2442        // Transition tier to Archival
2443        if mem.metadata.tier != crate::memory::Tier::Archival {
2444            let _ = mem.transition_tier(crate::memory::Tier::Archival);
2445        }
2446
2447        let record =
2448            crate::cold_storage::ColdRecord::new(&mem, distance, factors, digest_id, notes, codec)?;
2449
2450        // Store into cold archive
2451        self.put_cold_record(&record)?;
2452
2453        // Remove from active hot galaxy
2454        self.delete(galaxy, memory_id)?;
2455
2456        // Deindex from Tantivy search engine if provided
2457        if let Some(engine) = search {
2458            if let Ok(mut writer_guard) = engine.writer() {
2459                let _ = engine.delete_document(&mut writer_guard, &memory_id.to_string());
2460                let _ = engine.commit(&mut writer_guard);
2461            }
2462        }
2463
2464        Ok(record)
2465    }
2466
2467    /// Thaw a memory from compressed cold storage back into the hot active tier.
2468    ///
2469    /// Zero data loss: restores the original memory with all fields, transitions tier
2470    /// back to `Tier::Episodic`, bumps access/recall count, updates `accessed_at`,
2471    /// stores into the hot galaxy, and reindexes into Tantivy search (if provided).
2472    pub fn thaw_from_cold(
2473        &self,
2474        search: Option<&crate::SearchEngine>,
2475        memory_id: MemoryId,
2476    ) -> Result<Memory> {
2477        let record = self.get_cold_record(memory_id)?.ok_or_else(|| {
2478            CoreError::NotFound(format!("Memory {memory_id} not found in cold storage"))
2479        })?;
2480
2481        let mut mem = record.decompress()?;
2482
2483        // Transition tier back to Episodic (warm serving)
2484        let _ = mem.transition_tier(crate::memory::Tier::Episodic);
2485        mem.metadata.accessed_at = chrono::Utc::now();
2486        mem.metadata.access_count += 1;
2487        mem.metadata.recall_count += 1;
2488        if !mem.metadata.tags.iter().any(|t| t == "thawed:phagic") {
2489            mem.metadata.tags.push("thawed:phagic".to_string());
2490        }
2491
2492        // Put back into active hot galaxy
2493        self.put(record.galaxy, &mem)?;
2494
2495        // Reindex in Tantivy if provided
2496        if let Some(engine) = search {
2497            if let Ok(mut writer_guard) = engine.writer() {
2498                let _ = engine.index_memory(&mut writer_guard, &mem);
2499                let _ = engine.commit(&mut writer_guard);
2500            }
2501        }
2502
2503        // Remove from cold archive
2504        self.delete_cold_record(memory_id)?;
2505
2506        Ok(mem)
2507    }
2508
2509    /// Find a memory anywhere: in the active hot galaxies, or decompressed from cold storage.
2510    ///
2511    /// Returns `(galaxy, memory, is_cold)`.
2512    pub fn find_anywhere(&self, id: MemoryId) -> Result<Option<(Galaxy, Memory, bool)>> {
2513        if let Some((galaxy, mem)) = self.find_across_galaxies(id)? {
2514            return Ok(Some((galaxy, mem, false)));
2515        }
2516        if let Some(cold_record) = self.get_cold_record(id)? {
2517            let galaxy = cold_record.galaxy;
2518            let mem = cold_record.decompress()?;
2519            return Ok(Some((galaxy, mem, true)));
2520        }
2521        Ok(None)
2522    }
2523}
2524
2525#[cfg(test)]
2526mod tests {
2527    use super::*;
2528    use crate::content_hash;
2529
2530    #[test]
2531    fn open_and_create_galaxies() {
2532        let tmp = tempfile::tempdir().unwrap();
2533        let store = MemoryStore::open_default(tmp.path()).unwrap();
2534        for galaxy in Galaxy::all() {
2535            let _db = store.galaxy_db(galaxy).unwrap();
2536        }
2537    }
2538
2539    #[test]
2540    fn ensure_schema_completes_a_pre_cold_store() {
2541        use lmdb::{DatabaseFlags as LmdbFlags, Environment as LmdbEnv};
2542        use uuid::Uuid;
2543
2544        // Synthetic pre-cold store: every DBI a 9.0.0 store had, but no
2545        // `cold_storage` (the exact 2026-09-14 restore-drill finding).
2546        let tmp = tempfile::tempdir().unwrap();
2547        let path = tmp.path().join("old-store");
2548        std::fs::create_dir_all(&path).unwrap();
2549        {
2550            let env = LmdbEnv::new().set_max_dbs(64).open(&path).unwrap();
2551            for galaxy in Galaxy::all() {
2552                env.create_db(Some(galaxy.db_name()), LmdbFlags::default())
2553                    .unwrap();
2554            }
2555            for (name, flags) in crate::indexes::INDEX_DBS {
2556                env.create_db(Some(name), *flags).unwrap();
2557            }
2558            for (name, flags) in [
2559                ("episodic_records", LmdbFlags::default()),
2560                ("episodic_terms_v2", LmdbFlags::DUP_SORT),
2561                ("embedding_cache", LmdbFlags::default()),
2562                ("revisions", LmdbFlags::default()),
2563                (crate::attestation::ATTESTATIONS_DB, LmdbFlags::default()),
2564            ] {
2565                env.create_db(Some(name), flags).unwrap();
2566            }
2567        }
2568
2569        let files_before: Vec<String> = {
2570            let mut names: Vec<String> = std::fs::read_dir(&path)
2571                .unwrap()
2572                .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2573                .collect();
2574            names.sort();
2575            names
2576        };
2577        let data_before = std::fs::read(path.join("data.mdb")).unwrap();
2578
2579        let error = match MemoryStore::open_readonly(&path) {
2580            Ok(_) => panic!("strict read-only open must refuse an incomplete store"),
2581            Err(e) => e.to_string(),
2582        };
2583        assert!(error.contains("cold_storage"), "{error}");
2584        assert_eq!(
2585            std::fs::read(path.join("data.mdb")).unwrap(),
2586            data_before,
2587            "readonly refusal must not mutate the pre-cold store"
2588        );
2589        let files_after: Vec<String> = {
2590            let mut names: Vec<String> = std::fs::read_dir(&path)
2591                .unwrap()
2592                .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2593                .collect();
2594            names.sort();
2595            names
2596        };
2597        assert_eq!(
2598            files_after, files_before,
2599            "readonly refusal changed the store directory"
2600        );
2601
2602        let created = MemoryStore::ensure_schema(&path).unwrap();
2603        assert_eq!(created, vec!["cold_storage".to_string()], "{created:?}");
2604
2605        let store = MemoryStore::open_readonly(&path).unwrap();
2606        assert!(store.get_cold_record(Uuid::nil()).unwrap().is_none());
2607
2608        // Idempotent: a complete store reports nothing missing.
2609        assert!(MemoryStore::ensure_schema(&path).unwrap().is_empty());
2610    }
2611
2612    #[test]
2613    fn cold_discovery_hydrates_verifies_and_respects_visibility() {
2614        use crate::cold_storage::{ColdRecord, CompressionCodec, OuterRimFactors};
2615        let tmp = tempfile::tempdir().unwrap();
2616        let store = MemoryStore::open_default(tmp.path()).unwrap();
2617        let factors = OuterRimFactors {
2618            age_factor: 0.5,
2619            access_factor: 0.5,
2620            resonance_factor: 0.5,
2621            emotional_factor: 0.5,
2622            importance_factor: 0.5,
2623            distance: 0.5,
2624        };
2625
2626        let pub_mem = Memory::new(
2627            Galaxy::Codex,
2628            "public needle zxquniquecoldfact741 buried".into(),
2629        );
2630        let rec = ColdRecord::new(
2631            &pub_mem,
2632            0.5,
2633            factors.clone(),
2634            None,
2635            None,
2636            CompressionCodec::Gzip,
2637        )
2638        .unwrap();
2639        store.put_cold_record(&rec).unwrap();
2640        let out = store
2641            .find_cold_matching(&["zxquniquecoldfact741".to_string()], None, 10, 100)
2642            .unwrap();
2643        assert_eq!(out.matched, 1);
2644        assert_eq!(out.integrity_rejected, 0);
2645        assert_eq!(out.records.len(), 1);
2646
2647        // Private originals never surface over MCP discovery.
2648        let mut priv_mem = Memory::new(Galaxy::Codex, "private needle zxquniquecoldfact742".into());
2649        priv_mem.metadata.is_private = true;
2650        store
2651            .put_cold_record(
2652                &ColdRecord::new(&priv_mem, 0.5, factors, None, None, CompressionCodec::Gzip)
2653                    .unwrap(),
2654            )
2655            .unwrap();
2656        let out_priv = store
2657            .find_cold_matching(&["zxquniquecoldfact742".to_string()], None, 10, 100)
2658            .unwrap();
2659        assert_eq!(out_priv.matched, 0);
2660        assert_eq!(out_priv.private_skipped, 1);
2661
2662        // Tamper: the payload decompresses to content that no longer matches
2663        // the advertised content hash — refuse, never return.
2664        let mut tampered = rec.clone();
2665        let mut bad = Memory::new(Galaxy::Codex, "tampered needle zxquniquecoldfact743".into());
2666        bad.metadata.id = rec.id;
2667        let (payload, size) =
2668            crate::cold_storage::compress_memory(&bad, CompressionCodec::Gzip).unwrap();
2669        tampered.compressed_payload = payload;
2670        tampered.uncompressed_size = size;
2671        store.put_cold_record(&tampered).unwrap();
2672        let out_tamper = store
2673            .find_cold_matching(&["zxquniquecoldfact743".to_string()], None, 10, 100)
2674            .unwrap();
2675        assert_eq!(out_tamper.matched, 0);
2676        assert_eq!(out_tamper.integrity_rejected, 1);
2677
2678        // Even an internally consistent header/payload must not be accepted
2679        // under another physical UUID key (which would break known-ID read).
2680        store.delete_cold_record(rec.id).unwrap();
2681        let wrong_key = uuid::Uuid::from_u128(741);
2682        assert_ne!(wrong_key, rec.id);
2683        let value = rmp_serde::to_vec_named(&rec).unwrap();
2684        let mut tx = store.env.begin_rw_txn().unwrap();
2685        tx.put(
2686            store.cold_storage_db,
2687            wrong_key.as_bytes(),
2688            &value,
2689            WriteFlags::default(),
2690        )
2691        .unwrap();
2692        tx.commit().unwrap();
2693        let wrong_key_out = store
2694            .find_cold_matching(&["zxquniquecoldfact741".into()], None, 10, 100)
2695            .unwrap();
2696        assert!(wrong_key_out.records.is_empty());
2697        assert_eq!(wrong_key_out.integrity_rejected, 1);
2698    }
2699
2700    /// Substring filter (memory.query trap fix, 2026-08-29): literal
2701    /// case-insensitive content match, galaxy-wide — never an arbitrary
2702    /// page, never routed through the indexed fast paths.
2703    #[test]
2704    fn query_substring_filters_galaxy_wide() {
2705        let tmp = tempfile::tempdir().unwrap();
2706        let store = MemoryStore::open_default(tmp.path()).unwrap();
2707
2708        for (i, content) in [
2709            "the mesh joins at dawn",
2710            "unrelated content entirely",
2711            "MESH joins at dusk",
2712        ]
2713        .iter()
2714        .enumerate()
2715        {
2716            let mut m = Memory::new(Galaxy::Codex, content.to_string());
2717            m.metadata.importance = 0.5 + i as f32 / 10.0;
2718            store.put(Galaxy::Codex, &m).unwrap();
2719        }
2720
2721        let hits = store
2722            .query(
2723                Galaxy::Codex,
2724                &MemoryQuery::new().with_content_substring("mesh joins"),
2725            )
2726            .unwrap();
2727        assert_eq!(hits.len(), 2, "CI substring must match both: {hits:?}");
2728        assert!(
2729            hits.iter()
2730                .all(|m| m.content.to_lowercase().contains("mesh joins"))
2731        );
2732
2733        let none = store
2734            .query(
2735                Galaxy::Codex,
2736                &MemoryQuery::new().with_content_substring("quantum calendar"),
2737            )
2738            .unwrap();
2739        assert!(none.is_empty(), "no match must be an honest empty set");
2740
2741        // Substring + tag combined still applies (no fast-path bypass).
2742        let mut tagged = Memory::new(Galaxy::Codex, "mesh joins again".to_string());
2743        tagged.metadata.tags = vec!["mesh".into()];
2744        store.put(Galaxy::Codex, &tagged).unwrap();
2745        let combined = store
2746            .query(
2747                Galaxy::Codex,
2748                &MemoryQuery::new()
2749                    .with_tags(vec!["mesh".into()])
2750                    .with_content_substring("again"),
2751            )
2752            .unwrap();
2753        assert_eq!(combined.len(), 1);
2754        assert_eq!(combined[0].content, "mesh joins again");
2755    }
2756
2757    #[cfg(unix)]
2758    #[test]
2759    fn store_dir_has_restrictive_permissions() {
2760        let tmp = tempfile::tempdir().unwrap();
2761        let store_path = tmp.path().join("lmdb");
2762        let _store = MemoryStore::open_default(&store_path).unwrap();
2763        let perms = std::fs::metadata(&store_path).unwrap().permissions().mode();
2764        assert_eq!(
2765            perms & 0o777,
2766            0o700,
2767            "store directory should have 0700 permissions, got {:o}",
2768            perms & 0o777
2769        );
2770    }
2771
2772    #[test]
2773    fn put_get_delete_memory() {
2774        let tmp = tempfile::tempdir().unwrap();
2775        let store = MemoryStore::open_default(tmp.path()).unwrap();
2776
2777        let mem = Memory::new(Galaxy::Codex, "Hello world".to_string());
2778        let id = mem.metadata.id;
2779
2780        store.put(Galaxy::Codex, &mem).unwrap();
2781        let retrieved = store.get(Galaxy::Codex, id).unwrap();
2782        assert!(retrieved.is_some());
2783        assert_eq!(retrieved.unwrap().content, "Hello world");
2784
2785        let deleted = store.delete(Galaxy::Codex, id).unwrap();
2786        assert!(deleted);
2787
2788        let gone = store.get(Galaxy::Codex, id).unwrap();
2789        assert!(gone.is_none());
2790    }
2791
2792    #[test]
2793    fn scan_memories() {
2794        let tmp = tempfile::tempdir().unwrap();
2795        let store = MemoryStore::open_default(tmp.path()).unwrap();
2796
2797        for i in 0..5 {
2798            let mem = Memory::new(Galaxy::Codex, format!("memory-{i}"));
2799            store.put(Galaxy::Codex, &mem).unwrap();
2800        }
2801
2802        let all = store.scan(Galaxy::Codex, 100).unwrap();
2803        assert_eq!(all.len(), 5);
2804
2805        let limited = store.scan(Galaxy::Codex, 3).unwrap();
2806        assert_eq!(limited.len(), 3);
2807    }
2808
2809    #[test]
2810    fn overwrite_removes_stale_index_entries() {
2811        let tmp = tempfile::tempdir().unwrap();
2812        let store = MemoryStore::open_default(tmp.path()).unwrap();
2813
2814        // Original record: tag "alpha", importance 0.9.
2815        let mut mem = Memory::new(Galaxy::Codex, "overwrite target".to_string());
2816        mem.metadata.tags = vec!["alpha".to_string()];
2817        mem.metadata.importance = 0.9;
2818        let id = mem.metadata.id;
2819        store.put(Galaxy::Codex, &mem).unwrap();
2820
2821        // Overwrite with tag "beta", importance 0.1, new content hash.
2822        let mut updated = Memory::new(Galaxy::Codex, "overwritten content".to_string());
2823        updated.metadata.id = id;
2824        updated.metadata.tags = vec!["beta".to_string()];
2825        updated.metadata.importance = 0.1;
2826        store.put(Galaxy::Codex, &updated).unwrap();
2827
2828        // Stale entries must be gone, new entries must be queryable.
2829        let tx = store.env().begin_ro_txn().unwrap();
2830        let by_alpha = store
2831            .index_dbs()
2832            .find_by_tag(&tx, Galaxy::Codex, "alpha")
2833            .unwrap();
2834        let by_beta = store
2835            .index_dbs()
2836            .find_by_tag(&tx, Galaxy::Codex, "beta")
2837            .unwrap();
2838        assert!(
2839            by_alpha.is_empty(),
2840            "stale tag index entries must be removed on overwrite"
2841        );
2842        assert_eq!(by_beta, vec![id]);
2843
2844        let by_importance = store
2845            .index_dbs()
2846            .find_by_importance_range(&tx, Galaxy::Codex, 0.0, 0.2)
2847            .unwrap();
2848        assert!(
2849            by_importance.contains(&id),
2850            "new importance must be indexed"
2851        );
2852        let by_high = store
2853            .index_dbs()
2854            .find_by_importance_range(&tx, Galaxy::Codex, 0.8, 1.0)
2855            .unwrap();
2856        assert!(
2857            !by_high.contains(&id),
2858            "stale importance index entries must be removed on overwrite"
2859        );
2860
2861        let old_hash = content_hash("overwrite target");
2862        let new_hash = content_hash("overwritten content");
2863        assert_eq!(
2864            store
2865                .index_dbs()
2866                .find_by_content_hash(&tx, Galaxy::Codex, &old_hash)
2867                .unwrap(),
2868            None,
2869            "stale content-hash index entry must be removed"
2870        );
2871        assert_eq!(
2872            store
2873                .index_dbs()
2874                .find_by_content_hash(&tx, Galaxy::Codex, &new_hash)
2875                .unwrap(),
2876            Some(id)
2877        );
2878    }
2879
2880    #[test]
2881    fn count_memories() {
2882        let tmp = tempfile::tempdir().unwrap();
2883        let store = MemoryStore::open_default(tmp.path()).unwrap();
2884
2885        assert_eq!(store.count(Galaxy::Codex).unwrap(), 0);
2886
2887        for i in 0..3 {
2888            let mem = Memory::new(Galaxy::Codex, format!("count-{i}"));
2889            store.put(Galaxy::Codex, &mem).unwrap();
2890        }
2891
2892        assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
2893    }
2894
2895    /// `count_by_tag` uses the tag index and counts distinct records — the
2896    /// status surface reports logical sessions with it (one `start` tag per
2897    /// session, ignoring turns/checkpoints).
2898    #[test]
2899    fn count_by_tag_counts_indexed_records() {
2900        let tmp = tempfile::tempdir().unwrap();
2901        let store = MemoryStore::open_default(tmp.path()).unwrap();
2902
2903        assert_eq!(store.count_by_tag(Galaxy::Sessions, "start").unwrap(), 0);
2904
2905        let mut start = Memory::new(Galaxy::Sessions, "{\"type\":\"session_start\"}".into());
2906        start.metadata.tags = vec!["session".into(), "start".into()];
2907        store.put(Galaxy::Sessions, &start).unwrap();
2908        for i in 0..2 {
2909            let mut turn =
2910                Memory::new(Galaxy::Sessions, format!("{{\"type\":\"turn\",\"i\":{i}}}"));
2911            turn.metadata.tags = vec!["session".into(), "turn".into()];
2912            store.put(Galaxy::Sessions, &turn).unwrap();
2913        }
2914
2915        assert_eq!(store.count(Galaxy::Sessions).unwrap(), 3);
2916        assert_eq!(store.count_by_tag(Galaxy::Sessions, "start").unwrap(), 1);
2917        assert_eq!(store.count_by_tag(Galaxy::Sessions, "turn").unwrap(), 2);
2918        assert_eq!(store.count_by_tag(Galaxy::Sessions, "absent").unwrap(), 0);
2919    }
2920
2921    #[test]
2922    fn get_nonexistent_returns_none() {
2923        let tmp = tempfile::tempdir().unwrap();
2924        let store = MemoryStore::open_default(tmp.path()).unwrap();
2925        let result = store.get(Galaxy::Codex, uuid::Uuid::new_v4()).unwrap();
2926        assert!(result.is_none());
2927    }
2928
2929    #[test]
2930    fn raw_put_get() {
2931        let tmp = tempfile::tempdir().unwrap();
2932        let store = MemoryStore::open_default(tmp.path()).unwrap();
2933
2934        store
2935            .put_raw(Galaxy::Substrate, b"config:key", b"value123")
2936            .unwrap();
2937        let val = store.get_raw(Galaxy::Substrate, b"config:key").unwrap();
2938        assert_eq!(val, Some(b"value123".to_vec()));
2939    }
2940
2941    #[test]
2942    fn put_dedup_prevents_duplicates() {
2943        let tmp = tempfile::tempdir().unwrap();
2944        let store = MemoryStore::open_default(tmp.path()).unwrap();
2945
2946        let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
2947        let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2948
2949        let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
2950        let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2951
2952        assert_eq!(id1, id2, "dedup should return same ID for same content");
2953        assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
2954    }
2955
2956    #[test]
2957    fn put_dedup_allows_different_content() {
2958        let tmp = tempfile::tempdir().unwrap();
2959        let store = MemoryStore::open_default(tmp.path()).unwrap();
2960
2961        let mem1 = Memory::new(Galaxy::Codex, "content A".into());
2962        store.put_dedup(Galaxy::Codex, &mem1).unwrap();
2963
2964        let mem2 = Memory::new(Galaxy::Codex, "content B".into());
2965        store.put_dedup(Galaxy::Codex, &mem2).unwrap();
2966
2967        assert_eq!(store.count(Galaxy::Codex).unwrap(), 2);
2968    }
2969
2970    #[test]
2971    fn put_batch_atomic_write() {
2972        let tmp = tempfile::tempdir().unwrap();
2973        let store = MemoryStore::open_default(tmp.path()).unwrap();
2974
2975        let memories: Vec<Memory> = (0..10)
2976            .map(|i| Memory::new(Galaxy::Codex, format!("batch-{i}")))
2977            .collect();
2978
2979        store.put_batch(Galaxy::Codex, &memories).unwrap();
2980        assert_eq!(store.count(Galaxy::Codex).unwrap(), 10);
2981    }
2982
2983    #[test]
2984    fn query_by_tags() {
2985        let tmp = tempfile::tempdir().unwrap();
2986        let store = MemoryStore::open_default(tmp.path()).unwrap();
2987
2988        let mem1 = Memory::new(Galaxy::Codex, "tagged memory".into())
2989            .with_tags(vec!["rust".into(), "memory".into()]);
2990        let mem2 =
2991            Memory::new(Galaxy::Codex, "other memory".into()).with_tags(vec!["python".into()]);
2992        store.put(Galaxy::Codex, &mem1).unwrap();
2993        store.put(Galaxy::Codex, &mem2).unwrap();
2994
2995        let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
2996        let results = store.query(Galaxy::Codex, &query).unwrap();
2997        assert_eq!(results.len(), 1);
2998        assert_eq!(results[0].content, "tagged memory");
2999    }
3000
3001    #[test]
3002    fn query_by_importance_range() {
3003        let tmp = tempfile::tempdir().unwrap();
3004        let store = MemoryStore::open_default(tmp.path()).unwrap();
3005
3006        store
3007            .put(
3008                Galaxy::Codex,
3009                &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
3010            )
3011            .unwrap();
3012        store
3013            .put(
3014                Galaxy::Codex,
3015                &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
3016            )
3017            .unwrap();
3018        store
3019            .put(
3020                Galaxy::Codex,
3021                &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
3022            )
3023            .unwrap();
3024
3025        let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
3026        let results = store.query(Galaxy::Codex, &query).unwrap();
3027        assert_eq!(results.len(), 1);
3028        assert_eq!(results[0].content, "mid");
3029    }
3030
3031    #[test]
3032    fn memory_query_one_sided_time_bounds() {
3033        // `created_after` / `created_before` map onto the temporal filter
3034        // one side at a time (the API passthrough for memory.query).
3035        let old = Memory::new(Galaxy::Codex, "old".into());
3036        let mut recent = Memory::new(Galaxy::Codex, "recent".into());
3037        recent.metadata.created_at = old.metadata.created_at + chrono::Duration::days(30);
3038
3039        let cutoff = old.metadata.created_at + chrono::Duration::days(10);
3040        let after = MemoryQuery::new().with_created_after(cutoff);
3041        assert!(!after.matches(&old), "pre-cutoff memory must not match");
3042        assert!(after.matches(&recent), "post-cutoff memory must match");
3043
3044        let before = MemoryQuery::new().with_created_before(cutoff);
3045        assert!(before.matches(&old), "pre-cutoff memory must match");
3046        assert!(
3047            !before.matches(&recent),
3048            "post-cutoff memory must not match"
3049        );
3050
3051        // Bounds are inclusive.
3052        let edge = MemoryQuery::new().with_created_after(cutoff);
3053        let mut at = Memory::new(Galaxy::Codex, "at cutoff".into());
3054        at.metadata.created_at = cutoff;
3055        assert!(edge.matches(&at), "created_at == after bound is inclusive");
3056    }
3057
3058    #[test]
3059    fn embedding_put_get_delete() {
3060        let tmp = tempfile::tempdir().unwrap();
3061        let store = MemoryStore::open_default(tmp.path()).unwrap();
3062
3063        let id = uuid::Uuid::new_v4();
3064        let embedding = vec![0.1, 0.2, 0.3, 0.4, 0.5];
3065
3066        store.put_embedding(id, &embedding).unwrap();
3067        let retrieved = store.get_embedding(id).unwrap();
3068        assert!(retrieved.is_some());
3069        let retrieved = retrieved.unwrap();
3070        assert_eq!(retrieved.len(), 5);
3071        assert!((retrieved[0] - 0.1).abs() < f32::EPSILON);
3072
3073        assert!(store.delete_embedding(id).unwrap());
3074        assert!(store.get_embedding(id).unwrap().is_none());
3075    }
3076
3077    #[test]
3078    fn embedding_cache_roundtrip_batch_and_count() {
3079        let tmp = tempfile::tempdir().unwrap();
3080        let store = MemoryStore::open_default(tmp.path()).unwrap();
3081
3082        let entries: Vec<(String, Vec<f32>)> = (0..5)
3083            .map(|i| (format!("ns:model:{i:016x}"), vec![i as f32; 8]))
3084            .collect();
3085        store.put_embedding_cache_batch(&entries).unwrap();
3086        assert_eq!(store.embedding_cache_count().unwrap(), 5);
3087
3088        // Single read
3089        let hit = store
3090            .get_embedding_cache("ns:model:0000000000000003")
3091            .unwrap();
3092        assert_eq!(hit.unwrap(), vec![3.0f32; 8]);
3093        assert!(
3094            store
3095                .get_embedding_cache("ns:model:absent")
3096                .unwrap()
3097                .is_none()
3098        );
3099
3100        // Batched read aligns 1:1, misses are None
3101        let keys: Vec<String> = (0..6).map(|i| format!("ns:model:{i:016x}")).collect();
3102        let batch = store.get_embedding_cache_batch(&keys).unwrap();
3103        assert_eq!(batch.len(), 6);
3104        assert!(batch[0..5].iter().all(Option::is_some));
3105        assert!(batch[5].is_none());
3106
3107        // Overwrite is a put, not a duplicate
3108        store
3109            .put_embedding_cache("ns:model:0000000000000001", &[9.0; 8])
3110            .unwrap();
3111        assert_eq!(store.embedding_cache_count().unwrap(), 5);
3112        assert_eq!(
3113            store
3114                .get_embedding_cache("ns:model:0000000000000001")
3115                .unwrap()
3116                .unwrap(),
3117            vec![9.0f32; 8]
3118        );
3119    }
3120
3121    #[test]
3122    fn embedding_cache_survives_store_reopen() {
3123        // V8 ship list #2 acceptance shape: vectors persist across restart.
3124        let tmp = tempfile::tempdir().unwrap();
3125        {
3126            let store = MemoryStore::open_default(tmp.path()).unwrap();
3127            store
3128                .put_embedding_cache("onnx:bge-small:abc", &[0.5; 384])
3129                .unwrap();
3130        }
3131        let reopened = MemoryStore::open_default(tmp.path()).unwrap();
3132        let cached = reopened.get_embedding_cache("onnx:bge-small:abc").unwrap();
3133        assert_eq!(cached.unwrap(), vec![0.5f32; 384]);
3134    }
3135
3136    #[test]
3137    fn content_hash_is_sha256() {
3138        let hash1 = content_hash("test content");
3139        let hash2 = content_hash("test content");
3140        let hash3 = content_hash("different content");
3141
3142        assert_eq!(hash1, hash2, "same content should produce same hash");
3143        assert_ne!(
3144            hash1, hash3,
3145            "different content should produce different hash"
3146        );
3147        assert_eq!(hash1.len(), 64, "SHA-256 hex should be 64 chars");
3148    }
3149
3150    #[test]
3151    fn query_by_tag_uses_index() {
3152        let tmp = tempfile::tempdir().unwrap();
3153        let store = MemoryStore::open_default(tmp.path()).unwrap();
3154
3155        let mem1 = Memory::new(Galaxy::Codex, "tagged".into())
3156            .with_tags(vec!["rust".into(), "memory".into()]);
3157        let mem2 = Memory::new(Galaxy::Codex, "other".into()).with_tags(vec!["python".into()]);
3158        store.put(Galaxy::Codex, &mem1).unwrap();
3159        store.put(Galaxy::Codex, &mem2).unwrap();
3160
3161        let query = MemoryQuery::new().with_tags(vec!["rust".into()]);
3162        let results = store.query(Galaxy::Codex, &query).unwrap();
3163        assert_eq!(results.len(), 1);
3164        assert_eq!(results[0].content, "tagged");
3165    }
3166
3167    #[test]
3168    fn query_by_importance_uses_index() {
3169        let tmp = tempfile::tempdir().unwrap();
3170        let store = MemoryStore::open_default(tmp.path()).unwrap();
3171
3172        store
3173            .put(
3174                Galaxy::Codex,
3175                &Memory::new(Galaxy::Codex, "low".into()).with_importance(0.1),
3176            )
3177            .unwrap();
3178        store
3179            .put(
3180                Galaxy::Codex,
3181                &Memory::new(Galaxy::Codex, "mid".into()).with_importance(0.5),
3182            )
3183            .unwrap();
3184        store
3185            .put(
3186                Galaxy::Codex,
3187                &Memory::new(Galaxy::Codex, "high".into()).with_importance(0.9),
3188            )
3189            .unwrap();
3190
3191        let query = MemoryQuery::new().with_importance_range(0.4, 0.6);
3192        let results = store.query(Galaxy::Codex, &query).unwrap();
3193        assert_eq!(results.len(), 1);
3194        assert_eq!(results[0].content, "mid");
3195    }
3196
3197    #[test]
3198    fn query_by_time_uses_index() {
3199        let tmp = tempfile::tempdir().unwrap();
3200        let store = MemoryStore::open_default(tmp.path()).unwrap();
3201
3202        let t0 = chrono::Utc::now();
3203        std::thread::sleep(std::time::Duration::from_millis(10));
3204        let mem = Memory::new(Galaxy::Codex, "timed".into());
3205        store.put(Galaxy::Codex, &mem).unwrap();
3206        std::thread::sleep(std::time::Duration::from_millis(10));
3207        let t2 = chrono::Utc::now();
3208
3209        let query = MemoryQuery::new().with_time_range(t0, t2);
3210        let results = store.query(Galaxy::Codex, &query).unwrap();
3211        assert_eq!(results.len(), 1);
3212        assert_eq!(results[0].content, "timed");
3213    }
3214
3215    #[test]
3216    fn delete_removes_index_entries() {
3217        let tmp = tempfile::tempdir().unwrap();
3218        let store = MemoryStore::open_default(tmp.path()).unwrap();
3219
3220        let mem = Memory::new(Galaxy::Codex, "test".into())
3221            .with_tags(vec!["tag1".into()])
3222            .with_importance(0.7);
3223        let id = mem.metadata.id;
3224        let hash = mem.metadata.content_hash.clone();
3225        store.put(Galaxy::Codex, &mem).unwrap();
3226
3227        // Verify index entries exist
3228        assert!(
3229            store
3230                .find_by_content_hash(Galaxy::Codex, &hash)
3231                .unwrap()
3232                .is_some()
3233        );
3234
3235        // Delete
3236        store.delete(Galaxy::Codex, id).unwrap();
3237
3238        // Verify index entries are gone
3239        assert!(
3240            store
3241                .find_by_content_hash(Galaxy::Codex, &hash)
3242                .unwrap()
3243                .is_none()
3244        );
3245
3246        // Tag query should return 0
3247        let query = MemoryQuery::new().with_tags(vec!["tag1".into()]);
3248        let results = store.query(Galaxy::Codex, &query).unwrap();
3249        assert!(results.is_empty());
3250    }
3251
3252    #[test]
3253    fn put_batch_updates_indexes() {
3254        let tmp = tempfile::tempdir().unwrap();
3255        let store = MemoryStore::open_default(tmp.path()).unwrap();
3256
3257        let memories: Vec<Memory> = (0..5)
3258            .map(|i| {
3259                Memory::new(Galaxy::Codex, format!("batch-{i}"))
3260                    .with_tags(vec![format!("tag{i}")])
3261                    .with_importance(i as f32 * 0.2)
3262            })
3263            .collect();
3264        store.put_batch(Galaxy::Codex, &memories).unwrap();
3265
3266        for i in 0..5 {
3267            let query = MemoryQuery::new().with_tags(vec![format!("tag{i}")]);
3268            let results = store.query(Galaxy::Codex, &query).unwrap();
3269            assert_eq!(results.len(), 1, "tag{i} should have 1 result");
3270        }
3271    }
3272
3273    #[test]
3274    fn find_by_content_hash_indexed_matches_scan() {
3275        let tmp = tempfile::tempdir().unwrap();
3276        let store = MemoryStore::open_default(tmp.path()).unwrap();
3277
3278        let mem = Memory::new(Galaxy::Codex, "dedup test".into());
3279        let id = mem.metadata.id;
3280        let hash = mem.metadata.content_hash.clone();
3281        store.put(Galaxy::Codex, &mem).unwrap();
3282
3283        let indexed = store.find_by_content_hash(Galaxy::Codex, &hash).unwrap();
3284        let scanned = store
3285            .find_by_content_hash_scan(Galaxy::Codex, &hash)
3286            .unwrap();
3287
3288        assert_eq!(indexed, scanned);
3289        assert_eq!(indexed, Some(id));
3290    }
3291
3292    #[test]
3293    fn put_dedup_uses_index() {
3294        let tmp = tempfile::tempdir().unwrap();
3295        let store = MemoryStore::open_default(tmp.path()).unwrap();
3296
3297        let mem1 = Memory::new(Galaxy::Codex, "duplicate content".into());
3298        let id1 = store.put_dedup(Galaxy::Codex, &mem1).unwrap();
3299
3300        let mem2 = Memory::new(Galaxy::Codex, "duplicate content".into());
3301        let id2 = store.put_dedup(Galaxy::Codex, &mem2).unwrap();
3302
3303        assert_eq!(id1, id2, "dedup should return same ID for same content");
3304        assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
3305    }
3306
3307    #[test]
3308    fn put_semantic_updates_coord5d() {
3309        let tmp = tempfile::tempdir().unwrap();
3310        let store = MemoryStore::open_default(tmp.path()).unwrap();
3311
3312        let mut mem = Memory::new(
3313            Galaxy::Codex,
3314            "The algorithm computes data using a systematic method".to_string(),
3315        );
3316        let original_coord = mem.metadata.coord5d.clone();
3317        store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3318
3319        // coord5d should have changed from the SHA-256 hash-based encoding
3320        assert_ne!(
3321            mem.metadata.coord5d.x, original_coord.x,
3322            "semantic encoding should change x"
3323        );
3324        assert_ne!(
3325            mem.metadata.coord5d.y, original_coord.y,
3326            "semantic encoding should change y"
3327        );
3328
3329        // Verify it was stored with the semantic coordinate
3330        let retrieved = store.get(Galaxy::Codex, mem.metadata.id).unwrap().unwrap();
3331        assert_eq!(retrieved.metadata.coord5d.x, mem.metadata.coord5d.x);
3332    }
3333
3334    #[test]
3335    fn put_semantic_preserves_temporal_and_importance() {
3336        let tmp = tempfile::tempdir().unwrap();
3337        let store = MemoryStore::open_default(tmp.path()).unwrap();
3338
3339        let mut mem = Memory::new(Galaxy::Codex, "test content".into()).with_importance(0.8);
3340        mem.metadata.coord5d.w = 0.6;
3341        store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3342
3343        assert!((mem.metadata.coord5d.w - 0.6).abs() < f32::EPSILON);
3344        assert!((mem.metadata.coord5d.v - 0.8).abs() < f32::EPSILON);
3345    }
3346
3347    #[test]
3348    fn find_similar_returns_nearest_first() {
3349        let tmp = tempfile::tempdir().unwrap();
3350        let store = MemoryStore::open_default(tmp.path()).unwrap();
3351
3352        let mut logic_mem = Memory::new(
3353            Galaxy::Codex,
3354            "The algorithm computes data using systematic logic and analysis".to_string(),
3355        );
3356        store.put_semantic(Galaxy::Codex, &mut logic_mem).unwrap();
3357
3358        let mut emotion_mem = Memory::new(
3359            Galaxy::Codex,
3360            "I feel love and joy with deep passion and empathy in my heart".to_string(),
3361        );
3362        store.put_semantic(Galaxy::Codex, &mut emotion_mem).unwrap();
3363
3364        // Query with logic-like text should find the logic memory first
3365        let results = store
3366            .find_similar(Galaxy::Codex, "algorithm data systematic method", 10)
3367            .unwrap();
3368        assert!(!results.is_empty());
3369        assert_eq!(results[0].0.metadata.id, logic_mem.metadata.id);
3370
3371        // Query with emotion-like text should find the emotion memory first
3372        let results = store
3373            .find_similar(Galaxy::Codex, "love joy passion heart feeling", 10)
3374            .unwrap();
3375        assert!(!results.is_empty());
3376        assert_eq!(results[0].0.metadata.id, emotion_mem.metadata.id);
3377    }
3378
3379    #[test]
3380    fn find_similar_empty_galaxy() {
3381        let tmp = tempfile::tempdir().unwrap();
3382        let store = MemoryStore::open_default(tmp.path()).unwrap();
3383
3384        let results = store.find_similar(Galaxy::Codex, "anything", 10).unwrap();
3385        assert!(results.is_empty());
3386    }
3387
3388    #[test]
3389    fn find_similar_respects_limit() {
3390        let tmp = tempfile::tempdir().unwrap();
3391        let store = MemoryStore::open_default(tmp.path()).unwrap();
3392
3393        for i in 0..5 {
3394            let mut mem = Memory::new(Galaxy::Codex, format!("algorithm data method {i}"));
3395            store.put_semantic(Galaxy::Codex, &mut mem).unwrap();
3396        }
3397
3398        let results = store
3399            .find_similar(Galaxy::Codex, "algorithm data", 3)
3400            .unwrap();
3401        assert_eq!(results.len(), 3);
3402    }
3403
3404    #[test]
3405    fn semantic_encoder_accessible() {
3406        let tmp = tempfile::tempdir().unwrap();
3407        let store = MemoryStore::open_default(tmp.path()).unwrap();
3408
3409        let scores = store.semantic_encoder().encode("algorithm data logic");
3410        // Logic-heavy text → x < 0.5
3411        assert!(scores.x < 0.5);
3412    }
3413
3414    #[test]
3415    fn put_raw_batch_writes_atomically() {
3416        let tmp = tempfile::tempdir().unwrap();
3417        let store = MemoryStore::open_default(tmp.path()).unwrap();
3418
3419        let entries: &[(&[u8], &[u8])] =
3420            &[(b"key1", b"val1"), (b"key2", b"val2"), (b"key3", b"val3")];
3421        store.put_raw_batch(Galaxy::Karma, entries).unwrap();
3422
3423        assert_eq!(
3424            store.get_raw(Galaxy::Karma, b"key1").unwrap().unwrap(),
3425            b"val1"
3426        );
3427        assert_eq!(
3428            store.get_raw(Galaxy::Karma, b"key2").unwrap().unwrap(),
3429            b"val2"
3430        );
3431        assert_eq!(
3432            store.get_raw(Galaxy::Karma, b"key3").unwrap().unwrap(),
3433            b"val3"
3434        );
3435    }
3436
3437    #[test]
3438    fn put_raw_batch_empty_is_noop() {
3439        let tmp = tempfile::tempdir().unwrap();
3440        let store = MemoryStore::open_default(tmp.path()).unwrap();
3441
3442        store.put_raw_batch(Galaxy::Karma, &[]).unwrap();
3443        assert_eq!(store.count(Galaxy::Karma).unwrap(), 0);
3444    }
3445
3446    #[test]
3447    fn entry_limit_rejects_excess_writes() {
3448        let tmp = tempfile::tempdir().unwrap();
3449        let store = MemoryStore::open_default(tmp.path())
3450            .unwrap()
3451            .with_entry_limit(3);
3452
3453        for i in 0..3 {
3454            let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
3455            store.put(Galaxy::Codex, &mem).unwrap();
3456        }
3457
3458        // 4th write should be rejected
3459        let mem = Memory::new(Galaxy::Codex, "overflow memory".to_string());
3460        let result = store.put(Galaxy::Codex, &mem);
3461        assert!(result.is_err(), "write beyond limit should be rejected");
3462        let err_msg = result.unwrap_err().to_string();
3463        assert!(
3464            err_msg.contains("entry limit reached"),
3465            "error should mention entry limit: {err_msg}"
3466        );
3467        assert_eq!(store.count(Galaxy::Codex).unwrap(), 3);
3468    }
3469
3470    #[test]
3471    fn entry_limit_per_galaxy_independent() {
3472        let tmp = tempfile::tempdir().unwrap();
3473        let store = MemoryStore::open_default(tmp.path())
3474            .unwrap()
3475            .with_entry_limit(2);
3476
3477        // Fill Codex to limit
3478        for i in 0..2 {
3479            let mem = Memory::new(Galaxy::Codex, format!("codex {i}"));
3480            store.put(Galaxy::Codex, &mem).unwrap();
3481        }
3482
3483        // Writing to a different galaxy should still work
3484        let mem = Memory::new(Galaxy::Research, "science memory".to_string());
3485        let result = store.put(Galaxy::Research, &mem);
3486        assert!(
3487            result.is_ok(),
3488            "different galaxy should not be affected by limit"
3489        );
3490    }
3491
3492    #[test]
3493    fn entry_limit_none_allows_unlimited() {
3494        let tmp = tempfile::tempdir().unwrap();
3495        let store = MemoryStore::open_default(tmp.path()).unwrap();
3496
3497        // No limit set — should allow many writes
3498        for i in 0..50 {
3499            let mem = Memory::new(Galaxy::Codex, format!("memory {i}"));
3500            store.put(Galaxy::Codex, &mem).unwrap();
3501        }
3502        assert_eq!(store.count(Galaxy::Codex).unwrap(), 50);
3503    }
3504
3505    #[test]
3506    fn map_full_error_is_graceful() {
3507        // Small map: opening succeeds (galaxy + sidecar databases fit), but
3508        // the padded write loop fills it and MapFull surfaces from put().
3509        // 64KB proved too tight for eager create_db on macOS/arm64.
3510        let tmp = tempfile::tempdir().unwrap();
3511        let store = MemoryStore::open(tmp.path(), 512 * 1024).unwrap();
3512
3513        // Write memories until map is full
3514        let mut written = 0;
3515        let mut got_map_full = false;
3516        for i in 0..1000 {
3517            let mem = Memory::new(
3518                Galaxy::Codex,
3519                format!("memory content {i} {}", "with padding ".repeat(50)),
3520            );
3521            match store.put(Galaxy::Codex, &mem) {
3522                Ok(()) => written += 1,
3523                Err(e) => {
3524                    let msg = e.to_string();
3525                    if msg.contains("map full") {
3526                        got_map_full = true;
3527                        break;
3528                    }
3529                    // Other errors are fine too (e.g., serialize failed)
3530                    break;
3531                }
3532            }
3533        }
3534
3535        assert!(
3536            got_map_full || written < 1000,
3537            "should eventually hit map full or error"
3538        );
3539        assert!(written > 0, "should have written at least some memories");
3540    }
3541
3542    #[test]
3543    fn test_find_across_galaxies() {
3544        let tmp = tempfile::tempdir().unwrap();
3545        let store = MemoryStore::open_default(tmp.path()).unwrap();
3546
3547        let mem = Memory::new(Galaxy::Research, "Cross-galaxy research memo".into());
3548        let id = mem.metadata.id;
3549        store.put(Galaxy::Research, &mem).unwrap();
3550
3551        let found = store.find_across_galaxies(id).unwrap();
3552        assert!(found.is_some());
3553        let (galaxy, retrieved) = found.unwrap();
3554        assert_eq!(galaxy, Galaxy::Research);
3555        assert_eq!(retrieved.metadata.id, id);
3556        assert_eq!(retrieved.content, "Cross-galaxy research memo");
3557
3558        // Non-existent id returns None
3559        assert!(
3560            store
3561                .find_across_galaxies(uuid::Uuid::new_v4())
3562                .unwrap()
3563                .is_none()
3564        );
3565    }
3566}