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