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