Skip to main content

lean_ctx/core/
memory_archive.rs

1//! Multi-store, lossless memory archive (#995 Phase 1).
2//!
3//! Every memory store — facts, history, patterns — archives the
4//! items it evicts here *before* dropping them, so capacity management is never
5//! lossy and anything reclaimed can be restored. This is the single archive
6//! subsystem behind the memory-capacity module and the recall-miss
7//! rehydrate path.
8//!
9//! ## On-disk layout
10//! - Facts live under `memory/archive/facts/<project hash>/archive-*.json`; the
11//!   partition is always the request project's. Releases before 3.11.5 kept
12//!   unprotected facts in one installation-wide `memory/archive/archive-*.json`
13//!   without a provable project: those files are left in place, unread, and
14//!   are never listed, rehydrated or restored into any project.
15//! - Every other store lives under `memory/archive/<store>/<scope>/archive-*.json`,
16//!   where `<scope>` is the per-project hash so a restore lands in the right
17//!   project.
18//!
19//! ## Format
20//! A single envelope ([`ArchiveEnvelope`]) is used for all stores. The item
21//! collection serializes as `items`; the `facts` alias keeps legacy facts
22//! archives (which used that key) deserializable.
23
24use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize, de::DeserializeOwned};
26use std::path::{Path, PathBuf};
27
28/// Retention bound on archive files, kept well above the rehydrate reach so a
29/// recall miss can still find recently-evicted items. Overridable via
30/// `LEAN_CTX_ARCHIVE_MAX_FILES`.
31const DEFAULT_MAX_ARCHIVE_FILES: usize = 16;
32
33/// Which memory store an archive belongs to. Drives the on-disk path only — the
34/// archive is generic over the item type, so this stays decoupled from the
35/// concrete fact/insight/pattern structs.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum MemoryStore {
38    Facts,
39    History,
40    Patterns,
41}
42
43/// Reply to a restore that names `store=procedures`: that archive belonged to
44/// the procedure memory retired in 3.13.0 (DEPRECATIONS.toml
45/// `mcp-ctx-session-episodes-procedures`). Nothing is restored; archived
46/// procedure files stay on disk untouched.
47pub const RETIRED_PROCEDURES_STORE_NOTICE: &str = "DEPRECATION: store=procedures is retired with the procedure memory; nothing was restored. Use: facts, history, patterns";
48
49impl MemoryStore {
50    pub fn as_str(self) -> &'static str {
51        match self {
52            MemoryStore::Facts => "facts",
53            MemoryStore::History => "history",
54            MemoryStore::Patterns => "patterns",
55        }
56    }
57
58    pub fn parse(s: &str) -> Option<Self> {
59        match s.trim().to_lowercase().as_str() {
60            "facts" | "fact" => Some(MemoryStore::Facts),
61            "history" | "insights" => Some(MemoryStore::History),
62            "patterns" | "pattern" => Some(MemoryStore::Patterns),
63            _ => None,
64        }
65    }
66
67    /// Whether `s` names the retired procedure archive
68    /// ([`RETIRED_PROCEDURES_STORE_NOTICE`]).
69    pub fn is_retired(s: &str) -> bool {
70        matches!(
71            s.trim().to_lowercase().as_str(),
72            "procedures" | "procedure" | "procs"
73        )
74    }
75
76    /// All stores, for cross-store iteration (restore, reporting).
77    pub fn all() -> [MemoryStore; 3] {
78        [
79            MemoryStore::Facts,
80            MemoryStore::History,
81            MemoryStore::Patterns,
82        ]
83    }
84
85    /// Subdirectory under `memory/archive`. Facts return `None` (legacy root).
86    fn subdir(self) -> Option<&'static str> {
87        match self {
88            MemoryStore::Facts => None,
89            other => Some(other.as_str()),
90        }
91    }
92}
93
94/// Tunable archive bounds. `rehydrate_reach` is how many of the newest archives
95/// the recall-miss path scans; it defaults to `max_files` so every retained
96/// archive is actually reachable (closing the pre-#995 16-retained / 4-reachable
97/// gap). Both are overridable via env for ops tuning.
98#[derive(Debug, Clone, Copy)]
99pub struct ArchiveConfig {
100    pub max_files: usize,
101    pub rehydrate_reach: usize,
102}
103
104impl Default for ArchiveConfig {
105    fn default() -> Self {
106        Self {
107            max_files: DEFAULT_MAX_ARCHIVE_FILES,
108            rehydrate_reach: DEFAULT_MAX_ARCHIVE_FILES,
109        }
110    }
111}
112
113impl ArchiveConfig {
114    /// Read overrides from the environment. `rehydrate_reach` defaults to
115    /// `max_files` and is clamped to it (cannot reach more files than retained).
116    pub fn from_env() -> Self {
117        let mut cfg = Self::default();
118        if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_MAX_FILES")
119            && let Ok(n) = v.parse::<usize>()
120            && n > 0
121        {
122            cfg.max_files = n;
123            cfg.rehydrate_reach = n;
124        }
125        if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH")
126            && let Ok(n) = v.parse::<usize>()
127            && n > 0
128        {
129            cfg.rehydrate_reach = n;
130        }
131        cfg.rehydrate_reach = cfg.rehydrate_reach.min(cfg.max_files);
132        cfg
133    }
134}
135
136/// Unified archive envelope for reading every store. The collection is keyed
137/// `items`; the `facts` alias keeps legacy facts archives (which used that key)
138/// deserializable.
139#[derive(Debug, Deserialize)]
140pub struct ArchiveEnvelope<T> {
141    pub archived_at: DateTime<Utc>,
142    #[serde(default)]
143    pub store: String,
144    #[serde(default)]
145    pub scope: Option<String>,
146    /// The project root the archive was written for. The scope hash also
147    /// covers the project's identity; this root proves the project when the
148    /// identity changed (`legacy_adoption`).
149    #[serde(default)]
150    pub project_root: Option<String>,
151    #[serde(rename = "items", alias = "facts")]
152    pub items: Vec<T>,
153}
154
155/// Borrowed write-side envelope, so archiving never has to clone the evicted
156/// slice. Mirrors [`ArchiveEnvelope`]'s on-disk shape exactly.
157#[derive(Serialize)]
158struct ArchiveEnvelopeRef<'a, T: Serialize> {
159    archived_at: DateTime<Utc>,
160    store: &'a str,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    scope: Option<&'a str>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    project_root: Option<&'a str>,
165    items: &'a [T],
166}
167
168/// The project archives are keyed on: inside a request only its own bound
169/// project — a request without one has none, never the process's working
170/// directory (AP04 review S3); outside any request (the CLI) the directory
171/// it runs in.
172fn archive_project() -> Option<PathBuf> {
173    match crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone()) {
174        Ok(project) => project,
175        Err(_) => std::env::current_dir().ok(),
176    }
177}
178
179/// The scope an archive of `store` is written to and read from. Facts always
180/// belong to the request project's partition; a protected project pins every
181/// store to its own partition.
182fn effective_scope(store: MemoryStore, scope: Option<&str>) -> Result<Option<String>, String> {
183    let root = archive_project().ok_or("archive project unavailable")?;
184    let root = root.to_string_lossy();
185    let expected = crate::core::project_hash::hash_project_root(&root);
186    if crate::core::knowledge::protection::current(&root)?.is_some() {
187        if scope.is_some_and(|given| given != expected) {
188            return Err("archive scope does not match protected project".into());
189        }
190        Ok(Some(expected))
191    } else if store == MemoryStore::Facts {
192        if scope.is_some_and(|given| given != expected) {
193            return Err("facts archive scope does not match the request project".into());
194        }
195        Ok(Some(expected))
196    } else {
197        Ok(scope.map(str::to_owned))
198    }
199}
200
201fn archive_dir(store: MemoryStore, scope: Option<&str>) -> Result<PathBuf, String> {
202    let base = crate::core::data_dir::lean_ctx_data_dir()?
203        .join("memory")
204        .join("archive");
205    let scope = effective_scope(store, scope)?;
206    let dir = match (store.subdir(), scope.as_deref()) {
207        (None, Some(s)) => base.join("facts").join(sanitize_scope(s)),
208        (None, None) => return Err("facts archive requires a project partition".into()),
209        (Some(sub), None) => base.join(sub), // store-global
210        (Some(sub), Some(s)) => base.join(sub).join(sanitize_scope(s)),
211    };
212    Ok(dir)
213}
214
215/// Scopes are project hashes (hex). Guard against path traversal regardless,
216/// so a malformed scope can never escape the archive root.
217fn sanitize_scope(scope: &str) -> String {
218    scope
219        .chars()
220        .map(|c| {
221            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
222                c
223            } else {
224                '_'
225            }
226        })
227        .collect()
228}
229
230/// Archive `items` for `store`/`scope`, then prune the directory to
231/// `cfg.max_files` newest. Returns the written path, or `None` when there was
232/// nothing to archive. Ordering must be inspectable before writing; removal of
233/// old files remains best-effort.
234pub fn archive_items<T: Serialize>(
235    store: MemoryStore,
236    scope: Option<&str>,
237    items: &[T],
238    cfg: &ArchiveConfig,
239) -> Result<Option<PathBuf>, String> {
240    archive_items_at(store, scope, items, cfg, Utc::now())
241}
242
243fn archive_items_at<T: Serialize>(
244    store: MemoryStore,
245    scope: Option<&str>,
246    items: &[T],
247    cfg: &ArchiveConfig,
248    now: DateTime<Utc>,
249) -> Result<Option<PathBuf>, String> {
250    if knowledge_store(store) && !items.is_empty() {
251        let root = archive_project().ok_or("archive project unavailable")?;
252        return crate::core::policy::runtime::with_explicit_project_source_view(
253            &root,
254            |authority| {
255                let admission = crate::core::knowledge::protection::KnowledgeAdmission::capture(
256                    &root.to_string_lossy(),
257                )?;
258                archive_items_inner(
259                    store,
260                    scope,
261                    items,
262                    cfg,
263                    Some((admission.gateway_snapshot(), authority)),
264                    Some((&admission, authority)),
265                    now,
266                )
267            },
268        )?;
269    }
270    archive_items_inner(store, scope, items, cfg, None, None, now)
271}
272
273fn knowledge_store(store: MemoryStore) -> bool {
274    matches!(
275        store,
276        MemoryStore::Facts | MemoryStore::History | MemoryStore::Patterns
277    )
278}
279
280fn admits_knowledge_archive_item(
281    store: MemoryStore,
282    admission: &crate::core::knowledge::protection::KnowledgeAdmission,
283    value: &serde_json::Value,
284) -> bool {
285    match store {
286        MemoryStore::Facts => admission.admits_archive_fact_value(value),
287        MemoryStore::History => admission.admits_archive_history_value(value),
288        MemoryStore::Patterns => admission.admits_archive_pattern_value(value),
289    }
290}
291
292fn admits_typed_knowledge_archive_item(
293    store: MemoryStore,
294    admission: &crate::core::knowledge::protection::KnowledgeAdmission,
295    value: &serde_json::Value,
296) -> bool {
297    match store {
298        MemoryStore::Facts => admission.admits_typed_archive_fact_value(value),
299        MemoryStore::History => admission.admits_typed_archive_history_value(value),
300        MemoryStore::Patterns => admission.admits_typed_archive_pattern_value(value),
301    }
302}
303
304fn archive_store(value: &serde_json::Value) -> Option<MemoryStore> {
305    match value.get("store") {
306        Some(serde_json::Value::String(store)) => MemoryStore::parse(store),
307        None if value.get("facts").is_some() => Some(MemoryStore::Facts),
308        Some(_) | None => None,
309    }
310}
311
312fn archive_items_field(value: &serde_json::Value, store: MemoryStore) -> Option<&'static str> {
313    if value.get("items").is_some() {
314        Some("items")
315    } else if store == MemoryStore::Facts && value.get("facts").is_some() {
316        Some("facts")
317    } else {
318        None
319    }
320}
321
322fn archive_items_inner<T: Serialize>(
323    store: MemoryStore,
324    scope: Option<&str>,
325    items: &[T],
326    cfg: &ArchiveConfig,
327    authorization: Option<(
328        &crate::core::context_admission::stores::StoreAdmission,
329        &crate::core::policy::runtime::PublicationAuthority,
330    )>,
331    knowledge_authorization: Option<(
332        &crate::core::knowledge::protection::KnowledgeAdmission,
333        &crate::core::policy::runtime::PublicationAuthority,
334    )>,
335    now: DateTime<Utc>,
336) -> Result<Option<PathBuf>, String> {
337    if items.is_empty() {
338        return Ok(None);
339    }
340    if knowledge_store(store) && knowledge_authorization.is_none() {
341        return Err("knowledge archive requires an admitted publication scope".into());
342    }
343    let scope = effective_scope(store, scope)?;
344    let scope = scope.as_deref();
345    let dir = archive_dir(store, scope)?;
346    std::fs::create_dir_all(&dir).map_err(|e| format!("{e}"))?;
347    if !ordered_archive_files(&dir).1 {
348        return Err("archive chronology unavailable".into());
349    }
350
351    // Sub-second suffix avoids same-second filename collisions that would
352    // otherwise silently overwrite a prior archive in the same wall-clock second.
353    let suffix = now.timestamp_subsec_nanos();
354    let filename = format!("archive-{}-{suffix:09}.json", now.format("%Y%m%d-%H%M%S"));
355    let path = dir.join(filename);
356
357    let project_root =
358        crate::core::policy::diagnostics::project().map(|root| root.to_string_lossy().into_owned());
359    let envelope = ArchiveEnvelopeRef {
360        archived_at: now,
361        store: store.as_str(),
362        scope,
363        project_root: project_root.as_deref(),
364        items,
365    };
366    let original = serde_json::to_value(&envelope).map_err(|_| "archive cannot be inspected")?;
367    if let Some((admission, _)) = knowledge_authorization {
368        let records = original["items"]
369            .as_array()
370            .ok_or("archive items are not inspectable")?;
371        if !records
372            .iter()
373            .all(|record| admits_knowledge_archive_item(store, admission, record))
374        {
375            return Err("knowledge archive withheld by current policy".into());
376        }
377    }
378    let typed_items = knowledge_authorization.map(|(admission, _)| (store, admission));
379    let safe = inspect_archive(&original, typed_items)?;
380    let json = serde_json::to_string_pretty(&safe).map_err(|_| "archive serialization failed")?;
381    if inspect_archive(&safe, typed_items)? != safe {
382        return Err("archive rules changed before publication".into());
383    }
384    use std::io::Write;
385    let mut file = tempfile::NamedTempFile::new_in(&dir).map_err(|_| "archive staging failed")?;
386    file.write_all(json.as_bytes())
387        .map_err(|_| "archive write failed")?;
388    if let Some((admission, authority)) = authorization {
389        admission.authorize_publication(authority)?;
390    }
391    if let Some((admission, authority)) = knowledge_authorization {
392        admission.authorize_publication(authority)?;
393    }
394    // A clock collision must never overwrite an existing recovery artifact.
395    file.persist_noclobber(&path)
396        .map_err(|_| "archive publication failed")?;
397
398    let (archives, ordered) = ordered_archive_files(&dir);
399    if !ordered {
400        return Err("archive chronology changed before retention".into());
401    }
402    if archives.len() > cfg.max_files {
403        for old in &archives[..archives.len() - cfg.max_files] {
404            let _ = std::fs::remove_file(old);
405        }
406    }
407    Ok(Some(path))
408}
409
410/// The installation-wide facts archives of releases before 3.11.5, as
411/// `(path, envelope)`. They are served to no project; only the explicit
412/// `lean-ctx migrate legacy-context` adoption reads them.
413pub fn legacy_facts_archives() -> Vec<(PathBuf, serde_json::Value)> {
414    let Ok(base) = crate::core::data_dir::lean_ctx_data_dir().map(|d| d.join("memory/archive"))
415    else {
416        return Vec::new();
417    };
418    let Ok(entries) = std::fs::read_dir(&base) else {
419        return Vec::new();
420    };
421    let mut archives: Vec<_> = entries
422        .flatten()
423        .map(|entry| entry.path())
424        .filter(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "json"))
425        .filter_map(|path| {
426            let text = crate::core::policy::diagnostics::Target::read_file(&path)?;
427            let value = serde_json::from_str::<serde_json::Value>(&text).ok()?;
428            Some((path, value))
429        })
430        .collect();
431    archives.sort_by(|a, b| a.0.cmp(&b.0));
432    archives
433}
434
435/// Facts archives left in a partition that is no longer the current one of
436/// the root they recorded: that project's identity (remote, package name)
437/// changed since. Served to no project until adopted.
438pub fn orphaned_facts_archives() -> Vec<(PathBuf, serde_json::Value)> {
439    let Ok(base) =
440        crate::core::data_dir::lean_ctx_data_dir().map(|d| d.join("memory/archive/facts"))
441    else {
442        return Vec::new();
443    };
444    let Ok(partitions) = std::fs::read_dir(&base) else {
445        return Vec::new();
446    };
447    let mut current_of: std::collections::HashMap<String, String> =
448        std::collections::HashMap::new();
449    let mut archives = Vec::new();
450    for partition in partitions.flatten() {
451        let name = partition.file_name().to_string_lossy().into_owned();
452        let Ok(files) = std::fs::read_dir(partition.path()) else {
453            continue;
454        };
455        for path in files.flatten().map(|file| file.path()) {
456            if path.extension().is_none_or(|ext| ext != "json") {
457                continue;
458            }
459            let Some(value) = crate::core::policy::diagnostics::Target::read_file(&path)
460                .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
461            else {
462                continue;
463            };
464            let Some(root) = value["project_root"].as_str() else {
465                continue;
466            };
467            let current = current_of
468                .entry(root.to_string())
469                .or_insert_with(|| crate::core::project_hash::hash_project_root(root));
470            if *current != name {
471                archives.push((path, value));
472            }
473        }
474    }
475    archives.sort_by(|a, b| a.0.cmp(&b.0));
476    archives
477}
478
479/// The orphaned facts archives that recorded `project_root` (canonically).
480pub(crate) fn drifted_facts_archives(project_root: &Path) -> Vec<(PathBuf, serde_json::Value)> {
481    let canonical = |path: &Path| crate::core::pathutil::safe_canonicalize_bounded(path, 2000);
482    let wanted = canonical(project_root);
483    orphaned_facts_archives()
484        .into_iter()
485        .filter(|(_, value)| {
486            value["project_root"]
487                .as_str()
488                .is_some_and(|root| canonical(Path::new(root)) == wanted)
489        })
490        .collect()
491}
492
493/// Copy legacy archived `facts` into the facts partition of `project_root`
494/// (an explicit, canonical root) through the admission of a normal facts archive write. Each fact
495/// is admitted on its own; the admitted ones are archived together. Returns
496/// one flag per fact: `true` adopted, `false` withheld. A fact the partition
497/// already holds is not archived again, so a rerun after an interrupted
498/// adoption (copied, not yet journaled) leaves exactly one copy.
499pub fn adopt_legacy_facts(
500    project_root: &Path,
501    facts: &[serde_json::Value],
502) -> Result<Vec<bool>, String> {
503    let root = project_root;
504    crate::core::policy::runtime::with_explicit_project_source_view(root, |authority| {
505        let admission = crate::core::knowledge::protection::KnowledgeAdmission::capture(
506            &root.to_string_lossy(),
507        )?;
508        let admitted: Vec<bool> = facts
509            .iter()
510            .map(|fact| admits_knowledge_archive_item(MemoryStore::Facts, &admission, fact))
511            .collect();
512        let archived = archived_fact_identities()?;
513        let kept: Vec<&serde_json::Value> = facts
514            .iter()
515            .zip(&admitted)
516            .filter_map(|(fact, ok)| {
517                (*ok && !archived.contains(&fact_identity(fact))).then_some(fact)
518            })
519            .collect();
520        archive_items_inner(
521            MemoryStore::Facts,
522            None,
523            &kept,
524            &ArchiveConfig::from_env(),
525            Some((admission.gateway_snapshot(), authority)),
526            Some((&admission, authority)),
527            Utc::now(),
528        )?;
529        Ok(admitted)
530    })?
531}
532
533/// What makes an archived fact the same fact: its text fields. Numbers
534/// (confidence) are left out, they need not survive a JSON round trip bit
535/// for bit.
536fn fact_identity(fact: &serde_json::Value) -> [String; 3] {
537    ["category", "key", "value"].map(|field| fact[field].as_str().unwrap_or_default().to_owned())
538}
539
540/// The identities of the facts archived in the request project's partition.
541fn archived_fact_identities() -> Result<std::collections::HashSet<[String; 3]>, String> {
542    let dir = archive_dir(MemoryStore::Facts, None)?;
543    Ok(ordered_archive_files(&dir)
544        .0
545        .iter()
546        .filter_map(|path| crate::core::policy::diagnostics::Target::read_file(path))
547        .filter_map(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
548        .flat_map(|envelope| match envelope.get("items") {
549            Some(serde_json::Value::Array(items)) => items.clone(),
550            _ => Vec::new(),
551        })
552        .map(|fact| fact_identity(&fact))
553        .collect())
554}
555
556/// All archive files for `store`/`scope`, sorted by their recorded archive time.
557/// Uninspectable files remain listed, but prevent retention from deleting files.
558pub fn list_archives(store: MemoryStore, scope: Option<&str>) -> Vec<PathBuf> {
559    let Ok(dir) = archive_dir(store, scope) else {
560        return Vec::new();
561    };
562    ordered_archive_files(&dir).0
563}
564
565fn ordered_archive_files(dir: &Path) -> (Vec<PathBuf>, bool) {
566    #[derive(Deserialize)]
567    struct ArchiveTimestamp {
568        archived_at: DateTime<Utc>,
569    }
570
571    let Ok(entries) = std::fs::read_dir(dir) else {
572        return (Vec::new(), false);
573    };
574    let mut complete = true;
575    let mut archives = Vec::new();
576    for entry in entries {
577        let Ok(entry) = entry else {
578            complete = false;
579            continue;
580        };
581        let path = entry.path();
582        if path.extension().is_none_or(|ext| ext != "json") {
583            continue;
584        }
585        let Ok(kind) = entry.file_type() else {
586            complete = false;
587            continue;
588        };
589        if !kind.is_file() {
590            continue;
591        }
592        // Legacy six-digit names wrapped every millisecond, so their lexical
593        // order cannot establish chronology. Use the bounded, no-follow reader
594        // already used by restore_items, and retain only the envelope timestamp.
595        let timestamp = crate::core::policy::diagnostics::Target::read_file(&path)
596            .and_then(|text| serde_json::from_str::<ArchiveTimestamp>(&text).ok())
597            .map(|metadata| metadata.archived_at);
598        complete &= timestamp.is_some();
599        archives.push((timestamp, path));
600    }
601    // Path order is a deterministic tie-breaker and fallback for unreadable
602    // metadata; the caller must not prune unless every timestamp is known.
603    archives.sort_unstable();
604    (
605        archives.into_iter().map(|(_, path)| path).collect(),
606        complete,
607    )
608}
609
610/// The newest `cfg.rehydrate_reach` archives for `store`/`scope` — the set a
611/// recall miss should scan. Aligned with retention so nothing retained is
612/// unreachable.
613pub fn reachable_archives(
614    store: MemoryStore,
615    scope: Option<&str>,
616    cfg: &ArchiveConfig,
617) -> Vec<PathBuf> {
618    let mut archives = list_archives(store, scope);
619    if archives.len() > cfg.rehydrate_reach {
620        archives = archives[archives.len() - cfg.rehydrate_reach..].to_vec();
621    }
622    archives
623}
624
625/// Restore the items from a single archive file.
626pub fn restore_items<T: DeserializeOwned>(path: &Path) -> Result<Vec<T>, String> {
627    restore_items_inner(path, None)
628}
629
630pub fn restore_knowledge_items<T: DeserializeOwned>(
631    path: &Path,
632    admission: &crate::core::knowledge::protection::KnowledgeAdmission,
633) -> Result<Vec<T>, String> {
634    restore_items_inner(path, Some(admission))
635}
636
637fn restore_items_inner<T: DeserializeOwned>(
638    path: &Path,
639    supplied_admission: Option<&crate::core::knowledge::protection::KnowledgeAdmission>,
640) -> Result<Vec<T>, String> {
641    let data = crate::core::policy::diagnostics::Target::read_file(path)
642        .ok_or("archive is unavailable or uninspectable")?;
643    let original: serde_json::Value = serde_json::from_str(&data).map_err(|_| "invalid archive")?;
644    let root = archive_project().ok_or("archive project unavailable")?;
645    let canonical = |p: &Path| crate::core::pathutil::safe_canonicalize_bounded(p, 2000);
646    let protected = crate::core::knowledge::protection::current(&root.to_string_lossy())?.is_some();
647    if !protected && archive_store(&original) == Some(MemoryStore::Facts) {
648        // Facts restore only from the request project's own partition; an
649        // installation-wide legacy archive has no provable project.
650        let expected = archive_dir(MemoryStore::Facts, None)?;
651        if path.parent().map(canonical) != Some(canonical(&expected)) {
652            return Err("archive belongs to a different storage scope".into());
653        }
654    } else if protected {
655        let kind = match original["store"].as_str() {
656            Some("facts") => MemoryStore::Facts,
657            Some("history") => MemoryStore::History,
658            Some("patterns") => MemoryStore::Patterns,
659            _ => return Err("archive store identity unavailable".into()),
660        };
661        let scope = original["scope"]
662            .as_str()
663            .ok_or("archive has no protected scope")?;
664        let expected = archive_dir(kind, Some(scope))?;
665        if path.parent().map(canonical) != Some(canonical(&expected)) {
666            return Err("archive belongs to a different storage scope".into());
667        }
668    }
669    let store = archive_store(&original);
670    let mut candidate = original.clone();
671    let captured_admission = if store.is_some_and(knowledge_store) && supplied_admission.is_none() {
672        Some(
673            crate::core::knowledge::protection::KnowledgeAdmission::capture(
674                &root.to_string_lossy(),
675            )?,
676        )
677    } else {
678        None
679    };
680    let mut typed_items = None;
681    if let Some(store) = store.filter(|store| knowledge_store(*store)) {
682        let admission = supplied_admission
683            .or(captured_admission.as_ref())
684            .ok_or("archive admission unavailable")?;
685        let collection =
686            archive_items_field(&candidate, store).ok_or("archive items are unavailable")?;
687        let records = candidate[collection]
688            .as_array_mut()
689            .ok_or("archive items are invalid")?;
690        records.retain(|record| admits_knowledge_archive_item(store, admission, record));
691        admission.authorize_gateway()?;
692        typed_items = Some((store, admission));
693    }
694    let safe = inspect_archive(&candidate, typed_items)?;
695    if let Some((_, admission)) = typed_items {
696        admission.authorize_gateway()?;
697    }
698    let envelope: ArchiveEnvelope<T> = serde_json::from_value(safe)
699        .map_err(|_| "archive policy result is structurally invalid")?;
700    Ok(envelope.items)
701}
702
703fn inspect_archive(
704    original: &serde_json::Value,
705    typed_items: Option<(
706        MemoryStore,
707        &crate::core::knowledge::protection::KnowledgeAdmission,
708    )>,
709) -> Result<serde_json::Value, String> {
710    let root = archive_project().ok_or("archive project unavailable")?;
711    let active = crate::core::knowledge::protection::current(&root.to_string_lossy())?;
712    let mut inspection = original.clone();
713    let mut preserved_items = Vec::new();
714    if let Some((store, admission)) = typed_items {
715        if let Some(collection) = archive_items_field(&inspection, store)
716            && let Some(items) = inspection
717                .get_mut(collection)
718                .and_then(serde_json::Value::as_array_mut)
719        {
720            for (index, item) in items.iter_mut().enumerate() {
721                if admits_typed_knowledge_archive_item(store, admission, item) {
722                    preserved_items.push((index, item.clone()));
723                    *item = serde_json::Value::Null;
724                }
725            }
726        }
727    }
728    let mut safe = crate::core::policy::diagnostics::inspect(&inspection, active.as_deref())
729        .ok_or("archive withheld by current policy")?;
730    if let Some((store, _)) = typed_items {
731        if let Some(collection) = archive_items_field(&safe, store)
732            && let Some(items) = safe
733                .get_mut(collection)
734                .and_then(serde_json::Value::as_array_mut)
735        {
736            for (index, item) in preserved_items {
737                let slot = items
738                    .get_mut(index)
739                    .ok_or("archive item projection changed shape")?;
740                *slot = item;
741            }
742        }
743    }
744    for field in ["store", "scope", "archived_at"] {
745        if safe[field] != original[field] {
746            return Err("archive identity cannot be rewritten".into());
747        }
748    }
749    let before_items = original.get("items").or_else(|| original.get("facts"));
750    let after_items = safe.get("items").or_else(|| safe.get("facts"));
751    if let (Some(before), Some(after)) = (
752        before_items.and_then(serde_json::Value::as_array),
753        after_items.and_then(serde_json::Value::as_array),
754    ) {
755        for (a, b) in before.iter().zip(after) {
756            for field in [
757                "id",
758                "key",
759                "category",
760                "source_session",
761                "supersedes",
762                "from_sessions",
763                "pattern_type",
764            ] {
765                if a.get(field) != b.get(field) {
766                    return Err("archived item identity cannot be rewritten".into());
767                }
768            }
769        }
770    }
771    Ok(safe)
772}
773
774#[cfg(test)]
775pub mod tests {
776    use super::*;
777
778    fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
779        let _lock = crate::core::data_dir::test_env_lock();
780        let dir = std::env::temp_dir().join(format!(
781            "lctx-archive-{}-{}",
782            std::process::id(),
783            Utc::now().timestamp_nanos_opt().unwrap_or(0)
784        ));
785        let _ = std::fs::create_dir_all(&dir);
786        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
787        let out = f();
788        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
789        let _ = std::fs::remove_dir_all(&dir);
790        out
791    }
792
793    #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
794    struct Item {
795        id: u32,
796        label: String,
797    }
798
799    fn items(n: u32) -> Vec<Item> {
800        (0..n)
801            .map(|i| Item {
802                id: i,
803                label: format!("item-{i}"),
804            })
805            .collect()
806    }
807
808    #[test]
809    fn round_trip_each_store() {
810        with_temp_data_dir(|| {
811            let cfg = ArchiveConfig::default();
812            for store in MemoryStore::all() {
813                let scope = if store == MemoryStore::Facts {
814                    None
815                } else {
816                    Some("projhash")
817                };
818                let path = archive_items(store, scope, &items(3), &cfg)
819                    .unwrap()
820                    .expect("wrote an archive");
821                let restored: Vec<Item> = restore_items(&path).unwrap();
822                assert_eq!(restored, items(3), "round-trip for {}", store.as_str());
823            }
824        });
825    }
826
827    /// AP04 review S3: a request bound to no project has no archive; it is
828    /// not keyed on the directory the process runs in.
829    #[test]
830    fn a_request_without_a_project_has_no_archive() {
831        with_temp_data_dir(|| {
832            let archived = crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
833                std::cell::RefCell::new(None),
834                || {
835                    archive_items(
836                        MemoryStore::Facts,
837                        None,
838                        &items(1),
839                        &ArchiveConfig::default(),
840                    )
841                },
842            );
843            assert!(archived.is_err(), "{archived:?}");
844        });
845    }
846
847    #[test]
848    fn empty_archive_is_noop() {
849        with_temp_data_dir(|| {
850            let cfg = ArchiveConfig::default();
851            let res =
852                archive_items(MemoryStore::History, Some("p"), &Vec::<Item>::new(), &cfg).unwrap();
853            assert!(res.is_none());
854            {
855                let found = &list_archives(MemoryStore::History, Some("p"));
856                assert!(found.is_empty(), "{found:?}");
857            }
858        });
859    }
860
861    fn in_project<T>(root: &Path, f: impl FnOnce() -> T) -> T {
862        crate::core::policy::runtime::REQUEST_PROJECT
863            .sync_scope(std::cell::RefCell::new(Some(root.to_path_buf())), f)
864    }
865
866    /// AP05-G1: an unprotected project's evicted facts went to one
867    /// installation-wide archive, so a recall miss or `restore` in another
868    /// project rehydrated them there as local facts.
869    #[test]
870    fn facts_archives_are_partitioned_by_request_project() {
871        with_temp_data_dir(|| {
872            let cfg = ArchiveConfig::default();
873            let a = tempfile::tempdir().expect("project a");
874            let b = tempfile::tempdir().expect("project b");
875            let archived = in_project(a.path(), || {
876                archive_items(MemoryStore::Facts, None, &items(2), &cfg)
877                    .unwrap()
878                    .expect("wrote an archive")
879            });
880            let base = crate::core::data_dir::lean_ctx_data_dir()
881                .unwrap()
882                .join("memory")
883                .join("archive");
884            let legacy = base.join("archive-20260101-000000-000000.json");
885            std::fs::write(
886                &legacy,
887                r#"{"archived_at":"2026-01-01T00:00:00Z","facts":[{"id":7,"label":"old"}]}"#,
888            )
889            .unwrap();
890            in_project(b.path(), || {
891                assert!(list_archives(MemoryStore::Facts, None).is_empty());
892                assert!(restore_items::<Item>(&archived).is_err());
893                assert!(restore_items::<Item>(&legacy).is_err());
894            });
895            in_project(a.path(), || {
896                assert_eq!(
897                    list_archives(MemoryStore::Facts, None),
898                    vec![archived.clone()]
899                );
900                assert_eq!(restore_items::<Item>(&archived).unwrap(), items(2));
901                assert!(restore_items::<Item>(&legacy).is_err());
902            });
903        });
904    }
905
906    #[test]
907    fn legacy_facts_field_alias_still_deserializes() {
908        with_temp_data_dir(|| {
909            let dir = archive_dir(MemoryStore::Facts, None).unwrap();
910            std::fs::create_dir_all(&dir).unwrap();
911            // A pre-#995 facts archive used the `facts` key, no store/scope.
912            let legacy =
913                r#"{"archived_at":"2026-01-01T00:00:00Z","facts":[{"id":7,"label":"old"}]}"#;
914            let path = dir.join("archive-20260101-000000-000000.json");
915            std::fs::write(&path, legacy).unwrap();
916            let restored: Vec<Item> = restore_items(&path).unwrap();
917            assert_eq!(
918                restored,
919                vec![Item {
920                    id: 7,
921                    label: "old".into()
922                }]
923            );
924        });
925    }
926
927    #[test]
928    fn prune_keeps_newest_max_files() {
929        with_temp_data_dir(|| {
930            let cfg = ArchiveConfig {
931                max_files: 3,
932                rehydrate_reach: 3,
933            };
934            // These distinct instants collide under the old nanos % 1_000_000
935            // suffix and also cross its lexical-order wraparound.
936            for (id, nanos) in [
937                1_100_000, 1_900_000, 2_100_000, 2_900_000, 3_100_000, 3_900_000,
938            ]
939            .into_iter()
940            .enumerate()
941            {
942                let batch = [Item {
943                    id: u32::try_from(id).unwrap(),
944                    label: format!("batch-{id}"),
945                }];
946                archive_items_at(
947                    MemoryStore::Patterns,
948                    Some("p"),
949                    &batch,
950                    &cfg,
951                    DateTime::from_timestamp(1_700_000_000, nanos).unwrap(),
952                )
953                .unwrap();
954            }
955            let archives = list_archives(MemoryStore::Patterns, Some("p"));
956            let retained: Vec<u32> = archives
957                .iter()
958                .map(|path| restore_items::<Item>(path).unwrap()[0].id)
959                .collect();
960            assert_eq!(retained, [3, 4, 5]);
961        });
962    }
963
964    #[test]
965    fn retention_orders_legacy_names_by_the_recorded_timestamp() {
966        with_temp_data_dir(|| {
967            let store = MemoryStore::Patterns;
968            let dir = archive_dir(store, Some("p")).unwrap();
969            std::fs::create_dir_all(&dir).unwrap();
970            let old_time = DateTime::from_timestamp(1_700_000_000, 1_900_000).unwrap();
971            let legacy = dir.join(format!(
972                "archive-{}-900000.json",
973                old_time.format("%Y%m%d-%H%M%S")
974            ));
975            std::fs::write(
976                &legacy,
977                serde_json::to_vec(&ArchiveEnvelopeRef {
978                    archived_at: old_time,
979                    store: store.as_str(),
980                    scope: Some("p"),
981                    project_root: None,
982                    items: &items(1),
983                })
984                .unwrap(),
985            )
986            .unwrap();
987            let cfg = ArchiveConfig {
988                max_files: 1,
989                rehydrate_reach: 1,
990            };
991            let newest = archive_items_at(
992                store,
993                Some("p"),
994                &items(2),
995                &cfg,
996                DateTime::from_timestamp(1_700_000_000, 2_100_000).unwrap(),
997            )
998            .unwrap()
999            .unwrap();
1000            assert!(!legacy.exists());
1001            assert_eq!(
1002                list_archives(store, Some("p")).as_slice(),
1003                std::slice::from_ref(&newest)
1004            );
1005            assert_eq!(restore_items::<Item>(&newest).unwrap(), items(2));
1006        });
1007    }
1008
1009    #[test]
1010    fn unreadable_archive_chronology_blocks_writes_without_pruning() {
1011        with_temp_data_dir(|| {
1012            let store = MemoryStore::Patterns;
1013            let dir = archive_dir(store, Some("p")).unwrap();
1014            std::fs::create_dir_all(&dir).unwrap();
1015            let legacy = dir.join("archive-20231114-221320-900000.json");
1016            std::fs::write(&legacy, "not json").unwrap();
1017            let cfg = ArchiveConfig {
1018                max_files: 1,
1019                rehydrate_reach: 1,
1020            };
1021            let error = archive_items(store, Some("p"), &items(1), &cfg).unwrap_err();
1022            assert_eq!(error, "archive chronology unavailable");
1023            assert_eq!(
1024                list_archives(store, Some("p")).as_slice(),
1025                std::slice::from_ref(&legacy)
1026            );
1027            assert_eq!(std::fs::read_to_string(legacy).unwrap(), "not json");
1028        });
1029    }
1030
1031    #[test]
1032    fn reachable_is_bounded_and_aligns_with_retention_by_default() {
1033        let cfg = ArchiveConfig::default();
1034        assert_eq!(cfg.rehydrate_reach, cfg.max_files);
1035    }
1036
1037    #[test]
1038    fn from_env_reach_clamped_to_max() {
1039        let _lock = crate::core::data_dir::test_env_lock();
1040        crate::test_env::set_var("LEAN_CTX_ARCHIVE_MAX_FILES", "5");
1041        crate::test_env::set_var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH", "99");
1042        let cfg = ArchiveConfig::from_env();
1043        assert_eq!(cfg.max_files, 5);
1044        assert_eq!(cfg.rehydrate_reach, 5, "reach clamped to max_files");
1045        crate::test_env::remove_var("LEAN_CTX_ARCHIVE_MAX_FILES");
1046        crate::test_env::remove_var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH");
1047    }
1048
1049    #[test]
1050    fn store_parse_round_trips() {
1051        for store in MemoryStore::all() {
1052            assert_eq!(MemoryStore::parse(store.as_str()), Some(store));
1053        }
1054        assert_eq!(MemoryStore::parse("nope"), None);
1055        // The retired procedure archive is no store any more, only a notice.
1056        assert_eq!(MemoryStore::parse("procedures"), None);
1057        assert!(MemoryStore::is_retired(" Procedures "));
1058        assert!(!MemoryStore::is_retired("patterns"));
1059    }
1060
1061    #[test]
1062    fn unknown_explicit_store_does_not_fall_back_to_legacy_facts() {
1063        let invalid = serde_json::json!({"store":"unknown","facts":[]});
1064        assert_eq!(archive_store(&invalid), None);
1065
1066        let legacy = serde_json::json!({"facts":[]});
1067        assert_eq!(archive_store(&legacy), Some(MemoryStore::Facts));
1068    }
1069}