Skip to main content

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