Skip to main content

wm_memory/
store.rs

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