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/// Files that belong to a session snapshot `<id>.json` and live and die
612/// with it: the Task 35 journal and the in-flight turn draft
613/// (`session_draft`, `<id>.turn`).
614const PAIRED_SESSION_EXTENSIONS: [&str; 2] = ["journal", "turn"];
615
616/// True when `path` is a journal or turn draft whose snapshot sibling still
617/// exists — such a file is handled WITH its `.json` artifact, never as an
618/// independent retention candidate.
619fn is_paired_journal(path: &Path) -> bool {
620    path.extension()
621        .and_then(|e| e.to_str())
622        .is_some_and(|e| PAIRED_SESSION_EXTENSIONS.contains(&e))
623        && path.with_extension("json").exists()
624}
625
626/// Delete a session artifact: the file plus, for `.json` snapshots, any
627/// sibling journal and turn draft (Task 35 — a session and its journal die
628/// together, so a sweep can never orphan journal state).
629fn delete_session_artifact(path: &Path, outcome: &mut SweepOutcome) -> io::Result<()> {
630    delete_file(path, outcome)?;
631    if path.extension().and_then(|e| e.to_str()) == Some("json") {
632        for ext in PAIRED_SESSION_EXTENSIONS {
633            let paired = path.with_extension(ext);
634            if paired.exists() {
635                delete_file(&paired, outcome)?;
636            }
637        }
638    }
639    Ok(())
640}
641
642/// Rewrite one memory JSONL file, dropping tombstoned records, records past
643/// their own retention class (`ts + max_age_days < now`), and — when a
644/// global cutoff is set — records older than it. Atomic rewrite; returns
645/// the number of records physically removed.
646fn compact_memory_file(
647    path: &Path,
648    now_ms: u64,
649    global_cutoff_ms: Option<u64>,
650) -> io::Result<usize> {
651    let raw = fs::read_to_string(path)?;
652    let mut tombstoned: std::collections::HashSet<String> = std::collections::HashSet::new();
653    let mut records: Vec<(Option<String>, u64, Option<u64>, String)> = Vec::new();
654    let mut dropped = 0usize;
655
656    for line in raw.lines() {
657        let trimmed = line.trim();
658        if trimmed.is_empty() {
659            continue;
660        }
661        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
662            dropped += 1; // torn/garbage line — compacted away
663            continue;
664        };
665        if let Some(id) = value["tombstone"].as_str() {
666            tombstoned.insert(id.to_string());
667            continue;
668        }
669        let ts = value["timestamp_ms"].as_u64().unwrap_or(0);
670        let per_record_span_ms = value["retention"]["max_age_days"]
671            .as_u64()
672            .map(|days| days * DAY_MS);
673        records.push((
674            value["id"].as_str().map(String::from),
675            ts,
676            per_record_span_ms,
677            trimmed.to_string(),
678        ));
679    }
680
681    let mut kept: Vec<String> = Vec::new();
682    for (id, ts, per_record_span_ms, line) in records {
683        if id.as_ref().is_some_and(|id| tombstoned.contains(id)) {
684            dropped += 1;
685            continue;
686        }
687        if per_record_span_ms.is_some_and(|span| ts.saturating_add(span) < now_ms) {
688            dropped += 1;
689            continue;
690        }
691        if global_cutoff_ms.is_some_and(|cutoff| ts < cutoff) {
692            dropped += 1;
693            continue;
694        }
695        kept.push(line);
696    }
697
698    // Tombstones fully applied: kept lines only.
699    if dropped > 0 {
700        let mut body = kept.join("\n");
701        if !body.is_empty() {
702            body.push('\n');
703        }
704        crate::core::private_fs::write_atomic_private(path, body.as_bytes())
705            .map_err(io::Error::from)?;
706    }
707    Ok(dropped)
708}
709
710/// Headless export: copy sessions, memory, traces (recursive), and logs
711/// to `dest` with no symlink surface, no check/use races, and private
712/// modes throughout (CP-13 fix2): every destination directory and file is
713/// created RELATIVE to held directory handles beneath the trusted export
714/// root — a planted or concurrently swapped ancestor symlink fails the
715/// export closed instead of being written through. Unix-only; other
716/// platforms fail closed.
717#[cfg(unix)]
718pub fn export(roots: &RetentionRoots, dest: &Path) -> io::Result<ExportSummary> {
719    use crate::private_fs::ConfinedDir;
720    let mut summary = ExportSummary { files: 0, bytes: 0 };
721    let root = ConfinedDir::create_root(dest)?;
722
723    // Flat domains: discovery AND authoritative opens are relative to the
724    // same held trusted source-root handle. There is no full-path source
725    // reopen after discovery. A planted/swap-raced source entry is refused
726    // by open_file; a destination collision remains a hard error.
727    let flat = [
728        (roots.sessions_dir(), "sessions"),
729        (roots.memory_dir(), "memory"),
730    ];
731    for (src, sub) in flat {
732        let Ok(src_root) = ConfinedDir::open_root(&src) else {
733            continue; // absent domain — nothing to export
734        };
735        let out = root.child_dir(sub)?;
736        for entry in src_root.entries()? {
737            if !entry.is_file {
738                continue; // source symlink/non-regular entries are omitted
739            }
740            let components = [entry.name.clone()];
741            match src_root.open_file(&components) {
742                Ok(source) => {
743                    let bytes = copy_opened_into_confined(source, &out, &entry.name)?;
744                    summary.bytes += bytes;
745                    summary.files += 1;
746                }
747                // Entry changed after enumeration (including a swap to a
748                // symlink): refuse/skip it, never follow it.
749                Err(_) => continue,
750            }
751        }
752    }
753
754    // Traces: recursively walk entirely through source directory handles.
755    if let Ok(cache_root) = ConfinedDir::open_root(&roots.cache_dir) {
756        let traces_root = root.child_dir("traces")?;
757        export_tree_from_handles(&cache_root, &traces_root, &mut summary, 0)?;
758    }
759
760    // Logs: enumerate the config root through its handle, filter the
761    // handle-returned names, and open relative to the same handle.
762    if let Ok(logs_root) = ConfinedDir::open_root(&roots.config_dir) {
763        let logs_out = root.child_dir("logs")?;
764        for entry in logs_root.entries()? {
765            if !entry.is_file || !entry.name.starts_with("synaps.log") {
766                continue;
767            }
768            match logs_root.open_file(std::slice::from_ref(&entry.name)) {
769                Ok(source) => {
770                    let bytes = copy_opened_into_confined(source, &logs_out, &entry.name)?;
771                    summary.bytes += bytes;
772                    summary.files += 1;
773                }
774                Err(_) => continue,
775            }
776        }
777    }
778    Ok(summary)
779}
780
781/// Copy an ALREADY-CONFINED opened regular source handle into a destination
782/// created relative to a held destination directory handle.
783#[cfg(unix)]
784fn copy_opened_into_confined(
785    mut source: fs::File,
786    dest_dir: &crate::private_fs::ConfinedDir,
787    name: &str,
788) -> io::Result<u64> {
789    use std::io::{Read, Write};
790    if !source.metadata()?.is_file() {
791        return Err(io::Error::other("confined export source is not regular"));
792    }
793    let mut out = dest_dir.create_file(name)?;
794    let mut buf = [0u8; 64 * 1024];
795    let mut written = 0u64;
796    loop {
797        let n = source.read(&mut buf)?;
798        if n == 0 {
799            break;
800        }
801        out.write_all(&buf[..n])?;
802        written += n as u64;
803    }
804    Ok(written)
805}
806
807/// Recursive source traversal using held source and destination directory
808/// handles only. Symlink entries are never opened; a directory swapped after
809/// enumeration fails child `open_dirs` and is skipped. Bounded depth.
810#[cfg(unix)]
811fn export_tree_from_handles(
812    source: &crate::private_fs::ConfinedDir,
813    destination: &crate::private_fs::ConfinedDir,
814    summary: &mut ExportSummary,
815    depth: usize,
816) -> io::Result<()> {
817    if depth > 32 {
818        return Err(io::Error::other("confined export source depth exceeds 32"));
819    }
820    for entry in source.entries()? {
821        if entry.is_file {
822            match source.open_file(std::slice::from_ref(&entry.name)) {
823                Ok(opened) => {
824                    let bytes = copy_opened_into_confined(opened, destination, &entry.name)?;
825                    summary.bytes += bytes;
826                    summary.files += 1;
827                }
828                Err(_) => continue,
829            }
830        } else if entry.is_dir {
831            let source_child = match source.open_dirs(std::slice::from_ref(&entry.name)) {
832                Ok(child) => child,
833                Err(_) => continue,
834            };
835            let dest_child = destination.child_dir(&entry.name)?;
836            export_tree_from_handles(&source_child, &dest_child, summary, depth + 1)?;
837        }
838    }
839    Ok(())
840}
841
842/// Non-unix: confined export is unavailable — fail closed.
843#[cfg(not(unix))]
844pub fn export(_roots: &RetentionRoots, _dest: &Path) -> io::Result<ExportSummary> {
845    Err(io::Error::other(
846        "confined export requires a unix platform (directory-handle-relative creation)",
847    ))
848}
849
850/// Headless forget: delete one artifact by domain and id. Session chain
851/// heads fail closed; memory ids use `"<namespace>:<record-id>"` and
852/// tombstone through the store.
853pub fn forget(roots: &RetentionRoots, domain: RetentionDomain, id: &str) -> io::Result<()> {
854    match domain {
855        RetentionDomain::Sessions => {
856            // Sanitize BEFORE any path construction (CP-13 fix1 I5).
857            let file = sanitize_file_id(id)?;
858            let heads = chain_heads(roots).map_err(|e| io::Error::other(e.to_string()))?;
859            if heads.contains(&id.to_string()) {
860                return Err(io::Error::other(format!(
861                    "session {id} is the head of a named chain — delete or repoint the \
862                     chain first (retention never leaves chains dangling)"
863                )));
864            }
865            let path = roots
866                .sessions_dir()
867                .join(format!("{}.json", file.display()));
868            fs::remove_file(path)?;
869            // Pair-delete the opt-in journal (Task 35) and any turn draft —
870            // forgetting a session must not leave replayable state behind.
871            for ext in PAIRED_SESSION_EXTENSIONS {
872                let paired = roots
873                    .sessions_dir()
874                    .join(format!("{}.{ext}", file.display()));
875                match fs::remove_file(paired) {
876                    Err(e) if e.kind() == io::ErrorKind::NotFound => {}
877                    other => other?,
878                }
879            }
880            Ok(())
881        }
882        RetentionDomain::Traces => {
883            // Nested relative addressing with validated components and
884            // dir-handle confinement (CP-13 fix2 moderate): symlinked
885            // ancestors inside the cache are refused at open time.
886            #[cfg(unix)]
887            {
888                let mut components = crate::private_fs::validated_relative_components(id)
889                    .map_err(|_| io::Error::other(format!("invalid artifact id: {id:?}")))?;
890                let name = components
891                    .pop()
892                    .ok_or_else(|| io::Error::other(format!("invalid artifact id: {id:?}")))?;
893                let root = crate::private_fs::ConfinedDir::open_root(&roots.cache_dir)?;
894                let dir = root.open_dirs(&components)?;
895                dir.remove_file(&name)
896            }
897            #[cfg(not(unix))]
898            {
899                fs::remove_file(roots.cache_dir.join(sanitize_file_id(id)?))
900            }
901        }
902        RetentionDomain::Logs => {
903            let file = sanitize_file_id(id)?;
904            if !id.starts_with("synaps.log") {
905                return Err(io::Error::other(
906                    "invalid log id: must start with synaps.log",
907                ));
908            }
909            fs::remove_file(roots.config_dir.join(file))
910        }
911        RetentionDomain::MemoryIndex => {
912            let dir = roots.index_dir().join(sanitize_file_id(id)?);
913            fs::remove_dir_all(dir)
914        }
915        RetentionDomain::Memory => {
916            let (namespace, record_id) = id.split_once(':').ok_or_else(|| {
917                io::Error::other("invalid memory id: use \"<namespace>:<record-id>\"")
918            })?;
919            let namespace = sanitize_file_id(namespace)?;
920            if record_id.is_empty() || !record_id.starts_with("mem-") {
921                return Err(io::Error::other(format!(
922                    "invalid memory record id: {record_id:?}"
923                )));
924            }
925            let path = roots
926                .memory_dir()
927                .join(format!("{}.jsonl", namespace.display()));
928            let raw = fs::read_to_string(&path)?;
929
930            // EXACT live-id match (CP-13 fix1 M4): parse every line;
931            // substring/prefix probes and already-tombstoned ids fail
932            // closed.
933            let mut live = false;
934            let mut tombstoned = false;
935            for line in raw.lines() {
936                let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
937                    continue;
938                };
939                if value["tombstone"].as_str() == Some(record_id) {
940                    tombstoned = true;
941                } else if value["id"].as_str() == Some(record_id) {
942                    live = true;
943                }
944            }
945            if !live || tombstoned {
946                return Err(io::Error::other(format!(
947                    "record {record_id} not present as a live record in namespace {}",
948                    namespace.display()
949                )));
950            }
951            let line = serde_json::json!({
952                "tombstone": record_id,
953                "timestamp_ms": crate::epoch_millis(),
954            });
955            let mut body = raw;
956            if !body.ends_with('\n') && !body.is_empty() {
957                body.push('\n');
958            }
959            body.push_str(&line.to_string());
960            body.push('\n');
961            crate::core::private_fs::write_atomic_private(&path, body.as_bytes())
962                .map_err(io::Error::from)
963        }
964    }
965}
966
967/// Reject path-traversal in file-name ids.
968fn sanitize_file_id(id: &str) -> io::Result<PathBuf> {
969    if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
970        return Err(io::Error::other(format!("invalid artifact id: {id:?}")));
971    }
972    Ok(PathBuf::from(id))
973}