Skip to main content

supercode_harness/
store.rs

1//! A directory-backed store for supercode's own sessions — naming, titles,
2//! listing, archiving, and deletion. The analog of `claude --name` / the Codex
3//! `resume`/`archive`/`delete` session lifecycle.
4//!
5//! Each session is a `<name>.jsonl` transcript (one [`crate::ChatMessage`] per
6//! line) plus a `<name>.meta.json` sidecar carrying the title. Archiving moves
7//! the pair under an `archived/` subdirectory.
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14use crate::reduce::ReductionLog;
15
16/// Lightweight metadata about a stored session.
17#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct SessionInfo {
20    /// The session name (the file stem; unique within the store).
21    pub name: String,
22    /// A human-readable title.
23    #[serde(default)]
24    pub title: String,
25    /// Whether the session is archived.
26    #[serde(default)]
27    pub archived: bool,
28    /// Whether reduced-mode projection (A5) has ever been applied to this
29    /// session. `#[serde(default)]` so meta.json files written before A2
30    /// still parse (they simply read as `false`).
31    #[serde(default)]
32    pub reduced: bool,
33    /// The model tier (B1/D5) last used for this session, if tiers are
34    /// configured; empty otherwise.
35    #[serde(default)]
36    pub tier: String,
37    /// Serialized byte size of the full (unreduced) view, last measured (C9).
38    #[serde(default)]
39    pub full_bytes: u64,
40    /// Serialized byte size of the current reduced working view, last
41    /// measured (C9).
42    #[serde(default)]
43    pub view_bytes: u64,
44    /// Number of `[sc-reduced ...]` stubs currently standing in the working
45    /// view (C2/A4).
46    #[serde(default)]
47    pub stub_count: u32,
48    /// Number of escalation events recorded for this session (B5/C8).
49    #[serde(default)]
50    pub escalations: u32,
51}
52
53/// A filesystem session store rooted at a directory.
54pub struct SessionStore {
55    root: PathBuf,
56}
57
58impl SessionStore {
59    /// Address a store at `root` without touching the filesystem. Read-only
60    /// discovery paths use this so merely checking whether a named session
61    /// exists cannot create an empty store directory. Mutating methods still
62    /// create their required directories before writing.
63    pub fn at(root: impl Into<PathBuf>) -> Self {
64        SessionStore { root: root.into() }
65    }
66
67    /// Open (creating if needed) a store at `root`.
68    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
69        let root = root.into();
70        std::fs::create_dir_all(&root)?;
71        Ok(Self::at(root))
72    }
73
74    /// Reject session names that could escape the store root. Names are file
75    /// stems, so anything with a path separator, a `..` component, or a leading
76    /// dot/whitespace is invalid — without this, a name like `../../foo` would
77    /// read/write/delete files outside the store.
78    fn validate_name(name: &str) -> Result<()> {
79        let bad = name.is_empty()
80            || name.contains('/')
81            || name.contains('\\')
82            || name.contains('\0')
83            || name.split(['/', '\\']).any(|c| c == ".." || c == ".")
84            || std::path::Path::new(name).is_absolute()
85            || name.trim() != name;
86        if bad {
87            return Err(Error::Other(format!("invalid session name: `{name}`")));
88        }
89        Ok(())
90    }
91
92    fn transcript_path(&self, name: &str, archived: bool) -> PathBuf {
93        self.dir(archived).join(format!("{name}.jsonl"))
94    }
95    fn meta_path(&self, name: &str, archived: bool) -> PathBuf {
96        self.dir(archived).join(format!("{name}.meta.json"))
97    }
98    /// `<root>/<name>.sidecar.jsonl` (or under `archived/`) — the A1
99    /// native-v2 file, source of truth (D1).
100    fn sidecar_path_in(&self, name: &str, archived: bool) -> PathBuf {
101        self.dir(archived).join(format!("{name}.sidecar.jsonl"))
102    }
103    /// `<root>/<name>.reduction.json` (or under `archived/`) — the persisted
104    /// `ReductionLog`, the stub index (D1).
105    fn reduction_path(&self, name: &str, archived: bool) -> PathBuf {
106        self.dir(archived).join(format!("{name}.reduction.json"))
107    }
108    /// `<root>/<name>.events.jsonl` (or under `archived/`) — the CLI-owned
109    /// event log (C8). No reader/writer lives here yet; only the lifecycle
110    /// sweep (`archive`/`delete`) needs to know its name (D1).
111    fn events_path(&self, name: &str, archived: bool) -> PathBuf {
112        self.dir(archived).join(format!("{name}.events.jsonl"))
113    }
114    /// `<root>/<name>.usage.jsonl` (or under `archived/`) — P4b (design
115    /// §5.2 "P4", §1.6, catalog §4a "persisted per-turn usage records"): one
116    /// [`crate::usage_log::UsageRecord`] per line.
117    fn usage_path(&self, name: &str, archived: bool) -> PathBuf {
118        self.dir(archived).join(format!("{name}.usage.jsonl"))
119    }
120    /// `<root>/<name>.model_change.jsonl` (or under `archived/`) — P4c
121    /// (design §5.2 "P4" core NEW-significant, §1.10/§3.1
122    /// `core.model_switch.allow_switch`): one
123    /// [`crate::model_change::ModelChangeRecord`] per line.
124    fn model_change_path(&self, name: &str, archived: bool) -> PathBuf {
125        self.dir(archived)
126            .join(format!("{name}.model_change.jsonl"))
127    }
128    /// `<root>/<name>.git.json` (or under `archived/`) — P4e (design §5.2
129    /// "P4e", §1.6/§3.1 `core.session.git_metadata`): the single
130    /// [`crate::git_metadata::GitMetadataRecord`] captured for this
131    /// session, if any (a single record, not a JSONL log — see that
132    /// module's doc comment).
133    fn git_metadata_path(&self, name: &str, archived: bool) -> PathBuf {
134        self.dir(archived).join(format!("{name}.git.json"))
135    }
136    /// `<root>/<name>.fork.json` (or under `archived/`) — P4e (§1.6
137    /// obligation-6 "fork-to-new-file WITH provenance", CX shape): the
138    /// [`ForkProvenance`] record for a session created via [`Self::fork`].
139    /// Absent for a session that was never forked (the overwhelmingly
140    /// common case).
141    fn fork_path(&self, name: &str, archived: bool) -> PathBuf {
142        self.dir(archived).join(format!("{name}.fork.json"))
143    }
144    /// `<root>/<name>.tree.json` (or under `archived/`) — P5-5 (design §2
145    /// module 21 `session.tree`, §2.1 D-6): the persisted
146    /// [`crate::session_tree::SessionTree`] — the full in-place tree (every
147    /// node of every branch), typed and lossless. Absent for a session that
148    /// never invoked a tree operation (rewind/branch/label) — the
149    /// overwhelmingly common, degenerate-single-path case; the plain
150    /// `<name>.jsonl` transcript alone already IS that session's complete
151    /// record, so no sidecar is ever created for it, keeping default-off
152    /// behavior byte-identical to pre-P5-5.
153    fn tree_path(&self, name: &str, archived: bool) -> PathBuf {
154        self.dir(archived).join(format!("{name}.tree.json"))
155    }
156    /// Claude runtime-state manifest reconstructed at import time. Kept as a
157    /// separate family member so scheduling/control-plane state is never
158    /// flattened into the provider-visible message transcript.
159    fn claude_runtime_path(&self, name: &str, archived: bool) -> PathBuf {
160        self.dir(archived)
161            .join(format!("{name}.claude-runtime.json"))
162    }
163    /// `<root>/<name>.subagents/` (or under `archived/`) — P5-3 (design §2
164    /// module 9 D5 "subagent transcripts"): the directory holding one
165    /// `<child_id>.sidecar.jsonl` (the D5 transcript) + one
166    /// `<child_id>.lineage.json` (the typed [`crate::subagents::SubagentLineage`]
167    /// record) pair per child natively spawned under this parent session —
168    /// the D5 analog of Claude Code's own `<stem>/subagents/agent-*.jsonl`
169    /// on-disk convention (`crate::session::subagents_dir_for`), but for
170    /// sessions THIS store owns rather than an imported CC transcript.
171    fn subagents_dir(&self, parent_name: &str, archived: bool) -> PathBuf {
172        self.dir(archived).join(format!("{parent_name}.subagents"))
173    }
174    fn subagent_transcript_path(
175        &self,
176        parent_name: &str,
177        child_id: &str,
178        archived: bool,
179    ) -> PathBuf {
180        self.subagents_dir(parent_name, archived)
181            .join(format!("{child_id}.sidecar.jsonl"))
182    }
183    fn subagent_lineage_path(&self, parent_name: &str, child_id: &str, archived: bool) -> PathBuf {
184        self.subagents_dir(parent_name, archived)
185            .join(format!("{child_id}.lineage.json"))
186    }
187    fn dir(&self, archived: bool) -> PathBuf {
188        if archived {
189            self.root.join("archived")
190        } else {
191            self.root.clone()
192        }
193    }
194
195    /// The path a sidecar for `name` lives (or would live) at:
196    /// `<root>/<name>.sidecar.jsonl`. Does not validate `name` or touch the
197    /// filesystem — like the private `transcript_path`/`meta_path` helpers,
198    /// it's the read/write methods (`save_sidecar`, `load_sidecar`, and
199    /// `Agent::resume_recorded`'s caller) that enforce `validate_name` before
200    /// any I/O happens.
201    pub fn sidecar_path(&self, name: &str) -> PathBuf {
202        self.sidecar_path_in(name, false)
203    }
204
205    /// The active reduction-log path for `name`. Like [`Self::sidecar_path`],
206    /// this is a path projection only; callers that read or write must still
207    /// go through the validated store methods.
208    pub fn reduction_log_path(&self, name: &str) -> Result<PathBuf> {
209        Self::validate_name(name)?;
210        Ok(self.reduction_path(name, false))
211    }
212
213    /// Canonical active transcript location for a validated session name.
214    /// The file need not exist yet; runtime registration uses this to report
215    /// where the SDK owner will persist successful turns.
216    pub fn session_path(&self, name: &str) -> Result<PathBuf> {
217        Self::validate_name(name)?;
218        Ok(self.transcript_path(name, false))
219    }
220
221    /// Exact active/archive transcript path. Unlike [`Self::session_path`],
222    /// this preserves an explicit archived-family selection.
223    pub fn session_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
224        Self::validate_name(name)?;
225        Ok(self.transcript_path(name, archived))
226    }
227
228    /// Exact active/archive native-v2 sidecar path.
229    pub fn sidecar_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
230        Self::validate_name(name)?;
231        Ok(self.sidecar_path_in(name, archived))
232    }
233
234    /// Exact active/archive stored-child sidecar path.
235    pub fn subagent_transcript_path_for(
236        &self,
237        parent_name: &str,
238        child_id: &str,
239        archived: bool,
240    ) -> Result<PathBuf> {
241        Self::validate_name(parent_name)?;
242        Self::validate_name(child_id)?;
243        Ok(self.subagent_transcript_path(parent_name, child_id, archived))
244    }
245
246    /// Overwrite (or create) `<name>`'s sidecar file with `sidecar_jsonl`
247    /// verbatim.
248    pub fn save_sidecar(&self, name: &str, sidecar_jsonl: &str) -> Result<()> {
249        Self::validate_name(name)?;
250        std::fs::create_dir_all(self.dir(false))?;
251        std::fs::write(self.sidecar_path_in(name, false), sidecar_jsonl)?;
252        Ok(())
253    }
254
255    /// Read `<name>`'s sidecar file (active or archived), if it exists.
256    /// `None` when no sidecar has ever been recorded for this session (e.g.
257    /// a plain, non-reduced resume).
258    pub fn load_sidecar(&self, name: &str) -> Result<Option<String>> {
259        Self::validate_name(name)?;
260        let active = self.sidecar_path_in(name, false);
261        let path = if active.exists() {
262            active
263        } else {
264            self.sidecar_path_in(name, true)
265        };
266        if !path.exists() {
267            return Ok(None);
268        }
269        Ok(Some(std::fs::read_to_string(path)?))
270    }
271
272    /// Read a sidecar from exactly the selected active/archive family.
273    pub fn load_sidecar_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
274        Self::validate_name(name)?;
275        let path = self.sidecar_path_in(name, archived);
276        if !path.exists() {
277            return Ok(None);
278        }
279        Ok(Some(std::fs::read_to_string(path)?))
280    }
281
282    /// Persist `<name>`'s [`ReductionLog`] (the stub index) as
283    /// `<name>.reduction.json`.
284    pub fn save_reduction_log(&self, name: &str, log: &ReductionLog) -> Result<()> {
285        Self::validate_name(name)?;
286        std::fs::create_dir_all(self.dir(false))?;
287        let json = serde_json::to_string(log).map_err(Error::Decode)?;
288        std::fs::write(self.reduction_path(name, false), json)?;
289        Ok(())
290    }
291
292    /// Read `<name>`'s [`ReductionLog`] (active or archived), if one has
293    /// ever been saved.
294    pub fn load_reduction_log(&self, name: &str) -> Result<Option<ReductionLog>> {
295        Self::validate_name(name)?;
296        let active = self.reduction_path(name, false);
297        let path = if active.exists() {
298            active
299        } else {
300            self.reduction_path(name, true)
301        };
302        if !path.exists() {
303            return Ok(None);
304        }
305        let text = std::fs::read_to_string(path)?;
306        Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
307    }
308
309    /// Read a reduction log from exactly the selected active/archive family.
310    pub fn load_reduction_log_from(
311        &self,
312        name: &str,
313        archived: bool,
314    ) -> Result<Option<ReductionLog>> {
315        Self::validate_name(name)?;
316        let path = self.reduction_path(name, archived);
317        if !path.exists() {
318            return Ok(None);
319        }
320        let text = std::fs::read_to_string(path)?;
321        Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
322    }
323
324    /// P4b: overwrite (or create) `<name>`'s usage log with `records`
325    /// (bulk-write, like [`Self::save_reduction_log`] — not an incremental
326    /// append — so a caller with the full in-memory
327    /// [`crate::usage_log::UsageRecord`] list, e.g. [`crate::Agent::usage_records`],
328    /// can persist it in one call).
329    pub fn save_usage_log(
330        &self,
331        name: &str,
332        records: &[crate::usage_log::UsageRecord],
333    ) -> Result<()> {
334        Self::validate_name(name)?;
335        std::fs::create_dir_all(self.dir(false))?;
336        let jsonl = crate::usage_log::to_jsonl(records)?;
337        std::fs::write(self.usage_path(name, false), jsonl)?;
338        Ok(())
339    }
340
341    /// P4b: read `<name>`'s usage log (active or archived). Empty (not an
342    /// error) when no usage log has ever been saved for this session.
343    pub fn load_usage_log(&self, name: &str) -> Result<Vec<crate::usage_log::UsageRecord>> {
344        Self::validate_name(name)?;
345        let active = self.usage_path(name, false);
346        let path = if active.exists() {
347            active
348        } else {
349            self.usage_path(name, true)
350        };
351        if !path.exists() {
352            return Ok(Vec::new());
353        }
354        crate::usage_log::from_jsonl(&std::fs::read_to_string(path)?)
355    }
356
357    /// P4c: overwrite (or create) `<name>`'s model-change log with
358    /// `records` — same bulk-write shape as [`Self::save_usage_log`], for a
359    /// caller with the full in-memory [`crate::model_change::ModelChangeRecord`]
360    /// list (e.g. [`crate::Agent::model_change_records`]).
361    pub fn save_model_change_log(
362        &self,
363        name: &str,
364        records: &[crate::model_change::ModelChangeRecord],
365    ) -> Result<()> {
366        Self::validate_name(name)?;
367        std::fs::create_dir_all(self.dir(false))?;
368        let jsonl = crate::model_change::to_jsonl(records)?;
369        std::fs::write(self.model_change_path(name, false), jsonl)?;
370        Ok(())
371    }
372
373    /// P4c: read `<name>`'s model-change log (active or archived). Empty
374    /// (not an error) when no model-change log has ever been saved for this
375    /// session — the overwhelmingly common case (`allow_switch = false`,
376    /// the default, or a session that never switched models).
377    pub fn load_model_change_log(
378        &self,
379        name: &str,
380    ) -> Result<Vec<crate::model_change::ModelChangeRecord>> {
381        Self::validate_name(name)?;
382        let active = self.model_change_path(name, false);
383        let path = if active.exists() {
384            active
385        } else {
386            self.model_change_path(name, true)
387        };
388        if !path.exists() {
389            return Ok(Vec::new());
390        }
391        crate::model_change::from_jsonl(&std::fs::read_to_string(path)?)
392    }
393
394    /// P4e (§1.6/§3.1 `core.session.git_metadata`): persist `<name>`'s
395    /// captured git metadata as `<name>.git.json` — a single-record
396    /// overwrite, like [`Self::save_reduction_log`], not an append.
397    pub fn save_git_metadata(
398        &self,
399        name: &str,
400        record: &crate::git_metadata::GitMetadataRecord,
401    ) -> Result<()> {
402        Self::validate_name(name)?;
403        std::fs::create_dir_all(self.dir(false))?;
404        let json = crate::git_metadata::to_json(record)?;
405        std::fs::write(self.git_metadata_path(name, false), json)?;
406        Ok(())
407    }
408
409    /// P4e: read `<name>`'s captured git metadata (active or archived).
410    /// `None` (not an error) when no git metadata was ever saved for this
411    /// session — the default (`session_git_metadata = false`).
412    pub fn load_git_metadata(
413        &self,
414        name: &str,
415    ) -> Result<Option<crate::git_metadata::GitMetadataRecord>> {
416        Self::validate_name(name)?;
417        let active = self.git_metadata_path(name, false);
418        let path = if active.exists() {
419            active
420        } else {
421            self.git_metadata_path(name, true)
422        };
423        if !path.exists() {
424            return Ok(None);
425        }
426        Ok(Some(crate::git_metadata::from_json(
427            &std::fs::read_to_string(path)?,
428        )?))
429    }
430
431    /// Save (or overwrite) a session's transcript JSONL and title.
432    pub fn save(&self, name: &str, title: &str, transcript_jsonl: &str) -> Result<()> {
433        Self::validate_name(name)?;
434        std::fs::create_dir_all(self.dir(false))?;
435        std::fs::write(self.transcript_path(name, false), transcript_jsonl)?;
436        let info = SessionInfo {
437            name: name.to_string(),
438            title: title.to_string(),
439            archived: false,
440            ..Default::default()
441        };
442        std::fs::write(
443            self.meta_path(name, false),
444            serde_json::to_string(&info).map_err(Error::Decode)?,
445        )?;
446        Ok(())
447    }
448
449    /// Read a session's transcript JSONL (active or archived).
450    pub fn load(&self, name: &str) -> Result<String> {
451        Self::validate_name(name)?;
452        let active = self.transcript_path(name, false);
453        let path = if active.exists() {
454            active
455        } else {
456            self.transcript_path(name, true)
457        };
458        Ok(std::fs::read_to_string(path)?)
459    }
460
461    /// Read a transcript from exactly the selected active/archive family.
462    pub fn load_from(&self, name: &str, archived: bool) -> Result<String> {
463        Self::validate_name(name)?;
464        Ok(std::fs::read_to_string(
465            self.transcript_path(name, archived),
466        )?)
467    }
468
469    /// Read a transcript from exactly the selected family when its directory
470    /// entry exists, preserving [`Self::load_if_present`]'s error semantics.
471    pub fn load_if_present_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
472        Self::validate_name(name)?;
473        let path = self.transcript_path(name, archived);
474        match std::fs::symlink_metadata(&path) {
475            Ok(_) => Ok(Some(std::fs::read_to_string(path)?)),
476            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
477            Err(error) => Err(error.into()),
478        }
479    }
480
481    /// Read a session's transcript JSONL when a transcript directory entry
482    /// exists (active or archived).
483    ///
484    /// Unlike [`Self::transcript_mtime`], this distinguishes genuine absence
485    /// from metadata/read failures. A dangling symlink, directory in place of
486    /// the transcript, permission failure, or any other present-but-unreadable
487    /// entry is an error rather than `None`.
488    pub fn load_if_present(&self, name: &str) -> Result<Option<String>> {
489        Self::validate_name(name)?;
490        for archived in [false, true] {
491            let path = self.transcript_path(name, archived);
492            match std::fs::symlink_metadata(&path) {
493                Ok(_) => return Ok(Some(std::fs::read_to_string(path)?)),
494                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
495                Err(e) => return Err(e.into()),
496            }
497        }
498        Ok(None)
499    }
500
501    /// List all sessions (active and archived).
502    pub fn list(&self) -> Vec<SessionInfo> {
503        let mut out = Vec::new();
504        for archived in [false, true] {
505            let dir = self.dir(archived);
506            let Ok(rd) = std::fs::read_dir(&dir) else {
507                continue;
508            };
509            for entry in rd.flatten() {
510                let p = entry.path();
511                if p.extension().and_then(|e| e.to_str()) != Some("json") {
512                    continue;
513                }
514                // `*.meta.json`
515                if !p.to_string_lossy().ends_with(".meta.json") {
516                    continue;
517                }
518                if let Ok(text) = std::fs::read_to_string(&p) {
519                    if let Ok(mut info) = serde_json::from_str::<SessionInfo>(&text) {
520                        info.archived = archived;
521                        out.push(info);
522                    }
523                }
524            }
525        }
526        out.sort_by(|a, b| a.name.cmp(&b.name));
527        out
528    }
529
530    /// Move a session into the archive: the whole `<name>.*` family (D1) —
531    /// transcript, meta, sidecar, reduction log, and event log — tolerating
532    /// any member that doesn't exist (e.g. a session never recorded in
533    /// reduced mode has no sidecar/reduction/events file).
534    pub fn archive(&self, name: &str) -> Result<()> {
535        Self::validate_name(name)?;
536        std::fs::create_dir_all(self.dir(true))?;
537        for (from, to) in [
538            (
539                self.transcript_path(name, false),
540                self.transcript_path(name, true),
541            ),
542            (self.meta_path(name, false), self.meta_path(name, true)),
543            (
544                self.sidecar_path_in(name, false),
545                self.sidecar_path_in(name, true),
546            ),
547            (
548                self.reduction_path(name, false),
549                self.reduction_path(name, true),
550            ),
551            (self.events_path(name, false), self.events_path(name, true)),
552            (self.usage_path(name, false), self.usage_path(name, true)),
553            (
554                self.model_change_path(name, false),
555                self.model_change_path(name, true),
556            ),
557            (
558                self.git_metadata_path(name, false),
559                self.git_metadata_path(name, true),
560            ),
561            (self.fork_path(name, false), self.fork_path(name, true)),
562            (self.tree_path(name, false), self.tree_path(name, true)),
563            (
564                self.claude_runtime_path(name, false),
565                self.claude_runtime_path(name, true),
566            ),
567        ] {
568            if from.exists() {
569                std::fs::rename(&from, &to)?;
570            }
571        }
572        // P5-3 (D5 "folded into archive… like other session sidecars"): the
573        // `<name>.subagents/` directory is a WHOLE-DIRECTORY member of the
574        // family — moved as a unit (not file-by-file) since its member
575        // count varies per session.
576        let subagents_from = self.subagents_dir(name, false);
577        if subagents_from.exists() {
578            std::fs::rename(&subagents_from, self.subagents_dir(name, true))?;
579        }
580        Ok(())
581    }
582
583    /// Permanently delete a session (active or archived): the whole
584    /// `<name>.*` family (D1) — a delete that left a full-fidelity sidecar
585    /// behind would be a data-retention surprise. Tolerates any member that
586    /// doesn't exist.
587    pub fn delete(&self, name: &str) -> Result<()> {
588        Self::validate_name(name)?;
589        for archived in [false, true] {
590            for p in [
591                self.transcript_path(name, archived),
592                self.meta_path(name, archived),
593                self.sidecar_path_in(name, archived),
594                self.reduction_path(name, archived),
595                self.events_path(name, archived),
596                self.usage_path(name, archived),
597                self.model_change_path(name, archived),
598                self.git_metadata_path(name, archived),
599                self.fork_path(name, archived),
600                self.tree_path(name, archived),
601                self.claude_runtime_path(name, archived),
602            ] {
603                if p.exists() {
604                    std::fs::remove_file(p)?;
605                }
606            }
607            // P5-3 (D5 "…delete… like other session sidecars"): the whole
608            // `<name>.subagents/` directory, active and archived.
609            let subagents_dir = self.subagents_dir(name, archived);
610            if subagents_dir.exists() {
611                std::fs::remove_dir_all(&subagents_dir)?;
612            }
613        }
614        Ok(())
615    }
616
617    /// Rename the human-readable title of a session, preserving its other
618    /// recorded stats (`reduced`, `tier`, byte/stub counts, ...) rather than
619    /// resetting them to defaults.
620    pub fn set_title(&self, name: &str, title: &str) -> Result<()> {
621        Self::validate_name(name)?;
622        for archived in [false, true] {
623            let mp = self.meta_path(name, archived);
624            if mp.exists() {
625                let mut info: SessionInfo = std::fs::read_to_string(&mp)
626                    .ok()
627                    .and_then(|t| serde_json::from_str(&t).ok())
628                    .unwrap_or_else(|| SessionInfo {
629                        name: name.to_string(),
630                        ..Default::default()
631                    });
632                info.title = title.to_string();
633                info.archived = archived;
634                std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
635                return Ok(());
636            }
637        }
638        Err(Error::Other(format!("no session named `{name}`")))
639    }
640
641    /// The store's root directory.
642    pub fn root(&self) -> &Path {
643        &self.root
644    }
645
646    /// The transcript file's mtime (active or archived), if it exists.
647    ///
648    /// Legacy session names embed a creation timestamp (`<tag>-<micros>`),
649    /// so callers could derive age/order from the name alone. UX-25's
650    /// memorable names (`<tag>-<adjective>-<noun>`) carry no timestamp, so
651    /// callers that need one — ordering `sessions list`, resolving
652    /// `--continue`/`--last` — fall back to this instead.
653    pub fn transcript_mtime(&self, name: &str) -> Option<std::time::SystemTime> {
654        Self::validate_name(name).ok()?;
655        let active = self.transcript_path(name, false);
656        let path = if active.exists() {
657            active
658        } else {
659            self.transcript_path(name, true)
660        };
661        std::fs::metadata(path).ok()?.modified().ok()
662    }
663
664    /// Record (or update) a session's reduced-mode stats (C1/C9):
665    /// `reduced = true` plus the full/view byte counts and stub count.
666    /// Creates `<name>.meta.json` with `title` if it doesn't exist yet (so a
667    /// reduced-mode `resume` is visible to `sessions list` even before any
668    /// plain transcript has been saved for it under this name); otherwise
669    /// preserves the existing title/archived flag, like [`Self::set_title`].
670    pub fn set_reduction_stats(
671        &self,
672        name: &str,
673        title: &str,
674        full_bytes: u64,
675        view_bytes: u64,
676        stub_count: u32,
677    ) -> Result<()> {
678        Self::validate_name(name)?;
679        std::fs::create_dir_all(self.dir(false))?;
680        let mp = self.meta_path(name, false);
681        let mut info: SessionInfo = std::fs::read_to_string(&mp)
682            .ok()
683            .and_then(|t| serde_json::from_str(&t).ok())
684            .unwrap_or_else(|| SessionInfo {
685                name: name.to_string(),
686                title: title.to_string(),
687                ..Default::default()
688            });
689        info.reduced = true;
690        info.full_bytes = full_bytes;
691        info.view_bytes = view_bytes;
692        info.stub_count = stub_count;
693        std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
694        Ok(())
695    }
696
697    /// P4e (§1.6 obligation-6 "fork-to-new-file WITH provenance", CX shape:
698    /// "linear store, fork copies + truncation"): copy session `from`'s
699    /// transcript into a NEW session `to`, optionally truncated to the
700    /// first `truncate_at_message` lines (each line is one message; `None`
701    /// is a full, byte-identical copy — the pre-P4e `sessions fork`
702    /// behavior), and persist a [`ForkProvenance`] record for `to` (typed,
703    /// lossless per §1.13: an auditor/translator can always recover exactly
704    /// which session and message offset a fork came from). Does NOT touch
705    /// `from` at all -- the source session's own full fidelity is
706    /// unaffected regardless of whether `to` is truncated.
707    pub fn fork(
708        &self,
709        from: &str,
710        to: &str,
711        title: &str,
712        truncate_at_message: Option<usize>,
713        timestamp_ms: i64,
714    ) -> Result<ForkProvenance> {
715        Self::validate_name(from)?;
716        Self::validate_name(to)?;
717        let jsonl = self.load(from)?;
718        if let Some(n) = truncate_at_message {
719            Self::validate_safe_truncation(&jsonl, n)?;
720        }
721        let content = match truncate_at_message {
722            Some(n) => {
723                let lines: Vec<&str> = jsonl.lines().take(n).collect();
724                if lines.is_empty() {
725                    String::new()
726                } else {
727                    let mut s = lines.join("\n");
728                    s.push('\n');
729                    s
730                }
731            }
732            None => jsonl,
733        };
734        self.save(to, title, &content)?;
735
736        // DEFECT-4 fix (independent Fable-5 review of P4e): copy the whole
737        // `<name>.*` sidecar family so a fork of a REDUCED session stays
738        // expandable (§1.13 lossless: the fork doc claims lossless, but a
739        // fork that dropped the sidecar left dangling `.sidecar.jsonl`/
740        // `.reduction.json` references). Always copied WHOLE — even when
741        // `truncate_at_message` shortens the transcript — because the
742        // sidecar/logs are the full-fidelity source of truth the (possibly
743        // truncated) transcript is only ever a PROJECTION of; truncating
744        // them to match the transcript would throw away exactly the data
745        // `/expand`/handoff need to reconstruct anything beyond the cut
746        // line. `validate_safe_truncation` above is what keeps a truncated
747        // fork coherent instead: it refuses a cut that would leave the
748        // transcript ending on a dangling tool_call, so the transcript
749        // itself is always a valid, replayable prefix regardless of how
750        // much of the sidecar's fuller history now sits "ahead" of it.
751        self.copy_family_member(from, to, Self::sidecar_path_in)?;
752        self.copy_family_member(from, to, Self::reduction_path)?;
753        self.copy_family_member(from, to, Self::usage_path)?;
754        self.copy_family_member(from, to, Self::model_change_path)?;
755        self.copy_family_member(from, to, Self::git_metadata_path)?;
756        // P5-5: the `.tree.json` sidecar (if this session ever branched) is
757        // a full-fidelity family member too — copied whole, same rationale
758        // as the sidecar/reduction-log copies just above (the possibly
759        // truncated transcript is only ever a projection of it).
760        self.copy_family_member(from, to, Self::tree_path)?;
761        self.copy_family_member(from, to, Self::claude_runtime_path)?;
762
763        let provenance = ForkProvenance {
764            forked_from: from.to_string(),
765            forked_at_message: truncate_at_message,
766            timestamp_ms,
767        };
768        self.save_fork_provenance(to, &provenance)?;
769        Ok(provenance)
770    }
771
772    /// DEFECT-4 fix: copy one member of the `<name>.*` sidecar family from
773    /// `from` to `to`'s ACTIVE location (a fresh fork always lands active,
774    /// never pre-archived), reading `from`'s active copy if present, else
775    /// its archived one — mirrors every other member accessor's
776    /// active-or-archived fallback (`load_sidecar`, `load_reduction_log`,
777    /// ...). A no-op (not an error) when `from` never recorded this member
778    /// at all, matching [`Self::archive`]/[`Self::delete`]'s tolerance.
779    fn copy_family_member(
780        &self,
781        from: &str,
782        to: &str,
783        path_of: impl Fn(&Self, &str, bool) -> PathBuf,
784    ) -> Result<()> {
785        let active = path_of(self, from, false);
786        let src = if active.exists() {
787            active
788        } else {
789            let archived = path_of(self, from, true);
790            if !archived.exists() {
791                return Ok(());
792            }
793            archived
794        };
795        std::fs::create_dir_all(self.dir(false))?;
796        std::fs::copy(&src, path_of(self, to, false))?;
797        Ok(())
798    }
799
800    /// DEFECT-4 fix: refuse a `--at n` fork whose cut point would leave the
801    /// truncated transcript ending on an assistant `tool_calls` message
802    /// whose tool-result reply (or replies, for a parallel batch) falls at
803    /// or past `n` — i.e. a dangling tool_call with no matching tool
804    /// message in the kept prefix. Such a transcript is neither a valid
805    /// provider request (an assistant tool_calls turn MUST be followed by
806    /// matching tool results before the next real turn) nor safely
807    /// `/expand`-able. `n == 0` (an empty fork) and any `n` that lands on a
808    /// clean turn boundary both pass trivially.
809    fn validate_safe_truncation(jsonl: &str, n: usize) -> Result<()> {
810        let kept: Vec<crate::message::ChatMessage> = jsonl
811            .lines()
812            .take(n)
813            .filter(|l| !l.trim().is_empty())
814            .map(|l| serde_json::from_str(l).map_err(Error::Decode))
815            .collect::<Result<_>>()?;
816        let mut pending: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
817        for m in &kept {
818            if let Some(calls) = &m.tool_calls {
819                for c in calls {
820                    pending.insert(c.id.clone());
821                }
822            }
823            if let Some(id) = &m.tool_call_id {
824                pending.remove(id);
825            }
826        }
827        if !pending.is_empty() {
828            return Err(Error::Other(format!(
829                "fork --at {n} would cut off {} unresolved tool_call result(s) ({}) — \
830                 choose a boundary at or after the assistant's tool_calls message AND \
831                 all of its tool results",
832                pending.len(),
833                pending.into_iter().collect::<Vec<_>>().join(", "),
834            )));
835        }
836        Ok(())
837    }
838
839    /// Persist `<name>`'s [`ForkProvenance`] as `<name>.fork.json` —
840    /// overwrite semantics, like [`Self::save_reduction_log`].
841    pub fn save_fork_provenance(&self, name: &str, provenance: &ForkProvenance) -> Result<()> {
842        Self::validate_name(name)?;
843        std::fs::create_dir_all(self.dir(false))?;
844        let json = serde_json::to_string(provenance).map_err(Error::Decode)?;
845        std::fs::write(self.fork_path(name, false), json)?;
846        Ok(())
847    }
848
849    /// Read `<name>`'s [`ForkProvenance`] (active or archived). `None`
850    /// (not an error) when `<name>` was never created via [`Self::fork`].
851    pub fn load_fork_provenance(&self, name: &str) -> Result<Option<ForkProvenance>> {
852        Self::validate_name(name)?;
853        let active = self.fork_path(name, false);
854        let path = if active.exists() {
855            active
856        } else {
857            self.fork_path(name, true)
858        };
859        if !path.exists() {
860            return Ok(None);
861        }
862        Ok(Some(
863            serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
864        ))
865    }
866
867    /// P5-5 (design §2 module 21 `session.tree`, §1.6 "typed session data …
868    /// folded into archive/delete/list"): persist `<name>`'s
869    /// [`crate::session_tree::SessionTree`] as `<name>.tree.json` —
870    /// overwrite semantics, like [`Self::save_reduction_log`].
871    pub fn save_tree(&self, name: &str, tree: &crate::session_tree::SessionTree) -> Result<()> {
872        Self::validate_name(name)?;
873        std::fs::create_dir_all(self.dir(false))?;
874        let json = serde_json::to_string(tree).map_err(Error::Decode)?;
875        std::fs::write(self.tree_path(name, false), json)?;
876        Ok(())
877    }
878
879    /// Persist the non-executing Claude runtime manifest as a member of this
880    /// session's sidecar family.
881    pub fn save_claude_runtime_manifest(
882        &self,
883        name: &str,
884        manifest: &crate::claude_runtime_state::ClaudeRuntimeManifest,
885    ) -> Result<()> {
886        Self::validate_name(name)?;
887        std::fs::create_dir_all(self.dir(false))?;
888        let json = serde_json::to_string(manifest).map_err(Error::Decode)?;
889        std::fs::write(self.claude_runtime_path(name, false), json)?;
890        Ok(())
891    }
892
893    /// Load a Claude runtime manifest from the active or archived family.
894    pub fn load_claude_runtime_manifest(
895        &self,
896        name: &str,
897    ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
898        Self::validate_name(name)?;
899        let active = self.claude_runtime_path(name, false);
900        let path = if active.exists() {
901            active
902        } else {
903            self.claude_runtime_path(name, true)
904        };
905        if !path.exists() {
906            return Ok(None);
907        }
908        Ok(Some(
909            serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
910        ))
911    }
912
913    /// Load a Claude runtime manifest from exactly the selected family.
914    pub fn load_claude_runtime_manifest_from(
915        &self,
916        name: &str,
917        archived: bool,
918    ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
919        Self::validate_name(name)?;
920        let path = self.claude_runtime_path(name, archived);
921        if !path.exists() {
922            return Ok(None);
923        }
924        Ok(Some(
925            serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
926        ))
927    }
928
929    /// Read `<name>`'s [`crate::session_tree::SessionTree`] (active or
930    /// archived). `None` (not an error) when no tree operation was ever
931    /// persisted for this session — the default, degenerate-single-path
932    /// case (see `Self::tree_path`'s doc comment).
933    pub fn load_tree(&self, name: &str) -> Result<Option<crate::session_tree::SessionTree>> {
934        Self::validate_name(name)?;
935        let active = self.tree_path(name, false);
936        let path = if active.exists() {
937            active
938        } else {
939            self.tree_path(name, true)
940        };
941        if !path.exists() {
942            return Ok(None);
943        }
944        Ok(Some(
945            serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
946        ))
947    }
948
949    /// P5-3 (design §2 module 9 D5 "subagent transcripts"): persist a
950    /// natively-spawned child's full sidecar (native-v2 JSONL, typically
951    /// [`crate::session::Session::to_native_jsonl_v2`]'s output, carrying
952    /// the child's own lineage header — see that method's doc comment) at
953    /// `<parent_name>.subagents/<child_id>.sidecar.jsonl`. `child_id` is
954    /// validated exactly like a top-level session name (it becomes a file
955    /// stem too) — same path-traversal floor as `Self::validate_name`.
956    pub fn save_subagent_transcript(
957        &self,
958        parent_name: &str,
959        child_id: &str,
960        sidecar_jsonl: &str,
961    ) -> Result<()> {
962        Self::validate_name(parent_name)?;
963        Self::validate_name(child_id)?;
964        std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
965        std::fs::write(
966            self.subagent_transcript_path(parent_name, child_id, false),
967            sidecar_jsonl,
968        )?;
969        Ok(())
970    }
971
972    /// Persist every subagent attached to an imported [`crate::session::Session`]
973    /// into this store's existing `<parent_name>.subagents/` family.
974    ///
975    /// Each child is wrapped in native-v2 before it is written, so its
976    /// foreign-harness `raw` body survives a later process/disk reload
977    /// byte-for-byte. All ids are validated (and duplicates rejected) before
978    /// the first write: an import with incomplete lineage must fail loudly
979    /// instead of silently dropping or overwriting a child transcript.
980    pub fn save_imported_subagents(
981        &self,
982        parent_name: &str,
983        subagents: &[crate::session::Session],
984    ) -> Result<usize> {
985        Self::validate_name(parent_name)?;
986
987        let mut seen = std::collections::BTreeSet::new();
988        for child in subagents {
989            let child_id = child.meta.agent_id.as_deref().ok_or_else(|| {
990                Error::Other(format!(
991                    "cannot persist an imported subagent for `{parent_name}` without an agent id"
992                ))
993            })?;
994            Self::validate_name(child_id)?;
995            if !seen.insert(child_id.to_string()) {
996                return Err(Error::Other(format!(
997                    "duplicate imported subagent id `{child_id}` for `{parent_name}`"
998                )));
999            }
1000        }
1001
1002        // Serialize/write one at a time: real Claude sessions can have
1003        // hundreds of MiB of child logs, so retaining a second in-memory
1004        // copy of every child at once would defeat the resume path this
1005        // helper exists to support.
1006        for child in subagents {
1007            let child_id = child.meta.agent_id.as_deref().expect("validated above");
1008            self.save_subagent_transcript(parent_name, child_id, &child.to_native_jsonl_v2(&[]))?;
1009        }
1010        Ok(subagents.len())
1011    }
1012
1013    /// Read a child's sidecar (active or archived). `None` when this
1014    /// `(parent_name, child_id)` pair was never saved.
1015    pub fn load_subagent_transcript(
1016        &self,
1017        parent_name: &str,
1018        child_id: &str,
1019    ) -> Result<Option<String>> {
1020        Self::validate_name(parent_name)?;
1021        Self::validate_name(child_id)?;
1022        let active = self.subagent_transcript_path(parent_name, child_id, false);
1023        let path = if active.exists() {
1024            active
1025        } else {
1026            self.subagent_transcript_path(parent_name, child_id, true)
1027        };
1028        if !path.exists() {
1029            return Ok(None);
1030        }
1031        Ok(Some(std::fs::read_to_string(path)?))
1032    }
1033
1034    /// Read a child sidecar from exactly the selected parent family.
1035    pub fn load_subagent_transcript_from(
1036        &self,
1037        parent_name: &str,
1038        child_id: &str,
1039        archived: bool,
1040    ) -> Result<Option<String>> {
1041        Self::validate_name(parent_name)?;
1042        Self::validate_name(child_id)?;
1043        let path = self.subagent_transcript_path(parent_name, child_id, archived);
1044        if !path.exists() {
1045            return Ok(None);
1046        }
1047        Ok(Some(std::fs::read_to_string(path)?))
1048    }
1049
1050    /// P5-3: persist a child's typed [`crate::subagents::SubagentLineage`]
1051    /// record at `<parent_name>.subagents/<child_id>.lineage.json` —
1052    /// overwrite semantics, like [`Self::save_reduction_log`].
1053    pub fn save_subagent_lineage(
1054        &self,
1055        parent_name: &str,
1056        child_id: &str,
1057        record: &crate::subagents::SubagentLineage,
1058    ) -> Result<()> {
1059        Self::validate_name(parent_name)?;
1060        Self::validate_name(child_id)?;
1061        std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
1062        let json = serde_json::to_string(record).map_err(Error::Decode)?;
1063        std::fs::write(
1064            self.subagent_lineage_path(parent_name, child_id, false),
1065            json,
1066        )?;
1067        Ok(())
1068    }
1069
1070    /// Read a child's lineage record (active or archived). `None` when this
1071    /// `(parent_name, child_id)` pair was never saved.
1072    pub fn load_subagent_lineage(
1073        &self,
1074        parent_name: &str,
1075        child_id: &str,
1076    ) -> Result<Option<crate::subagents::SubagentLineage>> {
1077        Self::validate_name(parent_name)?;
1078        Self::validate_name(child_id)?;
1079        let active = self.subagent_lineage_path(parent_name, child_id, false);
1080        let path = if active.exists() {
1081            active
1082        } else {
1083            self.subagent_lineage_path(parent_name, child_id, true)
1084        };
1085        if !path.exists() {
1086            return Ok(None);
1087        }
1088        Ok(Some(
1089            serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1090        ))
1091    }
1092
1093    /// P5-3: every child id natively spawned under `parent_name` (active AND
1094    /// archived, deduped and sorted) — discovered from the `.sidecar.jsonl`
1095    /// members of `Self::subagents_dir`, the same "list what's on disk"
1096    /// posture [`Self::list`] uses for top-level sessions.
1097    pub fn list_subagent_ids(&self, parent_name: &str) -> Result<Vec<String>> {
1098        Self::validate_name(parent_name)?;
1099        let mut ids = std::collections::BTreeSet::new();
1100        for archived in [false, true] {
1101            let dir = self.subagents_dir(parent_name, archived);
1102            let Ok(rd) = std::fs::read_dir(&dir) else {
1103                continue;
1104            };
1105            for entry in rd.flatten() {
1106                let p = entry.path();
1107                if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1108                    if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1109                        ids.insert(id.to_string());
1110                    }
1111                }
1112            }
1113        }
1114        Ok(ids.into_iter().collect())
1115    }
1116
1117    /// List child ids from exactly the selected active/archive family.
1118    pub fn list_subagent_ids_from(&self, parent_name: &str, archived: bool) -> Result<Vec<String>> {
1119        Self::validate_name(parent_name)?;
1120        let mut ids = std::collections::BTreeSet::new();
1121        let dir = self.subagents_dir(parent_name, archived);
1122        let Ok(rd) = std::fs::read_dir(&dir) else {
1123            return Ok(Vec::new());
1124        };
1125        for entry in rd.flatten() {
1126            let p = entry.path();
1127            if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1128                if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1129                    ids.insert(id.to_string());
1130                }
1131            }
1132        }
1133        Ok(ids.into_iter().collect())
1134    }
1135
1136    /// P4e (§1.6/§3.1 `core.session.retention_days`): permanently delete
1137    /// every ARCHIVED session (never an active one — retention is a
1138    /// post-archive concern, matching every peer harness) whose transcript
1139    /// is older than `retention_days` days as of `now`. Returns the names
1140    /// deleted (empty if nothing was old enough, or `retention_days == 0`
1141    /// which this treats as "prune nothing" rather than "prune
1142    /// everything" -- an explicit, non-surprising floor).
1143    pub fn prune_expired(
1144        &self,
1145        retention_days: u32,
1146        now: std::time::SystemTime,
1147    ) -> Result<Vec<String>> {
1148        if retention_days == 0 {
1149            return Ok(Vec::new());
1150        }
1151        let Some(cutoff) = now.checked_sub(std::time::Duration::from_secs(
1152            retention_days as u64 * 86_400,
1153        )) else {
1154            return Ok(Vec::new());
1155        };
1156        let mut pruned = Vec::new();
1157        for info in self.list() {
1158            if !info.archived {
1159                continue;
1160            }
1161            let Some(mtime) = self.transcript_mtime(&info.name) else {
1162                continue;
1163            };
1164            if mtime < cutoff {
1165                self.delete(&info.name)?;
1166                pruned.push(info.name);
1167            }
1168        }
1169        Ok(pruned)
1170    }
1171}
1172
1173/// P4e (§1.6 obligation-6 "fork-to-new-file WITH provenance") — see
1174/// [`SessionStore::fork`].
1175#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1176pub struct ForkProvenance {
1177    /// The session name this fork was copied from.
1178    pub forked_from: String,
1179    /// If the fork was truncated, how many leading messages it kept.
1180    /// `None` means a full, untruncated copy.
1181    #[serde(default)]
1182    pub forked_at_message: Option<usize>,
1183    /// Unix-ms wall-clock time the fork was created.
1184    #[serde(default)]
1185    pub timestamp_ms: i64,
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191
1192    #[test]
1193    fn addressing_a_store_does_not_create_its_root() {
1194        let tmp = std::env::temp_dir().join(format!(
1195            "sc-store-address-only-{}-{}",
1196            std::process::id(),
1197            std::time::SystemTime::now()
1198                .duration_since(std::time::UNIX_EPOCH)
1199                .unwrap()
1200                .as_nanos()
1201        ));
1202        let store = SessionStore::at(&tmp);
1203        assert_eq!(store.root(), tmp);
1204        assert!(!tmp.exists());
1205        assert!(store.list().is_empty());
1206        assert!(!tmp.exists());
1207    }
1208
1209    #[test]
1210    fn rejects_path_traversal_names() {
1211        let tmp = std::env::temp_dir().join(format!("sc-store-test-{}", std::process::id()));
1212        let store = SessionStore::open(&tmp).unwrap();
1213        for bad in ["../escape", "..", "a/b", "/abs", "", "  ", ".", "x\0y"] {
1214            assert!(store.save(bad, "t", "{}").is_err(), "should reject `{bad}`");
1215            assert!(store.load(bad).is_err(), "should reject load `{bad}`");
1216            assert!(store.delete(bad).is_err(), "should reject delete `{bad}`");
1217        }
1218        // A normal name still works and stays inside the root.
1219        store.save("ok-name", "t", "{}").unwrap();
1220        assert!(tmp.join("ok-name.jsonl").exists());
1221        // Nothing escaped the root.
1222        assert!(!tmp.parent().unwrap().join("escape.jsonl").exists());
1223        let _ = std::fs::remove_dir_all(&tmp);
1224    }
1225
1226    fn temp_store() -> (SessionStore, std::path::PathBuf) {
1227        use std::sync::atomic::{AtomicU64, Ordering};
1228        static N: AtomicU64 = AtomicU64::new(0);
1229        let tmp = std::env::temp_dir().join(format!(
1230            "sc-store-model-change-{}-{}",
1231            std::process::id(),
1232            N.fetch_add(1, Ordering::SeqCst)
1233        ));
1234        (SessionStore::open(&tmp).unwrap(), tmp)
1235    }
1236
1237    /// P4c (S1.10, "round-trip losslessly through the store" — the explicit
1238    /// item-9 proof requirement): a saved `model_change` log survives a
1239    /// save/load round trip byte-for-byte in every field.
1240    #[test]
1241    fn model_change_log_round_trips_losslessly_through_the_store() {
1242        let (store, tmp) = temp_store();
1243        store.save("sess", "t", "[]").unwrap();
1244        let records = vec![
1245            crate::model_change::ModelChangeRecord::new(
1246                0,
1247                "vendor/model-a",
1248                "vendor/model-b",
1249                true,
1250                4,
1251                1_700_000_000_000,
1252            ),
1253            crate::model_change::ModelChangeRecord::new(
1254                5,
1255                "vendor/model-b",
1256                "vendor/model-c",
1257                true,
1258                0,
1259                1_700_000_050_000,
1260            ),
1261        ];
1262        store.save_model_change_log("sess", &records).unwrap();
1263        let loaded = store.load_model_change_log("sess").unwrap();
1264        assert_eq!(loaded, records);
1265        let _ = std::fs::remove_dir_all(&tmp);
1266    }
1267
1268    /// A session that never switched models has an empty (not missing/error)
1269    /// model-change log — the overwhelmingly common case.
1270    #[test]
1271    fn model_change_log_is_empty_when_never_saved() {
1272        let (store, tmp) = temp_store();
1273        store.save("sess", "t", "[]").unwrap();
1274        assert_eq!(store.load_model_change_log("sess").unwrap(), Vec::new());
1275        let _ = std::fs::remove_dir_all(&tmp);
1276    }
1277
1278    /// The `<name>.model_change.jsonl` sidecar is part of the `<name>.*`
1279    /// family: `archive`/`delete` move/remove it exactly like every other
1280    /// member (usage log, reduction log, sidecar, events).
1281    #[test]
1282    fn model_change_log_travels_with_archive_and_is_removed_by_delete() {
1283        let (store, tmp) = temp_store();
1284        store.save("sess", "t", "[]").unwrap();
1285        let records = vec![crate::model_change::ModelChangeRecord::new(
1286            0, "a", "b", true, 1, 1,
1287        )];
1288        store.save_model_change_log("sess", &records).unwrap();
1289        assert!(tmp.join("sess.model_change.jsonl").exists());
1290
1291        store.archive("sess").unwrap();
1292        assert!(!tmp.join("sess.model_change.jsonl").exists());
1293        assert!(tmp.join("archived/sess.model_change.jsonl").exists());
1294        // Still readable after archiving.
1295        assert_eq!(store.load_model_change_log("sess").unwrap(), records);
1296
1297        store.delete("sess").unwrap();
1298        assert!(!tmp.join("archived/sess.model_change.jsonl").exists());
1299        assert_eq!(store.load_model_change_log("sess").unwrap(), Vec::new());
1300        let _ = std::fs::remove_dir_all(&tmp);
1301    }
1302
1303    /// P4e (§1.13 "round-trip losslessly through the store"): a saved
1304    /// `GitMetadataRecord` survives a save/load round trip byte-for-byte.
1305    #[test]
1306    fn git_metadata_round_trips_losslessly_through_the_store() {
1307        let (store, tmp) = temp_store();
1308        store.save("sess", "t", "[]").unwrap();
1309        let record = crate::git_metadata::GitMetadataRecord {
1310            branch: Some("main".to_string()),
1311            sha: Some("deadbeef".to_string()),
1312            dirty: true,
1313            captured_at_ms: 1_700_000_000_000,
1314        };
1315        store.save_git_metadata("sess", &record).unwrap();
1316        assert_eq!(store.load_git_metadata("sess").unwrap(), Some(record));
1317        let _ = std::fs::remove_dir_all(&tmp);
1318    }
1319
1320    /// A session with `session_git_metadata` off (the default) never gets a
1321    /// `.git.json` file, and loading it back is `None`, not an error.
1322    #[test]
1323    fn git_metadata_is_none_when_never_saved() {
1324        let (store, tmp) = temp_store();
1325        store.save("sess", "t", "[]").unwrap();
1326        assert_eq!(store.load_git_metadata("sess").unwrap(), None);
1327        let _ = std::fs::remove_dir_all(&tmp);
1328    }
1329
1330    /// The `<name>.git.json` sidecar travels with `archive`/is removed by
1331    /// `delete`, exactly like every other `<name>.*` family member.
1332    #[test]
1333    fn git_metadata_travels_with_archive_and_is_removed_by_delete() {
1334        let (store, tmp) = temp_store();
1335        store.save("sess", "t", "[]").unwrap();
1336        let record = crate::git_metadata::GitMetadataRecord {
1337            branch: Some("main".to_string()),
1338            sha: None,
1339            dirty: false,
1340            captured_at_ms: 1,
1341        };
1342        store.save_git_metadata("sess", &record).unwrap();
1343        assert!(tmp.join("sess.git.json").exists());
1344
1345        store.archive("sess").unwrap();
1346        assert!(!tmp.join("sess.git.json").exists());
1347        assert!(tmp.join("archived/sess.git.json").exists());
1348        assert_eq!(store.load_git_metadata("sess").unwrap(), Some(record));
1349
1350        store.delete("sess").unwrap();
1351        assert!(!tmp.join("archived/sess.git.json").exists());
1352        assert_eq!(store.load_git_metadata("sess").unwrap(), None);
1353        let _ = std::fs::remove_dir_all(&tmp);
1354    }
1355
1356    // -------------------------------------------------------------------
1357    // P5-3 (design §2 module 9 D5 "subagent transcripts"): the
1358    // `<name>.subagents/` family member.
1359    // -------------------------------------------------------------------
1360
1361    fn sample_lineage(child_id: &str) -> crate::subagents::SubagentLineage {
1362        crate::subagents::SubagentLineage {
1363            child_agent_id: child_id.to_string(),
1364            parent_session_id: Some("parent-sess".to_string()),
1365            parent_tool_use_id: "call_1".to_string(),
1366            depth: 1,
1367            agent_type: Some("researcher".to_string()),
1368            task: "investigate the flaky test".to_string(),
1369            background: false,
1370            spawned_at_ms: 1_700_000_000_000,
1371            model: "vendor/model-a".to_string(),
1372        }
1373    }
1374
1375    /// §1.13 lossless round trip: a saved [`crate::subagents::SubagentLineage`]
1376    /// survives a save/load cycle byte-for-byte in every field.
1377    #[test]
1378    fn subagent_lineage_round_trips_losslessly_through_the_store() {
1379        let (store, tmp) = temp_store();
1380        store.save("parent-sess", "t", "[]").unwrap();
1381        let record = sample_lineage("agent-1");
1382        store
1383            .save_subagent_lineage("parent-sess", "agent-1", &record)
1384            .unwrap();
1385        assert_eq!(
1386            store
1387                .load_subagent_lineage("parent-sess", "agent-1")
1388                .unwrap(),
1389            Some(record)
1390        );
1391        let _ = std::fs::remove_dir_all(&tmp);
1392    }
1393
1394    /// A child transcript saved via `save_subagent_transcript` round-trips
1395    /// byte-for-byte AND parses back through `Session::from_native_str`
1396    /// (proving the whole native-write pipeline — lineage header included —
1397    /// not just the store's own byte plumbing).
1398    #[test]
1399    fn subagent_transcript_round_trips_and_parses_back_with_lineage() {
1400        let (store, tmp) = temp_store();
1401        store.save("parent-sess", "t", "[]").unwrap();
1402
1403        let mut session = crate::session::Session::from_claude_code_str("").unwrap();
1404        session.meta.agent_id = Some("agent-1".to_string());
1405        session.meta.parent_tool_use_id = Some("call_1".to_string());
1406        session
1407            .meta
1408            .lineage
1409            .insert("parent_thread_id".to_string(), "parent-sess".to_string());
1410        session
1411            .meta
1412            .lineage
1413            .insert("depth".to_string(), "1".to_string());
1414        let appended = vec![crate::message::ChatMessage::user("hello from the child")];
1415        let sidecar_jsonl = session.to_native_jsonl_v2(&appended);
1416
1417        store
1418            .save_subagent_transcript("parent-sess", "agent-1", &sidecar_jsonl)
1419            .unwrap();
1420        let loaded = store
1421            .load_subagent_transcript("parent-sess", "agent-1")
1422            .unwrap()
1423            .expect("just saved");
1424        assert_eq!(loaded, sidecar_jsonl);
1425
1426        let parsed = crate::session::Session::from_native_str(&loaded).unwrap();
1427        assert_eq!(parsed.meta.agent_id.as_deref(), Some("agent-1"));
1428        assert_eq!(parsed.meta.parent_tool_use_id.as_deref(), Some("call_1"));
1429        assert_eq!(
1430            parsed.meta.lineage.get("parent_thread_id"),
1431            Some(&"parent-sess".to_string())
1432        );
1433        assert_eq!(
1434            parsed.messages.last().and_then(|m| m.content.as_deref()),
1435            Some("hello from the child")
1436        );
1437
1438        assert_eq!(
1439            store.list_subagent_ids("parent-sess").unwrap(),
1440            vec!["agent-1".to_string()]
1441        );
1442        let _ = std::fs::remove_dir_all(&tmp);
1443    }
1444
1445    /// Imported Claude child logs use the same store family as native
1446    /// children, but retain their foreign raw body inside native-v2. Prove
1447    /// the real process boundary: close/reopen the store, parse the wrapper,
1448    /// and recover CRLF/trailing-whitespace source bytes exactly.
1449    #[test]
1450    fn imported_subagents_survive_disk_reload_with_verbatim_source_bytes() {
1451        let (store, tmp) = temp_store();
1452        let original = concat!(
1453            "{\"type\":\"user\",\"sessionId\":\"parent\",\"agentId\":\"child-7\",\"uuid\":\"u1\",\"parentUuid\":null,\"message\":{\"role\":\"user\",\"content\":\"inspect it\"}}  \r\n",
1454            "{\"type\":\"queue-operation\",\"operation\":\"dequeue\"}"
1455        );
1456        let mut child = crate::session::Session::from_claude_code_str(original).unwrap();
1457        child.meta.agent_id = Some("child-7".to_string());
1458        child.meta.parent_tool_use_id = Some("toolu_task_7".to_string());
1459
1460        assert_eq!(
1461            store
1462                .save_imported_subagents("parent-sess", &[child])
1463                .unwrap(),
1464            1
1465        );
1466        drop(store);
1467
1468        let reopened = SessionStore::open(&tmp).unwrap();
1469        let native = reopened
1470            .load_subagent_transcript("parent-sess", "child-7")
1471            .unwrap()
1472            .expect("imported child persisted");
1473        let loaded = crate::session::Session::from_sidecar_str(&native).unwrap();
1474        assert_eq!(
1475            loaded.meta.source,
1476            crate::session::SessionSource::ClaudeCode
1477        );
1478        assert_eq!(loaded.meta.agent_id.as_deref(), Some("child-7"));
1479        assert_eq!(
1480            loaded.meta.parent_tool_use_id.as_deref(),
1481            Some("toolu_task_7")
1482        );
1483        assert_eq!(loaded.raw_verbatim(), original);
1484        assert!(loaded.raw_is_verbatim);
1485
1486        let _ = std::fs::remove_dir_all(&tmp);
1487    }
1488
1489    /// Validate the complete imported child id set before writing anything,
1490    /// so a duplicate cannot silently overwrite the first transcript.
1491    #[test]
1492    fn imported_subagent_duplicate_ids_fail_before_any_write() {
1493        let (store, tmp) = temp_store();
1494        let mut first = crate::session::Session::from_claude_code_str("{}").unwrap();
1495        first.meta.agent_id = Some("same".to_string());
1496        let second = first.clone();
1497
1498        assert!(store
1499            .save_imported_subagents("parent-sess", &[first, second])
1500            .unwrap_err()
1501            .to_string()
1502            .contains("duplicate imported subagent id"));
1503        assert!(!tmp.join("parent-sess.subagents").exists());
1504
1505        let _ = std::fs::remove_dir_all(&tmp);
1506    }
1507
1508    /// The whole `<name>.subagents/` directory travels with `archive` and is
1509    /// removed by `delete`, exactly like every other `<name>.*` family
1510    /// member (D5 "folded into archive/delete/list").
1511    #[test]
1512    fn subagent_family_travels_with_archive_and_is_removed_by_delete() {
1513        let (store, tmp) = temp_store();
1514        store.save("parent-sess", "t", "[]").unwrap();
1515        store
1516            .save_subagent_lineage("parent-sess", "agent-1", &sample_lineage("agent-1"))
1517            .unwrap();
1518        store
1519            .save_subagent_transcript("parent-sess", "agent-1", "{}\n")
1520            .unwrap();
1521        assert!(tmp
1522            .join("parent-sess.subagents/agent-1.lineage.json")
1523            .exists());
1524        assert!(tmp
1525            .join("parent-sess.subagents/agent-1.sidecar.jsonl")
1526            .exists());
1527
1528        store.archive("parent-sess").unwrap();
1529        assert!(!tmp.join("parent-sess.subagents").exists());
1530        assert!(tmp
1531            .join("archived/parent-sess.subagents/agent-1.lineage.json")
1532            .exists());
1533        // Still readable (active-or-archived fallback) after archiving.
1534        assert_eq!(
1535            store.list_subagent_ids("parent-sess").unwrap(),
1536            vec!["agent-1".to_string()]
1537        );
1538        assert!(store
1539            .load_subagent_lineage("parent-sess", "agent-1")
1540            .unwrap()
1541            .is_some());
1542
1543        store.delete("parent-sess").unwrap();
1544        assert!(!tmp.join("archived/parent-sess.subagents").exists());
1545        assert_eq!(
1546            store
1547                .load_subagent_lineage("parent-sess", "agent-1")
1548                .unwrap(),
1549            None
1550        );
1551        assert!(store.list_subagent_ids("parent-sess").unwrap().is_empty());
1552        let _ = std::fs::remove_dir_all(&tmp);
1553    }
1554
1555    /// A session that never spawned any subagents has no `.subagents/`
1556    /// directory at all, and every accessor reports the empty/`None` case
1557    /// rather than erroring — the default-off, zero-cost posture.
1558    #[test]
1559    fn subagent_family_is_absent_by_default() {
1560        let (store, tmp) = temp_store();
1561        store.save("sess", "t", "[]").unwrap();
1562        assert_eq!(
1563            store.load_subagent_lineage("sess", "agent-1").unwrap(),
1564            None
1565        );
1566        assert_eq!(
1567            store.load_subagent_transcript("sess", "agent-1").unwrap(),
1568            None
1569        );
1570        assert!(store.list_subagent_ids("sess").unwrap().is_empty());
1571        assert!(!tmp.join("sess.subagents").exists());
1572        let _ = std::fs::remove_dir_all(&tmp);
1573    }
1574
1575    /// Happy path: a full (untruncated) fork is a byte-identical copy of
1576    /// the source transcript, with a provenance record naming the source
1577    /// and `forked_at_message = None`.
1578    #[test]
1579    fn fork_full_copy_is_byte_identical_with_provenance() {
1580        let (store, tmp) = temp_store();
1581        let transcript = "{\"role\":\"user\"}\n{\"role\":\"assistant\"}\n";
1582        store.save("orig", "t", transcript).unwrap();
1583        let provenance = store
1584            .fork("orig", "copy", "fork of t", None, 1_700_000_000_000)
1585            .unwrap();
1586        assert_eq!(store.load("copy").unwrap(), transcript);
1587        assert_eq!(provenance.forked_from, "orig");
1588        assert_eq!(provenance.forked_at_message, None);
1589        assert_eq!(
1590            store.load_fork_provenance("copy").unwrap(),
1591            Some(provenance)
1592        );
1593        // The source is untouched.
1594        assert_eq!(store.load("orig").unwrap(), transcript);
1595        let _ = std::fs::remove_dir_all(&tmp);
1596    }
1597
1598    /// Boundary: a truncated fork (CX shape: copy + truncation) keeps only
1599    /// the first N messages, and the provenance record honestly reports the
1600    /// truncation point so a reader can tell it's a partial fork.
1601    #[test]
1602    fn fork_with_truncation_keeps_only_leading_messages() {
1603        let (store, tmp) = temp_store();
1604        let transcript = "{\"role\":\"system\"}\n{\"role\":\"user\"}\n{\"role\":\"assistant\"}\n{\"role\":\"tool\"}\n";
1605        store.save("orig", "t", transcript).unwrap();
1606        let provenance = store
1607            .fork("orig", "partial", "partial fork", Some(2), 42)
1608            .unwrap();
1609        assert_eq!(
1610            store.load("partial").unwrap(),
1611            "{\"role\":\"system\"}\n{\"role\":\"user\"}\n"
1612        );
1613        assert_eq!(provenance.forked_at_message, Some(2));
1614        // The source retains every message — truncation only affects the
1615        // NEW fork, never the original.
1616        assert_eq!(store.load("orig").unwrap(), transcript);
1617        let _ = std::fs::remove_dir_all(&tmp);
1618    }
1619
1620    /// A session never created via `fork` has no provenance record.
1621    #[test]
1622    fn fork_provenance_is_none_for_a_plain_session() {
1623        let (store, tmp) = temp_store();
1624        store.save("sess", "t", "[]").unwrap();
1625        assert_eq!(store.load_fork_provenance("sess").unwrap(), None);
1626        let _ = std::fs::remove_dir_all(&tmp);
1627    }
1628
1629    /// `prune_expired` deletes only ARCHIVED sessions older than the
1630    /// retention window, leaving active sessions and fresh archives alone.
1631    #[test]
1632    fn prune_expired_deletes_only_old_archived_sessions() {
1633        let (store, tmp) = temp_store();
1634        store.save("old-archived", "t", "[]").unwrap();
1635        store.archive("old-archived").unwrap();
1636        store.save("fresh-archived", "t", "[]").unwrap();
1637        store.archive("fresh-archived").unwrap();
1638        store.save("active", "t", "[]").unwrap();
1639
1640        // Back-date the old archived session's mtime well past any
1641        // reasonable retention window.
1642        let old_path = tmp.join("archived/old-archived.jsonl");
1643        let ancient = std::time::SystemTime::now() - std::time::Duration::from_secs(400 * 86_400);
1644        std::fs::OpenOptions::new()
1645            .write(true)
1646            .open(&old_path)
1647            .unwrap()
1648            .set_modified(ancient)
1649            .unwrap();
1650
1651        let pruned = store
1652            .prune_expired(30, std::time::SystemTime::now())
1653            .unwrap();
1654        assert_eq!(pruned, vec!["old-archived".to_string()]);
1655        assert!(!old_path.exists());
1656        assert!(tmp.join("archived/fresh-archived.jsonl").exists());
1657        assert!(tmp.join("active.jsonl").exists());
1658        let _ = std::fs::remove_dir_all(&tmp);
1659    }
1660
1661    /// `retention_days == 0` is an explicit "prune nothing" floor, not
1662    /// "prune everything" — a config typo must never nuke every archive.
1663    #[test]
1664    fn prune_expired_zero_days_prunes_nothing() {
1665        let (store, tmp) = temp_store();
1666        store.save("sess", "t", "[]").unwrap();
1667        store.archive("sess").unwrap();
1668        let pruned = store
1669            .prune_expired(0, std::time::SystemTime::now())
1670            .unwrap();
1671        assert!(pruned.is_empty());
1672        assert!(tmp.join("archived/sess.jsonl").exists());
1673        let _ = std::fs::remove_dir_all(&tmp);
1674    }
1675
1676    /// Default-unchanged: an active (never-archived) session is never
1677    /// pruned, regardless of age.
1678    #[test]
1679    fn prune_expired_never_touches_active_sessions() {
1680        let (store, tmp) = temp_store();
1681        store.save("active", "t", "[]").unwrap();
1682        let ancient = std::time::SystemTime::now() - std::time::Duration::from_secs(400 * 86_400);
1683        std::fs::OpenOptions::new()
1684            .write(true)
1685            .open(tmp.join("active.jsonl"))
1686            .unwrap()
1687            .set_modified(ancient)
1688            .unwrap();
1689        let pruned = store
1690            .prune_expired(30, std::time::SystemTime::now())
1691            .unwrap();
1692        assert!(pruned.is_empty());
1693        assert!(tmp.join("active.jsonl").exists());
1694        let _ = std::fs::remove_dir_all(&tmp);
1695    }
1696
1697    // -------------------------------------------------------------------
1698    // P5-5 (design §2 module 21 `session.tree`): the `<name>.tree.json`
1699    // family member.
1700    // -------------------------------------------------------------------
1701
1702    fn sample_tree() -> crate::session_tree::SessionTree {
1703        let mut tree = crate::session_tree::SessionTree::from_linear(
1704            &[
1705                crate::message::ChatMessage::user("hello"),
1706                crate::message::ChatMessage::assistant("hi"),
1707            ],
1708            1_700_000_000_000,
1709        );
1710        tree.branch("n0", Some("side".to_string()), 1_700_000_001_000)
1711            .unwrap();
1712        tree.append_message(
1713            crate::message::ChatMessage::user("side turn"),
1714            1_700_000_002_000,
1715        );
1716        tree
1717    }
1718
1719    /// §1.13 lossless round trip: a saved [`crate::session_tree::SessionTree`]
1720    /// — nodes, branches, and the active-branch pointer — survives a
1721    /// save/load cycle with every branch's linear projection intact.
1722    #[test]
1723    fn session_tree_round_trips_losslessly_through_the_store() {
1724        let (store, tmp) = temp_store();
1725        store.save("sess", "t", "[]").unwrap();
1726        let tree = sample_tree();
1727        store.save_tree("sess", &tree).unwrap();
1728        let loaded = store.load_tree("sess").unwrap().expect("just saved");
1729
1730        assert_eq!(loaded.root, tree.root);
1731        assert_eq!(loaded.active_branch, tree.active_branch);
1732        assert_eq!(loaded.nodes.len(), tree.nodes.len());
1733        assert_eq!(
1734            loaded.linear_projection_of("main").unwrap().len(),
1735            tree.linear_projection_of("main").unwrap().len()
1736        );
1737        assert_eq!(
1738            loaded.linear_projection().unwrap().len(),
1739            tree.linear_projection().unwrap().len()
1740        );
1741        let _ = std::fs::remove_dir_all(&tmp);
1742    }
1743
1744    /// F1 (HIGH, ported from the Fable-5 adversarial review's
1745    /// `attack_tree_sidecar_drops_message_metadata`): a rewound-past branch
1746    /// — whose messages have NO backing beyond the `.tree.json` sidecar — must
1747    /// keep `ChatMessage::metadata` through a save/load cycle. Before the
1748    /// fix, `TreeNode` persisted via `ChatMessage`'s custom wire `Serialize`
1749    /// (`message.rs:49-79`), which deliberately OMITS `metadata` — so this
1750    /// assertion FAILING (metadata empty) would mean the sidecar reverted to
1751    /// that lossy behavior.
1752    #[test]
1753    fn attack_tree_sidecar_preserves_message_metadata() {
1754        let (store, tmp) = temp_store();
1755        let mut m = crate::message::ChatMessage::user("turn with provenance");
1756        m.metadata
1757            .insert("phase".to_string(), "commentary".to_string());
1758        m.metadata
1759            .insert("turn_id".to_string(), "cx-turn-42".to_string());
1760        m.metadata
1761            .insert("pi_entry_id".to_string(), "entry-7".to_string());
1762        let mut tree = crate::session_tree::SessionTree::from_linear(
1763            &[m.clone(), crate::message::ChatMessage::assistant("ok")],
1764            1,
1765        );
1766        // Rewind so n0..n1 becomes an off-path preserved branch whose ONLY
1767        // persistent record is the .tree.json sidecar.
1768        let preserved = tree.rewind("n0", 2).unwrap().unwrap();
1769        store.save("s", "t", "").unwrap();
1770        store.save_tree("s", &tree).unwrap();
1771        let loaded = store.load_tree("s").unwrap().unwrap();
1772        let recovered = loaded.linear_projection_of(&preserved).unwrap();
1773        assert_eq!(recovered.len(), 2);
1774        // The metadata must have survived the sidecar round trip in full.
1775        assert_eq!(
1776            recovered[0].metadata.get("phase"),
1777            Some(&"commentary".to_string())
1778        );
1779        assert_eq!(
1780            recovered[0].metadata.get("turn_id"),
1781            Some(&"cx-turn-42".to_string())
1782        );
1783        assert_eq!(
1784            recovered[0].metadata.get("pi_entry_id"),
1785            Some(&"entry-7".to_string())
1786        );
1787        let _ = std::fs::remove_dir_all(&tmp);
1788    }
1789
1790    /// F1 (HIGH, ported from the Fable-5 review's
1791    /// `attack_message_with_both_content_and_parts_loses_content_through_sidecar`):
1792    /// `ChatMessage`'s wire `Serialize` collapses `content` whenever
1793    /// `content_parts` is also set (parts win, plain string dropped) — the
1794    /// correct behavior for an OUTBOUND provider request, but wrong for this
1795    /// sidecar, which must keep both independently since it's the only
1796    /// durable record of an off-path branch. Before the fix this assertion
1797    /// (`content` surviving) would fail.
1798    #[test]
1799    fn attack_tree_sidecar_preserves_content_alongside_content_parts() {
1800        let (store, tmp) = temp_store();
1801        let mut m = crate::message::ChatMessage::user("plain content");
1802        m.content_parts = Some(vec![serde_json::json!({"type":"text","text":"part"})]);
1803        let tree = crate::session_tree::SessionTree::from_linear(&[m], 1);
1804        store.save("s", "t", "").unwrap();
1805        store.save_tree("s", &tree).unwrap();
1806        let loaded = store.load_tree("s").unwrap().unwrap();
1807        let got = &loaded.node("n0").unwrap().message;
1808        assert_eq!(got.content.as_deref(), Some("plain content"));
1809        assert_eq!(
1810            got.content_parts.as_ref().unwrap()[0]["text"],
1811            serde_json::json!("part")
1812        );
1813        let _ = std::fs::remove_dir_all(&tmp);
1814    }
1815
1816    /// F1: a FULL-FIDELITY round trip, not just length/content/role (the gap
1817    /// that hid the original defect — `session_tree_round_trips_losslessly_through_the_store`
1818    /// above only ever compared `.len()`). Asserts `metadata` and
1819    /// `content_parts` field-for-field equality on every node after a
1820    /// save/load cycle.
1821    #[test]
1822    fn session_tree_round_trip_preserves_full_message_fidelity_not_just_length() {
1823        let (store, tmp) = temp_store();
1824        let mut m0 = crate::message::ChatMessage::user("hello");
1825        m0.metadata
1826            .insert("promptSource".to_string(), "cli".to_string());
1827        m0.metadata.insert("isMeta".to_string(), "true".to_string());
1828        let mut m1 = crate::message::ChatMessage::assistant("hi");
1829        m1.content_parts = Some(vec![
1830            serde_json::json!({"type": "text", "text": "hi"}),
1831            serde_json::json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}),
1832        ]);
1833        m1.metadata
1834            .insert("review_findings".to_string(), "none".to_string());
1835        let tree = crate::session_tree::SessionTree::from_linear(
1836            &[m0.clone(), m1.clone()],
1837            1_700_000_000_000,
1838        );
1839        store.save("s", "t", "").unwrap();
1840        store.save_tree("s", &tree).unwrap();
1841        let loaded = store.load_tree("s").unwrap().unwrap();
1842
1843        let got0 = &loaded.node("n0").unwrap().message;
1844        assert_eq!(got0.content, m0.content);
1845        assert_eq!(got0.metadata, m0.metadata);
1846        assert_eq!(got0.content_parts, m0.content_parts);
1847
1848        let got1 = &loaded.node("n1").unwrap().message;
1849        assert_eq!(got1.content, m1.content);
1850        assert_eq!(got1.metadata, m1.metadata);
1851        assert_eq!(got1.content_parts, m1.content_parts);
1852
1853        let _ = std::fs::remove_dir_all(&tmp);
1854    }
1855
1856    /// Re-verification (Fable-5 review's `attack_tree_sidecar_save_load_save_is_byte_identical`):
1857    /// F1's serialization change (routing `TreeNode.message` through
1858    /// [`crate::sidecar::NativeTurn`] instead of `ChatMessage`'s own wire
1859    /// serde) must not break the sidecar's save→load→save byte-identity —
1860    /// in particular, `TreeNodeWire`'s `ts` field must be derived
1861    /// deterministically from the node's `created_at_ms`, NOT from
1862    /// wall-clock `now()` (which `NativeTurn::from(&ChatMessage)` normally
1863    /// stamps), or every reload-then-resave would produce different bytes.
1864    #[test]
1865    fn tree_sidecar_save_load_save_is_byte_identical_after_f1() {
1866        let (store, tmp) = temp_store();
1867        let mut tree = crate::session_tree::SessionTree::from_linear(
1868            &[
1869                crate::message::ChatMessage::user("a"),
1870                crate::message::ChatMessage::assistant("b"),
1871            ],
1872            1_700_000_000_000,
1873        );
1874        tree.branch("n0", Some("side".to_string()), 2).unwrap();
1875        tree.append_message(crate::message::ChatMessage::user("c"), 3);
1876        tree.label("n1", "checkpoint").unwrap();
1877        store.save("s", "t", "").unwrap();
1878        store.save_tree("s", &tree).unwrap();
1879        let bytes1 = std::fs::read(tmp.join("s.tree.json")).unwrap();
1880        let loaded = store.load_tree("s").unwrap().unwrap();
1881        store.save_tree("s", &loaded).unwrap();
1882        let bytes2 = std::fs::read(tmp.join("s.tree.json")).unwrap();
1883        assert_eq!(bytes1, bytes2);
1884        let _ = std::fs::remove_dir_all(&tmp);
1885    }
1886
1887    /// Re-verification (Fable-5 review's `attack_corrupt_tree_json_errors_on_load`):
1888    /// unchanged by F1 — a corrupt or empty `.tree.json` must still ERROR
1889    /// on load, never panic or silently return `None`.
1890    #[test]
1891    fn corrupt_tree_json_still_errors_on_load_after_the_fixes() {
1892        let (store, tmp) = temp_store();
1893        store.save("s", "t", "").unwrap();
1894        std::fs::write(tmp.join("s.tree.json"), "{not json").unwrap();
1895        assert!(store.load_tree("s").is_err());
1896        std::fs::write(tmp.join("s.tree.json"), "").unwrap();
1897        assert!(store.load_tree("s").is_err());
1898        let _ = std::fs::remove_dir_all(&tmp);
1899    }
1900
1901    /// A session that never invoked a tree operation has no `.tree.json`
1902    /// sidecar, and loading it back is `None`, not an error — the
1903    /// degenerate-single-path default.
1904    #[test]
1905    fn session_tree_is_absent_by_default() {
1906        let (store, tmp) = temp_store();
1907        store.save("sess", "t", "[]").unwrap();
1908        assert!(store.load_tree("sess").unwrap().is_none());
1909        assert!(!tmp.join("sess.tree.json").exists());
1910        let _ = std::fs::remove_dir_all(&tmp);
1911    }
1912
1913    /// The `<name>.tree.json` sidecar travels with `archive`/is removed by
1914    /// `delete`, exactly like every other `<name>.*` family member (D5
1915    /// "folded into archive/delete/list").
1916    #[test]
1917    fn session_tree_travels_with_archive_and_is_removed_by_delete() {
1918        let (store, tmp) = temp_store();
1919        store.save("sess", "t", "[]").unwrap();
1920        store.save_tree("sess", &sample_tree()).unwrap();
1921        assert!(tmp.join("sess.tree.json").exists());
1922
1923        store.archive("sess").unwrap();
1924        assert!(!tmp.join("sess.tree.json").exists());
1925        assert!(tmp.join("archived/sess.tree.json").exists());
1926        // Still readable after archiving.
1927        assert!(store.load_tree("sess").unwrap().is_some());
1928
1929        store.delete("sess").unwrap();
1930        assert!(!tmp.join("archived/sess.tree.json").exists());
1931        assert!(store.load_tree("sess").unwrap().is_none());
1932        let _ = std::fs::remove_dir_all(&tmp);
1933    }
1934
1935    /// A fork copies the source session's `.tree.json` sidecar whole (like
1936    /// the sidecar/reduction-log/usage/model-change/git-metadata members) —
1937    /// a fork of a branched session stays fully tree-addressable, not
1938    /// silently downgraded to linear-only.
1939    #[test]
1940    fn fork_copies_the_session_tree_sidecar() {
1941        let (store, tmp) = temp_store();
1942        let transcript = "{\"role\":\"user\"}\n";
1943        store.save("orig", "t", transcript).unwrap();
1944        store.save_tree("orig", &sample_tree()).unwrap();
1945
1946        store
1947            .fork("orig", "copy", "fork of t", None, 1_700_000_000_000)
1948            .unwrap();
1949        let copied = store.load_tree("copy").unwrap().expect("copied");
1950        let orig = store.load_tree("orig").unwrap().unwrap();
1951        assert_eq!(copied.nodes.len(), orig.nodes.len());
1952        assert_eq!(copied.branches.len(), orig.branches.len());
1953        let _ = std::fs::remove_dir_all(&tmp);
1954    }
1955
1956    #[test]
1957    fn claude_runtime_manifest_roundtrips_and_travels_with_family_lifecycle() {
1958        let (store, tmp) = temp_store();
1959        store.save("sess", "t", "[]").unwrap();
1960        let source = concat!(
1961            "{\"type\":\"permission-mode\",\"permissionMode\":\"bypassPermissions\",",
1962            "\"timestamp\":\"2026-07-14T00:00:00Z\"}\n"
1963        );
1964        let session = crate::Session::from_claude_code_str(source).unwrap();
1965        let manifest =
1966            crate::claude_runtime_state::ClaudeRuntimeManifest::from_session(&session).unwrap();
1967        store
1968            .save_claude_runtime_manifest("sess", &manifest)
1969            .unwrap();
1970        assert_eq!(
1971            store.load_claude_runtime_manifest("sess").unwrap(),
1972            Some(manifest.clone())
1973        );
1974
1975        store
1976            .fork("sess", "copy", "copy", None, 1_700_000_000_000)
1977            .unwrap();
1978        assert_eq!(
1979            store.load_claude_runtime_manifest("copy").unwrap(),
1980            Some(manifest.clone())
1981        );
1982        store.archive("sess").unwrap();
1983        assert!(!tmp.join("sess.claude-runtime.json").exists());
1984        assert!(tmp.join("archived/sess.claude-runtime.json").exists());
1985        assert_eq!(
1986            store.load_claude_runtime_manifest("sess").unwrap(),
1987            Some(manifest)
1988        );
1989        store.delete("sess").unwrap();
1990        assert!(store
1991            .load_claude_runtime_manifest("sess")
1992            .unwrap()
1993            .is_none());
1994        let _ = std::fs::remove_dir_all(&tmp);
1995    }
1996}