Skip to main content

agent_core/core/
retention.rs

1//! Task 34 — unified retention (spec §9.7): one sweep across sessions
2//! (including compaction parents), memory records, derived memory indexes,
3//! traces, and logs, with max-age and disk budgets and headless
4//! inspect/export/forget operations.
5//!
6//! Invariants:
7//!
8//! - CHAIN INTEGRITY: a session that is the head of a named chain is never
9//!   deleted by any sweep or forget — retention cannot leave a named chain
10//!   pointing at a deleted session.
11//! - Sessions age by their EMBEDDED `updated_at` (mtime fallback); memory
12//!   records age by their embedded timestamps plus per-record retention;
13//!   traces/logs age by file mtime.
14//! - Memory files are compacted by atomic rewrite (expired + tombstoned
15//!   records physically removed) rather than whole-file deletion; their
16//!   derived index directories are dropped alongside (rebuildable state).
17//! - The disk budget deletes oldest-first across whole-file artifacts
18//!   (sessions/traces/logs/indexes), skipping protected chain heads.
19
20use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23
24use serde::Serialize;
25
26/// Typed retention failure (CP-13 fix1 I4/M2).
27#[derive(Debug)]
28pub enum RetentionError {
29    Io(io::Error),
30    /// A named-chain file could not be read or parsed: its head is UNKNOWN,
31    /// so every destructive session operation fails closed.
32    UnreadableChain(PathBuf),
33    /// The disk budget could not be met after evicting every unprotected
34    /// artifact (typed instead of a silent overshoot).
35    BudgetUnmet {
36        budget: u64,
37        remaining: u64,
38        protected_chain_heads: usize,
39    },
40}
41
42impl std::fmt::Display for RetentionError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            RetentionError::Io(e) => write!(f, "retention io error: {e}"),
46            RetentionError::UnreadableChain(path) => write!(
47                f,
48                "unreadable named-chain file {} — refusing every destructive session \
49                 operation while chain protection is unknown",
50                path.display()
51            ),
52            RetentionError::BudgetUnmet {
53                budget,
54                remaining,
55                protected_chain_heads,
56            } => write!(
57                f,
58                "disk budget unmet: {remaining} bytes remain against a budget of \
59                 {budget} after evicting every unprotected artifact \
60                 ({protected_chain_heads} protected chain head(s) retained)"
61            ),
62        }
63    }
64}
65
66impl std::error::Error for RetentionError {}
67
68impl From<io::Error> for RetentionError {
69    fn from(e: io::Error) -> Self {
70        RetentionError::Io(e)
71    }
72}
73
74/// Filesystem roots one retention pass operates over. Production callers
75/// use [`RetentionRoots::resolve`]; tests inject temp roots.
76#[derive(Debug, Clone)]
77pub struct RetentionRoots {
78    /// Active config dir: sessions/, chains/, synaps.log* live here.
79    pub config_dir: PathBuf,
80    /// Base dir: memory/ lives here.
81    pub base_dir: PathBuf,
82    /// Cache dir: trace/telemetry JSONL files live here.
83    pub cache_dir: PathBuf,
84}
85
86impl RetentionRoots {
87    /// Resolve the production roots.
88    pub fn resolve() -> Self {
89        let cache_dir = std::env::var("HOME")
90            .map(|home| PathBuf::from(home).join(".cache/synaps"))
91            .unwrap_or_else(|_| crate::config::base_dir().join("cache"));
92        Self {
93            config_dir: crate::config::get_active_config_dir(),
94            base_dir: crate::config::base_dir(),
95            cache_dir,
96        }
97    }
98
99    fn sessions_dir(&self) -> PathBuf {
100        self.config_dir.join("sessions")
101    }
102    fn chains_dir(&self) -> PathBuf {
103        self.config_dir.join("chains")
104    }
105    fn memory_dir(&self) -> PathBuf {
106        self.base_dir.join("memory")
107    }
108    fn index_dir(&self) -> PathBuf {
109        self.memory_dir().join("index")
110    }
111}
112
113/// Artifact domains under unified retention.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
115#[serde(rename_all = "snake_case")]
116pub enum RetentionDomain {
117    Sessions,
118    Memory,
119    MemoryIndex,
120    Traces,
121    Logs,
122}
123
124/// Unified policy: maximum artifact age and total disk budget.
125#[derive(Debug, Clone, Copy, Default)]
126pub struct RetentionPolicy {
127    pub max_age_days: Option<u32>,
128    pub max_disk_bytes: Option<u64>,
129}
130
131const DAY_MS: u64 = 86_400_000;
132
133#[derive(Debug, Clone, Serialize)]
134pub struct DomainReport {
135    pub domain: RetentionDomain,
136    pub files: usize,
137    pub bytes: u64,
138}
139
140#[derive(Debug, Clone, Serialize)]
141pub struct RetentionReport {
142    pub domains: Vec<DomainReport>,
143    pub total_bytes: u64,
144}
145
146#[derive(Debug, Clone, Copy, Default, Serialize)]
147pub struct SweepOutcome {
148    pub deleted_files: usize,
149    pub freed_bytes: u64,
150    pub protected_chain_heads: usize,
151    pub memory_records_dropped: usize,
152}
153
154#[derive(Debug, Clone, Copy, Serialize)]
155pub struct ExportSummary {
156    pub files: usize,
157    pub bytes: u64,
158}
159
160fn list_files(dir: &Path) -> Vec<PathBuf> {
161    let mut out = Vec::new();
162    if let Ok(entries) = fs::read_dir(dir) {
163        for entry in entries.flatten() {
164            if entry.path().is_file() {
165                out.push(entry.path());
166            }
167        }
168    }
169    out.sort();
170    out
171}
172
173fn log_files(config_dir: &Path) -> Vec<PathBuf> {
174    let mut out: Vec<PathBuf> = list_files(config_dir)
175        .into_iter()
176        .filter(|p| {
177            p.file_name()
178                .and_then(|n| n.to_str())
179                .is_some_and(|n| n.starts_with("synaps.log"))
180        })
181        .collect();
182    out.sort();
183    out
184}
185
186fn dir_bytes(dir: &Path) -> u64 {
187    let mut total = 0;
188    if let Ok(entries) = fs::read_dir(dir) {
189        for entry in entries.flatten() {
190            let path = entry.path();
191            if path.is_dir() {
192                total += dir_bytes(&path);
193            } else if let Ok(meta) = entry.metadata() {
194                total += meta.len();
195            }
196        }
197    }
198    total
199}
200
201/// Session ids protected by named chains (heads must never dangle). Any
202/// unreadable or unparseable chain file fails CLOSED (CP-13 fix1 M2): with
203/// an unknown head, no destructive session operation may proceed.
204fn chain_heads(roots: &RetentionRoots) -> Result<Vec<String>, RetentionError> {
205    let mut heads = Vec::new();
206    for path in list_files(&roots.chains_dir()) {
207        let raw =
208            fs::read_to_string(&path).map_err(|_| RetentionError::UnreadableChain(path.clone()))?;
209        let value: serde_json::Value = serde_json::from_str(&raw)
210            .map_err(|_| RetentionError::UnreadableChain(path.clone()))?;
211        match value["head"].as_str() {
212            Some(head) => heads.push(head.to_string()),
213            None => return Err(RetentionError::UnreadableChain(path.clone())),
214        }
215    }
216    Ok(heads)
217}
218
219/// A session's age reference: embedded `updated_at`, mtime fallback.
220fn session_age_ms(path: &Path) -> Option<u64> {
221    if let Ok(raw) = fs::read_to_string(path) {
222        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) {
223            if let Some(updated) = value["updated_at"].as_str() {
224                if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(updated) {
225                    return Some(ts.timestamp_millis().max(0) as u64);
226                }
227            }
228        }
229    }
230    mtime_ms(path)
231}
232
233fn mtime_ms(path: &Path) -> Option<u64> {
234    fs::metadata(path)
235        .and_then(|m| m.modified())
236        .ok()
237        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
238        .map(|d| d.as_millis() as u64)
239}
240
241/// Headless inspect: per-domain file counts and byte totals.
242pub fn inspect(roots: &RetentionRoots) -> io::Result<RetentionReport> {
243    let sessions = list_files(&roots.sessions_dir());
244    let memory: Vec<PathBuf> = list_files(&roots.memory_dir())
245        .into_iter()
246        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl"))
247        .collect();
248    let traces = cache_files(&roots.cache_dir);
249    let logs = log_files(&roots.config_dir);
250    let index_files = walk_regular_files(&roots.index_dir());
251
252    let sum = |files: &[PathBuf]| -> u64 {
253        files
254            .iter()
255            .filter_map(|p| fs::metadata(p).ok().map(|m| m.len()))
256            .sum()
257    };
258    let index_bytes = dir_bytes(&roots.index_dir());
259
260    let domains = vec![
261        DomainReport {
262            domain: RetentionDomain::Sessions,
263            files: sessions.len(),
264            bytes: sum(&sessions),
265        },
266        DomainReport {
267            domain: RetentionDomain::Memory,
268            files: memory.len(),
269            bytes: sum(&memory),
270        },
271        DomainReport {
272            domain: RetentionDomain::MemoryIndex,
273            files: index_files.len(),
274            bytes: index_bytes,
275        },
276        DomainReport {
277            domain: RetentionDomain::Traces,
278            files: traces.len(),
279            bytes: sum(&traces),
280        },
281        DomainReport {
282            domain: RetentionDomain::Logs,
283            files: logs.len(),
284            bytes: sum(&logs),
285        },
286    ];
287    let total_bytes = domains.iter().map(|d| d.bytes).sum();
288    Ok(RetentionReport {
289        domains,
290        total_bytes,
291    })
292}
293
294/// Unified sweep at an explicit clock (tests inject `now_ms`; production
295/// uses [`sweep`]). Applies max-age per domain, compacts memory files, then
296/// enforces the disk budget oldest-first — never touching chain heads.
297pub fn sweep_at(
298    roots: &RetentionRoots,
299    policy: &RetentionPolicy,
300    now_ms: u64,
301) -> Result<SweepOutcome, RetentionError> {
302    let mut outcome = SweepOutcome::default();
303    // Chain protection resolves FIRST and fails closed — nothing is
304    // deleted while any chain head is unknown.
305    let protected: Vec<String> = chain_heads(roots)?;
306    let age_cutoff = policy
307        .max_age_days
308        .map(|days| now_ms.saturating_sub(days as u64 * DAY_MS));
309
310    // ── Orphan journals (Task 35): a `<id>.journal` whose snapshot is gone
311    // is unusable regardless of age — swept on every pass, unless a chain
312    // head claims the id (fail-safe keep). ──
313    for path in list_files(&roots.sessions_dir()) {
314        if path.extension().and_then(|e| e.to_str()) != Some("journal")
315            || path.with_extension("json").exists()
316        {
317            continue;
318        }
319        let id = path
320            .file_stem()
321            .and_then(|s| s.to_str())
322            .unwrap_or_default()
323            .to_string();
324        if protected.contains(&id) {
325            outcome.protected_chain_heads += 1;
326            continue;
327        }
328        delete_file(&path, &mut outcome)?;
329    }
330
331    // ── Age pass: sessions (embedded timestamps; compaction parents are
332    // ordinary sessions here) ──
333    if let Some(cutoff) = age_cutoff {
334        for path in list_files(&roots.sessions_dir()) {
335            // Paired journals age WITH their snapshot (Task 35) — never as
336            // independent candidates.
337            if is_paired_journal(&path) {
338                continue;
339            }
340            let id = path
341                .file_stem()
342                .and_then(|s| s.to_str())
343                .unwrap_or_default()
344                .to_string();
345            if protected.contains(&id) {
346                outcome.protected_chain_heads += 1;
347                continue;
348            }
349            let Some(age_ref) = session_age_ms(&path) else {
350                continue; // unknown age: fail safe, keep
351            };
352            if age_ref < cutoff {
353                delete_session_artifact(&path, &mut outcome)?;
354            }
355        }
356
357        // ── Traces + logs: mtime-aged whole files (traces recursive,
358        // symlink-confined) ──
359        for path in cache_files(&roots.cache_dir)
360            .into_iter()
361            .chain(log_files(&roots.config_dir))
362        {
363            if mtime_ms(&path).is_some_and(|m| m < cutoff) {
364                delete_file(&path, &mut outcome)?;
365            }
366        }
367    }
368
369    // ── Memory: atomic rewrite dropping tombstoned records, records past
370    // their OWN retention class, and (when a global age policy is set)
371    // records past the global cutoff. Runs on every sweep so per-record
372    // retention holds even without a global max age. ──
373    for path in list_files(&roots.memory_dir()) {
374        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
375            continue;
376        }
377        let dropped = compact_memory_file(&path, now_ms, age_cutoff)?;
378        if dropped > 0 {
379            outcome.memory_records_dropped += dropped;
380            // The derived index for a rewritten namespace is stale by
381            // construction — drop it; it rebuilds from the store.
382            if let Some(ns) = path.file_stem().and_then(|s| s.to_str()) {
383                let index_dir = roots.index_dir().join(ns);
384                if index_dir.exists() {
385                    outcome.freed_bytes += dir_bytes(&index_dir);
386                    fs::remove_dir_all(&index_dir)?;
387                }
388            }
389        }
390    }
391
392    // ── Disk budget (CP-13 fix1 I4): staged eviction until the final
393    // total actually fits, or a typed unmet error. Stage order minimizes
394    // data loss: (1) derived index dirs (rebuildable — free win), (2)
395    // whole-file artifacts oldest-first (never protected chain heads),
396    // (3) memory records oldest-first via atomic rewrite. ──
397    if let Some(budget) = policy.max_disk_bytes {
398        let total_now = |roots: &RetentionRoots| -> u64 {
399            inspect(roots).map(|r| r.total_bytes).unwrap_or(u64::MAX)
400        };
401
402        // Stage 1: derived index directories.
403        if total_now(roots) > budget {
404            if let Ok(entries) = fs::read_dir(roots.index_dir()) {
405                for entry in entries.flatten() {
406                    if total_now(roots) <= budget {
407                        break;
408                    }
409                    let dir = entry.path();
410                    if dir.is_dir() {
411                        outcome.freed_bytes += dir_bytes(&dir);
412                        fs::remove_dir_all(&dir).map_err(RetentionError::Io)?;
413                        outcome.deleted_files += 1;
414                    }
415                }
416            }
417        }
418
419        // Stage 2: whole-file artifacts, oldest-first, chain heads immune.
420        if total_now(roots) > budget {
421            let mut candidates: Vec<(u64, PathBuf, bool)> = Vec::new();
422            for path in list_files(&roots.sessions_dir()) {
423                // Paired journals evict WITH their snapshot (Task 35).
424                if is_paired_journal(&path) {
425                    continue;
426                }
427                let id = path
428                    .file_stem()
429                    .and_then(|s| s.to_str())
430                    .unwrap_or_default()
431                    .to_string();
432                let age = session_age_ms(&path).unwrap_or(u64::MAX);
433                candidates.push((age, path, protected.contains(&id)));
434            }
435            for path in cache_files(&roots.cache_dir)
436                .into_iter()
437                .chain(log_files(&roots.config_dir))
438            {
439                let age = mtime_ms(&path).unwrap_or(u64::MAX);
440                candidates.push((age, path, false));
441            }
442            candidates.sort_by_key(|item| item.0);
443            for (_, path, is_protected) in candidates {
444                if total_now(roots) <= budget {
445                    break;
446                }
447                if is_protected {
448                    outcome.protected_chain_heads += 1;
449                    continue;
450                }
451                delete_file(&path, &mut outcome).map_err(RetentionError::Io)?;
452            }
453        }
454
455        // Stage 3: memory records, globally oldest-first.
456        if total_now(roots) > budget {
457            let over = total_now(roots) - budget;
458            let dropped = evict_oldest_memory_records(roots, over)?;
459            outcome.memory_records_dropped += dropped;
460        }
461
462        // Final enforcement: fit or typed error.
463        let remaining = total_now(roots);
464        if remaining > budget {
465            return Err(RetentionError::BudgetUnmet {
466                budget,
467                remaining,
468                protected_chain_heads: protected.len(),
469            });
470        }
471    }
472
473    Ok(outcome)
474}
475
476/// Evict the globally oldest live memory records until at least
477/// `bytes_to_free` of line bytes are removed (atomic per-file rewrites).
478/// Returns the number of records dropped.
479fn evict_oldest_memory_records(
480    roots: &RetentionRoots,
481    bytes_to_free: u64,
482) -> Result<usize, RetentionError> {
483    // Collect (ts, file, id, line_len) for every live record.
484    let mut live: Vec<(u64, PathBuf, String, usize)> = Vec::new();
485    for path in list_files(&roots.memory_dir()) {
486        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
487            continue;
488        }
489        let raw = fs::read_to_string(&path).map_err(RetentionError::Io)?;
490        let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
491        let mut records: Vec<(u64, String, usize)> = Vec::new();
492        for line in raw.lines() {
493            let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
494                continue;
495            };
496            if let Some(id) = value["tombstone"].as_str() {
497                tombstoned.insert(id.to_string());
498                continue;
499            }
500            if let Some(id) = value["id"].as_str() {
501                records.push((
502                    value["timestamp_ms"].as_u64().unwrap_or(0),
503                    id.to_string(),
504                    line.len() + 1,
505                ));
506            }
507        }
508        for (ts, id, len) in records {
509            if !tombstoned.contains(&id) {
510                live.push((ts, path.clone(), id, len));
511            }
512        }
513    }
514    live.sort_by_key(|item| item.0);
515
516    let mut freed: u64 = 0;
517    let mut victims: std::collections::HashMap<PathBuf, std::collections::HashSet<String>> =
518        std::collections::HashMap::new();
519    let mut dropped = 0usize;
520    for (_, path, id, len) in live {
521        if freed >= bytes_to_free {
522            break;
523        }
524        victims.entry(path).or_default().insert(id);
525        freed += len as u64;
526        dropped += 1;
527    }
528
529    for (path, ids) in victims {
530        let raw = fs::read_to_string(&path).map_err(RetentionError::Io)?;
531        let kept: Vec<&str> = raw
532            .lines()
533            .filter(|line| {
534                let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
535                    return false; // garbage compacts away
536                };
537                if let Some(id) = value["id"].as_str() {
538                    return !ids.contains(id);
539                }
540                // Tombstone lines for evicted ids are no longer needed;
541                // keep other tombstones.
542                if let Some(id) = value["tombstone"].as_str() {
543                    return !ids.contains(id);
544                }
545                true
546            })
547            .collect();
548        let mut body = kept.join("\n");
549        if !body.is_empty() {
550            body.push('\n');
551        }
552        crate::core::private_fs::write_atomic_private(&path, body.as_bytes())
553            .map_err(io::Error::from)
554            .map_err(RetentionError::Io)?;
555    }
556    Ok(dropped)
557}
558
559/// Recursively list REGULAR files under a confined root. Symlinks (files
560/// or directories) are never followed — `DirEntry::file_type` does not
561/// follow, so symlinked escapes are simply skipped. Bounded depth.
562fn walk_regular_files(root: &Path) -> Vec<PathBuf> {
563    fn recurse(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
564        if depth > 32 {
565            return;
566        }
567        if let Ok(entries) = fs::read_dir(dir) {
568            for entry in entries.flatten() {
569                let Ok(file_type) = entry.file_type() else {
570                    continue;
571                };
572                if file_type.is_symlink() {
573                    continue; // confined: never follow
574                }
575                let path = entry.path();
576                if file_type.is_dir() {
577                    recurse(&path, depth + 1, out);
578                } else if file_type.is_file() {
579                    out.push(path);
580                }
581            }
582        }
583    }
584    let mut out = Vec::new();
585    recurse(root, 0, &mut out);
586    out.sort();
587    out
588}
589
590/// Cache files under the confined cache root (recursive, symlink-free).
591fn cache_files(cache_dir: &Path) -> Vec<PathBuf> {
592    walk_regular_files(cache_dir)
593}
594
595/// Production sweep at the current clock.
596pub fn sweep(
597    roots: &RetentionRoots,
598    policy: &RetentionPolicy,
599) -> Result<SweepOutcome, RetentionError> {
600    sweep_at(roots, policy, crate::epoch_millis())
601}
602
603fn delete_file(path: &Path, outcome: &mut SweepOutcome) -> io::Result<()> {
604    let len = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
605    fs::remove_file(path)?;
606    outcome.deleted_files += 1;
607    outcome.freed_bytes += len;
608    Ok(())
609}
610
611/// True when `path` is a Task 35 session journal whose snapshot sibling
612/// still exists — such a journal is handled WITH its `.json` artifact,
613/// never as an independent retention candidate.
614fn is_paired_journal(path: &Path) -> bool {
615    path.extension().and_then(|e| e.to_str()) == Some("journal")
616        && path.with_extension("json").exists()
617}
618
619/// Delete a session artifact: the file plus, for `.json` snapshots, any
620/// sibling journal (Task 35 — a session and its journal die together, so a
621/// sweep can never orphan journal state).
622fn delete_session_artifact(path: &Path, outcome: &mut SweepOutcome) -> io::Result<()> {
623    delete_file(path, outcome)?;
624    if path.extension().and_then(|e| e.to_str()) == Some("json") {
625        let journal = path.with_extension("journal");
626        if journal.exists() {
627            delete_file(&journal, outcome)?;
628        }
629    }
630    Ok(())
631}
632
633/// Rewrite one memory JSONL file, dropping tombstoned records, records past
634/// their own retention class (`ts + max_age_days < now`), and — when a
635/// global cutoff is set — records older than it. Atomic rewrite; returns
636/// the number of records physically removed.
637fn compact_memory_file(
638    path: &Path,
639    now_ms: u64,
640    global_cutoff_ms: Option<u64>,
641) -> io::Result<usize> {
642    let raw = fs::read_to_string(path)?;
643    let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
644    let mut records: Vec<(Option<String>, u64, Option<u64>, String)> = Vec::new();
645    let mut dropped = 0usize;
646
647    for line in raw.lines() {
648        let trimmed = line.trim();
649        if trimmed.is_empty() {
650            continue;
651        }
652        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
653            dropped += 1; // torn/garbage line — compacted away
654            continue;
655        };
656        if let Some(id) = value["tombstone"].as_str() {
657            tombstoned.insert(id.to_string());
658            continue;
659        }
660        let ts = value["timestamp_ms"].as_u64().unwrap_or(0);
661        let per_record_span_ms = value["retention"]["max_age_days"]
662            .as_u64()
663            .map(|days| days * DAY_MS);
664        records.push((
665            value["id"].as_str().map(String::from),
666            ts,
667            per_record_span_ms,
668            trimmed.to_string(),
669        ));
670    }
671
672    let mut kept: Vec<String> = Vec::new();
673    for (id, ts, per_record_span_ms, line) in records {
674        if id.as_ref().is_some_and(|id| tombstoned.contains(id)) {
675            dropped += 1;
676            continue;
677        }
678        if per_record_span_ms.is_some_and(|span| ts.saturating_add(span) < now_ms) {
679            dropped += 1;
680            continue;
681        }
682        if global_cutoff_ms.is_some_and(|cutoff| ts < cutoff) {
683            dropped += 1;
684            continue;
685        }
686        kept.push(line);
687    }
688
689    // Tombstones fully applied: kept lines only.
690    if dropped > 0 {
691        let mut body = kept.join("\n");
692        if !body.is_empty() {
693            body.push('\n');
694        }
695        crate::core::private_fs::write_atomic_private(path, body.as_bytes())
696            .map_err(io::Error::from)?;
697    }
698    Ok(dropped)
699}
700
701/// Headless export: copy sessions, memory, traces (recursive), and logs
702/// to `dest` with no symlink surface, no check/use races, and private
703/// modes throughout (CP-13 fix2): every destination directory and file is
704/// created RELATIVE to held directory handles beneath the trusted export
705/// root — a planted or concurrently swapped ancestor symlink fails the
706/// export closed instead of being written through. Unix-only; other
707/// platforms fail closed.
708#[cfg(unix)]
709pub fn export(roots: &RetentionRoots, dest: &Path) -> io::Result<ExportSummary> {
710    use crate::private_fs::ConfinedDir;
711    let mut summary = ExportSummary { files: 0, bytes: 0 };
712    let root = ConfinedDir::create_root(dest)?;
713
714    // Flat domains: discovery AND authoritative opens are relative to the
715    // same held trusted source-root handle. There is no full-path source
716    // reopen after discovery. A planted/swap-raced source entry is refused
717    // by open_file; a destination collision remains a hard error.
718    let flat = [
719        (roots.sessions_dir(), "sessions"),
720        (roots.memory_dir(), "memory"),
721    ];
722    for (src, sub) in flat {
723        let Ok(src_root) = ConfinedDir::open_root(&src) else {
724            continue; // absent domain — nothing to export
725        };
726        let out = root.child_dir(sub)?;
727        for entry in src_root.entries()? {
728            if !entry.is_file {
729                continue; // source symlink/non-regular entries are omitted
730            }
731            let components = [entry.name.clone()];
732            match src_root.open_file(&components) {
733                Ok(source) => {
734                    let bytes = copy_opened_into_confined(source, &out, &entry.name)?;
735                    summary.bytes += bytes;
736                    summary.files += 1;
737                }
738                // Entry changed after enumeration (including a swap to a
739                // symlink): refuse/skip it, never follow it.
740                Err(_) => continue,
741            }
742        }
743    }
744
745    // Traces: recursively walk entirely through source directory handles.
746    if let Ok(cache_root) = ConfinedDir::open_root(&roots.cache_dir) {
747        let traces_root = root.child_dir("traces")?;
748        export_tree_from_handles(&cache_root, &traces_root, &mut summary, 0)?;
749    }
750
751    // Logs: enumerate the config root through its handle, filter the
752    // handle-returned names, and open relative to the same handle.
753    if let Ok(logs_root) = ConfinedDir::open_root(&roots.config_dir) {
754        let logs_out = root.child_dir("logs")?;
755        for entry in logs_root.entries()? {
756            if !entry.is_file || !entry.name.starts_with("synaps.log") {
757                continue;
758            }
759            match logs_root.open_file(std::slice::from_ref(&entry.name)) {
760                Ok(source) => {
761                    let bytes = copy_opened_into_confined(source, &logs_out, &entry.name)?;
762                    summary.bytes += bytes;
763                    summary.files += 1;
764                }
765                Err(_) => continue,
766            }
767        }
768    }
769    Ok(summary)
770}
771
772/// Copy an ALREADY-CONFINED opened regular source handle into a destination
773/// created relative to a held destination directory handle.
774#[cfg(unix)]
775fn copy_opened_into_confined(
776    mut source: fs::File,
777    dest_dir: &crate::private_fs::ConfinedDir,
778    name: &str,
779) -> io::Result<u64> {
780    use std::io::{Read, Write};
781    if !source.metadata()?.is_file() {
782        return Err(io::Error::other("confined export source is not regular"));
783    }
784    let mut out = dest_dir.create_file(name)?;
785    let mut buf = [0u8; 64 * 1024];
786    let mut written = 0u64;
787    loop {
788        let n = source.read(&mut buf)?;
789        if n == 0 {
790            break;
791        }
792        out.write_all(&buf[..n])?;
793        written += n as u64;
794    }
795    Ok(written)
796}
797
798/// Recursive source traversal using held source and destination directory
799/// handles only. Symlink entries are never opened; a directory swapped after
800/// enumeration fails child `open_dirs` and is skipped. Bounded depth.
801#[cfg(unix)]
802fn export_tree_from_handles(
803    source: &crate::private_fs::ConfinedDir,
804    destination: &crate::private_fs::ConfinedDir,
805    summary: &mut ExportSummary,
806    depth: usize,
807) -> io::Result<()> {
808    if depth > 32 {
809        return Err(io::Error::other("confined export source depth exceeds 32"));
810    }
811    for entry in source.entries()? {
812        if entry.is_file {
813            match source.open_file(std::slice::from_ref(&entry.name)) {
814                Ok(opened) => {
815                    let bytes = copy_opened_into_confined(opened, destination, &entry.name)?;
816                    summary.bytes += bytes;
817                    summary.files += 1;
818                }
819                Err(_) => continue,
820            }
821        } else if entry.is_dir {
822            let source_child = match source.open_dirs(std::slice::from_ref(&entry.name)) {
823                Ok(child) => child,
824                Err(_) => continue,
825            };
826            let dest_child = destination.child_dir(&entry.name)?;
827            export_tree_from_handles(&source_child, &dest_child, summary, depth + 1)?;
828        }
829    }
830    Ok(())
831}
832
833/// Non-unix: confined export is unavailable — fail closed.
834#[cfg(not(unix))]
835pub fn export(_roots: &RetentionRoots, _dest: &Path) -> io::Result<ExportSummary> {
836    Err(io::Error::other(
837        "confined export requires a unix platform (directory-handle-relative creation)",
838    ))
839}
840
841/// Headless forget: delete one artifact by domain and id. Session chain
842/// heads fail closed; memory ids use `"<namespace>:<record-id>"` and
843/// tombstone through the store.
844pub fn forget(roots: &RetentionRoots, domain: RetentionDomain, id: &str) -> io::Result<()> {
845    match domain {
846        RetentionDomain::Sessions => {
847            // Sanitize BEFORE any path construction (CP-13 fix1 I5).
848            let file = sanitize_file_id(id)?;
849            let heads = chain_heads(roots).map_err(|e| io::Error::other(e.to_string()))?;
850            if heads.contains(&id.to_string()) {
851                return Err(io::Error::other(format!(
852                    "session {id} is the head of a named chain — delete or repoint the \
853                     chain first (retention never leaves chains dangling)"
854                )));
855            }
856            let path = roots
857                .sessions_dir()
858                .join(format!("{}.json", file.display()));
859            fs::remove_file(path)?;
860            // Pair-delete the opt-in journal (Task 35) — forgetting a
861            // session must not leave replayable journal state behind.
862            let journal = roots
863                .sessions_dir()
864                .join(format!("{}.journal", file.display()));
865            match fs::remove_file(journal) {
866                Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
867                other => other,
868            }
869        }
870        RetentionDomain::Traces => {
871            // Nested relative addressing with validated components and
872            // dir-handle confinement (CP-13 fix2 moderate): symlinked
873            // ancestors inside the cache are refused at open time.
874            #[cfg(unix)]
875            {
876                let mut components = crate::private_fs::validated_relative_components(id)
877                    .map_err(|_| io::Error::other(format!("invalid artifact id: {id:?}")))?;
878                let name = components
879                    .pop()
880                    .ok_or_else(|| io::Error::other(format!("invalid artifact id: {id:?}")))?;
881                let root = crate::private_fs::ConfinedDir::open_root(&roots.cache_dir)?;
882                let dir = root.open_dirs(&components)?;
883                dir.remove_file(&name)
884            }
885            #[cfg(not(unix))]
886            {
887                fs::remove_file(roots.cache_dir.join(sanitize_file_id(id)?))
888            }
889        }
890        RetentionDomain::Logs => {
891            let file = sanitize_file_id(id)?;
892            if !id.starts_with("synaps.log") {
893                return Err(io::Error::other(
894                    "invalid log id: must start with synaps.log",
895                ));
896            }
897            fs::remove_file(roots.config_dir.join(file))
898        }
899        RetentionDomain::MemoryIndex => {
900            let dir = roots.index_dir().join(sanitize_file_id(id)?);
901            fs::remove_dir_all(dir)
902        }
903        RetentionDomain::Memory => {
904            let (namespace, record_id) = id.split_once(':').ok_or_else(|| {
905                io::Error::other("invalid memory id: use \"<namespace>:<record-id>\"")
906            })?;
907            let namespace = sanitize_file_id(namespace)?;
908            if record_id.is_empty() || !record_id.starts_with("mem-") {
909                return Err(io::Error::other(format!(
910                    "invalid memory record id: {record_id:?}"
911                )));
912            }
913            let path = roots
914                .memory_dir()
915                .join(format!("{}.jsonl", namespace.display()));
916            let raw = fs::read_to_string(&path)?;
917
918            // EXACT live-id match (CP-13 fix1 M4): parse every line;
919            // substring/prefix probes and already-tombstoned ids fail
920            // closed.
921            let mut live = false;
922            let mut tombstoned = false;
923            for line in raw.lines() {
924                let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
925                    continue;
926                };
927                if value["tombstone"].as_str() == Some(record_id) {
928                    tombstoned = true;
929                } else if value["id"].as_str() == Some(record_id) {
930                    live = true;
931                }
932            }
933            if !live || tombstoned {
934                return Err(io::Error::other(format!(
935                    "record {record_id} not present as a live record in namespace {}",
936                    namespace.display()
937                )));
938            }
939            let line = serde_json::json!({
940                "tombstone": record_id,
941                "timestamp_ms": crate::epoch_millis(),
942            });
943            let mut body = raw;
944            if !body.ends_with('\n') && !body.is_empty() {
945                body.push('\n');
946            }
947            body.push_str(&line.to_string());
948            body.push('\n');
949            crate::core::private_fs::write_atomic_private(&path, body.as_bytes())
950                .map_err(io::Error::from)
951        }
952    }
953}
954
955/// Reject path-traversal in file-name ids.
956fn sanitize_file_id(id: &str) -> io::Result<PathBuf> {
957    if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
958        return Err(io::Error::other(format!("invalid artifact id: {id:?}")));
959    }
960    Ok(PathBuf::from(id))
961}