Skip to main content

mnemo_engine/
store.rs

1//! The Mnemo database — ties the storage engine and the memory model together.
2//!
3//! Layout of a populated file:
4//!
5//! ```text
6//!   page 0           : Header (unencrypted)
7//!   pages 1..W       : write-ahead log region
8//!   pages W..        : encrypted record runs + catalog runs + index +
9//!                      snapshot manifest (append-only)
10//! ```
11//!
12//! Durability is provided by a **write-ahead log** ([`crate::wal`]). A `flush`
13//! is one transaction: record data pages are written copy-on-write to fresh
14//! pages, then the new catalog, ANN index, and header are logged to the WAL
15//! and committed with a single fsync. A checkpoint then folds the WAL into the
16//! home pages. A crash before the commit leaves the previous state intact; a
17//! crash after it is repaired by replaying the WAL on open.
18//!
19//! Because pages are only ever appended, every past transaction's runs survive
20//! on disk. Each flush also appends an entry to a **snapshot manifest**, so
21//! [`Mnemo::restore_to`] can reinstate any past state. Stale pages — and the
22//! history along with them — are reclaimed by [`Mnemo::compact_file`].
23
24use std::collections::HashMap;
25use std::fs::{File, OpenOptions};
26use std::io::{Read, Seek, SeekFrom, Write};
27use std::path::{Path, PathBuf};
28
29use fs4::fs_std::FileExt;
30use serde::{Deserialize, Serialize};
31use serde_json::{Map, Value};
32use ulid::Ulid;
33
34use crate::crypto::{self, KdfParams};
35use crate::embed::{self, Embedder};
36use crate::error::{MnemoError, Result};
37use crate::format::{Header, FLAG_ENCRYPTED, PAGE_SIZE, PAYLOAD, VERSION, WRAPPED_DEK_LEN};
38use crate::index::{IndexConfig, IndexInfo, IvfPqIndex};
39use crate::memory::{
40    self, status as essence_status, CommitBatch, CommitReport, EssenceOp, EssenceOpKind,
41    EssenceScope, EssenceSection, ExportedTurn, Filter, IngestReport, KeyScope, Memory,
42    MemoryPatch, MemoryType, Metric, Scope, ScoreWeights, ThreadTurn, TurnPolicy, WriteOutcome,
43    CONTINUATION_TIMELINE_ENTRIES, ESSENCE_DEFAULT_RENDER_BYTES, ESSENCE_TRANSITIONS,
44    MNEMO_META_CONTINUES, MNEMO_META_INVALID_AT, MNEMO_META_KEY, MNEMO_META_KIND,
45    MNEMO_META_KIND_ESSENCE, MNEMO_META_KIND_THREAD_LINK, MNEMO_META_KIND_TURN, MNEMO_META_LENGTH,
46    MNEMO_META_PART, MNEMO_META_REFS, MNEMO_META_ROLE, MNEMO_META_SECTION, MNEMO_META_SEQ,
47    MNEMO_META_SHA256, MNEMO_META_STATUS, MNEMO_META_STATUS_HISTORY, MNEMO_META_SUPERSEDED_BY,
48    MNEMO_META_SUPERSEDES, MNEMO_META_THREAD, MNEMO_META_TURN_ID, MNEMO_META_UPDATED_AT,
49    THREAD_TOOL_RESULT_TRUNCATE_BYTES, THREAD_TURN_CHUNK_BYTES,
50};
51use crate::pager::Pager;
52use crate::result_cache::{
53    self, BatchState, CacheBudget, CacheDirectoryEntry, CacheEntry, CacheFlushPolicy, CachePutOpts,
54    CacheStats, CachedValue, CounterState, DirectoryIndex, SemanticCachePutOpts,
55};
56use crate::wal;
57
58/// Default initial size of the write-ahead log region, in pages (64 KiB).
59/// The region grows automatically when a transaction's control plane outgrows
60/// it, so this is a *floor* on the fresh-file footprint, not a cap on
61/// transaction size. Was 64 pages (512 KiB) in v0.1.0 — lowered because that
62/// reservation dominated small-file size (~62% of a 31-memory dogfood file).
63const DEFAULT_WAL_PAGES: u64 = 8;
64
65/// Hard floor on the WAL reservation. A single header-page flush already
66/// needs one full DATA frame plus a COMMIT frame; two pages are the bare
67/// minimum that lets the first transaction commit without immediate growth.
68const MIN_WAL_PAGES: u64 = 2;
69
70/// Default cap on retained snapshot manifest entries. Each `flush` appends
71/// one entry; without a cap, long-running processes accumulate entries
72/// forever, growing per-flush manifest-serialize cost as O(total flushes)
73/// and the manifest run size on disk in lockstep. 256 keeps roughly a
74/// week of hourly snapshots while staying tiny (256 × ~82 bytes ≈ 21 KiB
75/// of manifest). Set `MnemoConfig::max_snapshots = 0` to disable the cap.
76pub const DEFAULT_MAX_SNAPSHOTS: usize = 256;
77
78/// Chunk size for the torn-header scan's streaming read of the file.
79/// We walk the file 1 MiB at a time looking for 8 KiB-aligned `MWAL`
80/// magic. There is **no** cap on how far a given candidate's frame
81/// chain may extend — a 1M-vector IVF+PQ relocation can push a single
82/// transaction well past 16 MiB — so each candidate is read frame by
83/// frame via [`wal::scan_committed_txn`] rather than slurped into a
84/// bounded buffer. (DUR-1 R-002.1 part 2.)
85const TORN_HEADER_SCAN_CHUNK: usize = 1 << 20; // 1 MiB
86
87/// Walk the file for every 8 KiB-aligned page whose first four bytes are
88/// `MWAL`, treating each as a candidate WAL-region start, and return the
89/// frames of the highest-`txn_id` committed transaction whose header
90/// frame fully validates under `passphrase`.
91///
92/// A candidate is accepted only if:
93/// * `wal::scan_committed_txn` returns a committed transaction (chained
94///   CRC verifies, single `txn_id`, `KIND_COMMIT` closes it);
95/// * that transaction contains a `(0, header_page)` frame;
96/// * the header parses with `wal_start == candidate_page_no` and
97///   `wal_seq == txn_id` (so a frame cannot forge a region it was not
98///   written into);
99/// * deriving the KEK from `passphrase + header.salt`, unwrapping the
100///   wrapped DEK, and validating the header seal all succeed.
101///
102/// Returns `Ok(None)` when the scan finds no valid candidate — the
103/// caller is expected to propagate the original parse error so
104/// non-mnemo files still report `BadMagic`.
105///
106/// DUR-1 (R-002.1 part 2): there is no fixed scan window — the whole
107/// file is scanned. Reading at page granularity (not byte granularity)
108/// is sufficient because every WAL region in the format is page-aligned
109/// and every WAL commit writes its first frame at the region's start.
110fn scan_header_recovery_candidates(
111    file: &mut File,
112    passphrase: &str,
113) -> Result<Option<Vec<wal::Frame>>> {
114    let file_len = file.seek(SeekFrom::End(0))?;
115    if file_len < PAGE_SIZE as u64 * 2 {
116        return Ok(None); // No room for even one WAL page past the header.
117    }
118
119    let mut best: Option<(u64, Vec<wal::Frame>)> = None;
120    let page_sz = PAGE_SIZE as u64;
121    let mut offset = page_sz; // Skip page 0.
122
123    // Stream through the file in 1 MiB chunks, scanning each chunk for
124    // page-aligned `MWAL` candidates. Each candidate's frame chain is
125    // then read incrementally via `wal::scan_committed_txn`, which has
126    // no window cap — a 1M-vector IVF+PQ relocation can span tens of
127    // MiB in one committed transaction.
128    let mut chunk = vec![0u8; TORN_HEADER_SCAN_CHUNK];
129    while offset < file_len {
130        let want = (TORN_HEADER_SCAN_CHUNK as u64).min(file_len - offset) as usize;
131        chunk.resize(want, 0);
132        file.seek(SeekFrom::Start(offset))?;
133        let mut filled = 0;
134        while filled < want {
135            match file.read(&mut chunk[filled..])? {
136                0 => break,
137                n => filled += n,
138            }
139        }
140        if filled == 0 {
141            break;
142        }
143        let mut inner = 0usize;
144        while inner + 4 <= filled {
145            if &chunk[inner..inner + 4] == wal::FRAME_MAGIC_BYTES {
146                let page_no = (offset + inner as u64) / page_sz;
147                if let Some((txn_id, frames)) = try_recover_candidate(file, page_no, passphrase)? {
148                    let keep = match &best {
149                        None => true,
150                        Some((id, _)) => txn_id > *id,
151                    };
152                    if keep {
153                        best = Some((txn_id, frames));
154                    }
155                }
156            }
157            inner += PAGE_SIZE;
158        }
159        offset += filled as u64;
160    }
161
162    Ok(best.map(|(_, f)| f))
163}
164
165/// Try to recover a committed transaction from `candidate_page_no`,
166/// returning `(txn_id, frames)` only if the candidate's
167/// `(0, header_page)` frame fully validates: the frame chain commits
168/// (via [`wal::scan_committed_txn`]'s streaming reader), the header
169/// parses with `wal_start == candidate_page_no` **and**
170/// `wal_seq == txn_id`, the DEK unwraps under `passphrase`, and the
171/// AES-GCM seal validates. DUR-1 (R-002.1 item 2) requires the
172/// `wal_seq == txn_id` check and no fixed scan window — the streaming
173/// reader handles transactions of arbitrary size.
174fn try_recover_candidate(
175    file: &mut File,
176    candidate_page_no: u64,
177    passphrase: &str,
178) -> Result<Option<(u64, Vec<wal::Frame>)>> {
179    let Some((txn_id, frames)) = wal::scan_committed_txn(file, candidate_page_no)? else {
180        return Ok(None);
181    };
182
183    // Must contain a header frame at page 0.
184    let header_bytes = match frames.iter().find(|(p, _)| *p == 0) {
185        Some((_, b)) => b,
186        None => return Ok(None),
187    };
188    if header_bytes.len() != PAGE_SIZE {
189        return Ok(None);
190    }
191    let mut hbuf = [0u8; PAGE_SIZE];
192    hbuf.copy_from_slice(header_bytes);
193    let candidate = match Header::from_page(&hbuf) {
194        Ok(h) => h,
195        Err(_) => return Ok(None),
196    };
197
198    // The frame's claimed region must match where we found it, and the
199    // header frame's `wal_seq` must match the committed transaction's
200    // `txn_id` — this is what pins the frame to the region it was
201    // written into and keeps a stale checkpointed-and-copied frame
202    // from being accepted as a new region's root.
203    if candidate.wal_start != candidate_page_no || candidate.wal_seq != txn_id {
204        return Ok(None);
205    }
206    // DEK unwrap under this candidate's own salt + KDF params.
207    let kdf = KdfParams {
208        m_cost: candidate.m_cost,
209        t_cost: candidate.t_cost,
210        p_cost: candidate.p_cost,
211    };
212    let kek = match crypto::derive_kek(passphrase.as_bytes(), &candidate.salt, kdf) {
213        Ok(k) => k,
214        Err(_) => return Ok(None),
215    };
216    let dek = match crypto::unwrap_dek(&kek, &candidate.dek_nonce, &candidate.wrapped_dek) {
217        Ok(d) => d,
218        Err(_) => return Ok(None),
219    };
220    if candidate.validate_seal(&dek).is_err() {
221        return Ok(None);
222    }
223
224    Ok(Some((txn_id, frames)))
225}
226
227/// Configuration for creating a new database.
228#[derive(Clone, Copy, Debug)]
229pub struct MnemoConfig {
230    /// Embedding dimensionality. Every stored vector must match this.
231    pub dimensions: usize,
232    /// Key-derivation parameters.
233    pub kdf: KdfParams,
234    /// Initial size of the WAL region in 8 KiB pages. Default is
235    /// [`DEFAULT_WAL_PAGES`] (8 pages = 64 KiB), which keeps fresh files
236    /// small; the region auto-grows when a transaction's control plane needs
237    /// more space, so raising this is only a hint for the expected steady-state
238    /// transaction size. Write-heavy databases that routinely flush large
239    /// catalogs or ANN indexes can set this higher (e.g. 64 = 512 KiB) to
240    /// avoid early grow events; tiny embedded uses can leave it at the
241    /// default. Clamped to a minimum of [`MIN_WAL_PAGES`] (2 pages).
242    pub wal_pages_initial: u64,
243    /// Maximum number of snapshot manifest entries to retain. Each `flush`
244    /// appends one entry; once the cap is hit, the *oldest* entry is dropped
245    /// from the in-memory manifest before the new one is added, so the
246    /// retained set is always the most-recent N. Defaults to
247    /// [`DEFAULT_MAX_SNAPSHOTS`] (256). Set to `0` to disable the cap and
248    /// retain every snapshot forever (the pre-v0.3 behavior).
249    ///
250    /// Pages referenced *only* by pruned snapshots stay on disk until the
251    /// next [`Mnemo::compact_file`], which reclaims them. Point-in-time
252    /// recovery via [`Mnemo::restore_to`] into a pruned `txn_id` returns
253    /// [`MnemoError::NotFound`].
254    ///
255    /// Apply to an already-open database with [`Mnemo::set_max_snapshots`].
256    pub max_snapshots: usize,
257}
258
259impl Default for MnemoConfig {
260    fn default() -> Self {
261        Self {
262            dimensions: 768,
263            kdf: KdfParams::secure(),
264            wal_pages_initial: DEFAULT_WAL_PAGES,
265            max_snapshots: DEFAULT_MAX_SNAPSHOTS,
266        }
267    }
268}
269
270/// Catalog entry: maps a memory ID to its page run on disk.
271///
272/// **Schema (v5+):** carries `accessed_at` and `access_count` so `recall`
273/// can update access stats by mutating the catalog entry rather than
274/// rewriting the entire record (Phase 2.1 of the improvement plan). The
275/// values in a memory's serialized record body become a stale snapshot
276/// after the first recall; [`Mnemo::read_memory`] re-populates them from
277/// the catalog so consumers always see the live values.
278#[derive(Serialize, Deserialize, Clone, Debug)]
279struct CatalogEntry {
280    /// ULID as a raw `u128`.
281    id: u128,
282    start_page: u64,
283    page_count: u32,
284    /// Exact serialized byte length of the record.
285    len: u32,
286    deleted: bool,
287    /// Unix seconds of the most recent recall hit (or write, for fresh entries).
288    accessed_at: i64,
289    /// How many times `recall` has surfaced this memory.
290    access_count: u32,
291}
292
293/// Frozen v4 catalog entry shape, used only by the v4→v5 migration path in
294/// [`Mnemo::open`]. Its layout matches the catalog encoding written by
295/// pre-v5 builds (5 positional fields). Do not change this struct.
296#[derive(Serialize, Deserialize, Clone, Debug)]
297struct CatalogEntryV4 {
298    id: u128,
299    start_page: u64,
300    page_count: u32,
301    len: u32,
302    deleted: bool,
303}
304
305/// Returned by [`Mnemo::prepare_for_flush`] and consumed by [`Mnemo::flush`].
306/// Carries the already-serialized control-plane bytes through the prelude
307/// so we don't re-serialize after the lease has been persisted.
308struct FlushPrelude {
309    cat_bytes: Option<Vec<u8>>,
310    idx_bytes: Option<Vec<u8>>,
311    /// Serialized result-cache directory bytes when `dirty_cache` is
312    /// set (Phase 10.1). `None` if the cache didn't change since the
313    /// last flush — in that case the header keeps its current
314    /// `cache_start` / `cache_pages` / `cache_len` and no fresh cache
315    /// run is written.
316    cache_bytes: Option<Vec<u8>>,
317}
318
319/// One entry in the append-only snapshot manifest. Because record, catalog,
320/// and index pages are only ever *appended*, the runs a past flush wrote are
321/// still on disk; a `Snapshot` is the set of pointers needed to reconstruct
322/// the database exactly as that flush left it.
323#[derive(Serialize, Deserialize, Clone, Debug)]
324struct Snapshot {
325    txn_id: u64,
326    created_at: i64,
327    catalog_start: u64,
328    catalog_pages: u64,
329    catalog_len: u64,
330    index_start: u64,
331    index_pages: u64,
332    index_len: u64,
333    memory_count: u64,
334}
335
336/// A restorable point in a database's history — see [`Mnemo::snapshots`].
337#[derive(Clone, Copy, Debug)]
338pub struct SnapshotInfo {
339    /// Id of the transaction that produced this snapshot (monotonic from 1).
340    pub txn_id: u64,
341    /// When the snapshot was committed (unix seconds).
342    pub created_at: i64,
343    /// Live memory count captured in the snapshot.
344    pub memory_count: u64,
345}
346
347/// A retrieval request for [`Mnemo::recall`].
348#[derive(Clone, Debug)]
349pub struct RecallRequest {
350    /// Query embedding.
351    pub query: Vec<f32>,
352    /// Maximum results to return.
353    pub top_k: usize,
354    /// Restrict to these memory types (`None` = all types).
355    pub memory_types: Option<Vec<MemoryType>>,
356    /// Restrict to a single agent's view: its own memories plus shared ones.
357    pub agent_id: Option<String>,
358    /// Similarity metric.
359    pub metric: Metric,
360    /// Multi-signal score weights.
361    pub weights: ScoreWeights,
362    /// Index override: partitions to probe (`None` = index default). Ignored
363    /// when no ANN index is present.
364    pub n_probe: Option<usize>,
365    /// Index override: candidates to rerank (`None` = index default). Ignored
366    /// when no ANN index is present.
367    pub n_rerank: Option<usize>,
368    /// Whether to update each result's `accessed_at` and `access_count` in
369    /// the catalog. Defaults to `true`, matching pre-v5 behavior.
370    ///
371    /// Set to `false` for **fully read-only recall** — useful for batch
372    /// scoring, dry-run scoring, or recalls run by introspection tooling
373    /// (e.g. `mnemo about` consumers) where you don't want the score's own
374    /// observation to perturb the database. With `false`, recall does not
375    /// dirty the catalog and the next `flush` is a no-op.
376    pub track_access: bool,
377    /// Phase 12.6 pre-scoring filter: `session_id` + AND-ed metadata
378    /// conditions applied before the candidate is scored. Default is
379    /// [`Filter::default`] (no constraints). When the ANN index is on
380    /// and this filter is selective enough to starve the shortlist
381    /// below `top_k`, Phase 12.1's widen-then-exact-scan logic kicks
382    /// in so the result still carries up to `top_k` correct hits.
383    pub filter: Filter,
384    /// Phase 12.8 post-scoring budget: stop adding hits once the UTF-8
385    /// bytes of their `content` fields (not the serialized record
386    /// body) would exceed this many bytes. `None` (default) means no
387    /// budget and every hit through `top_k` is returned. If the top-1
388    /// hit is already over budget it is still returned — callers that
389    /// need an absolute cap should also pre-size `top_k`.
390    ///
391    /// Counts only the UTF-8 `content` bytes so the number is stable
392    /// across the Rust / Python / CLI / MCP surfaces; the JSON or
393    /// MessagePack envelope is excluded. Roughly 4 bytes per BPE
394    /// token for English text — a 4 KB budget comfortably holds ~1K
395    /// tokens.
396    ///
397    /// Observable through [`RecallResponse::truncated`]
398    /// ([`Mnemo::recall_budgeted`]): `true` iff at least one scored
399    /// hit was dropped by this budget, `false` otherwise.
400    pub max_bytes: Option<usize>,
401}
402
403impl RecallRequest {
404    /// A request with sensible defaults for a query embedding.
405    pub fn new(query: Vec<f32>) -> Self {
406        Self {
407            query,
408            top_k: 10,
409            memory_types: None,
410            agent_id: None,
411            metric: Metric::Cosine,
412            weights: ScoreWeights::default(),
413            n_probe: None,
414            n_rerank: None,
415            track_access: true,
416            filter: Filter::default(),
417            max_bytes: None,
418        }
419    }
420
421    /// Phase 12.6: attach a pre-scoring filter. See [`Filter`] and
422    /// [`FilterTerm`] for term shapes.
423    pub fn filter(mut self, filter: Filter) -> Self {
424        self.filter = filter;
425        self
426    }
427
428    /// Phase 12.8: cap the total UTF-8 bytes of returned `content`
429    /// fields. See [`RecallRequest::max_bytes`].
430    pub fn max_bytes(mut self, n: usize) -> Self {
431        self.max_bytes = Some(n);
432        self
433    }
434
435    /// Build a request by embedding `query_text` with the supplied
436    /// embedder (Phase 3). Shorthand for `embedder.embed(&[text])` +
437    /// [`RecallRequest::new`]; same defaults as `new`. Fails if the
438    /// embedder returns an empty vector list or an embedding failure.
439    pub fn from_text(embedder: &dyn Embedder, query_text: &str) -> Result<Self> {
440        let mut vectors = embedder.embed(&[query_text])?;
441        let v = vectors.pop().ok_or_else(|| {
442            MnemoError::Invalid("embedder returned no vector for the query text".into())
443        })?;
444        Ok(Self::new(v))
445    }
446    /// Set the result cap.
447    pub fn top_k(mut self, k: usize) -> Self {
448        self.top_k = k;
449        self
450    }
451    /// Restrict to specific memory types.
452    pub fn types(mut self, t: Vec<MemoryType>) -> Self {
453        self.memory_types = Some(t);
454        self
455    }
456    /// Restrict to one agent's view.
457    pub fn agent(mut self, agent_id: impl Into<String>) -> Self {
458        self.agent_id = Some(agent_id.into());
459        self
460    }
461    /// Set the similarity metric (default: cosine).
462    pub fn metric(mut self, metric: Metric) -> Self {
463        self.metric = metric;
464        self
465    }
466    /// Replace the multi-signal score weights.
467    pub fn weights(mut self, weights: ScoreWeights) -> Self {
468        self.weights = weights;
469        self
470    }
471    /// Override the number of IVF partitions probed (accuracy/speed dial).
472    pub fn n_probe(mut self, n: usize) -> Self {
473        self.n_probe = Some(n);
474        self
475    }
476    /// Override the number of candidates reranked exactly (accuracy dial).
477    pub fn n_rerank(mut self, n: usize) -> Self {
478        self.n_rerank = Some(n);
479        self
480    }
481    /// Toggle whether recall updates `accessed_at` / `access_count` on
482    /// the returned memories' catalog entries (default `true`). Pass
483    /// `false` for a fully read-only recall — see the field doc.
484    pub fn track_access(mut self, track: bool) -> Self {
485        self.track_access = track;
486        self
487    }
488}
489
490/// One scored result from [`Mnemo::recall`].
491#[derive(Clone, Debug)]
492pub struct RecallResult {
493    /// The retrieved memory.
494    pub memory: Memory,
495    /// Combined multi-signal score.
496    pub score: f32,
497    /// The bare similarity component (before other signals).
498    pub similarity: f32,
499}
500
501/// Phase 12.8 budgeted-recall envelope returned by
502/// [`Mnemo::recall_budgeted`]. Wraps the `Vec<RecallResult>` the
503/// legacy [`Mnemo::recall`] returns so callers can tell whether
504/// [`RecallRequest::max_bytes`] cut anything.
505#[derive(Clone, Debug)]
506pub struct RecallResponse {
507    /// Scored hits, best first, truncated to at most `top_k` and then
508    /// to fit the `max_bytes` budget if set.
509    pub hits: Vec<RecallResult>,
510    /// `true` iff the `max_bytes` budget dropped at least one hit
511    /// that would otherwise have been returned (i.e. the result is
512    /// shorter than what `top_k` alone would produce).
513    pub truncated: bool,
514    /// Phase 12.1 + 12.6 observability: `true` iff recall fell
515    /// through to an exact scan (ANN shortlist too narrow after
516    /// filtering, and the widen pass also came up short, so the
517    /// index was skipped for one full-corpus pass). Monitoring on
518    /// this flag is how callers detect selective-filter regressions
519    /// or supersession chains outgrowing the ANN shortlist.
520    pub used_exact_scan: bool,
521}
522
523/// Summary statistics for a database.
524#[derive(Clone, Debug)]
525pub struct Stats {
526    /// Live (non-deleted) memory count.
527    pub memories: usize,
528    /// Tombstoned entries awaiting compaction.
529    pub deleted: usize,
530    /// Embedding dimensionality.
531    pub dimensions: usize,
532    /// File size in bytes.
533    pub file_bytes: u64,
534    /// Distinct agent IDs present.
535    pub agents: Vec<String>,
536    /// Whether pages are encrypted (always true in v1).
537    pub encrypted: bool,
538    /// Creation time (unix seconds).
539    pub created_at: i64,
540    /// ANN index shape, if an index has been built.
541    pub index: Option<IndexInfo>,
542    /// Current size of the write-ahead log region, in 8 KiB pages.
543    pub wal_pages: u64,
544    /// Fsyncs issued by the most recent successful [`Mnemo::flush`] on
545    /// this handle. `0` on a fresh handle before any flush, or when
546    /// `flush()` short-circuited (nothing dirty). Phase 6 instrumentation
547    /// for the Phase 11.1 write-latency work — the planner wants this
548    /// before deciding whether cutting fsync count is a viable
549    /// durability-preserving fix.
550    pub fsyncs_last_flush: u32,
551}
552
553/// Result of a [`Mnemo::compact_file`] run.
554#[derive(Clone, Copy, Debug, Default)]
555pub struct CompactReport {
556    /// Live memories before compaction.
557    pub before: usize,
558    /// Live memories after compaction (expired ones dropped).
559    pub after: usize,
560    /// Live, non-expired cache entries before compaction. FIX-1
561    /// R-001.1: compaction now preserves the result cache instead of
562    /// silently dropping it.
563    pub cache_before: usize,
564    /// Live, non-expired cache entries after compaction (tombstoned
565    /// and TTL-expired ones dropped).
566    pub cache_after: usize,
567}
568
569/// An encrypted, single-file agent memory database.
570pub struct Mnemo {
571    pager: Pager,
572    header: Header,
573    catalog: Vec<CatalogEntry>,
574    index: HashMap<u128, usize>,
575    #[allow(dead_code)]
576    path: PathBuf,
577    dimensions: usize,
578    kdf: KdfParams,
579    /// Set whenever the catalog changes; drives whether `flush` rewrites it.
580    dirty_catalog: bool,
581    /// Optional IVF+PQ approximate-nearest-neighbour index.
582    ann: Option<IvfPqIndex>,
583    /// Set whenever the ANN index changes; drives whether `flush` rewrites it.
584    dirty_index: bool,
585    /// Append-only manifest of committed snapshots, oldest first. Capped
586    /// at [`Mnemo::max_snapshots`] entries on every flush — the oldest
587    /// entries are pruned first.
588    manifest: Vec<Snapshot>,
589    /// Maximum manifest entries to retain across flushes. `0` disables
590    /// the cap (retain forever). Defaults to [`DEFAULT_MAX_SNAPSHOTS`].
591    max_snapshots: usize,
592    /// If `true`, this handle was opened via [`Mnemo::open_read_only`] and
593    /// every mutating API returns [`MnemoError::ReadOnly`]. The underlying
594    /// file was opened without write permission and holds an OS shared
595    /// lock; the OS would reject a write() anyway, but gating in Rust
596    /// gives callers a clean, typed error.
597    read_only: bool,
598    /// Result-cache directory (Phase 10.1). See [`crate::result_cache`].
599    /// Loaded from `header.cache_start` on open; persisted on `flush`
600    /// via the same seal_run pipeline as catalog/index/manifest.
601    cache_dir: Vec<CacheDirectoryEntry>,
602    /// In-memory index over `cache_dir` for O(1) `(namespace, key_hash)`
603    /// lookups. Rebuilt on open + kept in sync on `cache_put`/`delete`.
604    cache_idx: DirectoryIndex,
605    /// Set whenever `cache_dir` changes; drives whether `flush` rewrites
606    /// the cache directory run.
607    dirty_cache: bool,
608    /// Per-namespace eviction budgets. Missing entries fall back to
609    /// [`CacheBudget::default`]. Not persisted (per-handle policy).
610    cache_budgets: std::collections::HashMap<String, CacheBudget>,
611    /// Flush policy for cache mutations. Memory writes ignore this and
612    /// always behave as `Strict`.
613    cache_flush_policy: CacheFlushPolicy,
614    /// Bookkeeping for `CacheFlushPolicy::Batched` — pending mutation
615    /// count and first-dirty timestamp.
616    cache_batch: BatchState,
617    /// In-memory hit/miss/eviction counters (reset on reopen).
618    cache_counters: CounterState,
619    /// Optional handle-scoped embedder (Phase 3, PR 10a). Registered
620    /// via [`Mnemo::set_embedder`]; consulted by [`Mnemo::remember_text`]
621    /// and [`RecallRequest::from_text`]. `None` means text-side APIs
622    /// return [`MnemoError::Invalid`] with a "no embedder" message.
623    /// Not persisted — the handle is re-registered after every open.
624    embedder: Option<std::sync::Arc<dyn Embedder>>,
625    /// Fsyncs issued by the most recent `flush()` call. Snapshotted
626    /// from the thread-local fsync counter in `lib.rs::fsync_counter`:
627    /// `flush()` reads it on entry, does its work (each `sync_all` the
628    /// pager / wal / recovery path makes bumps the counter), and
629    /// stores the delta here. `0` on a fresh handle before any flush,
630    /// or on a read-only handle where `flush()` short-circuits.
631    /// Exposed through [`Stats::fsyncs_last_flush`] for diagnostics —
632    /// the Phase 6 planner wants this before deciding whether cutting
633    /// fsync count is a viable write-latency win.
634    fsyncs_last_flush: u32,
635    /// FIX-1 R-001.8 part 2: lazy cache of the distinct agent IDs
636    /// across every live memory, built once on first `stats()` call
637    /// per handle. `remember()` adds to it in O(1); `delete()` can't
638    /// cheaply tell whether the removed memory was the last of its
639    /// agent (the agent lives in the encrypted body, not the catalog
640    /// — pre-v9 format), so it just invalidates the set and the next
641    /// `stats()` call rebuilds. Pre-FIX-1 every `stats()` call
642    /// decrypted every live memory to collect agents, turning an
643    /// otherwise-cheap diagnostic into an O(N) decrypt.
644    agent_set: Option<std::collections::BTreeSet<String>>,
645    /// Phase 12.1 lazy caches, built together on the first keyed op this
646    /// handle sees (`remember_keyed`, `update` that touches `_mnemo.key`,
647    /// or any `history(...)` call). Both are maintained incrementally
648    /// after that. `None` → not built yet; `Some(…)` → authoritative.
649    ///
650    /// * `key_index`: `(scope_owner, key) → ULID bytes` of the **live**
651    ///   record for each keyed slot. `scope_owner` is `Some(agent_id)`
652    ///   for a `Private` scope slot and `None` for a `Shared` one, so a
653    ///   private write never collides with a shared one.
654    /// * `superseded`: the ULID bytes of every record that has been
655    ///   superseded (`_mnemo.superseded_by` set on it). `recall` and
656    ///   `about` skip these before any decrypt.
657    ///
658    /// Building both costs one scan over live records (~1s at 100K with
659    /// hardware AES) and happens at most once per handle. A format bump
660    /// to v9 moves the key into the catalog row so the scan goes away.
661    key_index: Option<std::collections::HashMap<KeySlot, u128>>,
662    superseded: Option<std::collections::HashSet<u128>>,
663}
664
665/// Composite key for the lazy `key_index` on `Mnemo`. A slot is identified
666/// by `(scope_owner, key)`:
667/// * `Scope::Private` writes carry the owning agent → `Some(agent_id)`.
668/// * `Scope::Shared` writes are per-file → `None`.
669///
670/// A private write never supersedes a shared one, and the reverse is also
671/// true. Scope lives on `Memory.scope`, agent on `Memory.agent_id`.
672#[derive(Debug, Clone, PartialEq, Eq, Hash)]
673pub(crate) struct KeySlot {
674    /// `Some(agent_id)` for `Scope::Private`, `None` for `Scope::Shared`.
675    pub(crate) scope_owner: Option<String>,
676    /// The caller-supplied key string, exactly as given (no case-fold).
677    pub(crate) key: String,
678}
679
680/// Open a file and take an OS advisory lock, returning `Locked { path }`
681/// if the requested lock cannot be acquired. On drop of the returned
682/// [`File`], the OS releases the lock. `flock`/`LockFileEx` semantics
683/// (per file-descriptor on Unix, per-open-file-handle on Windows) mean
684/// the lock is scoped to this handle: cloning the file (or the returned
685/// `File`) shares the lock, but a fresh `open()` from any process gets a
686/// distinct handle and will collide.
687fn open_with_lock(path: &Path, write: bool, create: bool) -> Result<File> {
688    let mut opts = OpenOptions::new();
689    opts.read(true);
690    if write {
691        opts.write(true);
692    }
693    if create {
694        opts.create(true).truncate(true);
695    }
696    let file = opts.open(path)?;
697    // On success the returned `Result<()>` is `Ok(())`. On conflict:
698    //   * Unix `flock(2)` → `ErrorKind::WouldBlock` (EWOULDBLOCK / EAGAIN)
699    //   * Windows `LockFileEx` → raw OS code 33 (ERROR_LOCK_VIOLATION)
700    //     or 32 (ERROR_SHARING_VIOLATION), both surfaced by Rust as
701    //     `ErrorKind::Uncategorized` because `std::io::ErrorKind`
702    //     doesn't carry a portable "locked" variant.
703    // Anything else is a real I/O failure (permissions, invalid handle,
704    // ...) and gets bubbled up as `MnemoError::Io`.
705    let result = if write {
706        FileExt::try_lock_exclusive(&file)
707    } else {
708        FileExt::try_lock_shared(&file)
709    };
710    match result {
711        Ok(()) => Ok(file),
712        Err(e) if is_lock_conflict(&e) => Err(MnemoError::Locked {
713            path: path.to_path_buf(),
714        }),
715        Err(e) => Err(MnemoError::Io(e)),
716    }
717}
718
719/// Do two paths resolve to the same on-disk file? Used by
720/// [`Mnemo::export_encrypted`] to refuse overwriting the live
721/// database. On Unix we compare `(dev, ino)` via `MetadataExt`, which
722/// catches hard links and symlinks. On Windows we compare canonicalized
723/// paths, since there is no stable device+inode equivalent in `std`.
724/// Returns `None` if either side can't be stat'd / canonicalized —
725/// the caller treats that as "not the same file" so a missing `dest`
726/// (the normal export case) proceeds.
727fn paths_refer_to_same_file(a: &Path, b: &Path) -> Option<bool> {
728    #[cfg(unix)]
729    {
730        use std::os::unix::fs::MetadataExt;
731        let ma = std::fs::metadata(a).ok()?;
732        let mb = std::fs::metadata(b).ok()?;
733        Some(ma.dev() == mb.dev() && ma.ino() == mb.ino())
734    }
735    #[cfg(not(unix))]
736    {
737        let ca = std::fs::canonicalize(a).ok()?;
738        let cb = std::fs::canonicalize(b).ok()?;
739        Some(ca == cb)
740    }
741}
742
743/// A sibling temp-file path next to `dest`, suffixed with
744/// `.export-tmp`. Lives in `dest`'s own parent directory so the final
745/// [`std::fs::rename`] is same-filesystem (and therefore atomic on
746/// every platform mnemo supports).
747fn temp_sibling(dest: &Path) -> PathBuf {
748    let mut buf = dest.as_os_str().to_os_string();
749    buf.push(".export-tmp");
750    PathBuf::from(buf)
751}
752
753/// Was this I/O error the OS reporting a conflicting file lock, or a
754/// genuine I/O failure? Platform-conditional because
755/// [`std::io::ErrorKind`] carries no portable "resource-locked" variant
756/// as of Rust 1.75 — Windows surfaces `ERROR_LOCK_VIOLATION` /
757/// `ERROR_SHARING_VIOLATION` as `Uncategorized`.
758fn is_lock_conflict(e: &std::io::Error) -> bool {
759    if e.kind() == std::io::ErrorKind::WouldBlock {
760        return true;
761    }
762    #[cfg(windows)]
763    if let Some(code) = e.raw_os_error() {
764        // 33 = ERROR_LOCK_VIOLATION, 32 = ERROR_SHARING_VIOLATION —
765        // both mean "someone else holds an incompatible lock on this
766        // region of the file". See
767        // https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
768        if code == 33 || code == 32 {
769            return true;
770        }
771    }
772    false
773}
774
775/// Read a run of consecutive encrypted pages and concatenate their plaintext.
776fn read_run_bytes(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<u8>> {
777    let mut buf = Vec::with_capacity(len as usize);
778    for i in 0..pages {
779        buf.extend_from_slice(&pager.read_page(start + i)?);
780    }
781    buf.truncate(len as usize);
782    Ok(buf)
783}
784
785/// Load a v5+ catalog run (an empty `pages` yields an empty catalog).
786fn load_catalog(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<CatalogEntry>> {
787    if pages == 0 {
788        return Ok(Vec::new());
789    }
790    let buf = read_run_bytes(pager, start, pages, len)?;
791    rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
792}
793
794/// Load a **v4** catalog run using the frozen [`CatalogEntryV4`] shape.
795/// Used only by the v4→v5 migration path in [`Mnemo::open`].
796fn load_catalog_v4(
797    pager: &mut Pager,
798    start: u64,
799    pages: u64,
800    len: u64,
801) -> Result<Vec<CatalogEntryV4>> {
802    if pages == 0 {
803        return Ok(Vec::new());
804    }
805    let buf = read_run_bytes(pager, start, pages, len)?;
806    rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
807}
808
809/// Walk a v4 catalog and synthesize the v5 shape. For each live entry,
810/// reads the memory's serialized body and copies its `accessed_at` and
811/// `access_count` into the catalog row (the v4 record body still carries
812/// them — they're moving *from* the body *to* the catalog). Deleted
813/// entries skip the read and zero the fields.
814fn migrate_v4_catalog(pager: &mut Pager, v4: Vec<CatalogEntryV4>) -> Result<Vec<CatalogEntry>> {
815    let mut out = Vec::with_capacity(v4.len());
816    for e in &v4 {
817        let (accessed_at, access_count) = if e.deleted {
818            (0i64, 0u32)
819        } else {
820            let mut buf = Vec::with_capacity(e.len as usize);
821            for i in 0..e.page_count as u64 {
822                buf.extend_from_slice(&pager.read_page(e.start_page + i)?);
823            }
824            buf.truncate(e.len as usize);
825            let mem: Memory = rmp_serde::from_slice(&buf)
826                .map_err(|err| MnemoError::Serialize(err.to_string()))?;
827            (mem.accessed_at, mem.access_count)
828        };
829        out.push(CatalogEntry {
830            id: e.id,
831            start_page: e.start_page,
832            page_count: e.page_count,
833            len: e.len,
834            deleted: e.deleted,
835            accessed_at,
836            access_count,
837        });
838    }
839    Ok(out)
840}
841
842/// Load the snapshot manifest (an empty `pages` yields an empty manifest).
843fn load_manifest(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<Snapshot>> {
844    if pages == 0 {
845        return Ok(Vec::new());
846    }
847    let buf = read_run_bytes(pager, start, pages, len)?;
848    rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
849}
850
851/// Load the result-cache directory (v8+). An empty `pages` yields an
852/// empty directory, which is the natural state for v7 files migrated
853/// to v8 (no cache entries yet) and for freshly created v8 files.
854fn load_cache_directory(
855    pager: &mut Pager,
856    start: u64,
857    pages: u64,
858    len: u64,
859) -> Result<Vec<CacheDirectoryEntry>> {
860    if pages == 0 {
861        return Ok(Vec::new());
862    }
863    let buf = read_run_bytes(pager, start, pages, len)?;
864    rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
865}
866
867/// Load an ANN index run, validating its dimensionality.
868fn load_index(
869    pager: &mut Pager,
870    start: u64,
871    pages: u64,
872    len: u64,
873    dims: usize,
874) -> Result<Option<IvfPqIndex>> {
875    if pages == 0 {
876        return Ok(None);
877    }
878    let buf = read_run_bytes(pager, start, pages, len)?;
879    let mut idx: IvfPqIndex =
880        rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
881    if idx.dims() != dims {
882        return Err(MnemoError::Invalid(
883            "ANN index dimensionality does not match the database".into(),
884        ));
885    }
886    idx.rebuild_assignment();
887    Ok(Some(idx))
888}
889
890/// True if a memory's metadata marks it as the canonical onboarding manifest
891/// (`metadata.topic == "manifest"`). Used by [`Mnemo::about`] to hoist the
892/// manifest to the top of the briefing regardless of importance ordering.
893fn is_manifest(m: &Memory) -> bool {
894    m.metadata
895        .get("topic")
896        .and_then(|v| v.as_str())
897        .map(|s| s.eq_ignore_ascii_case("manifest"))
898        .unwrap_or(false)
899}
900
901/// Build the ULID → catalog-slot lookup map.
902fn build_id_index(catalog: &[CatalogEntry]) -> HashMap<u128, usize> {
903    let mut m = HashMap::with_capacity(catalog.len());
904    for (i, e) in catalog.iter().enumerate() {
905        m.insert(e.id, i);
906    }
907    m
908}
909
910// ---------------------------------------------------------------------------
911// Phase 12.7a helpers used by `ingest_thread`.
912// ---------------------------------------------------------------------------
913
914/// One incoming turn after chunking or truncation, ready to embed and
915/// write (Phase 12.7a, split out for 12.7d's batch embed).
916struct PendingTurn {
917    turn_id: String,
918    role: String,
919    created_at: i64,
920    parts: Vec<String>,
921    /// Hex sha256 + original length, set only for a truncated
922    /// tool-result turn. Applied to the single chunk the truncation
923    /// yielded.
924    truncation: Option<(String, u64)>,
925}
926
927/// Output of `Mnemo::plan_ingest`: the turns to write, the first free
928/// `_mnemo.seq`, and the counts gathered so far (skips, chunking,
929/// truncation).
930struct IngestPlan {
931    pending: Vec<PendingTurn>,
932    next_seq: u64,
933    report: IngestReport,
934}
935
936impl IngestPlan {
937    /// Every text to embed, in write order (turns, then parts).
938    fn texts(&self) -> Vec<&str> {
939        self.pending
940            .iter()
941            .flat_map(|p| p.parts.iter().map(String::as_str))
942            .collect()
943    }
944
945    fn text_count(&self) -> usize {
946        self.pending.iter().map(|p| p.parts.len()).sum()
947    }
948}
949
950/// Hex-encode a byte slice. Low-dependency alternative to pulling in a
951/// hex crate for the lone call site in `ingest_thread`'s SHA-256
952/// recording.
953fn hex_encode(bytes: &[u8]) -> String {
954    const HEX: &[u8; 16] = b"0123456789abcdef";
955    let mut out = String::with_capacity(bytes.len() * 2);
956    for b in bytes {
957        out.push(HEX[(b >> 4) as usize] as char);
958        out.push(HEX[(b & 0x0f) as usize] as char);
959    }
960    out
961}
962
963/// Return the longest UTF-8 prefix of `s` whose byte length is `<=
964/// max_bytes`. Always lands on a `char` boundary.
965fn take_utf8_prefix(s: &str, max_bytes: usize) -> String {
966    if s.len() <= max_bytes {
967        return s.to_string();
968    }
969    // Walk forward through char indices; the last index whose byte
970    // offset fits inside `max_bytes` tells us where to cut.
971    let mut cut = 0usize;
972    for (idx, _) in s.char_indices() {
973        if idx > max_bytes {
974            break;
975        }
976        cut = idx;
977    }
978    s[..cut].to_string()
979}
980
981/// Split `s` into chunks, each with `<= max_bytes` of UTF-8 content,
982/// landing on `char` boundaries. Preserves original ordering; a trailing
983/// chunk may be shorter than `max_bytes`.
984/// Phase 12.7b helper: pull the `_mnemo.status` string from an
985/// essence record. Returns `None` for timeline entries (they have no
986/// status) and for malformed records.
987fn status_of(m: &Memory) -> Option<&str> {
988    m.metadata.get(MNEMO_META_STATUS).and_then(|v| v.as_str())
989}
990
991/// Phase 12.7b helper: render one essence item as a markdown bullet.
992/// Timeline entries get a date tag; section items get `[open]` /
993/// `[resolved]` so the reader can scan active work quickly.
994fn render_essence_item(section: EssenceSection, m: &Memory) -> String {
995    if section == EssenceSection::Timeline {
996        let date = format_unix_date(m.created_at);
997        format!("- [{date}] {}\n", m.content)
998    } else {
999        let status = status_of(m).unwrap_or(essence_status::OPEN);
1000        format!("- [{status}] {}\n", m.content)
1001    }
1002}
1003
1004/// Minimal unix-seconds → `YYYY-MM-DD` formatter (no chrono/time
1005/// dependency; mirrors the ISO 8601 parser in `transcript.rs`).
1006fn format_unix_date(secs: i64) -> String {
1007    let days_total = secs.div_euclid(86_400);
1008    let (y, m, d) = civil_from_days(days_total);
1009    format!("{y:04}-{m:02}-{d:02}")
1010}
1011
1012/// Howard Hinnant's `civil_from_days` — inverse of `days_from_civil`,
1013/// adequate for Gregorian dates since 1970. Pure arithmetic, no
1014/// dependencies.
1015fn civil_from_days(z: i64) -> (i64, u32, u32) {
1016    let z = z + 719_468;
1017    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1018    let doe = (z - era * 146_097) as u64;
1019    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1020    let y = yoe as i64 + era * 400;
1021    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1022    let mp = (5 * doy + 2) / 153;
1023    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1024    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
1025    let y = if m <= 2 { y + 1 } else { y };
1026    (y, m, d)
1027}
1028
1029fn chunk_utf8(s: &str, max_bytes: usize) -> Vec<String> {
1030    if s.len() <= max_bytes {
1031        return vec![s.to_string()];
1032    }
1033    let mut out = Vec::new();
1034    let mut rest = s;
1035    while rest.len() > max_bytes {
1036        let head = take_utf8_prefix(rest, max_bytes);
1037        let head_bytes = head.len();
1038        out.push(head);
1039        rest = &rest[head_bytes..];
1040    }
1041    if !rest.is_empty() {
1042        out.push(rest.to_string());
1043    }
1044    out
1045}
1046
1047impl Mnemo {
1048    /// Create a brand-new encrypted database at `path`.
1049    pub fn create(
1050        path: impl Into<PathBuf>,
1051        passphrase: &str,
1052        config: MnemoConfig,
1053    ) -> Result<Mnemo> {
1054        let path: PathBuf = path.into();
1055        if config.dimensions == 0 {
1056            return Err(MnemoError::Invalid("dimensions must be > 0".into()));
1057        }
1058        // Exclusive lock covers the write path; second writer sees `Locked`.
1059        let file = open_with_lock(&path, /*write=*/ true, /*create=*/ true)?;
1060
1061        let salt = crypto::random_salt();
1062        let dek = crypto::random_dek();
1063        let kek = crypto::derive_kek(passphrase.as_bytes(), &salt, config.kdf)?;
1064        let dek_nonce = crypto::random_nonce();
1065        let wrapped = crypto::wrap_dek(&kek, &dek_nonce, &dek)?;
1066        if wrapped.len() != WRAPPED_DEK_LEN {
1067            return Err(MnemoError::Crypto("unexpected wrapped DEK length".into()));
1068        }
1069        let mut wrapped_dek = [0u8; WRAPPED_DEK_LEN];
1070        wrapped_dek.copy_from_slice(&wrapped);
1071
1072        let mut header = Header {
1073            version: VERSION,
1074            page_size: PAGE_SIZE as u32,
1075            flags: FLAG_ENCRYPTED,
1076            dimensions: config.dimensions as u32,
1077            created_at: memory::now_secs(),
1078            write_counter: 0,
1079            // Page 0 is the header; pages 1..=wal_pages are the WAL;
1080            // record/catalog/index pages start after it. Initial WAL size is
1081            // chosen per-config (default 8 pages = 64 KiB) and clamped to a
1082            // sane floor so the first transaction can always commit.
1083            next_page: 1 + config.wal_pages_initial.max(MIN_WAL_PAGES),
1084            catalog_start: 0,
1085            catalog_pages: 0,
1086            catalog_len: 0,
1087            vector_count: 0,
1088            m_cost: config.kdf.m_cost,
1089            t_cost: config.kdf.t_cost,
1090            p_cost: config.kdf.p_cost,
1091            salt,
1092            dek_nonce,
1093            wrapped_dek,
1094            index_start: 0,
1095            index_pages: 0,
1096            index_len: 0,
1097            wal_start: 1,
1098            wal_pages: config.wal_pages_initial.max(MIN_WAL_PAGES),
1099            wal_seq: 0,
1100            manifest_start: 0,
1101            manifest_pages: 0,
1102            manifest_len: 0,
1103            // Regenerated per write by `apply_seal`; `None` until we seal.
1104            seal_nonce: None,
1105            seal_tag: None,
1106            // v8+ result-cache pointer — zero-initialized on create,
1107            // populated only when `cache_put` runs at least once and
1108            // the next `flush` seals a directory run.
1109            cache_start: 0,
1110            cache_pages: 0,
1111            cache_len: 0,
1112        };
1113
1114        let mut pager = Pager::new(file, dek, 0, VERSION);
1115        let mut page = header.to_page();
1116        header.apply_seal(&mut page, pager.dek())?;
1117        pager.write_raw(0, &page)?;
1118        pager.sync()?;
1119
1120        Ok(Mnemo {
1121            pager,
1122            header,
1123            catalog: Vec::new(),
1124            index: HashMap::new(),
1125            path,
1126            dimensions: config.dimensions,
1127            kdf: config.kdf,
1128            dirty_catalog: false,
1129            ann: None,
1130            dirty_index: false,
1131            manifest: Vec::new(),
1132            max_snapshots: config.max_snapshots,
1133            read_only: false,
1134            cache_dir: Vec::new(),
1135            cache_idx: DirectoryIndex::new(),
1136            dirty_cache: false,
1137            cache_budgets: std::collections::HashMap::new(),
1138            cache_flush_policy: CacheFlushPolicy::default(),
1139            cache_batch: BatchState::default(),
1140            cache_counters: CounterState::default(),
1141            embedder: None,
1142            fsyncs_last_flush: 0,
1143            agent_set: None,
1144            key_index: None,
1145            superseded: None,
1146        })
1147    }
1148
1149    /// Open an existing database. A wrong passphrase fails cleanly with
1150    /// [`MnemoError::WrongPassphrase`]. Takes an OS exclusive lock for
1151    /// the lifetime of the returned handle; a second concurrent open
1152    /// (from any process) returns [`MnemoError::Locked`].
1153    ///
1154    /// For a caller that only needs to read (and can tolerate the
1155    /// pending-recovery / pending-migration cases documented on
1156    /// [`Mnemo::open_read_only`]), prefer that entry point — it takes a
1157    /// shared lock and coexists with other readers.
1158    pub fn open(path: impl Into<PathBuf>, passphrase: &str) -> Result<Mnemo> {
1159        let path: PathBuf = path.into();
1160        let mut file = open_with_lock(&path, /*write=*/ true, /*create=*/ false)?;
1161
1162        let mut hbuf = [0u8; PAGE_SIZE];
1163        file.seek(SeekFrom::Start(0))?;
1164        file.read_exact(&mut hbuf)?;
1165        let mut header = match Header::from_page(&hbuf) {
1166            Ok(h) => h,
1167            Err(e) => {
1168                // DUR-1 (R-002.1 part 2+3): page 0 did not parse — any
1169                // error is treated as a torn-header candidate, not just
1170                // `HeaderChecksum`. The whole 294-byte header lives in
1171                // the first 512-byte sector, so the realistic failure is
1172                // the sector zeroed or garbled, which returns `BadMagic`
1173                // or `UnsupportedVersion` *before* the CRC gate.
1174                //
1175                // Scan the file for valid committed header-frame WAL
1176                // regions, pick the highest `txn_id`, and replay it. If
1177                // nothing valid is found, return the *original* error so
1178                // non-mnemo files still report `BadMagic`.
1179                let scan = scan_header_recovery_candidates(&mut file, passphrase)?;
1180                let frames = scan.ok_or(e)?;
1181                for (page_no, bytes) in &frames {
1182                    if bytes.len() != PAGE_SIZE {
1183                        return Err(MnemoError::Invalid("WAL frame is not page-sized".into()));
1184                    }
1185                    file.seek(SeekFrom::Start(page_no * PAGE_SIZE as u64))?;
1186                    file.write_all(bytes)?;
1187                }
1188                file.sync_all()?;
1189                crate::bump_fsync();
1190                file.seek(SeekFrom::Start(0))?;
1191                file.read_exact(&mut hbuf)?;
1192                Header::from_page(&hbuf)?
1193            }
1194        };
1195
1196        // Crash recovery: replay a committed-but-uncheckpointed transaction.
1197        // Each frame is a finished page image; the header frame, replayed to
1198        // page 0, supersedes the header just read.
1199        if let Some(frames) = wal::recover(
1200            &mut file,
1201            header.wal_start,
1202            header.wal_pages,
1203            header.wal_seq,
1204        )? {
1205            for (page_no, bytes) in &frames {
1206                if bytes.len() != PAGE_SIZE {
1207                    return Err(MnemoError::Invalid("WAL frame is not page-sized".into()));
1208                }
1209                file.seek(SeekFrom::Start(page_no * PAGE_SIZE as u64))?;
1210                file.write_all(bytes)?;
1211            }
1212            file.sync_all()?;
1213            crate::bump_fsync();
1214            // Re-read the now-current header from the replayed page 0.
1215            file.seek(SeekFrom::Start(0))?;
1216            file.read_exact(&mut hbuf)?;
1217            header = Header::from_page(&hbuf)?;
1218        }
1219
1220        let kdf = KdfParams {
1221            m_cost: header.m_cost,
1222            t_cost: header.t_cost,
1223            p_cost: header.p_cost,
1224        };
1225        let kek = crypto::derive_kek(passphrase.as_bytes(), &header.salt, kdf)?;
1226        let dek = crypto::unwrap_dek(&kek, &header.dek_nonce, &header.wrapped_dek)?;
1227
1228        // Validate the v7 header seal now that we have the DEK. Returns
1229        // `MnemoError::HeaderTampered` if any of the sealed mutable fields
1230        // were rewritten after the last legitimate flush. No-op for v6 and
1231        // older — pre-v7 files don't carry a seal.
1232        //
1233        // DUR-1 (Planner decision 2026-11-01, Option B): a seal failure is
1234        // **always** reported as tampering, never auto-healed. R-002.1
1235        // part 3's seal-mismatch WAL fallback is withdrawn: the seal
1236        // exists for tamper-evidence; silently loading a stale committed
1237        // header from the WAL would erase that evidence. A real crash
1238        // cannot produce "CRC valid + seal invalid" either, because the
1239        // whole 294-byte header lies inside sector 0 which storage
1240        // writes atomically — a torn sector breaks magic, version or
1241        // CRC first, and *those* are healed by the scan above.
1242        header.validate_seal(&dek)?;
1243
1244        // Build the pager at the file's *current* version so initial reads
1245        // use the matching AAD scheme — `Pager::page_aad` returns empty AAD
1246        // for v4/v5 (no page binding) and `page_no.to_le_bytes()` for v6+.
1247        let on_disk_version = header.version;
1248        let mut pager = Pager::new(file, dek, header.write_counter, on_disk_version);
1249        let dimensions = header.dimensions as usize;
1250
1251        // === v4→v5 catalog migration =====================================
1252        // v6 reads directly; v5 reads directly; v4 replays the catalog into
1253        // the v5 shape by reading each memory's body to populate the new
1254        // `accessed_at` / `access_count` fields. All page reads here still
1255        // use the v4/v5 AAD scheme (none).
1256        let (catalog, migrated_from_v4) = if on_disk_version >= 5 {
1257            (
1258                load_catalog(
1259                    &mut pager,
1260                    header.catalog_start,
1261                    header.catalog_pages,
1262                    header.catalog_len,
1263                )?,
1264                false,
1265            )
1266        } else if on_disk_version == 4 {
1267            let v4 = load_catalog_v4(
1268                &mut pager,
1269                header.catalog_start,
1270                header.catalog_pages,
1271                header.catalog_len,
1272            )?;
1273            (migrate_v4_catalog(&mut pager, v4)?, true)
1274        } else {
1275            return Err(MnemoError::UnsupportedVersion(on_disk_version));
1276        };
1277        let index = build_id_index(&catalog);
1278        let ann = load_index(
1279            &mut pager,
1280            header.index_start,
1281            header.index_pages,
1282            header.index_len,
1283            dimensions,
1284        )?;
1285        let mut manifest = load_manifest(
1286            &mut pager,
1287            header.manifest_start,
1288            header.manifest_pages,
1289            header.manifest_len,
1290        )?;
1291
1292        // === v5→v6 page-encryption migration =============================
1293        // Pre-v6 data pages were encrypted under no AAD; v6 binds the home
1294        // `page_no` as AAD so a page-transplant attack fails authentication
1295        // on read. Re-stage every live record page through `write_page` so
1296        // the next flush re-encrypts it under v6 AAD at the same home slot.
1297        // Tombstoned entries skip — they're reclaimed by `compact_file`.
1298        // Old catalog / ANN / manifest pages stay as orphan ciphertext;
1299        // the next flush writes the new runs at fresh slots.
1300        let migrated_pages_to_v6 = on_disk_version < 6;
1301        if migrated_pages_to_v6 {
1302            for e in &catalog {
1303                if e.deleted {
1304                    continue;
1305                }
1306                for i in 0..e.page_count as u64 {
1307                    let payload = pager.read_page(e.start_page + i)?;
1308                    pager.write_page(e.start_page + i, &payload)?;
1309                }
1310            }
1311            pager.set_version(6);
1312        }
1313
1314        // === v6→v7 header-seal migration =================================
1315        // v7 adds an AES-GCM seal at the tail of the header page that
1316        // authenticates every mutable header field. Pre-v7 files have no
1317        // seal — we just bump the version in memory and mark `dirty_catalog`
1318        // so the next flush writes a sealed v7 header. Pages are untouched
1319        // (page crypto is unchanged from v6).
1320        let migrated_header_to_v7 = on_disk_version < 7;
1321        if migrated_header_to_v7 {
1322            pager.set_version(7);
1323        }
1324
1325        // === v7→v8 cache-directory migration =============================
1326        // v8 adds three u64 header fields (cache_start/pages/len) for the
1327        // result-cache directory (Phase 10.1). Pre-v8 files have those
1328        // three bytes as zero (the on-disk region beyond the v7 seal is
1329        // zero-initialized), which is the legal "empty cache directory"
1330        // state — no data migration required. We bump the version in
1331        // memory and mark dirty_catalog so the next flush writes a
1332        // v8-sealed header with the new AAD shape covering the cache
1333        // pointer. Pages are untouched.
1334        let migrated_header_to_v8 = on_disk_version < 8;
1335        if migrated_header_to_v8 {
1336            pager.set_version(VERSION);
1337        }
1338
1339        let migrated = migrated_from_v4
1340            || migrated_pages_to_v6
1341            || migrated_header_to_v7
1342            || migrated_header_to_v8;
1343        if migrated {
1344            // Stamp the version forward in memory; the next flush persists
1345            // it via the WAL-committed header frame (and the v7 seal).
1346            header.version = VERSION;
1347            // Snapshots written under an older format reference page runs
1348            // encrypted under the old AAD scheme; this build can't decrypt
1349            // them under the new pager. Drop the manifest so the next
1350            // flush records a fresh snapshot of the migrated state.
1351            // Point-in-time recovery into the pre-migration past is
1352            // sacrificed for migration simplicity. Old pages remain on
1353            // disk until `compact_file`. (For v6→v7 alone — which doesn't
1354            // change page crypto — the old snapshots would technically
1355            // still be readable, but dropping them uniformly keeps the
1356            // migration policy simple.)
1357            manifest.clear();
1358        }
1359
1360        // The ANN index pages on disk were sealed under the *old* AAD scheme
1361        // if we migrated pages to v6; force a rewrite at fresh v6-encrypted
1362        // slots on the next flush so a future reopen doesn't try to read
1363        // them under the new AAD and fail. (Catalog and manifest already
1364        // get rewritten via dirty_catalog and the manifest.clear() above.)
1365        let dirty_index = migrated_pages_to_v6 && ann.is_some();
1366
1367        // Load the cache directory (v8+ only). Pre-v8 files have
1368        // cache_pages == 0 by header layout, so this trivially yields
1369        // an empty directory without touching the disk.
1370        let cache_dir = load_cache_directory(
1371            &mut pager,
1372            header.cache_start,
1373            header.cache_pages,
1374            header.cache_len,
1375        )?;
1376        let cache_idx = DirectoryIndex::build(&cache_dir);
1377
1378        Ok(Mnemo {
1379            pager,
1380            header,
1381            catalog,
1382            index,
1383            path,
1384            dimensions,
1385            kdf,
1386            dirty_catalog: migrated,
1387            ann,
1388            dirty_index,
1389            manifest,
1390            // `Mnemo::open` has no [`MnemoConfig`] to read; default the cap
1391            // to [`DEFAULT_MAX_SNAPSHOTS`]. Override with
1392            // [`Mnemo::set_max_snapshots`] right after open if you need
1393            // unlimited retention or a different cap for this handle.
1394            max_snapshots: DEFAULT_MAX_SNAPSHOTS,
1395            read_only: false,
1396            cache_dir,
1397            cache_idx,
1398            dirty_cache: false,
1399            cache_budgets: std::collections::HashMap::new(),
1400            cache_flush_policy: CacheFlushPolicy::default(),
1401            cache_batch: BatchState::default(),
1402            cache_counters: CounterState::default(),
1403            embedder: None,
1404            fsyncs_last_flush: 0,
1405            agent_set: None,
1406            key_index: None,
1407            superseded: None,
1408        })
1409    }
1410
1411    /// Open an existing database in **read-only** mode. Takes an OS shared
1412    /// lock; multiple read-only handles can coexist, but any attempt to
1413    /// open the same file read-write elsewhere returns
1414    /// [`MnemoError::Locked`]. All mutating methods on the returned handle
1415    /// return [`MnemoError::ReadOnly`], including `recall` with
1416    /// `track_access(true)`.
1417    ///
1418    /// Two conditions block a read-only open and return
1419    /// [`MnemoError::NeedsWriteOpen`]:
1420    ///
1421    /// 1. The file has a committed-but-uncheckpointed WAL transaction that
1422    ///    would ordinarily be replayed on open. A read-only handle
1423    ///    cannot write the recovered pages back.
1424    /// 2. The on-disk format version is older than the current
1425    ///    `format::VERSION`, requiring an on-disk migration.
1426    ///
1427    /// In either case, open the file read-write once (which recovers /
1428    /// migrates in place), close the handle cleanly, and reopen
1429    /// read-only. This is documented in the README's Durability section.
1430    pub fn open_read_only(path: impl Into<PathBuf>, passphrase: &str) -> Result<Mnemo> {
1431        let path: PathBuf = path.into();
1432        let mut file = open_with_lock(&path, /*write=*/ false, /*create=*/ false)?;
1433
1434        let mut hbuf = [0u8; PAGE_SIZE];
1435        file.seek(SeekFrom::Start(0))?;
1436        file.read_exact(&mut hbuf)?;
1437        // A torn header on a read-only open cannot be healed (that requires
1438        // writing recovered frames back). Surface as `NeedsWriteOpen` so the
1439        // caller knows to reopen read-write once. DUR-1 (R-002.1 part 4):
1440        // any `Header::from_page` failure (`BadMagic`,
1441        // `UnsupportedVersion`, `HeaderChecksum`) qualifies — but only
1442        // when the file-wide scan actually finds a valid committed
1443        // header frame to recover from, so a non-mnemo file still
1444        // reports `BadMagic` cleanly. A `validate_seal` failure is NOT
1445        // routed here (Option B: tampering is never auto-healed).
1446        let header = match Header::from_page(&hbuf) {
1447            Ok(h) => h,
1448            Err(parse_err) => {
1449                let recoverable = scan_header_recovery_candidates(&mut file, passphrase)?;
1450                return Err(match (parse_err, recoverable) {
1451                    (_, Some(_)) => MnemoError::NeedsWriteOpen {
1452                        reason: "torn header page needs recovery from WAL",
1453                    },
1454                    (e, None) => e,
1455                });
1456            }
1457        };
1458
1459        // Refuse to migrate on read-only opens. v6→v7 is header-only
1460        // (no page writes required), but even that bumps `version` on
1461        // disk and re-seals the header — which needs write access. Keep
1462        // the policy uniform across all sub-version bumps.
1463        if header.version < VERSION {
1464            return Err(MnemoError::NeedsWriteOpen {
1465                reason: "pending format-version migration",
1466            });
1467        }
1468
1469        // Refuse if there is a committed-but-uncheckpointed transaction
1470        // waiting for `wal::recover` to fold it into home pages.
1471        if wal::recover(
1472            &mut file,
1473            header.wal_start,
1474            header.wal_pages,
1475            header.wal_seq,
1476        )?
1477        .is_some()
1478        {
1479            return Err(MnemoError::NeedsWriteOpen {
1480                reason: "pending WAL recovery",
1481            });
1482        }
1483
1484        let kdf = KdfParams {
1485            m_cost: header.m_cost,
1486            t_cost: header.t_cost,
1487            p_cost: header.p_cost,
1488        };
1489        let kek = crypto::derive_kek(passphrase.as_bytes(), &header.salt, kdf)?;
1490        let dek = crypto::unwrap_dek(&kek, &header.dek_nonce, &header.wrapped_dek)?;
1491        header.validate_seal(&dek)?;
1492
1493        let mut pager = Pager::new(file, dek, header.write_counter, header.version);
1494        let dimensions = header.dimensions as usize;
1495
1496        let catalog = load_catalog(
1497            &mut pager,
1498            header.catalog_start,
1499            header.catalog_pages,
1500            header.catalog_len,
1501        )?;
1502        let index = build_id_index(&catalog);
1503        let ann = load_index(
1504            &mut pager,
1505            header.index_start,
1506            header.index_pages,
1507            header.index_len,
1508            dimensions,
1509        )?;
1510        let manifest = load_manifest(
1511            &mut pager,
1512            header.manifest_start,
1513            header.manifest_pages,
1514            header.manifest_len,
1515        )?;
1516        let cache_dir = load_cache_directory(
1517            &mut pager,
1518            header.cache_start,
1519            header.cache_pages,
1520            header.cache_len,
1521        )?;
1522        let cache_idx = DirectoryIndex::build(&cache_dir);
1523
1524        Ok(Mnemo {
1525            pager,
1526            header,
1527            catalog,
1528            index,
1529            path,
1530            dimensions,
1531            kdf,
1532            // No mutation is possible; leave both dirty flags false so
1533            // `close()` is a genuine no-op even if we later add an
1534            // auto-flush on drop.
1535            dirty_catalog: false,
1536            ann,
1537            dirty_index: false,
1538            manifest,
1539            max_snapshots: DEFAULT_MAX_SNAPSHOTS,
1540            read_only: true,
1541            cache_dir,
1542            cache_idx,
1543            dirty_cache: false,
1544            cache_budgets: std::collections::HashMap::new(),
1545            cache_flush_policy: CacheFlushPolicy::default(),
1546            cache_batch: BatchState::default(),
1547            cache_counters: CounterState::default(),
1548            embedder: None,
1549            fsyncs_last_flush: 0,
1550            agent_set: None,
1551            key_index: None,
1552            superseded: None,
1553        })
1554    }
1555
1556    /// Override the manifest snapshot cap on this open handle. `0` disables
1557    /// the cap; any positive value keeps the most-recent `max` snapshots and
1558    /// drops the rest on the next flush. See [`MnemoConfig::max_snapshots`].
1559    pub fn set_max_snapshots(&mut self, max: usize) {
1560        self.max_snapshots = max;
1561    }
1562
1563    /// Embedding dimensionality this database expects.
1564    pub fn dimensions(&self) -> usize {
1565        self.dimensions
1566    }
1567
1568    /// Number of live (non-deleted) memories.
1569    pub fn len(&self) -> usize {
1570        self.catalog.iter().filter(|e| !e.deleted).count()
1571    }
1572
1573    /// True if the database holds no live memories.
1574    pub fn is_empty(&self) -> bool {
1575        self.len() == 0
1576    }
1577
1578    // --- internal page/record helpers -------------------------------------
1579
1580    fn write_record(&mut self, bytes: &[u8]) -> Result<(u64, u32)> {
1581        let pc = bytes.len().div_ceil(PAYLOAD).max(1);
1582        let start = self.header.next_page;
1583        self.header.next_page += pc as u64;
1584        for i in 0..pc {
1585            let lo = i * PAYLOAD;
1586            let hi = ((i + 1) * PAYLOAD).min(bytes.len());
1587            self.pager.write_page(start + i as u64, &bytes[lo..hi])?;
1588        }
1589        Ok((start, pc as u32))
1590    }
1591
1592    fn read_record(&mut self, e: &CatalogEntry) -> Result<Vec<u8>> {
1593        let mut buf = Vec::with_capacity(e.len as usize);
1594        for i in 0..e.page_count as u64 {
1595            buf.extend_from_slice(&self.pager.read_page(e.start_page + i)?);
1596        }
1597        buf.truncate(e.len as usize);
1598        Ok(buf)
1599    }
1600
1601    fn read_memory(&mut self, e: &CatalogEntry) -> Result<Memory> {
1602        let bytes = self.read_record(e)?;
1603        let mut m: Memory =
1604            rmp_serde::from_slice(&bytes).map_err(|err| MnemoError::Serialize(err.to_string()))?;
1605        // The catalog is the source of truth for access stats — the values
1606        // in the record body are a stale snapshot from when the record was
1607        // last written. Overwrite them here so consumers always see live
1608        // values regardless of when the record was last serialized.
1609        m.accessed_at = e.accessed_at;
1610        m.access_count = e.access_count;
1611        Ok(m)
1612    }
1613
1614    /// Serialize and store a memory (insert or overwrite by ID).
1615    fn put(&mut self, mut m: Memory) -> Result<Ulid> {
1616        if m.vector.len() != self.dimensions {
1617            return Err(MnemoError::DimensionMismatch {
1618                expected: self.dimensions,
1619                got: m.vector.len(),
1620            });
1621        }
1622        // Reject all-zero vectors (Phase 3 PR 10a; breaking vs 0.4.x).
1623        // A zero vector scores exactly 0.0 on every cosine query — it
1624        // can't contribute signal, but recency / importance can still
1625        // pull it into top-k as noise. We'd rather fail loudly at
1626        // `remember` than let a mis-embedded entry pollute recall
1627        // quality for the life of the database. Scaffold manifests now
1628        // use a sentinel unit vector so this check doesn't trip them.
1629        if embed::is_zero_vector(&m.vector) {
1630            return Err(MnemoError::Invalid(
1631                "all-zero vector refused: it scores 0.0 on every cosine query and \
1632                 pollutes recall; check your embedder returned a real embedding"
1633                    .into(),
1634            ));
1635        }
1636        if m.id == Ulid::nil() {
1637            m.id = Ulid::new();
1638        }
1639        let id_u: u128 = m.id.0;
1640        let bytes = rmp_serde::to_vec(&m).map_err(|e| MnemoError::Serialize(e.to_string()))?;
1641        let (start, pc) = self.write_record(&bytes)?;
1642        // For overwrites preserve the existing access stats; for inserts
1643        // seed them from the memory (which carries them in its body).
1644        let (prev_accessed, prev_count) = match self.index.get(&id_u).copied() {
1645            Some(idx) => (
1646                self.catalog[idx].accessed_at,
1647                self.catalog[idx].access_count,
1648            ),
1649            None => (m.accessed_at, m.access_count),
1650        };
1651        let entry = CatalogEntry {
1652            id: id_u,
1653            start_page: start,
1654            page_count: pc,
1655            len: bytes.len() as u32,
1656            deleted: false,
1657            accessed_at: prev_accessed,
1658            access_count: prev_count,
1659        };
1660        match self.index.get(&id_u).copied() {
1661            Some(idx) => self.catalog[idx] = entry,
1662            None => {
1663                self.index.insert(id_u, self.catalog.len());
1664                self.catalog.push(entry);
1665            }
1666        }
1667        self.dirty_catalog = true;
1668
1669        // Keep the ANN index complete: assign the (new or changed) vector to
1670        // its nearest partition. Centroids/codebook stay fixed until rebuild.
1671        if let Some(ann) = &mut self.ann {
1672            ann.add(id_u, &m.vector);
1673            self.dirty_index = true;
1674        }
1675
1676        // FIX-1 R-001.8 part 2: keep the lazy agent set in sync. If
1677        // it hasn't been built yet (no `stats()` call this session),
1678        // skip — the first `stats()` will build it from scratch. If
1679        // it has, add this memory's agent_id. An overwrite with the
1680        // same id and same agent is a no-op (BTreeSet::insert returns
1681        // false); an overwrite with a *changed* agent_id adds the new
1682        // agent but doesn't drop the old (we can't tell without a
1683        // decrypt whether the old agent still has live memories).
1684        // That's conservative in the right direction — `stats()` may
1685        // over-report by one agent until the next delete invalidates.
1686        if let Some(set) = &mut self.agent_set {
1687            set.insert(m.agent_id.clone());
1688        }
1689        Ok(m.id)
1690    }
1691
1692    // --- public CRUD ------------------------------------------------------
1693
1694    /// Register a text embedder for this handle. Enables
1695    /// [`Mnemo::remember_text`] and the text-side `RecallRequest`
1696    /// convenience path. Does two checks before storing the embedder:
1697    ///
1698    /// 1. **Dimension check.** The embedder's `identity().dimensions`
1699    ///    must equal [`Mnemo::dimensions`] (the width this database
1700    ///    was created with). Mismatch returns
1701    ///    [`MnemoError::DimensionMismatch`].
1702    /// 2. **Manifest identity check.** If the scaffold / onboarding
1703    ///    manifest already records an embedder — the canonical shape
1704    ///    is `metadata.embedder = { name, dimensions, normalize }`,
1705    ///    with the legacy flat `embedder_name` / `embedder_dimensions`
1706    ///    / `embedder_normalize` keys accepted as an alias — the new
1707    ///    embedder's identity must match exactly. A different name —
1708    ///    even at the same dimensions — returns
1709    ///    [`MnemoError::Invalid`] with
1710    ///    [`EmbedderIdentity::MISMATCH_MSG`] in the message. If the
1711    ///    manifest has no embedder metadata yet, this call upserts
1712    ///    the nested object into the first onboarding/manifest memory
1713    ///    (write + flush so a subsequent reopen sees the identity).
1714    ///
1715    /// **Read-only handles.** `set_embedder` on an `open_read_only`
1716    /// handle performs both checks and adopts the embedder in memory;
1717    /// it never writes. Readers (CLI `--text`, agent-side `recall_text`)
1718    /// can therefore use the text path next to a writer that holds
1719    /// the exclusive lock.
1720    ///
1721    /// On both failure paths the handle's embedder slot is left
1722    /// unchanged. On success the embedder is wrapped in an `Arc<dyn
1723    /// Embedder>` and consulted by every text-side call made through
1724    /// this handle. The slot is per-handle and not persisted — a
1725    /// fresh open must re-register (same identity, so the manifest
1726    /// check round-trips cleanly).
1727    pub fn set_embedder<E: Embedder + 'static>(&mut self, embedder: E) -> Result<()> {
1728        let identity = embedder.identity();
1729        if identity.dimensions != self.dimensions {
1730            return Err(MnemoError::DimensionMismatch {
1731                expected: self.dimensions,
1732                got: identity.dimensions,
1733            });
1734        }
1735
1736        // Find a scaffold/onboarding manifest to inspect (or upsert
1737        // into). `about()` already filters + sorts the onboarding set.
1738        let onboarding = self.about()?;
1739        let mut upsert_target: Option<usize> = None;
1740        for (i, m) in onboarding.iter().enumerate() {
1741            if let Some(saw) = embed::read_identity_from_metadata(&m.metadata) {
1742                if saw != identity {
1743                    return Err(embed::identity_mismatch_err(&saw, &identity));
1744                }
1745                // Match — nothing to persist; adopt + done. Works the
1746                // same on read-only and read-write handles.
1747                self.embedder = Some(std::sync::Arc::new(embedder));
1748                return Ok(());
1749            }
1750            if upsert_target.is_none() {
1751                upsert_target = Some(i);
1752            }
1753        }
1754
1755        // No existing identity on the manifest. On a read-write handle
1756        // upsert into the first onboarding memory so later opens see
1757        // it; on a read-only handle skip the write (readers must not
1758        // mutate the file), but still adopt the embedder in memory.
1759        if !self.read_only {
1760            if let Some(idx) = upsert_target {
1761                let mut m = onboarding[idx].clone();
1762                embed::write_identity_into_metadata(&mut m.metadata, &identity);
1763                // Reuse the memory's existing id so this is an in-place
1764                // update of the scaffold manifest, not a new entry.
1765                self.put(m)?;
1766                self.flush()?;
1767            }
1768        }
1769
1770        self.embedder = Some(std::sync::Arc::new(embedder));
1771        Ok(())
1772    }
1773
1774    /// Borrow the currently-registered embedder, if any. Returns
1775    /// `None` when no embedder has been set via
1776    /// [`Mnemo::set_embedder`]. Bindings use this to drive their own
1777    /// text-side convenience methods without duplicating the identity
1778    /// check.
1779    pub fn embedder(&self) -> Option<&dyn Embedder> {
1780        self.embedder.as_deref()
1781    }
1782
1783    /// Shared handle to the registered embedder, cloned so the caller
1784    /// can drop the borrow on this `Mnemo` before invoking `embed()`.
1785    /// FIX-1 / PY-1 (R-001.11): the Python binding needs this so an
1786    /// embedder callback that re-enters `Mnemo` (e.g. a Python
1787    /// embedder that calls `db.dimensions()` or `db.cache_get(...)`)
1788    /// doesn't hit PyO3's "Already borrowed" — we clone the Arc under
1789    /// a short `&self` borrow, release that borrow, then invoke
1790    /// `embed()` with no `&mut Mnemo` held.
1791    pub fn embedder_arc(&self) -> Option<std::sync::Arc<dyn Embedder>> {
1792        self.embedder.clone()
1793    }
1794
1795    /// Embed `content` with the registered embedder and `remember` the
1796    /// result. Returns the new memory's ULID. Convenience path for
1797    /// callers who have a string, want it stored, and don't need any
1798    /// of the per-memory options [`Memory`] exposes (agent, importance,
1799    /// metadata, ttl, scope) — build a [`Memory`] by hand and call
1800    /// [`Mnemo::remember`] for those.
1801    ///
1802    /// Fails with [`MnemoError::Invalid`] if no embedder has been
1803    /// registered on this handle (call [`Mnemo::set_embedder`] first);
1804    /// with [`MnemoError::DimensionMismatch`] if the embedder returned
1805    /// the wrong width for this database.
1806    pub fn remember_text(&mut self, content: &str, memory_type: MemoryType) -> Result<Ulid> {
1807        if self.read_only {
1808            return Err(MnemoError::ReadOnly);
1809        }
1810        let embedder = self.embedder.as_ref().ok_or_else(|| {
1811            MnemoError::Invalid(
1812                "no embedder registered on this handle; call set_embedder first".into(),
1813            )
1814        })?;
1815        let mut vectors = embedder.embed(&[content])?;
1816        let vector = vectors.pop().ok_or_else(|| {
1817            MnemoError::Invalid("embedder returned no vector for the input text".into())
1818        })?;
1819        if vector.len() != self.dimensions {
1820            return Err(MnemoError::DimensionMismatch {
1821                expected: self.dimensions,
1822                got: vector.len(),
1823            });
1824        }
1825        let memory = Memory::new(content.to_string(), memory_type, vector);
1826        self.remember(memory)
1827    }
1828
1829    /// Store a memory. Returns its ULID. If the memory's ID is nil a fresh
1830    /// one is assigned; an existing ID overwrites in place.
1831    pub fn remember(&mut self, memory: Memory) -> Result<Ulid> {
1832        if self.read_only {
1833            return Err(MnemoError::ReadOnly);
1834        }
1835        self.put(memory)
1836    }
1837
1838    /// Fetch a memory by ID.
1839    pub fn get(&mut self, id: &Ulid) -> Result<Memory> {
1840        let idx = *self
1841            .index
1842            .get(&id.0)
1843            .ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
1844        let entry = self.catalog[idx].clone();
1845        if entry.deleted {
1846            return Err(MnemoError::NotFound(id.to_string()));
1847        }
1848        self.read_memory(&entry)
1849    }
1850
1851    /// Soft-delete a memory (tombstoned; space reclaimed by `compact`).
1852    pub fn delete(&mut self, id: &Ulid) -> Result<()> {
1853        if self.read_only {
1854            return Err(MnemoError::ReadOnly);
1855        }
1856        let idx = *self
1857            .index
1858            .get(&id.0)
1859            .ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
1860        if !self.catalog[idx].deleted {
1861            self.catalog[idx].deleted = true;
1862            self.dirty_catalog = true;
1863        }
1864        if let Some(ann) = &mut self.ann {
1865            ann.remove(id.0);
1866            self.dirty_index = true;
1867        }
1868        // FIX-1 R-001.8 part 2: we can't tell whether this delete
1869        // removed the last memory of its agent without a decrypt
1870        // (the catalog entry doesn't carry `agent_id` in format v8),
1871        // so just invalidate the lazy set. The next `stats()` call
1872        // rebuilds from live memories.
1873        self.agent_set = None;
1874        // Phase 12.1: delete of a keyed record removes it from the slot,
1875        // and delete of a superseded record frees a `history` entry. In
1876        // both cases the exact (key, scope_owner) needs a decrypt we're
1877        // not willing to pay here, so drop the lazy caches and rebuild
1878        // on next access. Format v9 moves the key into the catalog row
1879        // and this becomes O(1).
1880        self.key_index = None;
1881        self.superseded = None;
1882        Ok(())
1883    }
1884
1885    // --- Phase 12.1: edit in place + keyed supersession -----------------
1886
1887    /// Scope-aware key-slot identifier for `memory` and a caller key.
1888    ///
1889    /// * [`Scope::Private`] → owned by the memory's `agent_id`.
1890    /// * [`Scope::Shared`]  → one slot across the whole file.
1891    fn key_slot_for(memory: &Memory, key: &str) -> crate::store::KeySlot {
1892        let scope_owner = match memory.scope {
1893            Scope::Private => Some(memory.agent_id.clone()),
1894            Scope::Shared => None,
1895        };
1896        crate::store::KeySlot {
1897            scope_owner,
1898            key: key.to_string(),
1899        }
1900    }
1901
1902    /// Build the `key_index` + `superseded` lazy caches from live
1903    /// records. Called by every 12.1 entrypoint on first use. Decrypts
1904    /// every live record once, so a fresh open pays one scan the first
1905    /// time a keyed op touches it; subsequent ops are O(1).
1906    fn ensure_key_caches(&mut self) -> Result<()> {
1907        if self.key_index.is_some() && self.superseded.is_some() {
1908            return Ok(());
1909        }
1910        let entries: Vec<CatalogEntry> = self
1911            .catalog
1912            .iter()
1913            .filter(|e| !e.deleted)
1914            .cloned()
1915            .collect();
1916        let mut keys: std::collections::HashMap<crate::store::KeySlot, u128> =
1917            std::collections::HashMap::new();
1918        let mut superseded: std::collections::HashSet<u128> = std::collections::HashSet::new();
1919        for e in &entries {
1920            let m = self.read_memory(e)?;
1921            if m.metadata.get(MNEMO_META_SUPERSEDED_BY).is_some() {
1922                superseded.insert(e.id);
1923                continue;
1924            }
1925            if let Some(Value::String(k)) = m.metadata.get(MNEMO_META_KEY) {
1926                let slot = Self::key_slot_for(&m, k);
1927                // Catalog order is insertion order; a later live record
1928                // with the same slot would only exist if a prior build
1929                // of this code allowed it. Keep the later id (newer
1930                // ULID), in case of a tie.
1931                keys.entry(slot)
1932                    .and_modify(|cur| {
1933                        if *cur < e.id {
1934                            *cur = e.id;
1935                        }
1936                    })
1937                    .or_insert(e.id);
1938            }
1939        }
1940        self.key_index = Some(keys);
1941        self.superseded = Some(superseded);
1942        Ok(())
1943    }
1944
1945    /// Stage-identical check for a 12.1 keyed no-op. The incoming write
1946    /// is byte-equal to the live record iff every user-visible field
1947    /// (content, type, importance, vector, user metadata) matches. The
1948    /// `_mnemo.*` reserved keys on the live record are **ignored** for
1949    /// this comparison — a repeat of an identical write re-issued by an
1950    /// extractor must be a NOOP even if the live record already carries
1951    /// supersession bookkeeping from a prior op.
1952    fn is_identical_keyed_write(incoming: &Memory, live: &Memory) -> bool {
1953        if incoming.content != live.content {
1954            return false;
1955        }
1956        if incoming.memory_type != live.memory_type {
1957            return false;
1958        }
1959        if (incoming.importance - live.importance).abs() > f32::EPSILON {
1960            return false;
1961        }
1962        if incoming.vector != live.vector {
1963            return false;
1964        }
1965        // Strip `_mnemo.*` from both sides before comparing user metadata.
1966        let strip = |m: &Map<String, Value>| -> Map<String, Value> {
1967            m.iter()
1968                .filter(|(k, _)| !k.starts_with(crate::memory::MNEMO_META_NS))
1969                .map(|(k, v)| (k.clone(), v.clone()))
1970                .collect()
1971        };
1972        strip(&incoming.metadata) == strip(&live.metadata)
1973    }
1974
1975    /// Store a memory under a keyed upsert slot. Returns the live record's
1976    /// id and a [`WriteOutcome`] saying whether this write created a new
1977    /// slot (`Added`), replaced a prior record (`Superseded`), or matched
1978    /// the existing live record byte-for-byte (`Unchanged`, no file write).
1979    ///
1980    /// The slot is identified by `(scope_owner, key)`:
1981    /// * `Scope::Private` memories share a slot per `(agent_id, key)`.
1982    /// * `Scope::Shared` memories share one slot per `key` across the file.
1983    ///
1984    /// On supersession the prior record is **not** deleted — it remains
1985    /// retrievable through [`Mnemo::history`] — but it is excluded from
1986    /// default [`Mnemo::recall`] and [`Mnemo::about`]. A caller that wants
1987    /// the full lineage of a key walks `history(key)`.
1988    ///
1989    /// This is the atomic 12.1 "supersede-or-noop" write path. Plain
1990    /// [`Mnemo::remember`] without a key is unchanged.
1991    pub fn remember_keyed(
1992        &mut self,
1993        mut memory: Memory,
1994        key: &str,
1995    ) -> Result<(Ulid, WriteOutcome)> {
1996        if self.read_only {
1997            return Err(MnemoError::ReadOnly);
1998        }
1999        if key.is_empty() {
2000            return Err(MnemoError::Invalid(
2001                "remember_keyed: 'key' must not be empty".into(),
2002            ));
2003        }
2004        self.ensure_key_caches()?;
2005        let slot = Self::key_slot_for(&memory, key);
2006        let existing = self.key_index.as_ref().and_then(|k| k.get(&slot).copied());
2007
2008        // Caller may already have set `_mnemo.key` on the memory; rewrite
2009        // it to our canonical value regardless so the record is
2010        // self-identifying.
2011        memory
2012            .metadata
2013            .insert(MNEMO_META_KEY.into(), Value::String(key.to_string()));
2014
2015        // NOOP check: if a live record exists for this slot and its
2016        // user-visible fields match, return the existing id without a
2017        // write.
2018        if let Some(prev_id_u) = existing {
2019            let prev_idx = *self
2020                .index
2021                .get(&prev_id_u)
2022                .ok_or_else(|| MnemoError::Invalid("key_index drift".into()))?;
2023            let prev_entry = self.catalog[prev_idx].clone();
2024            let prev_mem = self.read_memory(&prev_entry)?;
2025            if Self::is_identical_keyed_write(&memory, &prev_mem) {
2026                return Ok((Ulid(prev_id_u), WriteOutcome::Unchanged));
2027            }
2028
2029            // SUPERSESSION: insert the new record, then rewrite the old
2030            // with `_mnemo.superseded_by` + `_mnemo.invalid_at`.
2031            let now = memory::now_secs();
2032            memory.id = Ulid::nil(); // force a fresh ULID in `put`
2033            memory.metadata.insert(
2034                MNEMO_META_SUPERSEDES.into(),
2035                Value::String(Ulid(prev_id_u).to_string()),
2036            );
2037            let new_id = self.put(memory)?;
2038            // Mark the prior record.
2039            let mut prev_mut = prev_mem;
2040            prev_mut.metadata.insert(
2041                MNEMO_META_SUPERSEDED_BY.into(),
2042                Value::String(new_id.to_string()),
2043            );
2044            prev_mut
2045                .metadata
2046                .insert(MNEMO_META_INVALID_AT.into(), Value::Number(now.into()));
2047            // keep prev_mut.id so `put` overwrites in place.
2048            self.put(prev_mut)?;
2049            // Update caches incrementally.
2050            if let Some(k) = self.key_index.as_mut() {
2051                k.insert(slot, new_id.0);
2052            }
2053            if let Some(s) = self.superseded.as_mut() {
2054                s.insert(prev_id_u);
2055            }
2056            return Ok((new_id, WriteOutcome::Superseded));
2057        }
2058
2059        // ADDED: no prior record for this slot.
2060        memory.id = Ulid::nil();
2061        let new_id = self.put(memory)?;
2062        if let Some(k) = self.key_index.as_mut() {
2063            k.insert(slot, new_id.0);
2064        }
2065        Ok((new_id, WriteOutcome::Added))
2066    }
2067
2068    /// Text-side keyed write: embed `content` through the registered
2069    /// embedder, then [`Mnemo::remember_keyed`]. Same returns.
2070    pub fn remember_keyed_text(
2071        &mut self,
2072        content: &str,
2073        memory_type: MemoryType,
2074        key: &str,
2075    ) -> Result<(Ulid, WriteOutcome)> {
2076        if self.read_only {
2077            return Err(MnemoError::ReadOnly);
2078        }
2079        let embedder = self.embedder.as_ref().ok_or_else(|| {
2080            MnemoError::Invalid(
2081                "no embedder registered on this handle; call set_embedder first".into(),
2082            )
2083        })?;
2084        let mut vectors = embedder.embed(&[content])?;
2085        let vector = vectors.pop().ok_or_else(|| {
2086            MnemoError::Invalid("embedder returned no vector for the input text".into())
2087        })?;
2088        if vector.len() != self.dimensions {
2089            return Err(MnemoError::DimensionMismatch {
2090                expected: self.dimensions,
2091                got: vector.len(),
2092            });
2093        }
2094        let memory = Memory::new(content.to_string(), memory_type, vector);
2095        self.remember_keyed(memory, key)
2096    }
2097
2098    /// Update an existing memory in place, preserving its ULID,
2099    /// `created_at` and access stats. Returns [`WriteOutcome::Updated`]
2100    /// on a real change, [`WriteOutcome::Unchanged`] when every field in
2101    /// the patch already matches the live record.
2102    ///
2103    /// Reserved `_mnemo.*` metadata keys on the existing record are
2104    /// preserved across a `metadata` replacement; a `metadata` of
2105    /// `Some(map)` replaces every **user-visible** key with `map`'s
2106    /// entries. A `content` change requires a matching `vector`
2107    /// (use [`Mnemo::update_text`] to re-embed) — a bare `content` patch
2108    /// is rejected to stop text and embedding drifting apart silently.
2109    pub fn update(&mut self, id: &Ulid, patch: MemoryPatch) -> Result<WriteOutcome> {
2110        if self.read_only {
2111            return Err(MnemoError::ReadOnly);
2112        }
2113        if patch.content.is_some() && patch.vector.is_none() {
2114            return Err(MnemoError::Invalid(
2115                "update: a content change needs a matching vector (or call update_text \
2116                 to re-embed through the registered embedder)"
2117                    .into(),
2118            ));
2119        }
2120        let idx = *self
2121            .index
2122            .get(&id.0)
2123            .ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
2124        if self.catalog[idx].deleted {
2125            return Err(MnemoError::NotFound(id.to_string()));
2126        }
2127        let entry = self.catalog[idx].clone();
2128        let existing = self.read_memory(&entry)?;
2129
2130        // Apply patch into a candidate, detect no-op after the fact.
2131        let mut cand = existing.clone();
2132        if let Some(c) = &patch.content {
2133            cand.content = c.clone();
2134        }
2135        if let Some(v) = &patch.vector {
2136            if v.len() != self.dimensions {
2137                return Err(MnemoError::DimensionMismatch {
2138                    expected: self.dimensions,
2139                    got: v.len(),
2140                });
2141            }
2142            cand.vector = v.clone();
2143        }
2144        if let Some(i) = patch.importance {
2145            cand.importance = i.clamp(0.0, 1.0);
2146        }
2147        if let Some(mt) = patch.memory_type {
2148            cand.memory_type = mt;
2149        }
2150        if let Some(new_meta) = patch.metadata {
2151            // Keep reserved `_mnemo.*` keys from the existing record.
2152            let reserved: Vec<(String, Value)> = existing
2153                .metadata
2154                .iter()
2155                .filter(|(k, _)| k.starts_with(crate::memory::MNEMO_META_NS))
2156                .map(|(k, v)| (k.clone(), v.clone()))
2157                .collect();
2158            cand.metadata = new_meta;
2159            for (k, v) in reserved {
2160                cand.metadata.insert(k, v);
2161            }
2162        }
2163        if let Some(ttl) = patch.ttl_secs {
2164            cand.ttl_secs = ttl;
2165        }
2166
2167        // No-op detection: compare all user-visible fields.
2168        if cand.content == existing.content
2169            && cand.vector == existing.vector
2170            && (cand.importance - existing.importance).abs() <= f32::EPSILON
2171            && cand.memory_type == existing.memory_type
2172            && cand.metadata == existing.metadata
2173            && cand.ttl_secs == existing.ttl_secs
2174        {
2175            return Ok(WriteOutcome::Unchanged);
2176        }
2177
2178        // Stamp `_mnemo.updated_at` and preserve id + created_at.
2179        let now = memory::now_secs();
2180        cand.metadata
2181            .insert(MNEMO_META_UPDATED_AT.into(), Value::Number(now.into()));
2182        cand.id = existing.id;
2183        cand.created_at = existing.created_at;
2184        // put() preserves accessed_at/access_count via the index lookup.
2185        self.put(cand)?;
2186        // key_index may need maintenance if this update added or removed
2187        // `_mnemo.key`; cheapest safe path is to invalidate.
2188        self.key_index = None;
2189        Ok(WriteOutcome::Updated)
2190    }
2191
2192    /// In-place update that re-embeds `new_text` through the registered
2193    /// embedder. Shorthand for `update(id, patch.with_content_and_vector)`.
2194    pub fn update_text(
2195        &mut self,
2196        id: &Ulid,
2197        new_text: &str,
2198        mut patch: MemoryPatch,
2199    ) -> Result<WriteOutcome> {
2200        if self.read_only {
2201            return Err(MnemoError::ReadOnly);
2202        }
2203        let embedder = self.embedder.as_ref().ok_or_else(|| {
2204            MnemoError::Invalid(
2205                "no embedder registered on this handle; call set_embedder first".into(),
2206            )
2207        })?;
2208        let mut vectors = embedder.embed(&[new_text])?;
2209        let vector = vectors.pop().ok_or_else(|| {
2210            MnemoError::Invalid("embedder returned no vector for the input text".into())
2211        })?;
2212        if vector.len() != self.dimensions {
2213            return Err(MnemoError::DimensionMismatch {
2214                expected: self.dimensions,
2215                got: vector.len(),
2216            });
2217        }
2218        patch.content = Some(new_text.to_string());
2219        patch.vector = Some(vector);
2220        self.update(id, patch)
2221    }
2222
2223    /// Walk the full chain for a keyed slot: every record this key has
2224    /// ever held, newest first. Entry 0 is the **live** record (unless
2225    /// the slot is empty, in which case the vector is empty); subsequent
2226    /// entries are the superseded history in reverse-supersession order.
2227    ///
2228    /// **Slot resolution (v0.8.1 bugfix, lane A).** The lookup slot
2229    /// matches a keyed `remember` with the same arguments, so the
2230    /// obvious `history(key, None)` walks the private slot of the
2231    /// default agent — the same slot
2232    /// `remember_keyed(Memory::new(...), key)` writes to when the
2233    /// caller omits an explicit `agent_id` / `scope`. Pre-v0.8.1
2234    /// `agent_id = None` incorrectly looked up the shared slot and
2235    /// returned an empty chain for the common case; the old behavior
2236    /// is still reachable via `history_in(key, KeyScope::Shared)`.
2237    ///
2238    /// * `agent_id = Some(a)` → private slot for that agent.
2239    /// * `agent_id = None` → private slot for the default agent (same
2240    ///   as [`KeyScope::private_default`]).
2241    ///
2242    /// Use [`Mnemo::history_in`] with [`KeyScope::Shared`] for the
2243    /// shared slot.
2244    pub fn history(&mut self, key: &str, agent_id: Option<&str>) -> Result<Vec<Memory>> {
2245        let scope = match agent_id {
2246            Some(a) => KeyScope::Private(a.to_string()),
2247            None => KeyScope::private_default(),
2248        };
2249        self.history_in(key, scope)
2250    }
2251
2252    /// Walk the full chain for a keyed slot identified by an explicit
2253    /// [`KeyScope`]. Returns the same shape as [`Mnemo::history`] —
2254    /// live record first, then the superseded chain newest-first.
2255    ///
2256    /// Use this when you need the **shared** slot (`KeyScope::Shared`),
2257    /// which `history(key, None)` no longer reaches as of v0.8.1.
2258    pub fn history_in(&mut self, key: &str, scope: KeyScope) -> Result<Vec<Memory>> {
2259        self.ensure_key_caches()?;
2260        let slot = crate::store::KeySlot {
2261            scope_owner: match scope {
2262                KeyScope::Private(a) => Some(a),
2263                KeyScope::Shared => None,
2264            },
2265            key: key.to_string(),
2266        };
2267        let live_id = self.key_index.as_ref().and_then(|k| k.get(&slot).copied());
2268        let mut out = Vec::new();
2269        let mut cursor = live_id;
2270        while let Some(id_u) = cursor {
2271            let idx = *self.index.get(&id_u).ok_or_else(|| {
2272                MnemoError::Invalid("history: superseded chain references missing id".into())
2273            })?;
2274            let entry = self.catalog[idx].clone();
2275            if entry.deleted {
2276                break;
2277            }
2278            let m = self.read_memory(&entry)?;
2279            let next = m
2280                .metadata
2281                .get(MNEMO_META_SUPERSEDES)
2282                .and_then(|v| v.as_str())
2283                .and_then(|s| Ulid::from_string(s).ok())
2284                .map(|u| u.0);
2285            out.push(m);
2286            cursor = next;
2287        }
2288        Ok(out)
2289    }
2290
2291    // --- Phase 12.7a: thread archive --------------------------------
2292
2293    /// Ingest a batch of turns into the archive layer of `thread_id`.
2294    ///
2295    /// Each [`ThreadTurn`] becomes one or more `Episodic` records
2296    /// tagged with the usual thread-archive reserved-metadata set
2297    /// (`_mnemo.kind = "turn"`, `_mnemo.thread`, `_mnemo.turn_id`,
2298    /// `_mnemo.seq`, `_mnemo.role`). The ingest path is
2299    /// **idempotent** per `turn_id`: a turn whose `turn_id` already
2300    /// appears on a record in this thread is skipped, so re-running
2301    /// `ingest_thread` with an extended transcript appends only
2302    /// what's new.
2303    ///
2304    /// **Chunking.** A non-tool turn whose UTF-8 content exceeds
2305    /// [`THREAD_TURN_CHUNK_BYTES`] (8 KB) is split into chunks at
2306    /// char boundaries, each stored as its own record with
2307    /// `_mnemo.part = 0, 1, …` under the same `turn_id`.
2308    ///
2309    /// **Truncation.** A `role = "tool"` turn whose content exceeds
2310    /// [`THREAD_TOOL_RESULT_TRUNCATE_BYTES`] (16 KB) is stored
2311    /// truncated — the first 16 KB of UTF-8 goes into the record,
2312    /// and `_mnemo.sha256` + `_mnemo.length` capture the original
2313    /// hash and byte length so the full content can be recovered
2314    /// (or verified) out-of-band.
2315    ///
2316    /// **Embedding.** An embedder must be registered
2317    /// ([`Mnemo::set_embedder`]); the content strings (one per
2318    /// resulting chunk / truncated record) are batched into a single
2319    /// `embed()` call.
2320    ///
2321    /// Call `self.flush()` after a successful ingest to persist.
2322    pub fn ingest_thread(&mut self, thread_id: &str, turns: &[ThreadTurn]) -> Result<IngestReport> {
2323        if self.read_only {
2324            return Err(MnemoError::ReadOnly);
2325        }
2326        let plan = self.plan_ingest(thread_id, turns)?;
2327        if plan.pending.is_empty() {
2328            return Ok(plan.report);
2329        }
2330        // Batched embed: one round trip to the embedder for the whole
2331        // ingest, whatever the turn count.
2332        let vectors = {
2333            let embedder = self.embedder.as_ref().ok_or_else(|| {
2334                MnemoError::Invalid(
2335                    "ingest_thread: no embedder registered on this handle; call set_embedder first"
2336                        .into(),
2337                )
2338            })?;
2339            embedder.embed(&plan.texts())?
2340        };
2341        self.check_vectors("ingest_thread", &vectors, plan.text_count())?;
2342        self.apply_ingest(thread_id, plan, vectors, None)
2343    }
2344
2345    /// Check a batch of embedder output before anything is written:
2346    /// one vector per input, the file's width, finite and non-zero.
2347    fn check_vectors(&self, ctx: &str, vectors: &[Vec<f32>], expected: usize) -> Result<()> {
2348        if vectors.len() != expected {
2349            return Err(MnemoError::Invalid(format!(
2350                "{ctx}: embedder returned {} vectors for {expected} texts",
2351                vectors.len()
2352            )));
2353        }
2354        for v in vectors {
2355            self.check_vector(ctx, v)?;
2356        }
2357        Ok(())
2358    }
2359
2360    /// The checks `put` applies to one vector, run up front so a batch
2361    /// can't fail half-way through its writes.
2362    fn check_vector(&self, ctx: &str, v: &[f32]) -> Result<()> {
2363        if v.len() != self.dimensions {
2364            return Err(MnemoError::DimensionMismatch {
2365                expected: self.dimensions,
2366                got: v.len(),
2367            });
2368        }
2369        if embed::is_zero_vector(v) {
2370            return Err(MnemoError::Invalid(format!(
2371                "{ctx}: all-zero vector refused"
2372            )));
2373        }
2374        if v.iter().any(|x| !x.is_finite()) {
2375            return Err(MnemoError::Invalid(format!(
2376                "{ctx}: vector contains a non-finite value"
2377            )));
2378        }
2379        Ok(())
2380    }
2381
2382    /// Planning half of [`Mnemo::ingest_thread`]: validate the turns, skip
2383    /// the ones already archived, and chunk or truncate the rest. Reads
2384    /// only; nothing is written.
2385    fn plan_ingest(&mut self, thread_id: &str, turns: &[ThreadTurn]) -> Result<IngestPlan> {
2386        if thread_id.is_empty() {
2387            return Err(MnemoError::Invalid(
2388                "ingest_thread: 'thread_id' must not be empty".into(),
2389            ));
2390        }
2391        let mut report = IngestReport::default();
2392        if turns.is_empty() {
2393            return Ok(IngestPlan {
2394                pending: Vec::new(),
2395                next_seq: 0,
2396                report,
2397            });
2398        }
2399
2400        // Build the set of turn_ids already in this thread so the
2401        // idempotence check is O(1) per incoming turn. First-pass
2402        // cost: one decrypt per existing thread record. A future v9
2403        // format bump can move `_mnemo.turn_id` into the catalog row
2404        // and drop this scan.
2405        let existing_turn_ids = self.collect_turn_ids_in_thread(thread_id)?;
2406        // Also figure out the next `_mnemo.seq` value so a repeat
2407        // ingest continues the sequence instead of restarting at 0.
2408        let next_seq = self.max_seq_in_thread(thread_id)?.map_or(0, |n| n + 1);
2409
2410        // Determine each incoming turn's final text pieces (post-
2411        // chunking / truncation) before any writes, so the caller can
2412        // embed the whole batch in one `embed()` call.
2413        let mut pending: Vec<PendingTurn> = Vec::new();
2414        let now = memory::now_secs();
2415        for t in turns {
2416            if t.turn_id.is_empty() {
2417                return Err(MnemoError::Invalid(
2418                    "ingest_thread: turn_id must not be empty".into(),
2419                ));
2420            }
2421            if existing_turn_ids.contains(&t.turn_id) {
2422                report.skipped_existing_turns += 1;
2423                continue;
2424            }
2425            let content_bytes = t.content.len();
2426            let is_tool = t.role == "tool";
2427            let (parts, truncation) =
2428                if is_tool && content_bytes > THREAD_TOOL_RESULT_TRUNCATE_BYTES {
2429                    report.truncated_turns += 1;
2430                    let hash = {
2431                        use sha2::{Digest, Sha256};
2432                        let h = Sha256::digest(t.content.as_bytes());
2433                        hex_encode(&h)
2434                    };
2435                    let truncated = take_utf8_prefix(&t.content, THREAD_TOOL_RESULT_TRUNCATE_BYTES);
2436                    (vec![truncated], Some((hash, content_bytes as u64)))
2437                } else if content_bytes > THREAD_TURN_CHUNK_BYTES {
2438                    report.chunked_turns += 1;
2439                    (chunk_utf8(&t.content, THREAD_TURN_CHUNK_BYTES), None)
2440                } else {
2441                    (vec![t.content.clone()], None)
2442                };
2443            pending.push(PendingTurn {
2444                turn_id: t.turn_id.clone(),
2445                role: t.role.clone(),
2446                created_at: t.timestamp.unwrap_or(now),
2447                parts,
2448                truncation,
2449            });
2450        }
2451        Ok(IngestPlan {
2452            pending,
2453            next_seq,
2454            report,
2455        })
2456    }
2457
2458    /// Write half of [`Mnemo::ingest_thread`]. `vectors` holds one vector
2459    /// per text of `plan.texts()`, already checked. `ttl` stamps every
2460    /// new record (the `commit_deltas` `ExpireAfter` policy).
2461    fn apply_ingest(
2462        &mut self,
2463        thread_id: &str,
2464        plan: IngestPlan,
2465        vectors: Vec<Vec<f32>>,
2466        ttl: Option<i64>,
2467    ) -> Result<IngestReport> {
2468        let IngestPlan {
2469            pending,
2470            mut next_seq,
2471            mut report,
2472        } = plan;
2473        // Order: each pending turn's chunks in part order; each turn
2474        // gets a single `_mnemo.seq` slot, so `_mnemo.part`
2475        // disambiguates chunks of the same turn.
2476        let mut vectors = vectors.into_iter();
2477        for p in &pending {
2478            let this_seq = next_seq;
2479            next_seq += 1;
2480            for (part_idx, part_text) in p.parts.iter().enumerate() {
2481                let vec = vectors.next().ok_or_else(|| {
2482                    MnemoError::Invalid("ingest_thread: ran out of vectors".into())
2483                })?;
2484                let mut m =
2485                    Memory::new(part_text.clone(), MemoryType::Episodic, vec).with_importance(0.5);
2486                if let Some(t) = ttl {
2487                    m = m.with_ttl(t);
2488                }
2489                m.created_at = p.created_at;
2490                m.accessed_at = p.created_at;
2491                m.metadata.insert(
2492                    MNEMO_META_KIND.into(),
2493                    Value::String(MNEMO_META_KIND_TURN.into()),
2494                );
2495                m.metadata.insert(
2496                    MNEMO_META_THREAD.into(),
2497                    Value::String(thread_id.to_string()),
2498                );
2499                m.metadata
2500                    .insert(MNEMO_META_TURN_ID.into(), Value::String(p.turn_id.clone()));
2501                m.metadata
2502                    .insert(MNEMO_META_SEQ.into(), Value::Number(this_seq.into()));
2503                m.metadata
2504                    .insert(MNEMO_META_ROLE.into(), Value::String(p.role.clone()));
2505                if p.parts.len() > 1 {
2506                    m.metadata.insert(
2507                        MNEMO_META_PART.into(),
2508                        Value::Number((part_idx as u64).into()),
2509                    );
2510                }
2511                if let Some((sha, len)) = &p.truncation {
2512                    m.metadata
2513                        .insert(MNEMO_META_SHA256.into(), Value::String(sha.clone()));
2514                    m.metadata
2515                        .insert(MNEMO_META_LENGTH.into(), Value::Number((*len).into()));
2516                }
2517                self.put(m)?;
2518                report.added_records += 1;
2519            }
2520        }
2521        Ok(report)
2522    }
2523
2524    /// Phase 12.7a helper: collect every `_mnemo.turn_id` already
2525    /// stored in `thread_id`. One decrypt per record in the thread.
2526    fn collect_turn_ids_in_thread(
2527        &mut self,
2528        thread_id: &str,
2529    ) -> Result<std::collections::HashSet<String>> {
2530        let entries: Vec<CatalogEntry> = self
2531            .catalog
2532            .iter()
2533            .filter(|e| !e.deleted)
2534            .cloned()
2535            .collect();
2536        let mut out = std::collections::HashSet::new();
2537        for e in &entries {
2538            let m = self.read_memory(e)?;
2539            if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
2540                continue;
2541            }
2542            if let Some(tid) = m.metadata.get(MNEMO_META_TURN_ID).and_then(|v| v.as_str()) {
2543                out.insert(tid.to_string());
2544            }
2545        }
2546        Ok(out)
2547    }
2548
2549    /// Phase 12.7a helper: find the largest `_mnemo.seq` in a thread,
2550    /// so a resumed ingest continues the sequence.
2551    fn max_seq_in_thread(&mut self, thread_id: &str) -> Result<Option<u64>> {
2552        let entries: Vec<CatalogEntry> = self
2553            .catalog
2554            .iter()
2555            .filter(|e| !e.deleted)
2556            .cloned()
2557            .collect();
2558        let mut best: Option<u64> = None;
2559        for e in &entries {
2560            let m = self.read_memory(e)?;
2561            if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
2562                continue;
2563            }
2564            if let Some(n) = m.metadata.get(MNEMO_META_SEQ).and_then(|v| v.as_u64()) {
2565                best = Some(best.map_or(n, |cur| cur.max(n)));
2566            }
2567        }
2568        Ok(best)
2569    }
2570
2571    /// Export the archive layer for `thread_id` in `_mnemo.seq`
2572    /// order. Each entry is the Memory as stored; chunks of a
2573    /// multi-part turn come out in `_mnemo.part` order within their
2574    /// `_mnemo.seq`. The record `.content` is the text that was
2575    /// stored (post-chunking / post-truncation); callers that need
2576    /// the pre-truncation original should round-trip via
2577    /// `_mnemo.sha256` + `_mnemo.length`.
2578    pub fn thread_export(&mut self, thread_id: &str) -> Result<Vec<Memory>> {
2579        let entries: Vec<CatalogEntry> = self
2580            .catalog
2581            .iter()
2582            .filter(|e| !e.deleted)
2583            .cloned()
2584            .collect();
2585        let mut out: Vec<Memory> = Vec::new();
2586        for e in &entries {
2587            let m = self.read_memory(e)?;
2588            if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
2589                continue;
2590            }
2591            if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
2592                != Some(MNEMO_META_KIND_TURN)
2593            {
2594                continue;
2595            }
2596            out.push(m);
2597        }
2598        out.sort_by(|a, b| {
2599            let seq_a = a
2600                .metadata
2601                .get(MNEMO_META_SEQ)
2602                .and_then(|v| v.as_u64())
2603                .unwrap_or(0);
2604            let seq_b = b
2605                .metadata
2606                .get(MNEMO_META_SEQ)
2607                .and_then(|v| v.as_u64())
2608                .unwrap_or(0);
2609            let part_a = a
2610                .metadata
2611                .get(MNEMO_META_PART)
2612                .and_then(|v| v.as_u64())
2613                .unwrap_or(0);
2614            let part_b = b
2615                .metadata
2616                .get(MNEMO_META_PART)
2617                .and_then(|v| v.as_u64())
2618                .unwrap_or(0);
2619            seq_a.cmp(&seq_b).then_with(|| part_a.cmp(&part_b))
2620        });
2621        Ok(out)
2622    }
2623
2624    /// Export a thread's archive in the **ingest shape** (Phase 12.7d):
2625    /// one [`ExportedTurn`] per source turn, in `_mnemo.seq` order, with
2626    /// a chunked turn's parts joined back into one `content`. Passing
2627    /// the result back to [`Mnemo::ingest_thread`] for the same thread
2628    /// skips every turn; for a new thread it re-creates the same records.
2629    /// This is what Python `db.thread_export`, MCP `thread_export` and
2630    /// `mnemo thread export` return; [`Mnemo::thread_export`] still
2631    /// returns the raw records.
2632    pub fn thread_export_turns(&mut self, thread_id: &str) -> Result<Vec<ExportedTurn>> {
2633        let records = self.thread_export(thread_id)?;
2634        let mut out: Vec<ExportedTurn> = Vec::new();
2635        let mut last_seq: Option<u64> = None;
2636        for m in records {
2637            let meta_str = |k: &str| {
2638                m.metadata
2639                    .get(k)
2640                    .and_then(|v| v.as_str())
2641                    .unwrap_or("")
2642                    .to_string()
2643            };
2644            let seq = m.metadata.get(MNEMO_META_SEQ).and_then(|v| v.as_u64());
2645            // Records are sorted by (seq, part), so the parts of one turn
2646            // are adjacent and share a seq.
2647            if seq.is_some() && seq == last_seq {
2648                if let Some(prev) = out.last_mut() {
2649                    prev.content.push_str(&m.content);
2650                    continue;
2651                }
2652            }
2653            last_seq = seq;
2654            out.push(ExportedTurn {
2655                turn_id: meta_str(MNEMO_META_TURN_ID),
2656                role: meta_str(MNEMO_META_ROLE),
2657                timestamp: m.created_at,
2658                sha256: m
2659                    .metadata
2660                    .get(MNEMO_META_SHA256)
2661                    .and_then(|v| v.as_str())
2662                    .map(String::from),
2663                original_length: m.metadata.get(MNEMO_META_LENGTH).and_then(|v| v.as_u64()),
2664                content: m.content,
2665            });
2666        }
2667        Ok(out)
2668    }
2669
2670    /// Soft-delete every record in the archive layer of `thread_id`.
2671    /// Returns the number of records marked deleted. Permanent
2672    /// removal still requires a subsequent `compact_file`, same as
2673    /// any `Mnemo::delete`.
2674    ///
2675    /// Does **not** touch `_mnemo.kind = "essence"` records or other
2676    /// non-turn metadata the thread may carry — only `_mnemo.kind =
2677    /// "turn"` records matching the thread id.
2678    pub fn thread_forget(&mut self, thread_id: &str) -> Result<usize> {
2679        if self.read_only {
2680            return Err(MnemoError::ReadOnly);
2681        }
2682        // Collect first, then delete — can't hold catalog borrows
2683        // across `self.delete`.
2684        let entries: Vec<CatalogEntry> = self
2685            .catalog
2686            .iter()
2687            .filter(|e| !e.deleted)
2688            .cloned()
2689            .collect();
2690        let mut victims: Vec<Ulid> = Vec::new();
2691        for e in &entries {
2692            let m = self.read_memory(e)?;
2693            if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
2694                continue;
2695            }
2696            if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
2697                != Some(MNEMO_META_KIND_TURN)
2698            {
2699                continue;
2700            }
2701            victims.push(m.id);
2702        }
2703        let n = victims.len();
2704        for id in &victims {
2705            self.delete(id)?;
2706        }
2707        Ok(n)
2708    }
2709
2710    // --- Phase 12.7b: essence (timeline + sections) -----------------
2711
2712    /// Phase 12.7b: add a new essence item to a section (any section
2713    /// except [`EssenceSection::Timeline`]). Content ≤
2714    /// [`memory::ESSENCE_SECTION_MAX_BYTES`] (280 bytes); items over
2715    /// that are rejected. Starting status is `"open"`.
2716    ///
2717    /// Use [`Mnemo::essence_append_timeline`] for timeline entries —
2718    /// they have a different cap (600 bytes), are append-only, and
2719    /// carry no status lifecycle.
2720    pub fn essence_add(
2721        &mut self,
2722        scope: EssenceScope,
2723        section: EssenceSection,
2724        text: &str,
2725        refs: Option<Vec<String>>,
2726    ) -> Result<Ulid> {
2727        if self.read_only {
2728            return Err(MnemoError::ReadOnly);
2729        }
2730        Self::check_essence_add(section, text)?;
2731        self.essence_add_with(scope, section, text, refs, None)
2732    }
2733
2734    /// Validation shared by [`Mnemo::essence_add`] and `commit_deltas`.
2735    fn check_essence_add(section: EssenceSection, text: &str) -> Result<()> {
2736        if section == EssenceSection::Timeline {
2737            return Err(MnemoError::Invalid(
2738                "essence_add: use essence_append_timeline for the timeline section".into(),
2739            ));
2740        }
2741        if text.is_empty() {
2742            return Err(MnemoError::Invalid(
2743                "essence_add: text must not be empty".into(),
2744            ));
2745        }
2746        if text.len() > section.item_max_bytes() {
2747            return Err(MnemoError::Invalid(format!(
2748                "essence_add: '{}' items must be <= {} bytes; got {}",
2749                section.as_str(),
2750                section.item_max_bytes(),
2751                text.len()
2752            )));
2753        }
2754        Ok(())
2755    }
2756
2757    /// Write half of `essence_add`, with an optional precomputed vector.
2758    fn essence_add_with(
2759        &mut self,
2760        scope: EssenceScope,
2761        section: EssenceSection,
2762        text: &str,
2763        refs: Option<Vec<String>>,
2764        vector: Option<Vec<f32>>,
2765    ) -> Result<Ulid> {
2766        let now = memory::now_secs();
2767        let mem = self.build_essence_record(scope, section, text, refs, Some(now), vector)?;
2768        self.put(mem)
2769    }
2770
2771    /// Phase 12.7b: append a new timeline entry (append-only
2772    /// milestone). Content ≤ [`memory::ESSENCE_TIMELINE_MAX_BYTES`]
2773    /// (600 bytes); entries over that are rejected (not truncated —
2774    /// truncating a milestone silently loses the narrative).
2775    pub fn essence_append_timeline(
2776        &mut self,
2777        scope: EssenceScope,
2778        text: &str,
2779        refs: Option<Vec<String>>,
2780    ) -> Result<Ulid> {
2781        if self.read_only {
2782            return Err(MnemoError::ReadOnly);
2783        }
2784        Self::check_essence_append(text)?;
2785        self.essence_append_with(scope, text, refs, None)
2786    }
2787
2788    /// Validation shared by [`Mnemo::essence_append_timeline`] and
2789    /// `commit_deltas`.
2790    fn check_essence_append(text: &str) -> Result<()> {
2791        if text.is_empty() {
2792            return Err(MnemoError::Invalid(
2793                "essence_append_timeline: text must not be empty".into(),
2794            ));
2795        }
2796        if text.len() > EssenceSection::Timeline.item_max_bytes() {
2797            return Err(MnemoError::Invalid(format!(
2798                "essence_append_timeline: entries must be <= {} bytes; got {}",
2799                EssenceSection::Timeline.item_max_bytes(),
2800                text.len()
2801            )));
2802        }
2803        Ok(())
2804    }
2805
2806    /// Write half of `essence_append_timeline`, with an optional
2807    /// precomputed vector.
2808    fn essence_append_with(
2809        &mut self,
2810        scope: EssenceScope,
2811        text: &str,
2812        refs: Option<Vec<String>>,
2813        vector: Option<Vec<f32>>,
2814    ) -> Result<Ulid> {
2815        let mem =
2816            self.build_essence_record(scope, EssenceSection::Timeline, text, refs, None, vector)?;
2817        self.put(mem)
2818    }
2819
2820    /// Phase 12.7b: update an existing essence section item in place.
2821    /// Returns [`WriteOutcome::Updated`] / [`WriteOutcome::Unchanged`].
2822    /// Timeline entries are append-only — calling this on a timeline
2823    /// record is an error.
2824    pub fn essence_update(
2825        &mut self,
2826        id: &Ulid,
2827        text: &str,
2828        refs: Option<Vec<String>>,
2829    ) -> Result<WriteOutcome> {
2830        if self.read_only {
2831            return Err(MnemoError::ReadOnly);
2832        }
2833        let existing = self.load_essence_record(id)?;
2834        let section = self.section_of(&existing)?;
2835        if section == EssenceSection::Timeline {
2836            return Err(MnemoError::Invalid(
2837                "essence_update: timeline entries are append-only; append a new one instead".into(),
2838            ));
2839        }
2840        if text.is_empty() {
2841            return Err(MnemoError::Invalid(
2842                "essence_update: text must not be empty".into(),
2843            ));
2844        }
2845        if text.len() > section.item_max_bytes() {
2846            return Err(MnemoError::Invalid(format!(
2847                "essence_update: '{}' items must be <= {} bytes; got {}",
2848                section.as_str(),
2849                section.item_max_bytes(),
2850                text.len()
2851            )));
2852        }
2853        let mut patch = MemoryPatch {
2854            content: Some(text.to_string()),
2855            ..Default::default()
2856        };
2857        // We don't have a usable embedder-free path for an in-place
2858        // content update, so provide the same vector as the existing
2859        // record (the text-vector drift guard in `update` will accept
2860        // this).
2861        patch.vector = Some(existing.vector.clone());
2862        if let Some(r) = refs {
2863            let mut meta = Map::new();
2864            // Preserve the user-visible metadata (none for essence —
2865            // everything lives under `_mnemo.*`) and let `update`'s
2866            // metadata-replacement path keep the reserved keys.
2867            meta.insert(
2868                MNEMO_META_REFS.into(),
2869                Value::Array(r.into_iter().map(Value::String).collect()),
2870            );
2871            patch.metadata = Some(meta);
2872        }
2873        self.update(id, patch)
2874    }
2875
2876    /// Phase 12.7b: mark a section item as resolved (open → resolved
2877    /// transition). A new history entry lands on
2878    /// `_mnemo.status_history`. Idempotent — resolving a record that
2879    /// is already resolved is a no-op.
2880    pub fn essence_resolve(&mut self, id: &Ulid) -> Result<WriteOutcome> {
2881        self.essence_transition_status(id, essence_status::RESOLVED)
2882    }
2883
2884    /// Phase 12.7b: soft-delete a section item. Same semantics as
2885    /// [`Mnemo::delete`] on any record — space reclaimed by a later
2886    /// compact.
2887    pub fn essence_remove(&mut self, id: &Ulid) -> Result<()> {
2888        if self.read_only {
2889            return Err(MnemoError::ReadOnly);
2890        }
2891        self.delete(id)
2892    }
2893
2894    /// Phase 12.7b: deterministic, budgeted markdown render of the
2895    /// essence layer for a thread (or the project layer when
2896    /// `scope = EssenceScope::Project`). `max_bytes` caps the output;
2897    /// sections are emitted in the fixed [`EssenceSection::render_order`],
2898    /// and each section's own budget is proportional to its position
2899    /// in the order. Oldest timeline entries fold into a
2900    /// `+N earlier milestones` tail when the budget runs out.
2901    ///
2902    /// The render always ends with an archive pointer so the reader
2903    /// knows how to drill in deeper (`mnemo thread export …` or
2904    /// `recall --thread …`).
2905    pub fn essence(&mut self, scope: EssenceScope, max_bytes: usize) -> Result<String> {
2906        let max_bytes = if max_bytes == 0 {
2907            ESSENCE_DEFAULT_RENDER_BYTES
2908        } else {
2909            max_bytes
2910        };
2911        // Collect every live essence record for this scope.
2912        let entries: Vec<CatalogEntry> = self
2913            .catalog
2914            .iter()
2915            .filter(|e| !e.deleted)
2916            .cloned()
2917            .collect();
2918        let mut by_section: std::collections::BTreeMap<&'static str, Vec<Memory>> =
2919            std::collections::BTreeMap::new();
2920        for section in EssenceSection::render_order() {
2921            by_section.insert(section.as_str(), Vec::new());
2922        }
2923        for e in &entries {
2924            let m = self.read_memory(e)?;
2925            if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
2926                != Some(MNEMO_META_KIND_ESSENCE)
2927            {
2928                continue;
2929            }
2930            // Scope filter. Project-scope records are shown in every
2931            // thread's render (and in the project render); thread-scope
2932            // records only match when scope == same thread.
2933            let rec_thread = m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str());
2934            let matches_scope = match (&scope, rec_thread) {
2935                (EssenceScope::Project, None) => true,
2936                (EssenceScope::Thread(_), None) => true, // project-scope always shows
2937                (EssenceScope::Thread(t), Some(rt)) => rt == t,
2938                (EssenceScope::Project, Some(_)) => false,
2939            };
2940            if !matches_scope {
2941                continue;
2942            }
2943            let section_str = m
2944                .metadata
2945                .get(MNEMO_META_SECTION)
2946                .and_then(|v| v.as_str())
2947                .unwrap_or("timeline");
2948            by_section
2949                .entry(
2950                    EssenceSection::parse(section_str)
2951                        .map(|s| s.as_str())
2952                        .unwrap_or("timeline"),
2953                )
2954                .or_default()
2955                .push(m);
2956        }
2957
2958        // Sort each section's records:
2959        // - Decisions + Timeline: newest-first (by created_at desc).
2960        // - Everything else: sort open items before resolved, then
2961        //   newest-first. Cheap, deterministic.
2962        for (name, items) in by_section.iter_mut() {
2963            let newest_first = matches!(*name, "decisions" | "timeline");
2964            items.sort_by(|a, b| {
2965                let a_resolved = status_of(a) == Some(essence_status::RESOLVED);
2966                let b_resolved = status_of(b) == Some(essence_status::RESOLVED);
2967                match (a_resolved, b_resolved) {
2968                    (false, true) => std::cmp::Ordering::Less,
2969                    (true, false) => std::cmp::Ordering::Greater,
2970                    _ => {
2971                        if newest_first {
2972                            b.created_at.cmp(&a.created_at)
2973                        } else {
2974                            a.created_at.cmp(&b.created_at)
2975                        }
2976                    }
2977                }
2978            });
2979        }
2980
2981        // Per-section budget: give intent / conventions / decisions a
2982        // larger share (they frame the thread), timeline a smaller
2983        // share (it can fold). This is deterministic because it
2984        // depends only on `max_bytes`.
2985        let header_reserve = 256usize; // header + archive pointer
2986        let body_budget = max_bytes.saturating_sub(header_reserve);
2987        let per_section_budget = |section: EssenceSection| -> usize {
2988            match section {
2989                EssenceSection::Intent => body_budget / 8,
2990                EssenceSection::Conventions => body_budget / 6,
2991                EssenceSection::Decisions => body_budget / 4,
2992                EssenceSection::State => body_budget / 8,
2993                EssenceSection::Open => body_budget / 8,
2994                EssenceSection::Next => body_budget / 12,
2995                EssenceSection::Lessons => body_budget / 12,
2996                EssenceSection::Timeline => body_budget / 4,
2997            }
2998        };
2999
3000        let mut out = String::with_capacity(max_bytes);
3001        let scope_label = match &scope {
3002            EssenceScope::Thread(t) => format!("thread `{t}`"),
3003            EssenceScope::Project => "project".to_string(),
3004        };
3005        out.push_str(&format!("# essence — {scope_label}\n\n"));
3006
3007        // Phase 12.7c: continuation. If this is a thread render AND
3008        // the thread has a `_mnemo.continues` link, pull the prior
3009        // thread's open section items, its `next`, and its last-N
3010        // timeline entries in ahead of the new thread's own essence.
3011        if let EssenceScope::Thread(new_id) = &scope {
3012            if let Some(prev_id) = self.predecessor_thread(new_id)? {
3013                let continuation = self
3014                    .render_continuation_prelude(&prev_id, max_bytes.saturating_sub(out.len()))?;
3015                if !continuation.is_empty() && out.len() + continuation.len() <= max_bytes {
3016                    out.push_str(&continuation);
3017                }
3018            }
3019        }
3020
3021        for section in EssenceSection::render_order() {
3022            let items = match by_section.get(section.as_str()) {
3023                Some(v) if !v.is_empty() => v,
3024                _ => continue,
3025            };
3026            let budget = per_section_budget(*section);
3027            let section_header = format!("## {}\n", section.as_str());
3028            if out.len() + section_header.len() > max_bytes {
3029                break;
3030            }
3031            out.push_str(&section_header);
3032            let mut used = 0usize;
3033            let mut rendered = 0usize;
3034            for item in items {
3035                let line = render_essence_item(*section, item);
3036                if used + line.len() > budget {
3037                    break;
3038                }
3039                if out.len() + line.len() > max_bytes {
3040                    break;
3041                }
3042                out.push_str(&line);
3043                used += line.len();
3044                rendered += 1;
3045            }
3046            // Fold the remainder for timeline.
3047            if *section == EssenceSection::Timeline && rendered < items.len() {
3048                let fold = format!("- +{} earlier milestones\n", items.len() - rendered);
3049                if out.len() + fold.len() <= max_bytes {
3050                    out.push_str(&fold);
3051                }
3052            }
3053            if out.len() < max_bytes {
3054                out.push('\n');
3055            }
3056        }
3057
3058        // Archive pointer.
3059        let pointer = match &scope {
3060            EssenceScope::Thread(t) => format!(
3061                "> full history: `mnemo thread export <file> --thread {t}` or `recall --thread {t}`\n"
3062            ),
3063            EssenceScope::Project => String::from(
3064                "> full history: `mnemo thread export <file> --thread <id>` or `recall --thread <id>`\n",
3065            ),
3066        };
3067        if out.len() + pointer.len() <= max_bytes {
3068            out.push_str(&pointer);
3069        }
3070        Ok(out)
3071    }
3072
3073    // --- essence internal helpers ----------------------------------
3074
3075    /// Build an essence record. `vector` is a precomputed embedding (the
3076    /// 12.7d `commit_deltas` batch path); `None` embeds `text` with the
3077    /// registered embedder, or falls back to an axis-neutral unit vector
3078    /// when there is none. Essence records are read by `_mnemo.kind` and
3079    /// scope, not by similarity, so the fallback costs no retrieval
3080    /// quality; it only has to pass `put`'s non-zero guard.
3081    fn build_essence_record(
3082        &self,
3083        scope: EssenceScope,
3084        section: EssenceSection,
3085        text: &str,
3086        refs: Option<Vec<String>>,
3087        status_at: Option<i64>,
3088        vector: Option<Vec<f32>>,
3089    ) -> Result<Memory> {
3090        let v = match (vector, self.embedder.as_ref()) {
3091            (Some(v), _) => v,
3092            (None, Some(e)) => e.embed(&[text])?.pop().ok_or_else(|| {
3093                MnemoError::Invalid("essence: embedder returned no vector".into())
3094            })?,
3095            (None, None) => self.sentinel_vector(),
3096        };
3097        let mut m = Memory::new(text.to_string(), MemoryType::Semantic, v).with_importance(0.5);
3098        m.metadata.insert(
3099            MNEMO_META_KIND.into(),
3100            Value::String(MNEMO_META_KIND_ESSENCE.into()),
3101        );
3102        m.metadata.insert(
3103            MNEMO_META_SECTION.into(),
3104            Value::String(section.as_str().into()),
3105        );
3106        if let EssenceScope::Thread(t) = &scope {
3107            m.metadata
3108                .insert(MNEMO_META_THREAD.into(), Value::String(t.clone()));
3109        }
3110        if let Some(r) = refs {
3111            m.metadata.insert(
3112                MNEMO_META_REFS.into(),
3113                Value::Array(r.into_iter().map(Value::String).collect()),
3114            );
3115        }
3116        if let Some(at) = status_at {
3117            m.metadata.insert(
3118                MNEMO_META_STATUS.into(),
3119                Value::String(essence_status::OPEN.into()),
3120            );
3121            m.metadata.insert(
3122                MNEMO_META_STATUS_HISTORY.into(),
3123                Value::Array(vec![serde_json::json!({
3124                    "status": essence_status::OPEN,
3125                    "at": at,
3126                })]),
3127            );
3128        }
3129        Ok(m)
3130    }
3131
3132    /// Axis-neutral unit vector (every component `1/sqrt(dims)`), used by
3133    /// essence records without an embedder and by thread-link markers.
3134    fn sentinel_vector(&self) -> Vec<f32> {
3135        let dims = self.dimensions.max(1);
3136        vec![1.0 / (dims as f32).sqrt(); dims]
3137    }
3138
3139    fn load_essence_record(&mut self, id: &Ulid) -> Result<Memory> {
3140        let m = self.get(id)?;
3141        if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str()) != Some(MNEMO_META_KIND_ESSENCE)
3142        {
3143            return Err(MnemoError::Invalid(format!(
3144                "essence: record {id} is not an essence record (missing `_mnemo.kind = essence`)"
3145            )));
3146        }
3147        Ok(m)
3148    }
3149
3150    fn section_of(&self, m: &Memory) -> Result<EssenceSection> {
3151        let s = m
3152            .metadata
3153            .get(MNEMO_META_SECTION)
3154            .and_then(|v| v.as_str())
3155            .ok_or_else(|| {
3156                MnemoError::Invalid("essence: record missing `_mnemo.section`".into())
3157            })?;
3158        EssenceSection::parse(s)
3159            .ok_or_else(|| MnemoError::Invalid(format!("essence: unknown section '{s}' on record")))
3160    }
3161
3162    fn essence_transition_status(&mut self, id: &Ulid, to: &str) -> Result<WriteOutcome> {
3163        if self.read_only {
3164            return Err(MnemoError::ReadOnly);
3165        }
3166        let existing = self.load_essence_record(id)?;
3167        let section = self.section_of(&existing)?;
3168        if !section.has_status_lifecycle() {
3169            return Err(MnemoError::Invalid(format!(
3170                "essence_transition: '{}' section has no status lifecycle",
3171                section.as_str()
3172            )));
3173        }
3174        let from = existing
3175            .metadata
3176            .get(MNEMO_META_STATUS)
3177            .and_then(|v| v.as_str())
3178            .unwrap_or(essence_status::OPEN);
3179        if from == to {
3180            return Ok(WriteOutcome::Unchanged);
3181        }
3182        let allowed = ESSENCE_TRANSITIONS
3183            .iter()
3184            .any(|(f, t)| *f == from && *t == to);
3185        if !allowed {
3186            return Err(MnemoError::Invalid(format!(
3187                "essence_transition: '{from}' → '{to}' is not a valid transition for essence"
3188            )));
3189        }
3190        // Mutate metadata in place and `put` back under the same id
3191        // so `_mnemo.updated_at` gets stamped and access stats are
3192        // preserved.
3193        let mut new = existing.clone();
3194        new.metadata
3195            .insert(MNEMO_META_STATUS.into(), Value::String(to.to_string()));
3196        let now = memory::now_secs();
3197        let history_entry = serde_json::json!({ "status": to, "at": now });
3198        let history = new
3199            .metadata
3200            .entry(MNEMO_META_STATUS_HISTORY.to_string())
3201            .or_insert_with(|| Value::Array(Vec::new()));
3202        if let Value::Array(arr) = history {
3203            arr.push(history_entry);
3204        }
3205        new.metadata
3206            .insert(MNEMO_META_UPDATED_AT.into(), Value::Number(now.into()));
3207        new.id = existing.id;
3208        new.created_at = existing.created_at;
3209        self.put(new)?;
3210        Ok(WriteOutcome::Updated)
3211    }
3212
3213    // --- Phase 12.7c: continuation + commit_deltas ------------------
3214
3215    /// Start a new thread that continues from a previous one
3216    /// (Phase 12.7c). Writes a thread-link marker record with
3217    /// `_mnemo.kind = "thread_link"` + `_mnemo.thread = <new_id>` +
3218    /// `_mnemo.continues = <from_id>`. The marker holds no user
3219    /// content; its purpose is to let `essence(Thread(new_id))`
3220    /// discover the continuation chain.
3221    ///
3222    /// The render for the new thread pulls the previous thread's
3223    /// open section items, its `next`, and its last
3224    /// [`CONTINUATION_TIMELINE_ENTRIES`] timeline entries ahead of
3225    /// the new thread's own essence — the "new thread, same
3226    /// experience" path.
3227    pub fn thread_start(&mut self, new_thread_id: &str, from_thread_id: &str) -> Result<Ulid> {
3228        if self.read_only {
3229            return Err(MnemoError::ReadOnly);
3230        }
3231        if new_thread_id.is_empty() || from_thread_id.is_empty() {
3232            return Err(MnemoError::Invalid(
3233                "thread_start: 'new_thread_id' and 'from_thread_id' must not be empty".into(),
3234            ));
3235        }
3236        if new_thread_id == from_thread_id {
3237            return Err(MnemoError::Invalid(
3238                "thread_start: 'new_thread_id' must differ from 'from_thread_id'".into(),
3239            ));
3240        }
3241        // Store a tiny marker record so the continuation chain is
3242        // queryable from a fresh handle. The sentinel vector passes
3243        // `put`'s zero-vector guard; default recall skips link records.
3244        let v = self.sentinel_vector();
3245        let content = format!("thread link: {new_thread_id} continues from {from_thread_id}");
3246        let mut m = Memory::new(content, MemoryType::Semantic, v).with_importance(0.1);
3247        m.metadata.insert(
3248            MNEMO_META_KIND.into(),
3249            Value::String(MNEMO_META_KIND_THREAD_LINK.into()),
3250        );
3251        m.metadata.insert(
3252            MNEMO_META_THREAD.into(),
3253            Value::String(new_thread_id.to_string()),
3254        );
3255        m.metadata.insert(
3256            MNEMO_META_CONTINUES.into(),
3257            Value::String(from_thread_id.to_string()),
3258        );
3259        let id = self.put(m)?;
3260        Ok(id)
3261    }
3262
3263    /// Phase 12.7c helper: render the predecessor thread's open
3264    /// items + `next` + last-N timeline entries as a "continued from"
3265    /// prelude. Used by [`Mnemo::essence`] to carry forward the
3266    /// prior thread's live state into a fresh thread.
3267    fn render_continuation_prelude(
3268        &mut self,
3269        prev_thread: &str,
3270        max_bytes: usize,
3271    ) -> Result<String> {
3272        if max_bytes == 0 {
3273            return Ok(String::new());
3274        }
3275        let entries: Vec<CatalogEntry> = self
3276            .catalog
3277            .iter()
3278            .filter(|e| !e.deleted)
3279            .cloned()
3280            .collect();
3281        let mut open_items: Vec<Memory> = Vec::new();
3282        let mut next_items: Vec<Memory> = Vec::new();
3283        let mut timeline_items: Vec<Memory> = Vec::new();
3284        for e in &entries {
3285            let m = self.read_memory(e)?;
3286            if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
3287                != Some(MNEMO_META_KIND_ESSENCE)
3288            {
3289                continue;
3290            }
3291            if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(prev_thread) {
3292                continue;
3293            }
3294            let section = match m
3295                .metadata
3296                .get(MNEMO_META_SECTION)
3297                .and_then(|v| v.as_str())
3298                .and_then(EssenceSection::parse)
3299            {
3300                Some(s) => s,
3301                None => continue,
3302            };
3303            let status = m
3304                .metadata
3305                .get(MNEMO_META_STATUS)
3306                .and_then(|v| v.as_str())
3307                .unwrap_or(essence_status::OPEN);
3308            match section {
3309                EssenceSection::Open if status == essence_status::OPEN => {
3310                    open_items.push(m);
3311                }
3312                EssenceSection::Next if status == essence_status::OPEN => {
3313                    next_items.push(m);
3314                }
3315                EssenceSection::Timeline => timeline_items.push(m),
3316                _ => {}
3317            }
3318        }
3319        // Newest-first timeline, cap to last N.
3320        timeline_items.sort_by_key(|m| std::cmp::Reverse(m.created_at));
3321        timeline_items.truncate(CONTINUATION_TIMELINE_ENTRIES);
3322        // Oldest-first for stable chronological read in the render.
3323        timeline_items.reverse();
3324
3325        let mut out = String::new();
3326        let header = format!("## continued from `{prev_thread}`\n");
3327        if header.len() > max_bytes {
3328            return Ok(String::new());
3329        }
3330        out.push_str(&header);
3331        if !open_items.is_empty() {
3332            let sub = "### open\n";
3333            if out.len() + sub.len() <= max_bytes {
3334                out.push_str(sub);
3335            }
3336            for m in &open_items {
3337                let line = format!("- {}\n", m.content);
3338                if out.len() + line.len() > max_bytes {
3339                    break;
3340                }
3341                out.push_str(&line);
3342            }
3343        }
3344        if !next_items.is_empty() {
3345            let sub = "### next\n";
3346            if out.len() + sub.len() <= max_bytes {
3347                out.push_str(sub);
3348            }
3349            for m in &next_items {
3350                let line = format!("- {}\n", m.content);
3351                if out.len() + line.len() > max_bytes {
3352                    break;
3353                }
3354                out.push_str(&line);
3355            }
3356        }
3357        if !timeline_items.is_empty() {
3358            let sub = "### recent milestones\n";
3359            if out.len() + sub.len() <= max_bytes {
3360                out.push_str(sub);
3361            }
3362            for m in &timeline_items {
3363                let line = format!("- {}\n", m.content);
3364                if out.len() + line.len() > max_bytes {
3365                    break;
3366                }
3367                out.push_str(&line);
3368            }
3369        }
3370        if out.len() < max_bytes {
3371            out.push('\n');
3372        }
3373        Ok(out)
3374    }
3375
3376    /// Phase 12.7c helper: find the predecessor thread id for
3377    /// `thread_id` by reading its `_mnemo.kind = "thread_link"`
3378    /// marker. Returns `None` if the thread has no predecessor
3379    /// (fresh thread, no `thread_start` call).
3380    fn predecessor_thread(&mut self, thread_id: &str) -> Result<Option<String>> {
3381        let entries: Vec<CatalogEntry> = self
3382            .catalog
3383            .iter()
3384            .filter(|e| !e.deleted)
3385            .cloned()
3386            .collect();
3387        for e in &entries {
3388            let m = self.read_memory(e)?;
3389            if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
3390                != Some(MNEMO_META_KIND_THREAD_LINK)
3391            {
3392                continue;
3393            }
3394            let this = m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str());
3395            if this != Some(thread_id) {
3396                continue;
3397            }
3398            return Ok(m
3399                .metadata
3400                .get(MNEMO_META_CONTINUES)
3401                .and_then(|v| v.as_str())
3402                .map(String::from));
3403        }
3404        Ok(None)
3405    }
3406
3407    /// Atomic batch write combining 12.1 facts, 12.7b essence ops,
3408    /// and 12.7a archive turns in one flush (Phase 12.7c; batch
3409    /// embedding added in 12.7d).
3410    ///
3411    /// The batch runs in four steps:
3412    ///
3413    /// 1. **Structural checks** on every item: required fields, size
3414    ///    caps, empty keys, `thread_id` for turns, and that every
3415    ///    `Update` / `Resolve` / `Remove` targets a live essence record
3416    ///    the op can apply to.
3417    /// 2. **One `Embedder::embed` call** for every text that needs a
3418    ///    vector: facts whose `vector` is empty, essence `Add` /
3419    ///    `AppendTimeline` texts (when an embedder is registered;
3420    ///    without one, essence keeps its sentinel vector), and the
3421    ///    archive turns' chunks.
3422    /// 3. **Vector checks** (width, non-zero, finite) on supplied and
3423    ///    embedded vectors alike.
3424    /// 4. **Writes**: facts, then essence ops, then turns per
3425    ///    `turn_policy`, then exactly one `flush()`.
3426    ///
3427    /// A failure in steps 1–3 returns an error with nothing staged. A
3428    /// text-only fact, or any turn to archive, with no embedder
3429    /// registered is a step-1 error. `put` has no rollback, so an I/O
3430    /// error during step 4 can leave the batch's earlier writes staged
3431    /// on the handle; steps 1–3 exist so nothing else can fail that
3432    /// late.
3433    pub fn commit_deltas(&mut self, batch: CommitBatch) -> Result<CommitReport> {
3434        if self.read_only {
3435            return Err(MnemoError::ReadOnly);
3436        }
3437        let CommitBatch {
3438            mut facts,
3439            essence_ops,
3440            turns,
3441            thread_id,
3442            turn_policy,
3443        } = batch;
3444        let has_embedder = self.embedder.is_some();
3445        let in_ctx = |ctx: &str, e: MnemoError| MnemoError::Invalid(format!("{ctx}: {e}"));
3446
3447        // --- 1. Structural checks (reads only) ----------------------
3448        for (i, f) in facts.iter().enumerate() {
3449            if let Some(k) = &f.key {
3450                if k.is_empty() {
3451                    return Err(MnemoError::Invalid(format!(
3452                        "commit_deltas: facts[{i}] key is empty"
3453                    )));
3454                }
3455            }
3456            if f.memory.vector.is_empty() {
3457                if f.memory.content.is_empty() {
3458                    return Err(MnemoError::Invalid(format!(
3459                        "commit_deltas: facts[{i}] has neither a vector nor content to embed"
3460                    )));
3461                }
3462                if !has_embedder {
3463                    return Err(MnemoError::Invalid(format!(
3464                        "commit_deltas: facts[{i}] has no vector and no embedder is registered \
3465                         on this handle; pass a vector or call set_embedder first"
3466                    )));
3467                }
3468            }
3469        }
3470        let mut removed_in_batch: std::collections::HashSet<u128> =
3471            std::collections::HashSet::new();
3472        for (i, op) in essence_ops.iter().enumerate() {
3473            let ctx = format!("commit_deltas: essence_ops[{i}]");
3474            match op.kind {
3475                EssenceOpKind::Add => {
3476                    if op.scope.is_none() {
3477                        return Err(MnemoError::Invalid(format!("{ctx} Add missing 'scope'")));
3478                    }
3479                    let section = op.section.ok_or_else(|| {
3480                        MnemoError::Invalid(format!("{ctx} Add missing 'section'"))
3481                    })?;
3482                    let text = op
3483                        .text
3484                        .as_deref()
3485                        .ok_or_else(|| MnemoError::Invalid(format!("{ctx} Add missing 'text'")))?;
3486                    Self::check_essence_add(section, text).map_err(|e| in_ctx(&ctx, e))?;
3487                }
3488                EssenceOpKind::AppendTimeline => {
3489                    if op.scope.is_none() {
3490                        return Err(MnemoError::Invalid(format!(
3491                            "{ctx} AppendTimeline missing 'scope'"
3492                        )));
3493                    }
3494                    let text = op.text.as_deref().ok_or_else(|| {
3495                        MnemoError::Invalid(format!("{ctx} AppendTimeline missing 'text'"))
3496                    })?;
3497                    Self::check_essence_append(text).map_err(|e| in_ctx(&ctx, e))?;
3498                }
3499                EssenceOpKind::Update | EssenceOpKind::Resolve | EssenceOpKind::Remove => {
3500                    let id = op
3501                        .id
3502                        .ok_or_else(|| MnemoError::Invalid(format!("{ctx} needs 'id'")))?;
3503                    if removed_in_batch.contains(&id.0) {
3504                        return Err(MnemoError::Invalid(format!(
3505                            "{ctx}: record {id} is removed earlier in this batch"
3506                        )));
3507                    }
3508                    let existing = self.load_essence_record(&id).map_err(|e| in_ctx(&ctx, e))?;
3509                    let section = self.section_of(&existing).map_err(|e| in_ctx(&ctx, e))?;
3510                    match op.kind {
3511                        EssenceOpKind::Update => {
3512                            let text = op.text.as_deref().ok_or_else(|| {
3513                                MnemoError::Invalid(format!("{ctx} Update needs 'text'"))
3514                            })?;
3515                            if section == EssenceSection::Timeline {
3516                                return Err(MnemoError::Invalid(format!(
3517                                    "{ctx}: timeline entries are append-only"
3518                                )));
3519                            }
3520                            if text.is_empty() || text.len() > section.item_max_bytes() {
3521                                return Err(MnemoError::Invalid(format!(
3522                                    "{ctx}: '{}' items must be 1..={} bytes; got {}",
3523                                    section.as_str(),
3524                                    section.item_max_bytes(),
3525                                    text.len()
3526                                )));
3527                            }
3528                        }
3529                        EssenceOpKind::Resolve => {
3530                            if !section.has_status_lifecycle() {
3531                                return Err(MnemoError::Invalid(format!(
3532                                    "{ctx}: '{}' section has no status lifecycle",
3533                                    section.as_str()
3534                                )));
3535                            }
3536                        }
3537                        _ => {
3538                            removed_in_batch.insert(id.0);
3539                        }
3540                    }
3541                }
3542            }
3543        }
3544        if !turns.is_empty() && thread_id.is_none() {
3545            return Err(MnemoError::Invalid(
3546                "commit_deltas: 'turns' non-empty but 'thread_id' missing".into(),
3547            ));
3548        }
3549        let archive_turns = !turns.is_empty() && !matches!(turn_policy, TurnPolicy::Drop);
3550        if archive_turns && !has_embedder {
3551            return Err(MnemoError::Invalid(
3552                "commit_deltas: archiving 'turns' needs an embedder registered on this handle; \
3553                 call set_embedder first or use turn_policy Drop"
3554                    .into(),
3555            ));
3556        }
3557        let plan = match (&thread_id, archive_turns) {
3558            (Some(t), true) => Some(self.plan_ingest(t, &turns)?),
3559            _ => None,
3560        };
3561
3562        // --- 2. One batched embed -----------------------------------
3563        let embeds_essence = |op: &EssenceOp| {
3564            has_embedder && matches!(op.kind, EssenceOpKind::Add | EssenceOpKind::AppendTimeline)
3565        };
3566        let (vectors, n_texts) = {
3567            let mut texts: Vec<&str> = Vec::new();
3568            for f in &facts {
3569                if f.memory.vector.is_empty() {
3570                    texts.push(f.memory.content.as_str());
3571                }
3572            }
3573            for op in essence_ops.iter().filter(|op| embeds_essence(op)) {
3574                texts.push(op.text.as_deref().expect("checked in step 1"));
3575            }
3576            if let Some(p) = &plan {
3577                texts.extend(p.texts());
3578            }
3579            let vectors = match (texts.is_empty(), self.embedder.as_ref()) {
3580                (true, _) => Vec::new(),
3581                (false, Some(e)) => e.embed(&texts)?,
3582                (false, None) => unreachable!("texts need an embedder; checked in step 1"),
3583            };
3584            (vectors, texts.len())
3585        };
3586
3587        // --- 3. Vector checks ---------------------------------------
3588        self.check_vectors("commit_deltas", &vectors, n_texts)?;
3589        for (i, f) in facts.iter().enumerate() {
3590            let v = &f.memory.vector;
3591            if v.is_empty() {
3592                continue;
3593            }
3594            if v.len() != self.dimensions {
3595                return Err(MnemoError::Invalid(format!(
3596                    "commit_deltas: facts[{i}] vector has {} dims; this file is {}",
3597                    v.len(),
3598                    self.dimensions
3599                )));
3600            }
3601            let ctx = format!("commit_deltas: facts[{i}]");
3602            self.check_vector(&ctx, v)?;
3603        }
3604
3605        // Hand the embedded vectors back out, in the order they were
3606        // requested: facts, then essence ops, then turn chunks.
3607        let mut vectors = vectors.into_iter();
3608        for f in facts.iter_mut() {
3609            if f.memory.vector.is_empty() {
3610                f.memory.vector = vectors.next().expect("one vector per text");
3611            }
3612        }
3613        let essence_vectors: Vec<Option<Vec<f32>>> = essence_ops
3614            .iter()
3615            .map(|op| embeds_essence(op).then(|| vectors.next().expect("one vector per text")))
3616            .collect();
3617        let turn_vectors: Vec<Vec<f32>> = vectors.collect();
3618
3619        // --- 4. Writes ----------------------------------------------
3620        let mut report = CommitReport::default();
3621
3622        for f in facts {
3623            match f.key {
3624                Some(k) => {
3625                    let (_id, outcome) = self.remember_keyed(f.memory, &k)?;
3626                    match outcome {
3627                        WriteOutcome::Added => report.facts_added += 1,
3628                        WriteOutcome::Superseded | WriteOutcome::Updated => {
3629                            report.facts_superseded += 1
3630                        }
3631                        WriteOutcome::Unchanged => report.facts_unchanged += 1,
3632                    }
3633                }
3634                None => {
3635                    self.remember(f.memory)?;
3636                    report.facts_added += 1;
3637                }
3638            }
3639        }
3640
3641        for (op, vector) in essence_ops.into_iter().zip(essence_vectors) {
3642            let outcome = match op.kind {
3643                EssenceOpKind::Add => {
3644                    self.essence_add_with(
3645                        op.scope.expect("checked in step 1"),
3646                        op.section.expect("checked in step 1"),
3647                        op.text.as_deref().expect("checked in step 1"),
3648                        op.refs,
3649                        vector,
3650                    )?;
3651                    WriteOutcome::Added
3652                }
3653                EssenceOpKind::AppendTimeline => {
3654                    self.essence_append_with(
3655                        op.scope.expect("checked in step 1"),
3656                        op.text.as_deref().expect("checked in step 1"),
3657                        op.refs,
3658                        vector,
3659                    )?;
3660                    WriteOutcome::Added
3661                }
3662                EssenceOpKind::Update => self.essence_update(
3663                    op.id.as_ref().expect("checked in step 1"),
3664                    op.text.as_deref().expect("checked in step 1"),
3665                    op.refs,
3666                )?,
3667                EssenceOpKind::Resolve => {
3668                    self.essence_resolve(op.id.as_ref().expect("checked in step 1"))?
3669                }
3670                EssenceOpKind::Remove => {
3671                    self.essence_remove(op.id.as_ref().expect("checked in step 1"))?;
3672                    WriteOutcome::Updated
3673                }
3674            };
3675            match outcome {
3676                WriteOutcome::Unchanged => report.essence_ops_unchanged += 1,
3677                _ => report.essence_ops_applied += 1,
3678            }
3679        }
3680
3681        if let (Some(plan), Some(t)) = (plan, thread_id.as_deref()) {
3682            let ttl = match turn_policy {
3683                TurnPolicy::ExpireAfter(secs) => Some(secs),
3684                _ => None,
3685            };
3686            let r = self.apply_ingest(t, plan, turn_vectors, ttl)?;
3687            report.turns_added += r.added_records;
3688            report.turns_skipped += r.skipped_existing_turns;
3689            report.turns_truncated += r.truncated_turns;
3690            report.turns_chunked += r.chunked_turns;
3691        }
3692
3693        self.flush()?;
3694        Ok(report)
3695    }
3696
3697    /// Return every live memory (used by tooling and compaction).
3698    pub fn memories(&mut self) -> Result<Vec<Memory>> {
3699        let entries: Vec<CatalogEntry> = self
3700            .catalog
3701            .iter()
3702            .filter(|e| !e.deleted)
3703            .cloned()
3704            .collect();
3705        let mut out = Vec::with_capacity(entries.len());
3706        for e in &entries {
3707            out.push(self.read_memory(e)?);
3708        }
3709        Ok(out)
3710    }
3711
3712    /// Return the database's **self-describing onboarding memories** — the
3713    /// ones tagged `metadata.area = "onboarding"`. This is the engine-level
3714    /// surface for the *single-file philosophy*: an agent who receives a
3715    /// `.mnemo` file (and its passphrase) can call this to learn what the
3716    /// file is, which embedder it expects, the recommended agent id, and any
3717    /// other conventions the file's author chose to record — all without
3718    /// needing any external documentation.
3719    ///
3720    /// Ordering: the canonical manifest (tag `metadata.topic = "manifest"`)
3721    /// always comes first; everything else follows in `importance` descending,
3722    /// then `created_at` ascending for deterministic results.
3723    pub fn about(&mut self) -> Result<Vec<Memory>> {
3724        // Phase 12.1: drop records that have been superseded by a newer
3725        // keyed write. They still live in snapshot history and
3726        // `history(key)` — just not here.
3727        let mut out: Vec<Memory> = self
3728            .memories()?
3729            .into_iter()
3730            .filter(|m| !m.metadata.contains_key(MNEMO_META_SUPERSEDED_BY))
3731            .filter(|m| {
3732                m.metadata
3733                    .get("area")
3734                    .and_then(|v| v.as_str())
3735                    .map(|s| s.eq_ignore_ascii_case("onboarding"))
3736                    .unwrap_or(false)
3737            })
3738            .collect();
3739        out.sort_by(|a, b| {
3740            let a_manifest = is_manifest(a);
3741            let b_manifest = is_manifest(b);
3742            // Manifest topic wins first; then importance desc; then created_at asc.
3743            b_manifest
3744                .cmp(&a_manifest)
3745                .then_with(|| b.importance.total_cmp(&a.importance))
3746                .then_with(|| a.created_at.cmp(&b.created_at))
3747        });
3748        Ok(out)
3749    }
3750
3751    /// The project essence for the `about` briefing (Phase 12.7d): the
3752    /// [`EssenceScope::Project`] render at the default budget
3753    /// ([`ESSENCE_DEFAULT_RENDER_BYTES`]), or `None` when the file has no
3754    /// open project-scope item or timeline entry. The CLI and MCP `about`
3755    /// append it after the manifest and onboarding memories; [`Mnemo::about`]
3756    /// itself keeps returning records.
3757    pub fn about_essence(&mut self) -> Result<Option<String>> {
3758        let entries: Vec<CatalogEntry> = self
3759            .catalog
3760            .iter()
3761            .filter(|e| !e.deleted)
3762            .cloned()
3763            .collect();
3764        let mut any = false;
3765        for e in &entries {
3766            let m = self.read_memory(e)?;
3767            let is_project_essence = m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
3768                == Some(MNEMO_META_KIND_ESSENCE)
3769                && !m.metadata.contains_key(MNEMO_META_THREAD);
3770            if is_project_essence && status_of(&m) != Some(essence_status::RESOLVED) {
3771                any = true;
3772                break;
3773            }
3774        }
3775        if !any {
3776            return Ok(None);
3777        }
3778        self.essence(EssenceScope::Project, ESSENCE_DEFAULT_RENDER_BYTES)
3779            .map(Some)
3780    }
3781
3782    // --- retrieval --------------------------------------------------------
3783
3784    /// Phase-1 brute-force search: rank live memories by raw similarity only.
3785    /// Read-only — does not update access statistics.
3786    pub fn search(
3787        &mut self,
3788        query: &[f32],
3789        top_k: usize,
3790        metric: Metric,
3791    ) -> Result<Vec<(Memory, f32)>> {
3792        if query.len() != self.dimensions {
3793            return Err(MnemoError::DimensionMismatch {
3794                expected: self.dimensions,
3795                got: query.len(),
3796            });
3797        }
3798        let now = memory::now_secs();
3799        let entries: Vec<CatalogEntry> = self
3800            .catalog
3801            .iter()
3802            .filter(|e| !e.deleted)
3803            .cloned()
3804            .collect();
3805
3806        let mut scored: Vec<(Memory, f32)> = Vec::new();
3807        for e in &entries {
3808            let m = self.read_memory(e)?;
3809            if m.is_expired(now) {
3810                continue;
3811            }
3812            let sim = memory::similarity(metric, query, &m.vector);
3813            scored.push((m, sim));
3814        }
3815        scored.sort_by(|a, b| b.1.total_cmp(&a.1));
3816        scored.truncate(top_k);
3817        Ok(scored)
3818    }
3819
3820    /// Phase-5 multi-signal recall: rank by
3821    /// `α·similarity + β·recency + γ·importance + δ·ln(freq)`, with type and
3822    /// agent filtering. Updates `accessed_at` / `access_count` on the
3823    /// returned memories (persisted on the next `flush`).
3824    ///
3825    /// When an ANN index has been built ([`Mnemo::build_index`]), recall runs
3826    /// the tiered IVF→PQ→rerank pipeline: it scores only the similarity-nearest
3827    /// `n_rerank` candidates rather than every memory. Without an index it
3828    /// scores every live memory exactly.
3829    /// One pass of filter + score for [`Mnemo::recall`]. Factored out of
3830    /// `recall` so the outer path can retry with a wider shortlist when
3831    /// the type/agent/supersession/TTL filters starve the ANN-narrowed
3832    /// result below `top_k` (Phase 12.1 short-results-on-indexed-path
3833    /// fix).
3834    ///
3835    /// * `force_exact = false` → use the ANN index when one exists; fall
3836    ///   back to a full scan only if none is built.
3837    /// * `force_exact = true` → skip the ANN index and scan every live
3838    ///   non-superseded record. Used by the outer path as the last-resort
3839    ///   attempt when widening the shortlist would already touch half or
3840    ///   more of the live non-superseded set.
3841    ///
3842    /// Returns an unsorted, un-truncated `Vec<RecallResult>`; the outer
3843    /// `recall` sorts and truncates once.
3844    fn score_once(
3845        &mut self,
3846        req: &RecallRequest,
3847        now: i64,
3848        force_exact: bool,
3849    ) -> Result<Vec<RecallResult>> {
3850        // Candidate set: ANN-narrowed when an index exists and the
3851        // caller hasn't forced an exact scan, else everything live.
3852        let entries: Vec<CatalogEntry> = match (&self.ann, force_exact) {
3853            (Some(ann), false) => {
3854                let ids = ann.query(&req.query, req.n_probe, req.n_rerank);
3855                ids.iter()
3856                    .filter_map(|id| self.index.get(id).copied())
3857                    .map(|i| self.catalog[i].clone())
3858                    .filter(|e| !e.deleted)
3859                    .collect()
3860            }
3861            _ => self
3862                .catalog
3863                .iter()
3864                .filter(|e| !e.deleted)
3865                .cloned()
3866                .collect(),
3867        };
3868
3869        let mut scored: Vec<RecallResult> = Vec::with_capacity(entries.len());
3870        for e in &entries {
3871            // Phase 12.1: drop superseded records.
3872            //
3873            // Fast path: if the lazy `superseded` set has already been
3874            // built by a prior keyed op on this handle, check it before
3875            // decrypt — O(1), no AES work. On a fresh read-only handle
3876            // it is typically `None`, so we fall through to the slow
3877            // path (one `_mnemo.superseded_by` lookup per surviving
3878            // record, O(k) over user metadata), and never trigger the
3879            // full-file scan that `ensure_key_caches()` would do.
3880            if self.superseded.as_ref().is_some_and(|s| s.contains(&e.id)) {
3881                continue;
3882            }
3883            let m = self.read_memory(e)?;
3884            if m.metadata.contains_key(MNEMO_META_SUPERSEDED_BY) {
3885                continue;
3886            }
3887            if m.is_expired(now) {
3888                continue;
3889            }
3890            if let Some(types) = &req.memory_types {
3891                if !types.contains(&m.memory_type) {
3892                    continue;
3893                }
3894            }
3895            if let Some(agent) = &req.agent_id {
3896                let visible = m.agent_id == *agent || m.scope == Scope::Shared;
3897                if !visible {
3898                    continue;
3899                }
3900            }
3901            // Phase 12.6: pre-scoring filter (session_id + metadata
3902            // conditions). Cheap when `req.filter.is_empty()`; the
3903            // filter struct's `matches` is a short-circuit AND.
3904            if !req.filter.is_empty() && !req.filter.matches(&m) {
3905                continue;
3906            }
3907            // Phase 12.7: default-exclude the thread layers from fact
3908            // recall: archive turns (12.7a), essence items (12.7b; read
3909            // by scope through the render, not by similarity) and
3910            // thread-link markers (12.7c; they carry an all-equal
3911            // sentinel vector and would surface as junk hits). A caller
3912            // that wants them opts in by placing any `_mnemo.*` term in
3913            // the filter (equality, `$in`, `$exists`, or a range on
3914            // `_mnemo.thread` / `_mnemo.kind` / `_mnemo.seq`).
3915            if !req.filter.mentions_mnemo_meta()
3916                && matches!(
3917                    m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str()),
3918                    Some(MNEMO_META_KIND_TURN)
3919                        | Some(MNEMO_META_KIND_ESSENCE)
3920                        | Some(MNEMO_META_KIND_THREAD_LINK)
3921                )
3922            {
3923                continue;
3924            }
3925            let sim = memory::similarity(req.metric, &req.query, &m.vector);
3926            let age_secs = (now - m.created_at) as f32;
3927            let score = req
3928                .weights
3929                .score(sim, age_secs, m.importance, m.access_count);
3930            scored.push(RecallResult {
3931                memory: m,
3932                score,
3933                similarity: sim,
3934            });
3935        }
3936        Ok(scored)
3937    }
3938
3939    /// Phase-5 multi-signal recall under the v0.7.0 scoring-v2 formula
3940    /// (see [`crate::ScoreWeights`]). Thin wrapper over
3941    /// [`Mnemo::recall_budgeted`] that drops the `truncated` flag;
3942    /// use the budgeted form if you need to know whether
3943    /// [`RecallRequest::max_bytes`] cut anything.
3944    pub fn recall(&mut self, req: &RecallRequest) -> Result<Vec<RecallResult>> {
3945        Ok(self.recall_budgeted(req)?.hits)
3946    }
3947
3948    /// Phase 12.8 budgeted recall: scoring v2 (see [`crate::ScoreWeights`])
3949    /// with an optional UTF-8-content byte cap. Ranks by
3950    /// `sim + max(sim, 0) · (β·recency + γ·(importance − 0.5) + δ·tanh(uses/5))`,
3951    /// with type / agent / supersession / TTL filters and the 12.6
3952    /// `Filter` applied before the scorer. Updates `accessed_at` /
3953    /// `access_count` on the returned memories (persisted on the next
3954    /// `flush`).
3955    ///
3956    /// When an ANN index has been built ([`Mnemo::build_index`]),
3957    /// recall runs the tiered IVF→PQ→rerank pipeline. If the filters
3958    /// (type / agent / supersession / TTL / 12.6) reduce the ANN
3959    /// shortlist below `top_k`, Phase 12.1's short-results-on-indexed-
3960    /// path fix kicks in: the query is retried with `n_probe` and
3961    /// `n_rerank` doubled, and if that would already touch half or
3962    /// more of the live non-superseded records, the ANN pass is
3963    /// skipped for an exact scan. Without an index, recall scores
3964    /// every live non-superseded memory.
3965    ///
3966    /// Phase 12.8: after `top_k` is applied, if
3967    /// [`RecallRequest::max_bytes`] is set, hits are dropped in rank
3968    /// order once the running sum of `content` UTF-8 bytes would
3969    /// exceed the budget. The top-1 hit is always kept even if it
3970    /// alone exceeds the budget. [`RecallResponse::truncated`] is
3971    /// `true` iff at least one hit was dropped by the budget.
3972    pub fn recall_budgeted(&mut self, req: &RecallRequest) -> Result<RecallResponse> {
3973        if req.query.len() != self.dimensions {
3974            return Err(MnemoError::DimensionMismatch {
3975                expected: self.dimensions,
3976                got: req.query.len(),
3977            });
3978        }
3979        // Access-tracking bumps `accessed_at` / `access_count` in the
3980        // catalog, which is a mutation. On a read-only handle the caller
3981        // must pass `track_access(false)` (via CLI `--no-track`).
3982        if self.read_only && req.track_access {
3983            return Err(MnemoError::ReadOnly);
3984        }
3985        // Phase 12.1 **fix** (lane A v0.8.0 REVIEW): do NOT call
3986        // `ensure_key_caches()` here. A fresh read-only handle that
3987        // only ever recalls would otherwise pay a full-file decrypt
3988        // on its first query to build the lazy superseded set. Instead
3989        // `score_once` checks `_mnemo.superseded_by` per-record after
3990        // decrypt (slow path) and consults the set only when it has
3991        // already been built by a prior keyed op on this handle (fast
3992        // path). `live_non_superseded` is not knowable without a
3993        // decrypt pass either, so we approximate by `live_count`; the
3994        // exact-scan gate uses this as a conservative upper bound,
3995        // which errs toward widening (cheaper) rather than skipping
3996        // straight to a full scan.
3997        let live_count = self.catalog.iter().filter(|e| !e.deleted).count();
3998        let live_non_superseded = live_count;
3999        let now = memory::now_secs();
4000
4001        // Scoring v2 (shipped v0.7.0). Single-pass filter + score:
4002        //   * `age_secs = now - m.created_at`, so a recall doesn't bump
4003        //     its own results' recency on later queries.
4004        //   * `ScoreWeights::score` applies the similarity-gated,
4005        //     importance-centered, saturating-usage formula documented
4006        //     on `ScoreWeights`.
4007        //
4008        // Phase 12.1 + 12.6 short-results-on-indexed-path fix: when the
4009        // ANN shortlist is narrower than the live count, the filters
4010        // (type / agent / supersession / TTL / 12.6) can reduce the
4011        // result below `top_k`. If that happens, re-query the index
4012        // with `n_probe` / `n_rerank` doubled from their *effective*
4013        // base; if that would already cover ≥50% of the live set,
4014        // skip the index and do an exact scan.
4015        //
4016        // Lane A v0.8.0 REVIEW fix: widen from
4017        // `IvfPqIndex::effective_defaults()` when the caller didn't
4018        // pin the knobs. The pre-fix code only doubled when `req.n_*`
4019        // was already `Some(_)`, which skipped the widen step for the
4020        // default (unpinned) recall path and jumped straight to an
4021        // exact scan on moderately selective filters.
4022        let mut scored = self.score_once(req, now, false)?;
4023        let mut used_exact_scan = self.ann.is_none();
4024        if scored.len() < req.top_k && live_non_superseded > req.top_k {
4025            if let Some(ann) = &self.ann {
4026                let (default_probe, default_rerank) = ann.effective_defaults();
4027                let base_probe = req.n_probe.unwrap_or(default_probe);
4028                let base_rerank = req.n_rerank.unwrap_or(default_rerank);
4029                let widen_probe = base_probe.saturating_mul(2);
4030                let widen_rerank = base_rerank.saturating_mul(2);
4031                // Attempt 2: widen once, 2× from the effective base.
4032                // Only worth another ANN pass if either knob actually
4033                // grew (saturating_mul on usize::MAX would cap).
4034                if widen_probe > base_probe || widen_rerank > base_rerank {
4035                    let widened = RecallRequest {
4036                        n_probe: Some(widen_probe),
4037                        n_rerank: Some(widen_rerank),
4038                        ..req.clone()
4039                    };
4040                    scored = self.score_once(&widened, now, false)?;
4041                }
4042                // Attempt 3: if still short after widening, pay the
4043                // exact scan — it's the only path that guarantees
4044                // `top_k` correct hits when the filter is more
4045                // selective than `widen_rerank / live_count`. Before
4046                // the fix this was gated on `widened_rerank * 2 >=
4047                // live_non_superseded`, which silently returned fewer
4048                // than `top_k` hits on selective filters that fell in
4049                // the gap. Exact scan is bounded (one decrypt per
4050                // live record); the previous widen is an optimization.
4051                if scored.len() < req.top_k {
4052                    scored = self.score_once(req, now, true)?;
4053                    used_exact_scan = true;
4054                }
4055            }
4056        }
4057        scored.sort_by(|a, b| b.score.total_cmp(&a.score));
4058        scored.truncate(req.top_k);
4059
4060        // Update access statistics for everything we surfaced — but only
4061        // mutate the catalog entry, not the full record on disk. Pre-v5
4062        // this loop called `self.put(r.memory.clone())`, which serialized
4063        // and rewrote every result (vector included) to fresh pages,
4064        // turning a top-K recall into K full record rewrites. Now the
4065        // catalog is the source of truth for these two fields and the
4066        // record body stays untouched until the next real edit.
4067        if req.track_access {
4068            for r in &mut scored {
4069                if let Some(&idx) = self.index.get(&r.memory.id.0) {
4070                    let entry = &mut self.catalog[idx];
4071                    entry.accessed_at = now;
4072                    entry.access_count = entry.access_count.saturating_add(1);
4073                    // Propagate the just-updated values onto the returned
4074                    // Memory so the caller sees the post-recall state.
4075                    r.memory.accessed_at = entry.accessed_at;
4076                    r.memory.access_count = entry.access_count;
4077                }
4078            }
4079            // Mark the catalog dirty so the next flush persists these
4080            // bumps. The records themselves are not dirty.
4081            if !scored.is_empty() {
4082                self.dirty_catalog = true;
4083            }
4084        }
4085
4086        // Phase 12.8: apply the `max_bytes` budget *after* tracking has
4087        // seen the full top-k list. Access stats record what the scorer
4088        // chose; the byte budget shouldn't suppress the signal.
4089        let truncated = if let Some(budget) = req.max_bytes {
4090            let orig_len = scored.len();
4091            let mut running = 0usize;
4092            let mut cut_at: Option<usize> = None;
4093            for (i, r) in scored.iter().enumerate() {
4094                let w = r.memory.content.len();
4095                if i == 0 {
4096                    // Always keep the top hit, even if it's over budget
4097                    // on its own. Avoid "I asked for top_k results and
4098                    // got zero" when a single memory exceeds the cap.
4099                    running += w;
4100                    continue;
4101                }
4102                if running.saturating_add(w) > budget {
4103                    cut_at = Some(i);
4104                    break;
4105                }
4106                running += w;
4107            }
4108            if let Some(idx) = cut_at {
4109                scored.truncate(idx);
4110                scored.len() < orig_len
4111            } else {
4112                false
4113            }
4114        } else {
4115            false
4116        };
4117
4118        Ok(RecallResponse {
4119            hits: scored,
4120            truncated,
4121            used_exact_scan,
4122        })
4123    }
4124
4125    // --- approximate index ------------------------------------------------
4126
4127    /// Build an IVF+PQ approximate-nearest-neighbour index over every live
4128    /// memory, using default tuning. After this, [`Mnemo::recall`] runs the
4129    /// tiered pipeline instead of an exact scan. Persisted on the next
4130    /// `flush`. Returns a snapshot of the index shape.
4131    pub fn build_index(&mut self) -> Result<IndexInfo> {
4132        self.build_index_with(IndexConfig::default())
4133    }
4134
4135    /// Build the ANN index with explicit tuning.
4136    pub fn build_index_with(&mut self, cfg: IndexConfig) -> Result<IndexInfo> {
4137        if self.read_only {
4138            return Err(MnemoError::ReadOnly);
4139        }
4140        let mems = self.memories()?;
4141        if mems.is_empty() {
4142            return Err(MnemoError::Invalid(
4143                "cannot build an index over an empty database".into(),
4144            ));
4145        }
4146        let items: Vec<(u128, &[f32])> =
4147            mems.iter().map(|m| (m.id.0, m.vector.as_slice())).collect();
4148        let idx = IvfPqIndex::build(self.dimensions, &items, cfg)?;
4149        let info = idx.info();
4150        self.ann = Some(idx);
4151        self.dirty_index = true;
4152        Ok(info)
4153    }
4154
4155    /// Rebuild the ANN index from scratch — re-clusters centroids and retrains
4156    /// the PQ codebook, undoing the cluster drift that accumulates as memories
4157    /// are inserted against fixed centroids.
4158    pub fn rebuild_index(&mut self) -> Result<IndexInfo> {
4159        // ANN-1.1: re-derive every parameter (partitions, subspaces,
4160        // n_probe, n_rerank) for the current corpus size by passing
4161        // `IndexConfig::default()`. Previously this freezed the
4162        // stored `(n_probe, n_rerank)` from the current index,
4163        // defeating the point of rebuilding on a grown corpus. If
4164        // you really want to pin specific values, call
4165        // `build_index_with(IndexConfig { … })` directly.
4166        self.build_index_with(IndexConfig::default())
4167    }
4168
4169    /// Drop the ANN index; recall reverts to exact scans. Persisted on flush.
4170    /// No-op on a read-only handle (signature is infallible — callers of
4171    /// `build_index` / `flush` on a read-only handle already get a typed
4172    /// `ReadOnly` error, which is the enforceable boundary).
4173    pub fn drop_index(&mut self) {
4174        if self.read_only {
4175            return;
4176        }
4177        if self.ann.is_some() {
4178            self.ann = None;
4179            self.dirty_index = true;
4180        }
4181    }
4182
4183    /// Whether an ANN index is currently loaded.
4184    pub fn has_index(&self) -> bool {
4185        self.ann.is_some()
4186    }
4187
4188    // --- durability & maintenance ----------------------------------------
4189
4190    /// Seal a serialized buffer into a fresh run of encrypted home pages and
4191    /// append a WAL frame for each. Returns the run's `(start_page, pages)`.
4192    /// The pages are *not* written to their home locations here — they go to
4193    /// the WAL and are folded in later by [`Mnemo::checkpoint`].
4194    fn seal_run(&mut self, bytes: &[u8], frames: &mut Vec<wal::Frame>) -> Result<(u64, u32)> {
4195        let pc = bytes.len().div_ceil(PAYLOAD).max(1);
4196        let start = self.header.next_page;
4197        self.header.next_page += pc as u64;
4198        for i in 0..pc {
4199            let lo = i * PAYLOAD;
4200            let hi = ((i + 1) * PAYLOAD).min(bytes.len());
4201            let page_no = start + i as u64;
4202            let sealed = self.pager.seal_page(page_no, &bytes[lo..hi])?;
4203            frames.push((page_no, sealed.to_vec()));
4204        }
4205        Ok((start, pc as u32))
4206    }
4207
4208    /// Grow the WAL region if `frame_pages` worth of frames would not fit.
4209    /// Called at the top of `flush`, where the WAL is always spent (every
4210    /// flush ends with a checkpoint), so relocating it cannot strand a
4211    /// committed transaction.
4212    fn ensure_wal_capacity(&mut self, frame_pages: usize) -> Result<()> {
4213        let need = wal::txn_byte_len(frame_pages, PAGE_SIZE);
4214        if need <= self.header.wal_pages * PAGE_SIZE as u64 {
4215            return Ok(());
4216        }
4217        // Allocate a new, larger region at the file tail (~1.5x headroom).
4218        let req = need.div_ceil(PAGE_SIZE as u64);
4219        let new_pages = (req + req / 2 + 4).max(DEFAULT_WAL_PAGES);
4220        let old_wal_start = self.header.wal_start;
4221        let new_start = self.header.next_page;
4222        self.header.next_page += new_pages;
4223        self.header.wal_start = new_start;
4224        self.header.wal_pages = new_pages;
4225        self.header.wal_seq += 1;
4226        // DUR-1 (R-002.1): WAL-log the header change into the *new* region
4227        // first, checkpoint page 0, then invalidate the old region. A crash
4228        // anywhere here either keeps the old state (if page 0 wasn't yet
4229        // overwritten) or recovers to the new state via the torn-header
4230        // scan in `Mnemo::open`, which prefers the highest-txn-id valid
4231        // committed header frame.
4232        self.relocate_header_through_wal(old_wal_start)
4233    }
4234
4235    /// Shared helper for every post-`create` page-0 commit: WAL-log the
4236    /// new header into the current WAL region, checkpoint page 0, and
4237    /// invalidate `old_wal_start` (the previous region, or the current
4238    /// region when it hasn't moved). The caller is expected to have
4239    /// mutated `self.header` to its target values *including a bumped
4240    /// `wal_seq`* (one more than the last on-disk `wal_seq`); this
4241    /// routine applies the seal, writes the frame + commit, folds page 0,
4242    /// then zeroes the old region's first frame header.
4243    ///
4244    /// If `old_wal_start == self.header.wal_start`, the invalidation is
4245    /// skipped — the region didn't move, so overwriting its first frame
4246    /// would destroy the just-committed one.
4247    fn relocate_header_through_wal(&mut self, old_wal_start: u64) -> Result<()> {
4248        self.header.write_counter = self.pager.write_counter;
4249        let mut page = self.header.to_page();
4250        self.header.apply_seal(&mut page, self.pager.dek())?;
4251        let txn_id = self.header.wal_seq;
4252        let frames = vec![(0u64, page.to_vec())];
4253        wal::commit(
4254            self.pager.file_mut(),
4255            self.header.wal_start,
4256            self.header.wal_pages,
4257            txn_id,
4258            &frames,
4259        )?;
4260        self.pager.write_raw(0, &page)?;
4261        self.pager.sync()?;
4262        if old_wal_start != self.header.wal_start {
4263            wal::invalidate_region(self.pager.file_mut(), old_wal_start)?;
4264        }
4265        Ok(())
4266    }
4267
4268    /// Fold a committed transaction's WAL frames into their home pages.
4269    fn checkpoint(&mut self, frames: &[wal::Frame]) -> Result<()> {
4270        for (page_no, bytes) in frames {
4271            let mut img = [0u8; PAGE_SIZE];
4272            img.copy_from_slice(bytes);
4273            self.pager.write_sealed(*page_no, &img)?;
4274        }
4275        self.pager.sync()?;
4276        Ok(())
4277    }
4278
4279    /// Persist all pending changes as one **write-ahead-logged transaction**.
4280    ///
4281    /// Sequence:
4282    ///
4283    /// 1. **Prepare.** Serialize the control plane (catalog, ANN index),
4284    ///    grow the WAL if needed, and **lease** counter and page slots —
4285    ///    bump `header.write_counter` and `header.next_page` by upper
4286    ///    bounds on this transaction's consumption and persist that
4287    ///    *leased* header to disk before any encrypted page is written.
4288    /// 2. **Data pages.** [`Pager::flush`] writes dirty record pages
4289    ///    copy-on-write under fresh nonces and fsyncs them. The orphan-page
4290    ///    nonce-reuse window (see [Phase 1.1 of the improvement plan])
4291    ///    is closed because the leased header on disk already records a
4292    ///    `write_counter` past anything this step can produce.
4293    /// 3. **Control plane.** Seal the new catalog / ANN index / snapshot
4294    ///    manifest into fresh home page runs.
4295    /// 4. **Commit.** Log all of step 3's frames plus the final header
4296    ///    frame into the WAL and fsync — the durability point.
4297    /// 5. **Checkpoint.** Fold the WAL into the home pages.
4298    ///
4299    /// A crash before step 4 leaves the previous state intact. A crash
4300    /// after step 4 is repaired by [`Mnemo::open`] replaying the WAL.
4301    /// Safe to call repeatedly.
4302    pub fn flush(&mut self) -> Result<()> {
4303        if self.read_only {
4304            return Err(MnemoError::ReadOnly);
4305        }
4306        // Snapshot the thread-local fsync counter. Every `sync_all` the
4307        // pager / wal / checkpoint path makes between here and the end
4308        // of this function bumps the same counter; `delta` lands on
4309        // `self.fsyncs_last_flush` so `Stats::fsyncs_last_flush` can
4310        // report it. The planner wants this to decide whether cutting
4311        // fsync count is a viable write-latency win before we touch
4312        // the write path's durability model.
4313        let fsync_before = crate::fsync_count();
4314        if !self.dirty_catalog && !self.dirty_index && !self.dirty_cache {
4315            // No control-plane changes pending. Because every `put` that
4316            // dirties a data page also dirties the catalog, and every
4317            // `cache_put` that dirties a data page also dirties
4318            // `dirty_cache`, there can't be dirty data pages either —
4319            // nothing to do.
4320            self.fsyncs_last_flush = 0;
4321            return Ok(());
4322        }
4323
4324        // FIX-1 R-001.8 part 3: snapshot the memory/index-dirty flags
4325        // before step 3b decides whether to push a restore point.
4326        // Only flushes that change the memory catalog or ANN index
4327        // need a snapshot — a cache-only flush (`dirty_cache` set,
4328        // `dirty_catalog` + `dirty_index` both false) must not push,
4329        // because snapshots don't capture cache state and every push
4330        // counts against `max_snapshots` (default 256), so a cache-
4331        // heavy workload would otherwise evict every real memory
4332        // restore point within ~256 cache writes.
4333        let is_memory_or_index_flush = self.dirty_catalog || self.dirty_index;
4334
4335        // 1. Serialize control plane, ensure WAL capacity, lease counter +
4336        //    page slots, and persist the leased header *before* any data
4337        //    page hits the disk.
4338        let ctx = self.prepare_for_flush()?;
4339
4340        // 2. Record (vector) data pages: copy-on-write to fresh pages, fsynced.
4341        //    Safe: the lease guarantees that even if we crash here, reopen
4342        //    will see a `write_counter` past every nonce this step uses.
4343        self.pager.flush()?;
4344
4345        // 3. Seal the catalog / index control pages into fresh home runs.
4346        let mut frames: Vec<wal::Frame> = Vec::new();
4347        if let Some(bytes) = &ctx.cat_bytes {
4348            let (start, pages) = self.seal_run(bytes, &mut frames)?;
4349            self.header.catalog_start = start;
4350            self.header.catalog_pages = pages as u64;
4351            self.header.catalog_len = bytes.len() as u64;
4352        }
4353        if self.dirty_index {
4354            match &ctx.idx_bytes {
4355                Some(bytes) => {
4356                    let (start, pages) = self.seal_run(bytes, &mut frames)?;
4357                    self.header.index_start = start;
4358                    self.header.index_pages = pages as u64;
4359                    self.header.index_len = bytes.len() as u64;
4360                }
4361                None => {
4362                    self.header.index_start = 0;
4363                    self.header.index_pages = 0;
4364                    self.header.index_len = 0;
4365                }
4366            }
4367        }
4368        // v8+ result-cache directory (Phase 10.1). Seal the serialized
4369        // directory into a fresh page run and update the pointer in the
4370        // header. The seal AAD already covers cache_start/pages/len for
4371        // v8+ (see `header_seal_aad`), so tampering with the pointer
4372        // trips the header seal on the next open.
4373        if self.dirty_cache {
4374            match &ctx.cache_bytes {
4375                Some(bytes) if !self.cache_dir.is_empty() => {
4376                    let (start, pages) = self.seal_run(bytes, &mut frames)?;
4377                    self.header.cache_start = start;
4378                    self.header.cache_pages = pages as u64;
4379                    self.header.cache_len = bytes.len() as u64;
4380                }
4381                _ => {
4382                    // Either the directory serialized to nothing (all
4383                    // entries deleted after tombstone-only migration) or
4384                    // it's empty — collapse the pointer to zero.
4385                    self.header.cache_start = 0;
4386                    self.header.cache_pages = 0;
4387                    self.header.cache_len = 0;
4388                }
4389            }
4390        }
4391
4392        // 3b. Record this transaction as a restorable snapshot. The manifest
4393        //     update is staged in a local and adopted only once the commit
4394        //     below succeeds, so a failed flush leaves no phantom entry.
4395        //
4396        //     The cap (`self.max_snapshots`) is applied to the staged local
4397        //     before the new entry lands, so after appending the manifest
4398        //     holds at most `max_snapshots` entries — the most-recent N.
4399        //     `max_snapshots == 0` disables the cap. Pages referenced only
4400        //     by pruned snapshots stay on disk until `compact_file`.
4401        //
4402        //     FIX-1 R-001.8 part 3: skip the snapshot push on a
4403        //     cache-only flush. Snapshots don't capture the cache
4404        //     anyway, and every push counts against `max_snapshots`
4405        //     (default 256) + rewrites the manifest run — so a
4406        //     cache-heavy workload would otherwise evict every real
4407        //     memory restore point within ~256 cache writes. On a
4408        //     cache-only flush we reuse the current manifest
4409        //     verbatim; the manifest run already lives at
4410        //     `self.header.manifest_{start,pages,len}` (set by the
4411        //     last memory-touching flush, or at `create` time).
4412        let live = self.len() as u64;
4413        let txn_id = self.header.wal_seq + 1;
4414        let mut manifest = self.manifest.clone();
4415        if is_memory_or_index_flush {
4416            if self.max_snapshots > 0 {
4417                // Drop the oldest entries so there's room for the new one.
4418                while manifest.len() >= self.max_snapshots {
4419                    manifest.remove(0);
4420                }
4421            }
4422            manifest.push(Snapshot {
4423                txn_id,
4424                created_at: memory::now_secs(),
4425                catalog_start: self.header.catalog_start,
4426                catalog_pages: self.header.catalog_pages,
4427                catalog_len: self.header.catalog_len,
4428                index_start: self.header.index_start,
4429                index_pages: self.header.index_pages,
4430                index_len: self.header.index_len,
4431                memory_count: live,
4432            });
4433            let man_bytes =
4434                rmp_serde::to_vec(&manifest).map_err(|e| MnemoError::Serialize(e.to_string()))?;
4435            let (m_start, m_pages) = self.seal_run(&man_bytes, &mut frames)?;
4436            self.header.manifest_start = m_start;
4437            self.header.manifest_pages = m_pages as u64;
4438            self.header.manifest_len = man_bytes.len() as u64;
4439        }
4440
4441        // 3c. The header is the transaction's final frame; stamp the new id
4442        //     and the *actual* (post-flush) write counter, which is <= the
4443        //     leased value persisted in `prepare_for_flush`. This is the
4444        //     value that ultimately replaces the leased header on checkpoint.
4445        self.header.vector_count = live;
4446        self.header.write_counter = self.pager.write_counter;
4447        self.header.wal_seq = txn_id;
4448        let mut hpage = self.header.to_page();
4449        self.header.apply_seal(&mut hpage, self.pager.dek())?;
4450        frames.push((0, hpage.to_vec()));
4451
4452        // 4. COMMIT — log the transaction and fsync. Nothing at a home page
4453        //    other than the leased header has changed yet; this single
4454        //    fsync is what makes the rest durable.
4455        let (wal_start, wal_pages) = (self.header.wal_start, self.header.wal_pages);
4456        wal::commit(self.pager.file_mut(), wal_start, wal_pages, txn_id, &frames)?;
4457
4458        // 5. Checkpoint — fold the WAL into the home pages.
4459        self.checkpoint(&frames)?;
4460
4461        self.manifest = manifest;
4462        self.dirty_catalog = false;
4463        self.dirty_index = false;
4464        self.dirty_cache = false;
4465        // Batched policy: successful flush consumes the pending batch.
4466        self.cache_batch.reset();
4467        // Record how many fsyncs this flush issued, for Stats reporting.
4468        // Thread-local counter guarantees we see only this handle's own
4469        // sync_all calls between the snapshot above and this read.
4470        self.fsyncs_last_flush =
4471            (crate::fsync_count().wrapping_sub(fsync_before)).min(u32::MAX as u64) as u32;
4472        Ok(())
4473    }
4474
4475    /// Pre-flush prelude shared by [`Mnemo::flush`] and the
4476    /// `__crash_partial_flush_for_testing` hook.
4477    ///
4478    /// Serializes the control plane (catalog, ANN index, cache directory)
4479    /// and grows the WAL if needed. That's it: this used to persist a
4480    /// leased header too (SEC-1 obsoleted it; see DUR-1 R-002.1 note in
4481    /// the body for the full rationale), but after DUR-1 the only page-0
4482    /// writes outside `create` go through the WAL via
4483    /// [`Mnemo::relocate_header_through_wal`].
4484    ///
4485    /// The historical pre-stamping used to close the nonce-reuse window
4486    /// flagged in Phase 1.1 of the improvement plan: a crash anywhere
4487    /// between here and the WAL commit left a file whose on-disk header
4488    /// recorded a `write_counter` past every nonce the rest of this
4489    /// transaction could produce, so the next reopen started from a
4490    /// counter that guaranteed uniqueness against any orphan data pages
4491    /// still on disk. SEC-1's per-session random nonce prefix gives the
4492    /// same guarantee without needing a persisted counter, which is why
4493    /// this routine no longer writes page 0.
4494    fn prepare_for_flush(&mut self) -> Result<FlushPrelude> {
4495        // Serialize.
4496        let cat_bytes = if self.dirty_catalog {
4497            Some(
4498                rmp_serde::to_vec(&self.catalog)
4499                    .map_err(|e| MnemoError::Serialize(e.to_string()))?,
4500            )
4501        } else {
4502            None
4503        };
4504        let idx_bytes: Option<Vec<u8>> = if self.dirty_index {
4505            match &self.ann {
4506                Some(ann) => {
4507                    Some(rmp_serde::to_vec(ann).map_err(|e| MnemoError::Serialize(e.to_string()))?)
4508                }
4509                None => None,
4510            }
4511        } else {
4512            None
4513        };
4514        // Serialize the result-cache directory when it's dirty. An
4515        // empty directory serializes to a small header + 0-element
4516        // array; still cheap enough that we don't special-case it.
4517        let cache_bytes: Option<Vec<u8>> = if self.dirty_cache {
4518            Some(
4519                rmp_serde::to_vec(&self.cache_dir)
4520                    .map_err(|e| MnemoError::Serialize(e.to_string()))?,
4521            )
4522        } else {
4523            None
4524        };
4525
4526        let cat_pc = cat_bytes
4527            .as_ref()
4528            .map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
4529        let idx_pc = idx_bytes
4530            .as_ref()
4531            .map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
4532        let cache_pc = cache_bytes
4533            .as_ref()
4534            .map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
4535        // Upper bound on the manifest run: one extra entry, <=82 bytes each
4536        // (rmp_serde encodes a Snapshot as a 9-element fixarray of i64/u64,
4537        // worst case 81 bytes plus a 1-byte array header).
4538        let man_upper = 9 + (self.manifest.len() + 1) * 82;
4539        let man_pc_est = man_upper.div_ceil(PAYLOAD).max(1);
4540
4541        // Grow WAL if needed (DUR-1: relocation is WAL-logged and does
4542        // its own fsyncs). The cache directory rides in the same
4543        // transaction so its page count contributes to the reservation.
4544        self.ensure_wal_capacity(cat_pc + idx_pc + cache_pc + man_pc_est + 1)?;
4545
4546        // DUR-1 (R-002.1): the pre-flush "leased header" write that used
4547        // to live here is gone. It existed to close the Phase 1.1 nonce
4548        // reuse window (reopen must see a `write_counter` past any nonce
4549        // this flush could produce before data pages hit disk). SEC-1
4550        // superseded that: nonces now carry a per-session random prefix
4551        // and `write_counter` is no longer a nonce input, so the leased
4552        // write bought us nothing but an extra fsync per flush. Orphan
4553        // data pages past `header.next_page` on a crashed flush are
4554        // still safe — the next session's random prefix makes any later
4555        // write at the same page_no uniquely nonced regardless of
4556        // `write_counter`.
4557
4558        Ok(FlushPrelude {
4559            cat_bytes,
4560            idx_bytes,
4561            cache_bytes,
4562        })
4563    }
4564
4565    /// Flush and close. Equivalent to `flush()` on a read-write handle; on
4566    /// a read-only handle it's a no-op (no dirty state to persist). The
4567    /// file is released on drop, which also drops the OS advisory lock.
4568    pub fn close(&mut self) -> Result<()> {
4569        if self.read_only {
4570            return Ok(());
4571        }
4572        self.flush()
4573    }
4574
4575    /// **TEST ONLY — do not use.** Reproduces a crash inside
4576    /// [`Mnemo::flush`] *after* the prelude has leased counter+page slots
4577    /// and persisted the leased header, but *before* the WAL is committed.
4578    /// Runs `prepare_for_flush` + `pager.flush` and returns — leaving
4579    /// data pages and a leased header on disk, with no commit frame.
4580    ///
4581    /// Used by `tests/integration.rs::nonce_unique_after_crashed_data_flush`
4582    /// to verify that this window (Phase 1.1 of the improvement plan) does
4583    /// not enable AES-GCM nonce reuse. Calling this in production code
4584    /// strands data pages; never expose it from a binding.
4585    #[doc(hidden)]
4586    pub fn __crash_partial_flush_for_testing(&mut self) -> Result<()> {
4587        if self.read_only {
4588            return Err(MnemoError::ReadOnly);
4589        }
4590        if !self.dirty_catalog && !self.dirty_index {
4591            return Ok(());
4592        }
4593        let _prelude = self.prepare_for_flush()?;
4594        self.pager.flush()
4595    }
4596
4597    /// Bound the in-memory page cache to `pages` decrypted pages.
4598    ///
4599    /// The cache holds decrypted page payloads to speed repeated reads. By
4600    /// default it is capped at 8192 pages (~64 MiB); lower the cap to trade
4601    /// hit rate for a smaller footprint, or raise it for a hotter cache. The
4602    /// cap governs *clean* pages — pages with un-flushed writes are always
4603    /// retained until [`Mnemo::flush`], regardless of the cap.
4604    pub fn set_cache_capacity(&mut self, pages: usize) {
4605        self.pager.set_cache_capacity(pages);
4606    }
4607
4608    /// Page-cache occupancy: `(pages_cached, capacity)`. Renamed from
4609    /// `cache_stats` in v0.4.0: the result-cache API (Phase 10.1) took
4610    /// that name for its per-namespace [`CacheStats`] summary, and the
4611    /// page-cache pair is the older/less-user-facing of the two, so it
4612    /// gets the more-specific name.
4613    pub fn page_cache_stats(&self) -> (usize, usize) {
4614        self.pager.cache_stats()
4615    }
4616
4617    // === Result cache (Phase 10.1 + 10.3) ============================
4618
4619    /// Insert or overwrite a cache entry at `(namespace, key)`.
4620    ///
4621    /// The engine SHA-256-hashes `key` — callers pass the raw input
4622    /// string. Persists to disk on the next `flush()` under the current
4623    /// flush policy (see [`Mnemo::set_cache_flush_policy`]); in
4624    /// [`CacheFlushPolicy::Batched`] the engine may call `flush()`
4625    /// internally when a threshold trips.
4626    ///
4627    /// On overflow of the namespace's [`CacheBudget`] (default 10,000
4628    /// entries / 64 MiB) the LRU entry is tombstoned before the new
4629    /// insert. Tombstones are reclaimed by
4630    /// [`Mnemo::compact_file`], not the runtime flush.
4631    ///
4632    /// Returns [`MnemoError::ReadOnly`] on a read-only handle.
4633    pub fn cache_put(
4634        &mut self,
4635        namespace: &str,
4636        key: &str,
4637        value: &[u8],
4638        opts: CachePutOpts,
4639    ) -> Result<()> {
4640        if self.read_only {
4641            return Err(MnemoError::ReadOnly);
4642        }
4643        let key_hash = result_cache::hash_key(key);
4644        let key_preview = result_cache::key_preview(key);
4645        let now = memory::now_secs();
4646        let size_bytes = value.len() as u64;
4647
4648        let entry = CacheEntry {
4649            namespace: namespace.to_string(),
4650            key_hash,
4651            key_preview: key_preview.clone(),
4652            value: value.to_vec(),
4653            content_type: opts.content_type.clone(),
4654            created_at: now,
4655            accessed_at: now,
4656            access_count: 0,
4657            ttl_secs: opts.ttl_secs,
4658            cost_hint_ms: opts.cost_hint_ms,
4659            size_bytes,
4660            vector: None,
4661            model: None,
4662        };
4663        let bytes = rmp_serde::to_vec(&entry).map_err(|e| MnemoError::Serialize(e.to_string()))?;
4664        let (start_page, page_count) = self.write_record(&bytes)?;
4665
4666        // Tombstone any prior entry for this key. Its pages remain on
4667        // disk until `compact_file`, exactly like memory-record
4668        // overwrites — the tombstone just hides it from lookups.
4669        if let Some(prev_pos) = self.cache_idx.get(namespace, &key_hash) {
4670            self.cache_dir[prev_pos].deleted = true;
4671            self.cache_idx.remove(namespace, &key_hash);
4672        }
4673
4674        let dir_entry = CacheDirectoryEntry {
4675            namespace: namespace.to_string(),
4676            key_hash,
4677            start_page,
4678            page_count,
4679            len: bytes.len() as u32,
4680            deleted: false,
4681            accessed_at: now,
4682            access_count: 0,
4683            size_bytes,
4684            ttl_secs: opts.ttl_secs,
4685            created_at: now,
4686            vector: None,
4687            model: None,
4688        };
4689        let pos = self.cache_dir.len();
4690        self.cache_dir.push(dir_entry);
4691        self.cache_idx.insert(namespace, key_hash, pos);
4692
4693        // Enforce budget — evict LRU tombstones until the namespace
4694        // fits under both caps.
4695        self.evict_to_budget(namespace)?;
4696
4697        self.dirty_cache = true;
4698        self.cache_batch.record_dirty();
4699
4700        // Batched policy: auto-flush if a threshold tripped. Successful
4701        // `flush()` calls `cache_batch.reset()` for us.
4702        if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
4703            self.flush()?;
4704        }
4705        Ok(())
4706    }
4707
4708    /// Look up `(namespace, key)`. Returns `None` on miss (either the
4709    /// key was never inserted or the entry is TTL-expired). On hit
4710    /// updates catalog-side access stats (mirrors the recall v5-trick);
4711    /// on a read-only handle the stats update is silently skipped.
4712    ///
4713    /// A miss is recorded as a miss in this handle's counters even if
4714    /// the key was TTL-expired — from the caller's perspective the two
4715    /// are indistinguishable.
4716    pub fn cache_get(&mut self, namespace: &str, key: &str) -> Result<Option<CachedValue>> {
4717        // FIX-1 R-001.8 part 4: under `Batched`, honor `max_age` on
4718        // reads too. Pre-FIX-1 the age check only fired on `cache_put`,
4719        // so a workload that wrote a few entries and then switched to
4720        // pure reads could sit with its pending batch past the
4721        // nominal max_age indefinitely. `max_dirty` is already
4722        // maintained per-write; this adds the symmetric time-bound
4723        // check on the read path. Only runs on read-write handles —
4724        // flush() returns ReadOnly for shared-lock readers.
4725        if !self.read_only && self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
4726            self.flush()?;
4727        }
4728        let key_hash = result_cache::hash_key(key);
4729        let pos = match self.cache_idx.get(namespace, &key_hash) {
4730            Some(p) => p,
4731            None => {
4732                self.cache_counters.misses += 1;
4733                return Ok(None);
4734            }
4735        };
4736        let now = memory::now_secs();
4737        // Snapshot the directory entry fields we need before any
4738        // mutation — the borrow checker gets grumpy otherwise.
4739        let (start_page, page_count, len, ttl_secs, expired, created_at) = {
4740            let e = &self.cache_dir[pos];
4741            (
4742                e.start_page,
4743                e.page_count,
4744                e.len,
4745                e.ttl_secs,
4746                e.is_expired(now),
4747                e.created_at,
4748            )
4749        };
4750        if expired {
4751            // Treat expired as a miss for the caller. Don't tombstone
4752            // here — cache_purge does that. Just skip.
4753            self.cache_counters.misses += 1;
4754            return Ok(None);
4755        }
4756        // Decrypt and decode the record body.
4757        let mut buf = Vec::with_capacity(len as usize);
4758        for i in 0..page_count as u64 {
4759            buf.extend_from_slice(&self.pager.read_page(start_page + i)?);
4760        }
4761        buf.truncate(len as usize);
4762        let entry: CacheEntry =
4763            rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
4764
4765        // Bump access stats catalog-only (v5 trick — no full record
4766        // rewrite on a hit). Skipped on read-only handles so a shared-lock
4767        // reader can't dirty the catalog.
4768        if !self.read_only {
4769            self.cache_dir[pos].accessed_at = now;
4770            self.cache_dir[pos].access_count = self.cache_dir[pos].access_count.saturating_add(1);
4771            self.dirty_cache = true;
4772        }
4773        self.cache_counters.hits += 1;
4774        // On a mutating handle: access_count reflects the just-bumped
4775        // value. On a read-only handle: the on-disk directory value is
4776        // unchanged, so returning it verbatim is honest (the caller's
4777        // reads did not, and cannot, alter the persisted counter).
4778        //
4779        // FIX-1 R-002.2 part 1: `accessed_at` must also come from the
4780        // directory on a read-only handle, not from `entry.accessed_at`
4781        // in the record body. The record body is written once at insert
4782        // time and never updated on a hit — every live `accessed_at`
4783        // update lands in the directory (see the `!self.read_only`
4784        // branch above). The pre-FIX-1 code returned
4785        // `entry.accessed_at`, which is really the entry's **creation
4786        // time** by then, so a read-only reader saw "last accessed =
4787        // when it was inserted" even after many successful hits by
4788        // concurrent writers.
4789        let access_count = self.cache_dir[pos].access_count;
4790        let accessed_at = if self.read_only {
4791            self.cache_dir[pos].accessed_at
4792        } else {
4793            now
4794        };
4795        Ok(Some(CachedValue {
4796            value: entry.value,
4797            content_type: entry.content_type,
4798            created_at,
4799            accessed_at,
4800            access_count,
4801            ttl_secs,
4802        }))
4803    }
4804
4805    /// Tombstone the entry at `(namespace, key)`. Silently succeeds if
4806    /// no such entry exists. Space is reclaimed by `compact_file`.
4807    pub fn cache_delete(&mut self, namespace: &str, key: &str) -> Result<()> {
4808        if self.read_only {
4809            return Err(MnemoError::ReadOnly);
4810        }
4811        let key_hash = result_cache::hash_key(key);
4812        if let Some(pos) = self.cache_idx.get(namespace, &key_hash) {
4813            self.cache_dir[pos].deleted = true;
4814            self.cache_idx.remove(namespace, &key_hash);
4815            self.dirty_cache = true;
4816            self.cache_batch.record_dirty();
4817            if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
4818                self.flush()?;
4819            }
4820        }
4821        Ok(())
4822    }
4823
4824    /// Purge cache entries. If `namespace` is `Some`, restrict to that
4825    /// namespace; otherwise purge across the whole cache. If
4826    /// `expired_only` is `true`, only TTL-expired entries are removed;
4827    /// otherwise every live entry in the target scope is tombstoned.
4828    /// Returns the number of entries newly tombstoned.
4829    pub fn cache_purge(&mut self, namespace: Option<&str>, expired_only: bool) -> Result<usize> {
4830        if self.read_only {
4831            return Err(MnemoError::ReadOnly);
4832        }
4833        let now = memory::now_secs();
4834        let mut purged = 0usize;
4835        // Iterate the directory by index; mark matching entries as
4836        // deleted and pull them out of the in-memory index.
4837        for pos in 0..self.cache_dir.len() {
4838            if self.cache_dir[pos].deleted {
4839                continue;
4840            }
4841            if let Some(ns) = namespace {
4842                if self.cache_dir[pos].namespace != ns {
4843                    continue;
4844                }
4845            }
4846            if expired_only && !self.cache_dir[pos].is_expired(now) {
4847                continue;
4848            }
4849            let key_hash = self.cache_dir[pos].key_hash;
4850            let ns = self.cache_dir[pos].namespace.clone();
4851            self.cache_dir[pos].deleted = true;
4852            self.cache_idx.remove(&ns, &key_hash);
4853            purged += 1;
4854        }
4855        if purged > 0 {
4856            self.dirty_cache = true;
4857            self.cache_batch.record_dirty();
4858            if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
4859                self.flush()?;
4860            }
4861        }
4862        Ok(purged)
4863    }
4864
4865    /// Statistics for `namespace` (or the whole cache if `None`).
4866    /// `hits`/`misses`/`evictions` are per-handle in-memory counters
4867    /// that reset on reopen; `entries`/`bytes` reflect the durable
4868    /// on-disk state (live entries only, tombstones excluded).
4869    pub fn cache_stats(&self, namespace: Option<&str>) -> CacheStats {
4870        let mut entries = 0usize;
4871        let mut bytes = 0u64;
4872        for e in &self.cache_dir {
4873            if e.deleted {
4874                continue;
4875            }
4876            if let Some(ns) = namespace {
4877                if e.namespace != ns {
4878                    continue;
4879                }
4880            }
4881            entries += 1;
4882            bytes += e.size_bytes;
4883        }
4884        let hits = self.cache_counters.hits;
4885        let misses = self.cache_counters.misses;
4886        let total = hits + misses;
4887        let hit_rate = if total > 0 {
4888            hits as f64 / total as f64
4889        } else {
4890            0.0
4891        };
4892        CacheStats {
4893            entries,
4894            bytes,
4895            hits,
4896            misses,
4897            hit_rate,
4898            evictions: self.cache_counters.evictions,
4899        }
4900    }
4901
4902    /// Set (or update) the eviction budget for `namespace`. Applies to
4903    /// subsequent `cache_put` calls; does not retroactively evict
4904    /// entries that already sit under a looser cap.
4905    pub fn set_cache_budget(&mut self, namespace: &str, budget: CacheBudget) {
4906        self.cache_budgets.insert(namespace.to_string(), budget);
4907    }
4908
4909    /// Set the per-handle cache flush policy. Defaults to
4910    /// [`CacheFlushPolicy::Strict`]. Memory writes are never batched;
4911    /// this affects only cache mutations.
4912    pub fn set_cache_flush_policy(&mut self, policy: CacheFlushPolicy) {
4913        self.cache_flush_policy = policy;
4914        // Switching mid-run: if we're moving to Strict and the batch is
4915        // non-empty, the pending count still counts toward dirty_cache
4916        // which the next flush() will consume.
4917    }
4918
4919    /// Evict LRU live entries in `namespace` until it fits under both
4920    /// budget caps. Called from `cache_put` after the insert; the new
4921    /// entry is included in the count so we always leave room.
4922    fn evict_to_budget(&mut self, namespace: &str) -> Result<()> {
4923        let budget = self
4924            .cache_budgets
4925            .get(namespace)
4926            .copied()
4927            .unwrap_or_default();
4928        loop {
4929            // Sum current live count + bytes in the namespace.
4930            let mut live_count = 0usize;
4931            let mut live_bytes = 0u64;
4932            for e in &self.cache_dir {
4933                if !e.deleted && e.namespace == namespace {
4934                    live_count += 1;
4935                    live_bytes += e.size_bytes;
4936                }
4937            }
4938            if live_count <= budget.max_entries && live_bytes <= budget.max_bytes {
4939                return Ok(());
4940            }
4941            // Find the LRU (oldest accessed_at) live entry.
4942            let victim_pos = self
4943                .cache_dir
4944                .iter()
4945                .enumerate()
4946                .filter(|(_, e)| !e.deleted && e.namespace == namespace)
4947                .min_by_key(|(_, e)| e.accessed_at)
4948                .map(|(i, _)| i);
4949            let Some(pos) = victim_pos else {
4950                // Nothing to evict but still over budget — the single
4951                // just-inserted entry itself is bigger than
4952                // max_bytes. Nothing we can safely do here without
4953                // rejecting the put; leave it and return.
4954                return Ok(());
4955            };
4956            let key_hash = self.cache_dir[pos].key_hash;
4957            let ns = self.cache_dir[pos].namespace.clone();
4958            self.cache_dir[pos].deleted = true;
4959            self.cache_idx.remove(&ns, &key_hash);
4960            self.cache_counters.evictions += 1;
4961        }
4962    }
4963
4964    // === Semantic cache (Phase 10.2) ==================================
4965
4966    /// Insert or overwrite a **semantic** cache entry — same as
4967    /// [`Mnemo::cache_put`] but with a required embedding vector and
4968    /// model identifier. The vector is stored on the directory entry
4969    /// so [`Mnemo::cache_get_semantic`] can score without decrypting
4970    /// record bodies.
4971    ///
4972    /// Vector dimensionality is validated against the database's
4973    /// configured `dimensions` — a mismatch returns
4974    /// [`MnemoError::DimensionMismatch`], the same signal `remember`
4975    /// gives for the memory catalog.
4976    ///
4977    /// The `model` string is matched **exactly** on lookup — a
4978    /// different model's cache is invisible to a given query. Pick
4979    /// canonical identifiers (`"text-embedding-3-small"`,
4980    /// `"bge-large-en-v1.5"`, ...) and stick to them.
4981    pub fn cache_put_semantic(
4982        &mut self,
4983        namespace: &str,
4984        key: &str,
4985        vector: Vec<f32>,
4986        value: &[u8],
4987        opts: SemanticCachePutOpts,
4988    ) -> Result<()> {
4989        if self.read_only {
4990            return Err(MnemoError::ReadOnly);
4991        }
4992        if vector.len() != self.dimensions {
4993            return Err(MnemoError::DimensionMismatch {
4994                expected: self.dimensions,
4995                got: vector.len(),
4996            });
4997        }
4998        let key_hash = result_cache::hash_key(key);
4999        let key_preview = result_cache::key_preview(key);
5000        let now = memory::now_secs();
5001        let size_bytes = value.len() as u64;
5002
5003        let entry = CacheEntry {
5004            namespace: namespace.to_string(),
5005            key_hash,
5006            key_preview: key_preview.clone(),
5007            value: value.to_vec(),
5008            content_type: opts.content_type.clone(),
5009            created_at: now,
5010            accessed_at: now,
5011            access_count: 0,
5012            ttl_secs: opts.ttl_secs,
5013            cost_hint_ms: opts.cost_hint_ms,
5014            size_bytes,
5015            vector: Some(vector.clone()),
5016            model: Some(opts.model.clone()),
5017        };
5018        let bytes = rmp_serde::to_vec(&entry).map_err(|e| MnemoError::Serialize(e.to_string()))?;
5019        let (start_page, page_count) = self.write_record(&bytes)?;
5020
5021        if let Some(prev_pos) = self.cache_idx.get(namespace, &key_hash) {
5022            self.cache_dir[prev_pos].deleted = true;
5023            self.cache_idx.remove(namespace, &key_hash);
5024        }
5025
5026        let dir_entry = CacheDirectoryEntry {
5027            namespace: namespace.to_string(),
5028            key_hash,
5029            start_page,
5030            page_count,
5031            len: bytes.len() as u32,
5032            deleted: false,
5033            accessed_at: now,
5034            access_count: 0,
5035            size_bytes,
5036            ttl_secs: opts.ttl_secs,
5037            created_at: now,
5038            vector: Some(vector),
5039            model: Some(opts.model),
5040        };
5041        let pos = self.cache_dir.len();
5042        self.cache_dir.push(dir_entry);
5043        self.cache_idx.insert(namespace, key_hash, pos);
5044
5045        self.evict_to_budget(namespace)?;
5046
5047        self.dirty_cache = true;
5048        self.cache_batch.record_dirty();
5049        if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
5050            self.flush()?;
5051        }
5052        Ok(())
5053    }
5054
5055    /// Semantic recall over the cache: score every live entry in
5056    /// `namespace` whose `model` matches against `query_vector` using
5057    /// cosine similarity; return the top-1 hit and its similarity iff
5058    /// `sim >= threshold`. `None` otherwise.
5059    ///
5060    /// Uses [`crate::result_cache::DEFAULT_SEMANTIC_THRESHOLD`] if you
5061    /// want a sensible default — pass it explicitly (`0.97` today).
5062    ///
5063    /// Exact-key entries (those without a vector) are transparently
5064    /// skipped, as are entries whose model doesn't match — a
5065    /// different-model hit would be a semantic bug, not a win.
5066    /// TTL-expired entries are also skipped.
5067    ///
5068    /// Access-stat updates on hit follow the same policy as
5069    /// [`Mnemo::cache_get`]: bumped on read-write handles, silently
5070    /// skipped on read-only. Hits and misses are recorded in the
5071    /// per-handle counters just like exact-key lookups.
5072    pub fn cache_get_semantic(
5073        &mut self,
5074        namespace: &str,
5075        query_vector: &[f32],
5076        threshold: f32,
5077        model: &str,
5078    ) -> Result<Option<(CachedValue, f32)>> {
5079        if query_vector.len() != self.dimensions {
5080            return Err(MnemoError::DimensionMismatch {
5081                expected: self.dimensions,
5082                got: query_vector.len(),
5083            });
5084        }
5085        // FIX-1 R-001.8 part 4: same Batched max_age check as
5086        // `cache_get`. See the comment there.
5087        if !self.read_only && self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
5088            self.flush()?;
5089        }
5090        let now = memory::now_secs();
5091
5092        // Scan namespace live entries, filter by model, compute
5093        // cosine, keep the best. Exact-key entries have `vector: None`
5094        // and are skipped by the filter.
5095        let mut best: Option<(usize, f32)> = None;
5096        for (i, e) in self.cache_dir.iter().enumerate() {
5097            if e.deleted || e.namespace != namespace || e.is_expired(now) {
5098                continue;
5099            }
5100            let Some(ref v) = e.vector else { continue };
5101            let Some(ref m) = e.model else { continue };
5102            if m != model {
5103                continue;
5104            }
5105            let sim = memory::cosine(query_vector, v);
5106            if best.map(|(_, s)| sim > s).unwrap_or(true) {
5107                best = Some((i, sim));
5108            }
5109        }
5110
5111        let Some((pos, sim)) = best else {
5112            self.cache_counters.misses += 1;
5113            return Ok(None);
5114        };
5115        if sim < threshold {
5116            self.cache_counters.misses += 1;
5117            return Ok(None);
5118        }
5119
5120        // Extract fields before any mutation to keep the borrow checker
5121        // happy.
5122        let (start_page, page_count, len, ttl_secs, created_at) = {
5123            let e = &self.cache_dir[pos];
5124            (e.start_page, e.page_count, e.len, e.ttl_secs, e.created_at)
5125        };
5126
5127        // Decrypt and decode the record body.
5128        let mut buf = Vec::with_capacity(len as usize);
5129        for i in 0..page_count as u64 {
5130            buf.extend_from_slice(&self.pager.read_page(start_page + i)?);
5131        }
5132        buf.truncate(len as usize);
5133        let entry: CacheEntry =
5134            rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
5135
5136        if !self.read_only {
5137            self.cache_dir[pos].accessed_at = now;
5138            self.cache_dir[pos].access_count = self.cache_dir[pos].access_count.saturating_add(1);
5139            self.dirty_cache = true;
5140        }
5141        self.cache_counters.hits += 1;
5142        let access_count = self.cache_dir[pos].access_count;
5143        // FIX-1 R-002.2 part 1: same read-only `accessed_at` fix as
5144        // `cache_get`. The directory carries the live value; the record
5145        // body only holds the insert-time snapshot.
5146        let accessed_at = if self.read_only {
5147            self.cache_dir[pos].accessed_at
5148        } else {
5149            now
5150        };
5151        Ok(Some((
5152            CachedValue {
5153                value: entry.value,
5154                content_type: entry.content_type,
5155                created_at,
5156                accessed_at,
5157                access_count,
5158                ttl_secs,
5159            },
5160            sim,
5161        )))
5162    }
5163
5164    /// Begin a conversation [`Session`](crate::Session) for `agent_id`.
5165    ///
5166    /// The session borrows the database for its lifetime, records turns as
5167    /// working memory, and consolidates them into episodic memory when closed.
5168    pub fn session(&mut self, agent_id: impl Into<String>) -> crate::session::Session<'_> {
5169        crate::session::Session::new(self, agent_id.into())
5170    }
5171
5172    // --- snapshots & point-in-time recovery ------------------------------
5173
5174    /// Every committed transaction, oldest first — the restore points
5175    /// available to [`Mnemo::restore_to`] and [`Mnemo::restore_to_time`].
5176    ///
5177    /// Each `flush` appends one snapshot. Because the storage engine is
5178    /// append-only, the pages a past flush wrote are still on disk, so any
5179    /// listed snapshot can be reinstated exactly. The history reaches back to
5180    /// the last [`Mnemo::compact_file`], which reclaims space by collapsing
5181    /// it.
5182    pub fn snapshots(&self) -> Vec<SnapshotInfo> {
5183        let mut v: Vec<SnapshotInfo> = self
5184            .manifest
5185            .iter()
5186            .map(|s| SnapshotInfo {
5187                txn_id: s.txn_id,
5188                created_at: s.created_at,
5189                memory_count: s.memory_count,
5190            })
5191            .collect();
5192        v.sort_by_key(|s| s.txn_id);
5193        v
5194    }
5195
5196    /// Load a past snapshot's state and commit it as a new transaction.
5197    fn apply_snapshot(&mut self, snap: &Snapshot) -> Result<()> {
5198        let catalog = load_catalog(
5199            &mut self.pager,
5200            snap.catalog_start,
5201            snap.catalog_pages,
5202            snap.catalog_len,
5203        )?;
5204        let ann = load_index(
5205            &mut self.pager,
5206            snap.index_start,
5207            snap.index_pages,
5208            snap.index_len,
5209            self.dimensions,
5210        )?;
5211        self.index = build_id_index(&catalog);
5212        self.catalog = catalog;
5213        self.ann = ann;
5214        // Re-commit as a fresh transaction: crash-safe, and itself recorded
5215        // as a new snapshot so a restore can always be undone.
5216        self.dirty_catalog = true;
5217        self.dirty_index = true;
5218        self.flush()
5219    }
5220
5221    /// Restore the database to the snapshot produced by transaction `txn_id`.
5222    ///
5223    /// The restore is itself a new committed transaction (and a new
5224    /// snapshot), so it is crash-safe and reversible — restoring forward to a
5225    /// later snapshot afterwards works just as well.
5226    pub fn restore_to(&mut self, txn_id: u64) -> Result<SnapshotInfo> {
5227        if self.read_only {
5228            return Err(MnemoError::ReadOnly);
5229        }
5230        let snap = self
5231            .manifest
5232            .iter()
5233            .find(|s| s.txn_id == txn_id)
5234            .cloned()
5235            .ok_or_else(|| MnemoError::NotFound(format!("snapshot for transaction {txn_id}")))?;
5236        self.apply_snapshot(&snap)?;
5237        Ok(SnapshotInfo {
5238            txn_id: snap.txn_id,
5239            created_at: snap.created_at,
5240            memory_count: snap.memory_count,
5241        })
5242    }
5243
5244    /// Restore the database to the latest snapshot committed at or before
5245    /// `unix_secs`. Returns [`MnemoError::NotFound`] if no snapshot is that
5246    /// old. Like [`Mnemo::restore_to`], the restore is a new transaction.
5247    pub fn restore_to_time(&mut self, unix_secs: i64) -> Result<SnapshotInfo> {
5248        if self.read_only {
5249            return Err(MnemoError::ReadOnly);
5250        }
5251        let snap = self
5252            .manifest
5253            .iter()
5254            .filter(|s| s.created_at <= unix_secs)
5255            .max_by_key(|s| s.txn_id)
5256            .cloned()
5257            .ok_or_else(|| {
5258                MnemoError::NotFound(format!("no snapshot at or before time {unix_secs}"))
5259            })?;
5260        self.apply_snapshot(&snap)?;
5261        Ok(SnapshotInfo {
5262            txn_id: snap.txn_id,
5263            created_at: snap.created_at,
5264            memory_count: snap.memory_count,
5265        })
5266    }
5267
5268    /// Return the KDF (Argon2id) parameters this file currently uses to
5269    /// wrap its DEK. Needed by callers that want to [`rekey`] while
5270    /// keeping the file's original protection level (e.g. the Python
5271    /// binding's `rekey(fast=False)`, whose docstring promises exactly
5272    /// that). FIX-1 R-001.5.
5273    pub fn kdf_params(&self) -> KdfParams {
5274        self.kdf
5275    }
5276
5277    /// Flush any pending writes, then stream the (encrypted) file to
5278    /// `dest` atomically: writes to a sibling temp file in `dest`'s
5279    /// parent directory, `sync_all`s it, renames onto `dest`, and
5280    /// fsyncs the parent directory on Unix so a crash never leaves a
5281    /// partial export.
5282    ///
5283    /// Refuses when `dest` resolves to the file this handle has open
5284    /// — that case would otherwise `File::create`-truncate the live
5285    /// database to 0 bytes (`fs::copy` on Unix reads *after* the
5286    /// `O_TRUNC`, so a naive export-to-self destroys the source).
5287    /// Unix: compares `(dev, ino)` of both paths via `MetadataExt`,
5288    /// catching symlinks and hard links. Windows: compares
5289    /// canonicalized paths; a rename onto an open file also fails on
5290    /// Windows because of the mandatory lock the handle holds, so the
5291    /// rename step is a second line of defense.
5292    ///
5293    /// Reads the bytes from the already-open file handle, not by
5294    /// `fs::copy`. On Windows that matters: `LockFileEx` locks are
5295    /// mandatory, so a second handle opened by `fs::copy` would fail
5296    /// while this writer still holds the lock.
5297    ///
5298    /// FIX-1 R-001.5 + R-002.2 part 3.
5299    pub fn export_encrypted(&mut self, dest: impl AsRef<Path>) -> Result<()> {
5300        let dest = dest.as_ref();
5301
5302        // Step 1 — refuse export to the open file itself. We compare
5303        // by identity, not string, so symlinks and hard links to
5304        // `self.path` are caught too. The check runs before any flush
5305        // so a self-targeted export is a pure no-op.
5306        if paths_refer_to_same_file(&self.path, dest).unwrap_or(false) {
5307            return Err(MnemoError::Invalid(format!(
5308                "export_encrypted refuses to overwrite the open database at {}",
5309                dest.display()
5310            )));
5311        }
5312
5313        // Step 2 — flush, so on-disk bytes match the live state.
5314        if !self.read_only {
5315            self.flush()?;
5316        }
5317
5318        // Step 3 — stream from the open handle into a sibling temp.
5319        // Using the open handle (instead of a second open on `self.path`)
5320        // means a Windows mandatory lock can't block the copy.
5321        let parent = dest.parent().ok_or_else(|| {
5322            MnemoError::Invalid(format!(
5323                "export_encrypted: dest {} has no parent directory",
5324                dest.display()
5325            ))
5326        })?;
5327        let tmp = temp_sibling(dest);
5328        // Scope the temp handle so it closes before the rename on
5329        // Windows (which refuses to rename over an open file).
5330        {
5331            let src = self.pager.file_mut();
5332            src.seek(SeekFrom::Start(0))?;
5333            let mut out = OpenOptions::new()
5334                .write(true)
5335                .create(true)
5336                .truncate(true)
5337                .open(&tmp)?;
5338            let mut buf = vec![0u8; 1 << 20]; // 1 MiB
5339            loop {
5340                let n = src.read(&mut buf)?;
5341                if n == 0 {
5342                    break;
5343                }
5344                out.write_all(&buf[..n])?;
5345            }
5346            out.sync_all()?;
5347            crate::bump_fsync();
5348        }
5349
5350        // Step 4 — atomic rename onto `dest`.
5351        if let Err(e) = std::fs::rename(&tmp, dest) {
5352            // Best-effort cleanup; propagate the rename error.
5353            let _ = std::fs::remove_file(&tmp);
5354            return Err(MnemoError::Io(e));
5355        }
5356
5357        // Step 5 — fsync the parent directory on Unix so the rename
5358        // itself is durable. On Windows directory syncs aren't
5359        // supported; the rename above is atomic enough on NTFS.
5360        #[cfg(unix)]
5361        {
5362            if let Ok(dir) = File::open(parent) {
5363                let _ = dir.sync_all();
5364                crate::bump_fsync();
5365            }
5366        }
5367        #[cfg(not(unix))]
5368        {
5369            let _ = parent;
5370        }
5371
5372        Ok(())
5373    }
5374
5375    /// Change the passphrase. Cheap: re-derives the KEK and re-wraps the DEK;
5376    /// the encrypted pages are never rewritten.
5377    pub fn rekey(&mut self, new_passphrase: &str, kdf: KdfParams) -> Result<()> {
5378        if self.read_only {
5379            return Err(MnemoError::ReadOnly);
5380        }
5381        self.flush()?;
5382        let new_salt = crypto::random_salt();
5383        let new_kek = crypto::derive_kek(new_passphrase.as_bytes(), &new_salt, kdf)?;
5384        let new_nonce = crypto::random_nonce();
5385        let wrapped = crypto::wrap_dek(&new_kek, &new_nonce, self.pager.dek())?;
5386        if wrapped.len() != WRAPPED_DEK_LEN {
5387            return Err(MnemoError::Crypto("unexpected wrapped DEK length".into()));
5388        }
5389        let mut wrapped_dek = [0u8; WRAPPED_DEK_LEN];
5390        wrapped_dek.copy_from_slice(&wrapped);
5391
5392        self.header.salt = new_salt;
5393        self.header.dek_nonce = new_nonce;
5394        self.header.wrapped_dek = wrapped_dek;
5395        self.header.m_cost = kdf.m_cost;
5396        self.header.t_cost = kdf.t_cost;
5397        self.header.p_cost = kdf.p_cost;
5398        self.kdf = kdf;
5399
5400        // DUR-1 (R-002.1): WAL-log the new page 0 before touching it.
5401        // Allocate a fresh WAL region at the tail so that a crash during
5402        // the rekey leaves either the old region (old passphrase works)
5403        // or the new region (new passphrase works) fully committed —
5404        // overwriting the existing region would risk destroying both.
5405        let old_wal_start = self.header.wal_start;
5406        let new_pages = self.header.wal_pages;
5407        let new_start = self.header.next_page;
5408        self.header.next_page += new_pages;
5409        self.header.wal_start = new_start;
5410        self.header.wal_pages = new_pages;
5411        self.header.wal_seq += 1;
5412        self.relocate_header_through_wal(old_wal_start)
5413    }
5414
5415    /// Decrypt and validate every live record. Returns the count verified
5416    /// across memories **and** cache entries (FIX-1 R-001.1: now that the
5417    /// cache survives compaction, `verify()` has to walk it too — the
5418    /// pre-FIX-1 implementation only checked `self.catalog`, so a
5419    /// corrupted cache record went unnoticed).
5420    pub fn verify(&mut self) -> Result<usize> {
5421        let entries: Vec<CatalogEntry> = self
5422            .catalog
5423            .iter()
5424            .filter(|e| !e.deleted)
5425            .cloned()
5426            .collect();
5427        let mut n = entries.len();
5428        for e in &entries {
5429            let bytes = self.read_record(e)?;
5430            let _m: Memory = rmp_serde::from_slice(&bytes)
5431                .map_err(|err| MnemoError::Serialize(err.to_string()))?;
5432        }
5433
5434        // Also decode every live cache entry. Tombstoned entries are
5435        // skipped — their pages are orphan ciphertext awaiting
5436        // `compact_file`, same as deleted memories. Expired entries
5437        // are *still* on disk until compaction, so we verify them too.
5438        let cache_positions: Vec<(u64, u32, u32)> = self
5439            .cache_dir
5440            .iter()
5441            .filter(|e| !e.deleted)
5442            .map(|e| (e.start_page, e.page_count, e.len))
5443            .collect();
5444        for (start_page, page_count, len) in cache_positions {
5445            let mut buf = Vec::with_capacity(len as usize);
5446            for i in 0..page_count as u64 {
5447                buf.extend_from_slice(&self.pager.read_page(start_page + i)?);
5448            }
5449            buf.truncate(len as usize);
5450            let _c: CacheEntry = rmp_serde::from_slice(&buf)
5451                .map_err(|err| MnemoError::Serialize(err.to_string()))?;
5452            n += 1;
5453        }
5454        Ok(n)
5455    }
5456
5457    /// Summary statistics for the open database.
5458    pub fn stats(&mut self) -> Result<Stats> {
5459        let deleted = self.catalog.iter().filter(|e| e.deleted).count();
5460        // FIX-1 R-001.8 part 2: build the agent set lazily on first
5461        // call per handle, then maintain in `put` / invalidate in
5462        // `delete`. Pre-FIX-1 every `stats()` call decrypted every
5463        // live memory (O(N) AES-GCM decrypts just to list a usually-
5464        // tiny set of distinct agent IDs). On a 1M-memory database
5465        // this was in the multi-second range on hardware AES.
5466        if self.agent_set.is_none() {
5467            let built: std::collections::BTreeSet<String> =
5468                self.memories()?.into_iter().map(|m| m.agent_id).collect();
5469            self.agent_set = Some(built);
5470        }
5471        let agents: Vec<String> = self
5472            .agent_set
5473            .as_ref()
5474            .expect("agent_set just filled above")
5475            .iter()
5476            .cloned()
5477            .collect();
5478        let file_bytes = self.header.next_page * PAGE_SIZE as u64;
5479        Ok(Stats {
5480            memories: self.len(),
5481            deleted,
5482            dimensions: self.dimensions,
5483            file_bytes,
5484            agents,
5485            encrypted: self.header.flags & FLAG_ENCRYPTED != 0,
5486            created_at: self.header.created_at,
5487            index: self.ann.as_ref().map(|a| a.info()),
5488            wal_pages: self.header.wal_pages,
5489            fsyncs_last_flush: self.fsyncs_last_flush,
5490        })
5491    }
5492
5493    /// Rewrite the file, dropping tombstoned and expired memories and
5494    /// reclaiming stale pages left by updates. Done out-of-place via a temp
5495    /// file and an atomic rename.
5496    ///
5497    /// FIX-1 R-001.1 + R-002.2 part 2: the result cache now survives
5498    /// compaction. Every live, non-expired directory entry is copied
5499    /// into the new file verbatim — `created_at`, `ttl_secs`,
5500    /// `cost_hint_ms`, `key_preview`, `size_bytes`, `content_type`,
5501    /// `vector` and `model` come from the record body; `accessed_at`
5502    /// and `access_count` come from the *directory* (the record body
5503    /// only holds the insert-time snapshot — the directory is the
5504    /// source of truth for the live counts, as of v5's recall trick).
5505    /// Tombstoned and TTL-expired entries are dropped. Pre-FIX-1
5506    /// compaction kept only the memory catalog and silently emptied
5507    /// the cache; the Planner's interim README caveat (c417d2e) called
5508    /// that out — this PR removes the surprise.
5509    pub fn compact_file(path: &str, passphrase: &str) -> Result<CompactReport> {
5510        let mut old = Mnemo::open(path, passphrase)?;
5511        let dims = old.dimensions;
5512        let kdf = old.kdf;
5513        let tmp = format!("{path}.compact-tmp");
5514
5515        let mut new = Mnemo::create(
5516            &tmp,
5517            passphrase,
5518            MnemoConfig {
5519                dimensions: dims,
5520                kdf,
5521                ..Default::default()
5522            },
5523        )?;
5524        // ANN-1.1: compaction is "re-tune for the current size" by
5525        // default. Pass IndexConfig::default() so partitions,
5526        // subspaces, n_probe and n_rerank are all re-derived from the
5527        // compacted vector count. Previously we froze the pre-compact
5528        // (n_probe, n_rerank), which left the new index on stale
5529        // numbers for the smaller post-compact corpus.
5530        let want_index = old.ann.is_some();
5531        let now = memory::now_secs();
5532        let all = old.memories()?;
5533        let before = all.len();
5534        let mut after = 0;
5535        for m in all {
5536            if m.is_expired(now) {
5537                continue;
5538            }
5539            new.remember(m)?;
5540            after += 1;
5541        }
5542        if want_index && after > 0 {
5543            new.build_index_with(IndexConfig::default())?;
5544        }
5545
5546        // Count + copy cache entries. We walk `old.cache_dir` by index
5547        // so each hop reads the directory's live `accessed_at` /
5548        // `access_count` directly rather than going through
5549        // `cache_get` (which would also treat read-only semantics,
5550        // tombstones, etc. — we want the raw, authoritative values).
5551        let mut cache_before = 0usize;
5552        let mut cache_after = 0usize;
5553        let positions: Vec<usize> = (0..old.cache_dir.len()).collect();
5554        for pos in positions {
5555            let keep = {
5556                let e = &old.cache_dir[pos];
5557                !e.deleted && !e.is_expired(now)
5558            };
5559            if keep {
5560                cache_before += 1;
5561                new.copy_cache_entry_from(&mut old, pos)?;
5562                cache_after += 1;
5563            }
5564        }
5565
5566        new.flush()?;
5567        drop(new);
5568        drop(old);
5569
5570        std::fs::rename(&tmp, path)?;
5571        Ok(CompactReport {
5572            before,
5573            after,
5574            cache_before,
5575            cache_after,
5576        })
5577    }
5578
5579    /// Copy one live cache entry from `old` at directory position
5580    /// `pos` into `self` (a freshly-created compaction target).
5581    /// Decrypts the old record body, substitutes the old *directory*'s
5582    /// `accessed_at` + `access_count` (the record body's copies are
5583    /// stale as of v5's directory-only hit path), re-encodes, writes
5584    /// a fresh record run, and pushes a matching directory entry.
5585    ///
5586    /// Caller is expected to have already filtered out tombstoned /
5587    /// expired entries and to call this on a read-write handle.
5588    pub(crate) fn copy_cache_entry_from(&mut self, old: &mut Mnemo, pos: usize) -> Result<()> {
5589        if self.read_only {
5590            return Err(MnemoError::ReadOnly);
5591        }
5592        // Snapshot the directory fields we need, so the borrow on
5593        // `old.cache_dir` ends before we touch `old.pager`.
5594        let (start_page, page_count, len, dir_accessed_at, dir_access_count) = {
5595            let e = &old.cache_dir[pos];
5596            (
5597                e.start_page,
5598                e.page_count,
5599                e.len,
5600                e.accessed_at,
5601                e.access_count,
5602            )
5603        };
5604
5605        // Read + decrypt the old record body.
5606        let mut buf = Vec::with_capacity(len as usize);
5607        for i in 0..page_count as u64 {
5608            buf.extend_from_slice(&old.pager.read_page(start_page + i)?);
5609        }
5610        buf.truncate(len as usize);
5611        let mut entry: CacheEntry =
5612            rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
5613
5614        // Overwrite the stale body copies with the directory's live
5615        // values before persisting. Everything else — `created_at`,
5616        // `ttl_secs`, `cost_hint_ms`, `key_preview`, `size_bytes`,
5617        // `content_type`, `value`, `vector`, `model` — stays as the
5618        // body already recorded it.
5619        entry.accessed_at = dir_accessed_at;
5620        entry.access_count = dir_access_count;
5621
5622        let bytes =
5623            rmp_serde::to_vec(&entry).map_err(|err| MnemoError::Serialize(err.to_string()))?;
5624        let (new_start_page, new_page_count) = self.write_record(&bytes)?;
5625
5626        let dir_entry = CacheDirectoryEntry {
5627            namespace: entry.namespace.clone(),
5628            key_hash: entry.key_hash,
5629            start_page: new_start_page,
5630            page_count: new_page_count,
5631            len: bytes.len() as u32,
5632            deleted: false,
5633            accessed_at: dir_accessed_at,
5634            access_count: dir_access_count,
5635            size_bytes: entry.size_bytes,
5636            ttl_secs: entry.ttl_secs,
5637            created_at: entry.created_at,
5638            vector: entry.vector.clone(),
5639            model: entry.model.clone(),
5640        };
5641        let new_pos = self.cache_dir.len();
5642        self.cache_idx
5643            .insert(&entry.namespace, entry.key_hash, new_pos);
5644        self.cache_dir.push(dir_entry);
5645        self.dirty_cache = true;
5646        Ok(())
5647    }
5648}