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    /// BP-8 (catalog:153 "Format versioning/migration"): the on-disk
52    /// generation this session's family was last written at. `0` — the
53    /// `#[serde(default)]`, and what every pre-BP-8 meta.json reads as —
54    /// means "unmarked", which is what [`SessionStore::upgrade_in_place`]
55    /// keys off.
56    #[serde(default)]
57    pub format_version: u32,
58}
59
60/// A filesystem session store rooted at a directory.
61pub struct SessionStore {
62    root: PathBuf,
63}
64
65impl SessionStore {
66    /// Address a store at `root` without touching the filesystem. Read-only
67    /// discovery paths use this so merely checking whether a named session
68    /// exists cannot create an empty store directory. Mutating methods still
69    /// create their required directories before writing.
70    pub fn at(root: impl Into<PathBuf>) -> Self {
71        SessionStore { root: root.into() }
72    }
73
74    /// Open (creating if needed) a store at `root`.
75    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
76        let root = root.into();
77        std::fs::create_dir_all(&root)?;
78        Ok(Self::at(root))
79    }
80
81    /// Reject session names that could escape the store root. Names are file
82    /// stems, so anything with a path separator, a `..` component, or a leading
83    /// dot/whitespace is invalid — without this, a name like `../../foo` would
84    /// read/write/delete files outside the store.
85    fn validate_name(name: &str) -> Result<()> {
86        let bad = name.is_empty()
87            || name.contains('/')
88            || name.contains('\\')
89            || name.contains('\0')
90            || name.split(['/', '\\']).any(|c| c == ".." || c == ".")
91            || std::path::Path::new(name).is_absolute()
92            || name.trim() != name;
93        if bad {
94            return Err(Error::Other(format!("invalid session name: `{name}`")));
95        }
96        Ok(())
97    }
98
99    fn transcript_path(&self, name: &str, archived: bool) -> PathBuf {
100        self.dir(archived).join(format!("{name}.jsonl"))
101    }
102    fn meta_path(&self, name: &str, archived: bool) -> PathBuf {
103        self.dir(archived).join(format!("{name}.meta.json"))
104    }
105    /// `<root>/<name>.sidecar.jsonl` (or under `archived/`) — the A1
106    /// native-v2 file, source of truth (D1).
107    fn sidecar_path_in(&self, name: &str, archived: bool) -> PathBuf {
108        self.dir(archived).join(format!("{name}.sidecar.jsonl"))
109    }
110    /// `<root>/<name>.reduction.json` (or under `archived/`) — the persisted
111    /// `ReductionLog`, the stub index (D1).
112    fn reduction_path(&self, name: &str, archived: bool) -> PathBuf {
113        self.dir(archived).join(format!("{name}.reduction.json"))
114    }
115    /// `<root>/<name>.events.jsonl` (or under `archived/`) — the session's
116    /// per-round-trip marker log (C8/D1). BP-7 filled this slot in:
117    /// [`Self::save_turn_records`]/[`Self::load_turn_records`] write and
118    /// read [`crate::turn_record::TurnRecord`]s here, and the lifecycle
119    /// sweep (`archive`/`delete`) carries the file with the family.
120    fn events_path(&self, name: &str, archived: bool) -> PathBuf {
121        self.dir(archived).join(format!("{name}.events.jsonl"))
122    }
123    /// `<root>/<name>.usage.jsonl` (or under `archived/`) — P4b (design
124    /// §5.2 "P4", §1.6, catalog §4a "persisted per-turn usage records"): one
125    /// [`crate::usage_log::UsageRecord`] per line.
126    fn usage_path(&self, name: &str, archived: bool) -> PathBuf {
127        self.dir(archived).join(format!("{name}.usage.jsonl"))
128    }
129    /// `<root>/<name>.model_change.jsonl` (or under `archived/`) — P4c
130    /// (design §5.2 "P4" core NEW-significant, §1.10/§3.1
131    /// `core.model_switch.allow_switch`): one
132    /// [`crate::model_change::ModelChangeRecord`] per line.
133    fn model_change_path(&self, name: &str, archived: bool) -> PathBuf {
134        self.dir(archived)
135            .join(format!("{name}.model_change.jsonl"))
136    }
137    /// `<root>/<name>.git.json` (or under `archived/`) — P4e (design §5.2
138    /// "P4e", §1.6/§3.1 `core.session.git_metadata`): the single
139    /// [`crate::git_metadata::GitMetadataRecord`] captured for this
140    /// session, if any (a single record, not a JSONL log — see that
141    /// module's doc comment).
142    fn git_metadata_path(&self, name: &str, archived: bool) -> PathBuf {
143        self.dir(archived).join(format!("{name}.git.json"))
144    }
145    /// `<root>/<name>.goal.json` (or under `archived/`) — BP-7 (catalog
146    /// §4a "Goals"): the session's single [`crate::goals::GoalRecord`], if
147    /// one was ever set. A single record, not a log, exactly like
148    /// `<name>.git.json`.
149    fn goal_path(&self, name: &str, archived: bool) -> PathBuf {
150        self.dir(archived).join(format!("{name}.goal.json"))
151    }
152    /// `<root>/<name>.fork.json` (or under `archived/`) — P4e (§1.6
153    /// obligation-6 "fork-to-new-file WITH provenance", CX shape): the
154    /// [`ForkProvenance`] record for a session created via [`Self::fork`].
155    /// Absent for a session that was never forked (the overwhelmingly
156    /// common case).
157    fn fork_path(&self, name: &str, archived: bool) -> PathBuf {
158        self.dir(archived).join(format!("{name}.fork.json"))
159    }
160    /// `<root>/<name>.tree.json` (or under `archived/`) — P5-5 (design §2
161    /// module 21 `session.tree`, §2.1 D-6): the persisted
162    /// [`crate::session_tree::SessionTree`] — the full in-place tree (every
163    /// node of every branch), typed and lossless. Absent for a session that
164    /// never invoked a tree operation (rewind/branch/label) — the
165    /// overwhelmingly common, degenerate-single-path case; the plain
166    /// `<name>.jsonl` transcript alone already IS that session's complete
167    /// record, so no sidecar is ever created for it, keeping default-off
168    /// behavior byte-identical to pre-P5-5.
169    fn tree_path(&self, name: &str, archived: bool) -> PathBuf {
170        self.dir(archived).join(format!("{name}.tree.json"))
171    }
172    /// `<root>/<name>.journal.jsonl` (or under `archived/`) — BP-8
173    /// (catalog:150 "Append-only durable transcript", catalog:154
174    /// "Queued-prompt persistence", catalog:156 "Todos/plan persisted per
175    /// session"): the append-only, flush-per-record operation log
176    /// (`crate::session_journal`). Absent for a session whose config never
177    /// armed it (`[core.session] append_only`), which is every pre-BP-8
178    /// caller — the plain `<name>.jsonl` transcript alone stays that
179    /// session's complete record.
180    fn journal_path_in(&self, name: &str, archived: bool) -> PathBuf {
181        self.dir(archived).join(format!("{name}.journal.jsonl"))
182    }
183    /// `<root>/<name>.plan.json` (or under `archived/`) — BP-8
184    /// (catalog:156): the session's current `update_plan` checklist, the
185    /// folded head of the journal's `plan` records, written so a reader
186    /// that only wants the plan does not have to replay the whole log.
187    fn plan_path_in(&self, name: &str, archived: bool) -> PathBuf {
188        self.dir(archived).join(format!("{name}.plan.json"))
189    }
190    /// Claude runtime-state manifest reconstructed at import time. Kept as a
191    /// separate family member so scheduling/control-plane state is never
192    /// flattened into the provider-visible message transcript.
193    fn claude_runtime_path(&self, name: &str, archived: bool) -> PathBuf {
194        self.dir(archived)
195            .join(format!("{name}.claude-runtime.json"))
196    }
197    /// `<root>/<name>.subagents/` (or under `archived/`) — P5-3 (design §2
198    /// module 9 D5 "subagent transcripts"): the directory holding one
199    /// `<child_id>.sidecar.jsonl` (the D5 transcript) + one
200    /// `<child_id>.lineage.json` (the typed [`crate::subagents::SubagentLineage`]
201    /// record) pair per child natively spawned under this parent session —
202    /// the D5 analog of Claude Code's own `<stem>/subagents/agent-*.jsonl`
203    /// on-disk convention (`crate::session::subagents_dir_for`), but for
204    /// sessions THIS store owns rather than an imported CC transcript.
205    fn subagents_dir(&self, parent_name: &str, archived: bool) -> PathBuf {
206        self.dir(archived).join(format!("{parent_name}.subagents"))
207    }
208    fn subagent_transcript_path(
209        &self,
210        parent_name: &str,
211        child_id: &str,
212        archived: bool,
213    ) -> PathBuf {
214        self.subagents_dir(parent_name, archived)
215            .join(format!("{child_id}.sidecar.jsonl"))
216    }
217    fn subagent_lineage_path(&self, parent_name: &str, child_id: &str, archived: bool) -> PathBuf {
218        self.subagents_dir(parent_name, archived)
219            .join(format!("{child_id}.lineage.json"))
220    }
221    fn dir(&self, archived: bool) -> PathBuf {
222        if archived {
223            self.root.join("archived")
224        } else {
225            self.root.clone()
226        }
227    }
228
229    /// The path a sidecar for `name` lives (or would live) at:
230    /// `<root>/<name>.sidecar.jsonl`. Does not validate `name` or touch the
231    /// filesystem — like the private `transcript_path`/`meta_path` helpers,
232    /// it's the read/write methods (`save_sidecar`, `load_sidecar`, and
233    /// `Agent::resume_recorded`'s caller) that enforce `validate_name` before
234    /// any I/O happens.
235    pub fn sidecar_path(&self, name: &str) -> PathBuf {
236        self.sidecar_path_in(name, false)
237    }
238
239    /// The active reduction-log path for `name`. Like [`Self::sidecar_path`],
240    /// this is a path projection only; callers that read or write must still
241    /// go through the validated store methods.
242    pub fn reduction_log_path(&self, name: &str) -> Result<PathBuf> {
243        Self::validate_name(name)?;
244        Ok(self.reduction_path(name, false))
245    }
246
247    /// Canonical active transcript location for a validated session name.
248    /// The file need not exist yet; runtime registration uses this to report
249    /// where the SDK owner will persist successful turns.
250    pub fn session_path(&self, name: &str) -> Result<PathBuf> {
251        Self::validate_name(name)?;
252        Ok(self.transcript_path(name, false))
253    }
254
255    /// Exact active/archive transcript path. Unlike [`Self::session_path`],
256    /// this preserves an explicit archived-family selection.
257    pub fn session_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
258        Self::validate_name(name)?;
259        Ok(self.transcript_path(name, archived))
260    }
261
262    /// Exact active/archive native-v2 sidecar path.
263    pub fn sidecar_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
264        Self::validate_name(name)?;
265        Ok(self.sidecar_path_in(name, archived))
266    }
267
268    /// Exact active/archive stored-child sidecar path.
269    pub fn subagent_transcript_path_for(
270        &self,
271        parent_name: &str,
272        child_id: &str,
273        archived: bool,
274    ) -> Result<PathBuf> {
275        Self::validate_name(parent_name)?;
276        Self::validate_name(child_id)?;
277        Ok(self.subagent_transcript_path(parent_name, child_id, archived))
278    }
279
280    /// Overwrite (or create) `<name>`'s sidecar file with `sidecar_jsonl`
281    /// verbatim.
282    pub fn save_sidecar(&self, name: &str, sidecar_jsonl: &str) -> Result<()> {
283        Self::validate_name(name)?;
284        std::fs::create_dir_all(self.dir(false))?;
285        std::fs::write(self.sidecar_path_in(name, false), sidecar_jsonl)?;
286        Ok(())
287    }
288
289    /// Read `<name>`'s sidecar file (active or archived), if it exists.
290    /// `None` when no sidecar has ever been recorded for this session (e.g.
291    /// a plain, non-reduced resume).
292    pub fn load_sidecar(&self, name: &str) -> Result<Option<String>> {
293        Self::validate_name(name)?;
294        let active = self.sidecar_path_in(name, false);
295        let path = if active.exists() {
296            active
297        } else {
298            self.sidecar_path_in(name, true)
299        };
300        if !path.exists() {
301            return Ok(None);
302        }
303        Ok(Some(std::fs::read_to_string(path)?))
304    }
305
306    /// Read a sidecar from exactly the selected active/archive family.
307    pub fn load_sidecar_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
308        Self::validate_name(name)?;
309        let path = self.sidecar_path_in(name, archived);
310        if !path.exists() {
311            return Ok(None);
312        }
313        Ok(Some(std::fs::read_to_string(path)?))
314    }
315
316    /// Persist `<name>`'s [`ReductionLog`] (the stub index) as
317    /// `<name>.reduction.json`.
318    pub fn save_reduction_log(&self, name: &str, log: &ReductionLog) -> Result<()> {
319        Self::validate_name(name)?;
320        std::fs::create_dir_all(self.dir(false))?;
321        let json = serde_json::to_string(log).map_err(Error::Decode)?;
322        std::fs::write(self.reduction_path(name, false), json)?;
323        Ok(())
324    }
325
326    /// Read `<name>`'s [`ReductionLog`] (active or archived), if one has
327    /// ever been saved.
328    pub fn load_reduction_log(&self, name: &str) -> Result<Option<ReductionLog>> {
329        Self::validate_name(name)?;
330        let active = self.reduction_path(name, false);
331        let path = if active.exists() {
332            active
333        } else {
334            self.reduction_path(name, true)
335        };
336        if !path.exists() {
337            return Ok(None);
338        }
339        let text = std::fs::read_to_string(path)?;
340        Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
341    }
342
343    /// Read a reduction log from exactly the selected active/archive family.
344    pub fn load_reduction_log_from(
345        &self,
346        name: &str,
347        archived: bool,
348    ) -> Result<Option<ReductionLog>> {
349        Self::validate_name(name)?;
350        let path = self.reduction_path(name, archived);
351        if !path.exists() {
352            return Ok(None);
353        }
354        let text = std::fs::read_to_string(path)?;
355        Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
356    }
357
358    /// P4b: overwrite (or create) `<name>`'s usage log with `records`
359    /// (bulk-write, like [`Self::save_reduction_log`] — not an incremental
360    /// append — so a caller with the full in-memory
361    /// [`crate::usage_log::UsageRecord`] list, e.g. [`crate::Agent::usage_records`],
362    /// can persist it in one call).
363    pub fn save_usage_log(
364        &self,
365        name: &str,
366        records: &[crate::usage_log::UsageRecord],
367    ) -> Result<()> {
368        Self::validate_name(name)?;
369        std::fs::create_dir_all(self.dir(false))?;
370        let jsonl = crate::usage_log::to_jsonl(records)?;
371        std::fs::write(self.usage_path(name, false), jsonl)?;
372        Ok(())
373    }
374
375    /// P4b: read `<name>`'s usage log (active or archived). Empty (not an
376    /// error) when no usage log has ever been saved for this session.
377    pub fn load_usage_log(&self, name: &str) -> Result<Vec<crate::usage_log::UsageRecord>> {
378        Self::validate_name(name)?;
379        let active = self.usage_path(name, false);
380        let path = if active.exists() {
381            active
382        } else {
383            self.usage_path(name, true)
384        };
385        if !path.exists() {
386            return Ok(Vec::new());
387        }
388        crate::usage_log::from_jsonl(&std::fs::read_to_string(path)?)
389    }
390
391    /// BP-7 (catalog §4a "Goals"): write `<name>`'s standing objective.
392    pub fn save_goal(&self, name: &str, goal: &crate::goals::GoalRecord) -> Result<()> {
393        Self::validate_name(name)?;
394        std::fs::create_dir_all(self.dir(false))?;
395        let json = serde_json::to_string_pretty(goal).map_err(crate::Error::Decode)?;
396        std::fs::write(self.goal_path(name, false), json)?;
397        Ok(())
398    }
399
400    /// BP-7: read `<name>`'s standing objective (active or archived).
401    /// `None` when the session never set one.
402    pub fn load_goal(&self, name: &str) -> Result<Option<crate::goals::GoalRecord>> {
403        Self::validate_name(name)?;
404        let active = self.goal_path(name, false);
405        let path = if active.exists() {
406            active
407        } else {
408            self.goal_path(name, true)
409        };
410        if !path.exists() {
411            return Ok(None);
412        }
413        let text = std::fs::read_to_string(path)?;
414        if text.trim().is_empty() {
415            return Ok(None);
416        }
417        Ok(Some(
418            serde_json::from_str(&text).map_err(crate::Error::Decode)?,
419        ))
420    }
421
422    /// BP-7: drop `<name>`'s standing objective (both locations). A no-op
423    /// when none was ever written.
424    pub fn clear_goal(&self, name: &str) -> Result<()> {
425        Self::validate_name(name)?;
426        for archived in [false, true] {
427            let p = self.goal_path(name, archived);
428            if p.exists() {
429                std::fs::remove_file(p)?;
430            }
431        }
432        Ok(())
433    }
434
435    /// BP-7 (catalog §4a "Turn/step bracketing records"): overwrite (or
436    /// create) `<name>`'s per-round-trip marker log — the
437    /// `<name>.events.jsonl` family member this store has always reserved
438    /// and swept but never had a writer for. Same bulk-write shape as
439    /// [`Self::save_usage_log`], for a caller holding the full in-memory
440    /// [`crate::turn_record::TurnRecord`] list (e.g.
441    /// [`crate::Agent::turn_records`]).
442    pub fn save_turn_records(
443        &self,
444        name: &str,
445        records: &[crate::turn_record::TurnRecord],
446    ) -> Result<()> {
447        Self::validate_name(name)?;
448        std::fs::create_dir_all(self.dir(false))?;
449        let jsonl = crate::turn_record::to_jsonl(records)?;
450        std::fs::write(self.events_path(name, false), jsonl)?;
451        Ok(())
452    }
453
454    /// BP-7: read `<name>`'s marker log (active or archived). Empty (not an
455    /// error) when none has ever been written for this session.
456    pub fn load_turn_records(&self, name: &str) -> Result<Vec<crate::turn_record::TurnRecord>> {
457        Self::validate_name(name)?;
458        let active = self.events_path(name, false);
459        let path = if active.exists() {
460            active
461        } else {
462            self.events_path(name, true)
463        };
464        if !path.exists() {
465            return Ok(Vec::new());
466        }
467        crate::turn_record::from_jsonl(&std::fs::read_to_string(path)?)
468    }
469
470    /// P4c: overwrite (or create) `<name>`'s model-change log with
471    /// `records` — same bulk-write shape as [`Self::save_usage_log`], for a
472    /// caller with the full in-memory [`crate::model_change::ModelChangeRecord`]
473    /// list (e.g. [`crate::Agent::model_change_records`]).
474    pub fn save_model_change_log(
475        &self,
476        name: &str,
477        records: &[crate::model_change::ModelChangeRecord],
478    ) -> Result<()> {
479        Self::validate_name(name)?;
480        std::fs::create_dir_all(self.dir(false))?;
481        let jsonl = crate::model_change::to_jsonl(records)?;
482        std::fs::write(self.model_change_path(name, false), jsonl)?;
483        Ok(())
484    }
485
486    /// P4c: read `<name>`'s model-change log (active or archived). Empty
487    /// (not an error) when no model-change log has ever been saved for this
488    /// session — the overwhelmingly common case (`allow_switch = false`,
489    /// the default, or a session that never switched models).
490    pub fn load_model_change_log(
491        &self,
492        name: &str,
493    ) -> Result<Vec<crate::model_change::ModelChangeRecord>> {
494        Self::validate_name(name)?;
495        let active = self.model_change_path(name, false);
496        let path = if active.exists() {
497            active
498        } else {
499            self.model_change_path(name, true)
500        };
501        if !path.exists() {
502            return Ok(Vec::new());
503        }
504        crate::model_change::from_jsonl(&std::fs::read_to_string(path)?)
505    }
506
507    /// P4e (§1.6/§3.1 `core.session.git_metadata`): persist `<name>`'s
508    /// captured git metadata as `<name>.git.json` — a single-record
509    /// overwrite, like [`Self::save_reduction_log`], not an append.
510    pub fn save_git_metadata(
511        &self,
512        name: &str,
513        record: &crate::git_metadata::GitMetadataRecord,
514    ) -> Result<()> {
515        Self::validate_name(name)?;
516        std::fs::create_dir_all(self.dir(false))?;
517        let json = crate::git_metadata::to_json(record)?;
518        std::fs::write(self.git_metadata_path(name, false), json)?;
519        Ok(())
520    }
521
522    /// P4e: read `<name>`'s captured git metadata (active or archived).
523    /// `None` (not an error) when no git metadata was ever saved for this
524    /// session — the default (`session_git_metadata = false`).
525    pub fn load_git_metadata(
526        &self,
527        name: &str,
528    ) -> Result<Option<crate::git_metadata::GitMetadataRecord>> {
529        Self::validate_name(name)?;
530        let active = self.git_metadata_path(name, false);
531        let path = if active.exists() {
532            active
533        } else {
534            self.git_metadata_path(name, true)
535        };
536        if !path.exists() {
537            return Ok(None);
538        }
539        Ok(Some(crate::git_metadata::from_json(
540            &std::fs::read_to_string(path)?,
541        )?))
542    }
543
544    /// Save (or overwrite) a session's transcript JSONL and title.
545    pub fn save(&self, name: &str, title: &str, transcript_jsonl: &str) -> Result<()> {
546        Self::validate_name(name)?;
547        std::fs::create_dir_all(self.dir(false))?;
548        std::fs::write(self.transcript_path(name, false), transcript_jsonl)?;
549        let info = SessionInfo {
550            name: name.to_string(),
551            title: title.to_string(),
552            archived: false,
553            ..Default::default()
554        };
555        std::fs::write(
556            self.meta_path(name, false),
557            serde_json::to_string(&info).map_err(Error::Decode)?,
558        )?;
559        Ok(())
560    }
561
562    /// Read a session's transcript JSONL (active or archived).
563    pub fn load(&self, name: &str) -> Result<String> {
564        Self::validate_name(name)?;
565        let active = self.transcript_path(name, false);
566        let path = if active.exists() {
567            active
568        } else {
569            self.transcript_path(name, true)
570        };
571        Ok(std::fs::read_to_string(path)?)
572    }
573
574    /// Read a transcript from exactly the selected active/archive family.
575    pub fn load_from(&self, name: &str, archived: bool) -> Result<String> {
576        Self::validate_name(name)?;
577        Ok(std::fs::read_to_string(
578            self.transcript_path(name, archived),
579        )?)
580    }
581
582    /// Read a transcript from exactly the selected family when its directory
583    /// entry exists, preserving [`Self::load_if_present`]'s error semantics.
584    pub fn load_if_present_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
585        Self::validate_name(name)?;
586        let path = self.transcript_path(name, archived);
587        match std::fs::symlink_metadata(&path) {
588            Ok(_) => Ok(Some(std::fs::read_to_string(path)?)),
589            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
590            Err(error) => Err(error.into()),
591        }
592    }
593
594    /// Read a session's transcript JSONL when a transcript directory entry
595    /// exists (active or archived).
596    ///
597    /// Unlike [`Self::transcript_mtime`], this distinguishes genuine absence
598    /// from metadata/read failures. A dangling symlink, directory in place of
599    /// the transcript, permission failure, or any other present-but-unreadable
600    /// entry is an error rather than `None`.
601    pub fn load_if_present(&self, name: &str) -> Result<Option<String>> {
602        Self::validate_name(name)?;
603        for archived in [false, true] {
604            let path = self.transcript_path(name, archived);
605            match std::fs::symlink_metadata(&path) {
606                Ok(_) => return Ok(Some(std::fs::read_to_string(path)?)),
607                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
608                Err(e) => return Err(e.into()),
609            }
610        }
611        Ok(None)
612    }
613
614    /// List all sessions (active and archived).
615    pub fn list(&self) -> Vec<SessionInfo> {
616        let mut out = Vec::new();
617        for archived in [false, true] {
618            let dir = self.dir(archived);
619            let Ok(rd) = std::fs::read_dir(&dir) else {
620                continue;
621            };
622            for entry in rd.flatten() {
623                let p = entry.path();
624                if p.extension().and_then(|e| e.to_str()) != Some("json") {
625                    continue;
626                }
627                // `*.meta.json`
628                if !p.to_string_lossy().ends_with(".meta.json") {
629                    continue;
630                }
631                if let Ok(text) = std::fs::read_to_string(&p) {
632                    if let Ok(mut info) = serde_json::from_str::<SessionInfo>(&text) {
633                        info.archived = archived;
634                        out.push(info);
635                    }
636                }
637            }
638        }
639        out.sort_by(|a, b| a.name.cmp(&b.name));
640        out
641    }
642
643    /// Move a session into the archive: the whole `<name>.*` family (D1) —
644    /// transcript, meta, sidecar, reduction log, and event log — tolerating
645    /// any member that doesn't exist (e.g. a session never recorded in
646    /// reduced mode has no sidecar/reduction/events file).
647    pub fn archive(&self, name: &str) -> Result<()> {
648        Self::validate_name(name)?;
649        std::fs::create_dir_all(self.dir(true))?;
650        for (from, to) in [
651            (
652                self.transcript_path(name, false),
653                self.transcript_path(name, true),
654            ),
655            (self.meta_path(name, false), self.meta_path(name, true)),
656            (
657                self.sidecar_path_in(name, false),
658                self.sidecar_path_in(name, true),
659            ),
660            (
661                self.reduction_path(name, false),
662                self.reduction_path(name, true),
663            ),
664            (self.events_path(name, false), self.events_path(name, true)),
665            (self.usage_path(name, false), self.usage_path(name, true)),
666            (
667                self.model_change_path(name, false),
668                self.model_change_path(name, true),
669            ),
670            (
671                self.git_metadata_path(name, false),
672                self.git_metadata_path(name, true),
673            ),
674            (self.goal_path(name, false), self.goal_path(name, true)),
675            (self.fork_path(name, false), self.fork_path(name, true)),
676            (self.tree_path(name, false), self.tree_path(name, true)),
677            (
678                self.journal_path_in(name, false),
679                self.journal_path_in(name, true),
680            ),
681            (
682                self.plan_path_in(name, false),
683                self.plan_path_in(name, true),
684            ),
685            (
686                self.claude_runtime_path(name, false),
687                self.claude_runtime_path(name, true),
688            ),
689        ] {
690            if from.exists() {
691                std::fs::rename(&from, &to)?;
692            }
693        }
694        // P5-3 (D5 "folded into archive… like other session sidecars"): the
695        // `<name>.subagents/` directory is a WHOLE-DIRECTORY member of the
696        // family — moved as a unit (not file-by-file) since its member
697        // count varies per session.
698        let subagents_from = self.subagents_dir(name, false);
699        if subagents_from.exists() {
700            std::fs::rename(&subagents_from, self.subagents_dir(name, true))?;
701        }
702        Ok(())
703    }
704
705    /// Permanently delete a session (active or archived): the whole
706    /// `<name>.*` family (D1) — a delete that left a full-fidelity sidecar
707    /// behind would be a data-retention surprise. Tolerates any member that
708    /// doesn't exist.
709    pub fn delete(&self, name: &str) -> Result<()> {
710        Self::validate_name(name)?;
711        for archived in [false, true] {
712            for p in [
713                self.transcript_path(name, archived),
714                self.meta_path(name, archived),
715                self.sidecar_path_in(name, archived),
716                self.reduction_path(name, archived),
717                self.events_path(name, archived),
718                self.usage_path(name, archived),
719                self.model_change_path(name, archived),
720                self.git_metadata_path(name, archived),
721                self.goal_path(name, archived),
722                self.fork_path(name, archived),
723                self.tree_path(name, archived),
724                self.journal_path_in(name, archived),
725                self.plan_path_in(name, archived),
726                self.claude_runtime_path(name, archived),
727            ] {
728                if p.exists() {
729                    std::fs::remove_file(p)?;
730                }
731            }
732            // P5-3 (D5 "…delete… like other session sidecars"): the whole
733            // `<name>.subagents/` directory, active and archived.
734            let subagents_dir = self.subagents_dir(name, archived);
735            if subagents_dir.exists() {
736                std::fs::remove_dir_all(&subagents_dir)?;
737            }
738        }
739        Ok(())
740    }
741
742    /// Rename the human-readable title of a session, preserving its other
743    /// recorded stats (`reduced`, `tier`, byte/stub counts, ...) rather than
744    /// resetting them to defaults.
745    pub fn set_title(&self, name: &str, title: &str) -> Result<()> {
746        Self::validate_name(name)?;
747        for archived in [false, true] {
748            let mp = self.meta_path(name, archived);
749            if mp.exists() {
750                let mut info: SessionInfo = std::fs::read_to_string(&mp)
751                    .ok()
752                    .and_then(|t| serde_json::from_str(&t).ok())
753                    .unwrap_or_else(|| SessionInfo {
754                        name: name.to_string(),
755                        ..Default::default()
756                    });
757                info.title = title.to_string();
758                info.archived = archived;
759                std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
760                return Ok(());
761            }
762        }
763        Err(Error::Other(format!("no session named `{name}`")))
764    }
765
766    /// The store's root directory.
767    pub fn root(&self) -> &Path {
768        &self.root
769    }
770
771    /// The transcript file's mtime (active or archived), if it exists.
772    ///
773    /// Legacy session names embed a creation timestamp (`<tag>-<micros>`),
774    /// so callers could derive age/order from the name alone. UX-25's
775    /// memorable names (`<tag>-<adjective>-<noun>`) carry no timestamp, so
776    /// callers that need one — ordering `sessions list`, resolving
777    /// `--continue`/`--last` — fall back to this instead.
778    pub fn transcript_mtime(&self, name: &str) -> Option<std::time::SystemTime> {
779        Self::validate_name(name).ok()?;
780        let active = self.transcript_path(name, false);
781        let path = if active.exists() {
782            active
783        } else {
784            self.transcript_path(name, true)
785        };
786        std::fs::metadata(path).ok()?.modified().ok()
787    }
788
789    /// Record (or update) a session's reduced-mode stats (C1/C9):
790    /// `reduced = true` plus the full/view byte counts and stub count.
791    /// Creates `<name>.meta.json` with `title` if it doesn't exist yet (so a
792    /// reduced-mode `resume` is visible to `sessions list` even before any
793    /// plain transcript has been saved for it under this name); otherwise
794    /// preserves the existing title/archived flag, like [`Self::set_title`].
795    pub fn set_reduction_stats(
796        &self,
797        name: &str,
798        title: &str,
799        full_bytes: u64,
800        view_bytes: u64,
801        stub_count: u32,
802    ) -> Result<()> {
803        Self::validate_name(name)?;
804        std::fs::create_dir_all(self.dir(false))?;
805        let mp = self.meta_path(name, false);
806        let mut info: SessionInfo = std::fs::read_to_string(&mp)
807            .ok()
808            .and_then(|t| serde_json::from_str(&t).ok())
809            .unwrap_or_else(|| SessionInfo {
810                name: name.to_string(),
811                title: title.to_string(),
812                ..Default::default()
813            });
814        info.reduced = true;
815        info.full_bytes = full_bytes;
816        info.view_bytes = view_bytes;
817        info.stub_count = stub_count;
818        std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
819        Ok(())
820    }
821
822    /// P4e (§1.6 obligation-6 "fork-to-new-file WITH provenance", CX shape:
823    /// "linear store, fork copies + truncation"): copy session `from`'s
824    /// transcript into a NEW session `to`, optionally truncated to the
825    /// first `truncate_at_message` lines (each line is one message; `None`
826    /// is a full, byte-identical copy — the pre-P4e `sessions fork`
827    /// behavior), and persist a [`ForkProvenance`] record for `to` (typed,
828    /// lossless per §1.13: an auditor/translator can always recover exactly
829    /// which session and message offset a fork came from). Does NOT touch
830    /// `from` at all -- the source session's own full fidelity is
831    /// unaffected regardless of whether `to` is truncated.
832    pub fn fork(
833        &self,
834        from: &str,
835        to: &str,
836        title: &str,
837        truncate_at_message: Option<usize>,
838        timestamp_ms: i64,
839    ) -> Result<ForkProvenance> {
840        Self::validate_name(from)?;
841        Self::validate_name(to)?;
842        let jsonl = self.load(from)?;
843        if let Some(n) = truncate_at_message {
844            Self::validate_safe_truncation(&jsonl, n)?;
845        }
846        let content = match truncate_at_message {
847            Some(n) => {
848                let lines: Vec<&str> = jsonl.lines().take(n).collect();
849                if lines.is_empty() {
850                    String::new()
851                } else {
852                    let mut s = lines.join("\n");
853                    s.push('\n');
854                    s
855                }
856            }
857            None => jsonl,
858        };
859        self.save(to, title, &content)?;
860
861        // DEFECT-4 fix (independent Fable-5 review of P4e): copy the whole
862        // `<name>.*` sidecar family so a fork of a REDUCED session stays
863        // expandable (§1.13 lossless: the fork doc claims lossless, but a
864        // fork that dropped the sidecar left dangling `.sidecar.jsonl`/
865        // `.reduction.json` references). Always copied WHOLE — even when
866        // `truncate_at_message` shortens the transcript — because the
867        // sidecar/logs are the full-fidelity source of truth the (possibly
868        // truncated) transcript is only ever a PROJECTION of; truncating
869        // them to match the transcript would throw away exactly the data
870        // `/expand`/handoff need to reconstruct anything beyond the cut
871        // line. `validate_safe_truncation` above is what keeps a truncated
872        // fork coherent instead: it refuses a cut that would leave the
873        // transcript ending on a dangling tool_call, so the transcript
874        // itself is always a valid, replayable prefix regardless of how
875        // much of the sidecar's fuller history now sits "ahead" of it.
876        self.copy_family_member(from, to, Self::sidecar_path_in)?;
877        self.copy_family_member(from, to, Self::reduction_path)?;
878        self.copy_family_member(from, to, Self::usage_path)?;
879        self.copy_family_member(from, to, Self::model_change_path)?;
880        self.copy_family_member(from, to, Self::git_metadata_path)?;
881        // P5-5: the `.tree.json` sidecar (if this session ever branched) is
882        // a full-fidelity family member too — copied whole, same rationale
883        // as the sidecar/reduction-log copies just above (the possibly
884        // truncated transcript is only ever a projection of it).
885        self.copy_family_member(from, to, Self::tree_path)?;
886        self.copy_family_member(from, to, Self::claude_runtime_path)?;
887
888        let provenance = ForkProvenance {
889            forked_from: from.to_string(),
890            forked_at_message: truncate_at_message,
891            timestamp_ms,
892        };
893        self.save_fork_provenance(to, &provenance)?;
894        Ok(provenance)
895    }
896
897    /// DEFECT-4 fix: copy one member of the `<name>.*` sidecar family from
898    /// `from` to `to`'s ACTIVE location (a fresh fork always lands active,
899    /// never pre-archived), reading `from`'s active copy if present, else
900    /// its archived one — mirrors every other member accessor's
901    /// active-or-archived fallback (`load_sidecar`, `load_reduction_log`,
902    /// ...). A no-op (not an error) when `from` never recorded this member
903    /// at all, matching [`Self::archive`]/[`Self::delete`]'s tolerance.
904    fn copy_family_member(
905        &self,
906        from: &str,
907        to: &str,
908        path_of: impl Fn(&Self, &str, bool) -> PathBuf,
909    ) -> Result<()> {
910        let active = path_of(self, from, false);
911        let src = if active.exists() {
912            active
913        } else {
914            let archived = path_of(self, from, true);
915            if !archived.exists() {
916                return Ok(());
917            }
918            archived
919        };
920        std::fs::create_dir_all(self.dir(false))?;
921        std::fs::copy(&src, path_of(self, to, false))?;
922        Ok(())
923    }
924
925    /// DEFECT-4 fix: refuse a `--at n` fork whose cut point would leave the
926    /// truncated transcript ending on an assistant `tool_calls` message
927    /// whose tool-result reply (or replies, for a parallel batch) falls at
928    /// or past `n` — i.e. a dangling tool_call with no matching tool
929    /// message in the kept prefix. Such a transcript is neither a valid
930    /// provider request (an assistant tool_calls turn MUST be followed by
931    /// matching tool results before the next real turn) nor safely
932    /// `/expand`-able. `n == 0` (an empty fork) and any `n` that lands on a
933    /// clean turn boundary both pass trivially.
934    fn validate_safe_truncation(jsonl: &str, n: usize) -> Result<()> {
935        let kept: Vec<crate::message::ChatMessage> = jsonl
936            .lines()
937            .take(n)
938            .filter(|l| !l.trim().is_empty())
939            .map(|l| serde_json::from_str(l).map_err(Error::Decode))
940            .collect::<Result<_>>()?;
941        let mut pending: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
942        for m in &kept {
943            if let Some(calls) = &m.tool_calls {
944                for c in calls {
945                    pending.insert(c.id.clone());
946                }
947            }
948            if let Some(id) = &m.tool_call_id {
949                pending.remove(id);
950            }
951        }
952        if !pending.is_empty() {
953            return Err(Error::Other(format!(
954                "fork --at {n} would cut off {} unresolved tool_call result(s) ({}) — \
955                 choose a boundary at or after the assistant's tool_calls message AND \
956                 all of its tool results",
957                pending.len(),
958                pending.into_iter().collect::<Vec<_>>().join(", "),
959            )));
960        }
961        Ok(())
962    }
963
964    /// Persist `<name>`'s [`ForkProvenance`] as `<name>.fork.json` —
965    /// overwrite semantics, like [`Self::save_reduction_log`].
966    pub fn save_fork_provenance(&self, name: &str, provenance: &ForkProvenance) -> Result<()> {
967        Self::validate_name(name)?;
968        std::fs::create_dir_all(self.dir(false))?;
969        let json = serde_json::to_string(provenance).map_err(Error::Decode)?;
970        std::fs::write(self.fork_path(name, false), json)?;
971        Ok(())
972    }
973
974    /// Read `<name>`'s [`ForkProvenance`] (active or archived). `None`
975    /// (not an error) when `<name>` was never created via [`Self::fork`].
976    pub fn load_fork_provenance(&self, name: &str) -> Result<Option<ForkProvenance>> {
977        Self::validate_name(name)?;
978        let active = self.fork_path(name, false);
979        let path = if active.exists() {
980            active
981        } else {
982            self.fork_path(name, true)
983        };
984        if !path.exists() {
985            return Ok(None);
986        }
987        Ok(Some(
988            serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
989        ))
990    }
991
992    /// P5-5 (design §2 module 21 `session.tree`, §1.6 "typed session data …
993    /// folded into archive/delete/list"): persist `<name>`'s
994    /// [`crate::session_tree::SessionTree`] as `<name>.tree.json` —
995    /// overwrite semantics, like [`Self::save_reduction_log`].
996    pub fn save_tree(&self, name: &str, tree: &crate::session_tree::SessionTree) -> Result<()> {
997        Self::validate_name(name)?;
998        std::fs::create_dir_all(self.dir(false))?;
999        let json = serde_json::to_string(tree).map_err(Error::Decode)?;
1000        std::fs::write(self.tree_path(name, false), json)?;
1001        Ok(())
1002    }
1003
1004    /// Persist the non-executing Claude runtime manifest as a member of this
1005    /// session's sidecar family.
1006    pub fn save_claude_runtime_manifest(
1007        &self,
1008        name: &str,
1009        manifest: &crate::claude_runtime_state::ClaudeRuntimeManifest,
1010    ) -> Result<()> {
1011        Self::validate_name(name)?;
1012        std::fs::create_dir_all(self.dir(false))?;
1013        let json = serde_json::to_string(manifest).map_err(Error::Decode)?;
1014        std::fs::write(self.claude_runtime_path(name, false), json)?;
1015        Ok(())
1016    }
1017
1018    /// Load a Claude runtime manifest from the active or archived family.
1019    pub fn load_claude_runtime_manifest(
1020        &self,
1021        name: &str,
1022    ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
1023        Self::validate_name(name)?;
1024        let active = self.claude_runtime_path(name, false);
1025        let path = if active.exists() {
1026            active
1027        } else {
1028            self.claude_runtime_path(name, true)
1029        };
1030        if !path.exists() {
1031            return Ok(None);
1032        }
1033        Ok(Some(
1034            serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1035        ))
1036    }
1037
1038    /// Load a Claude runtime manifest from exactly the selected family.
1039    pub fn load_claude_runtime_manifest_from(
1040        &self,
1041        name: &str,
1042        archived: bool,
1043    ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
1044        Self::validate_name(name)?;
1045        let path = self.claude_runtime_path(name, archived);
1046        if !path.exists() {
1047            return Ok(None);
1048        }
1049        Ok(Some(
1050            serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1051        ))
1052    }
1053
1054    /// Read `<name>`'s [`crate::session_tree::SessionTree`] (active or
1055    /// archived). `None` (not an error) when no tree operation was ever
1056    /// persisted for this session — the default, degenerate-single-path
1057    /// case (see `Self::tree_path`'s doc comment).
1058    pub fn load_tree(&self, name: &str) -> Result<Option<crate::session_tree::SessionTree>> {
1059        Self::validate_name(name)?;
1060        let active = self.tree_path(name, false);
1061        let path = if active.exists() {
1062            active
1063        } else {
1064            self.tree_path(name, true)
1065        };
1066        if !path.exists() {
1067            return Ok(None);
1068        }
1069        Ok(Some(
1070            serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1071        ))
1072    }
1073
1074    /// P5-3 (design §2 module 9 D5 "subagent transcripts"): persist a
1075    /// natively-spawned child's full sidecar (native-v2 JSONL, typically
1076    /// [`crate::session::Session::to_native_jsonl_v2`]'s output, carrying
1077    /// the child's own lineage header — see that method's doc comment) at
1078    /// `<parent_name>.subagents/<child_id>.sidecar.jsonl`. `child_id` is
1079    /// validated exactly like a top-level session name (it becomes a file
1080    /// stem too) — same path-traversal floor as `Self::validate_name`.
1081    pub fn save_subagent_transcript(
1082        &self,
1083        parent_name: &str,
1084        child_id: &str,
1085        sidecar_jsonl: &str,
1086    ) -> Result<()> {
1087        Self::validate_name(parent_name)?;
1088        Self::validate_name(child_id)?;
1089        std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
1090        std::fs::write(
1091            self.subagent_transcript_path(parent_name, child_id, false),
1092            sidecar_jsonl,
1093        )?;
1094        Ok(())
1095    }
1096
1097    /// Persist every subagent attached to an imported [`crate::session::Session`]
1098    /// into this store's existing `<parent_name>.subagents/` family.
1099    ///
1100    /// Each child is wrapped in native-v2 before it is written, so its
1101    /// foreign-harness `raw` body survives a later process/disk reload
1102    /// byte-for-byte. All ids are validated (and duplicates rejected) before
1103    /// the first write: an import with incomplete lineage must fail loudly
1104    /// instead of silently dropping or overwriting a child transcript.
1105    pub fn save_imported_subagents(
1106        &self,
1107        parent_name: &str,
1108        subagents: &[crate::session::Session],
1109    ) -> Result<usize> {
1110        Self::validate_name(parent_name)?;
1111
1112        let mut seen = std::collections::BTreeSet::new();
1113        for child in subagents {
1114            let child_id = child.meta.agent_id.as_deref().ok_or_else(|| {
1115                Error::Other(format!(
1116                    "cannot persist an imported subagent for `{parent_name}` without an agent id"
1117                ))
1118            })?;
1119            Self::validate_name(child_id)?;
1120            if !seen.insert(child_id.to_string()) {
1121                return Err(Error::Other(format!(
1122                    "duplicate imported subagent id `{child_id}` for `{parent_name}`"
1123                )));
1124            }
1125        }
1126
1127        // Serialize/write one at a time: real Claude sessions can have
1128        // hundreds of MiB of child logs, so retaining a second in-memory
1129        // copy of every child at once would defeat the resume path this
1130        // helper exists to support.
1131        for child in subagents {
1132            let child_id = child.meta.agent_id.as_deref().expect("validated above");
1133            self.save_subagent_transcript(parent_name, child_id, &child.to_native_jsonl_v2(&[]))?;
1134        }
1135        Ok(subagents.len())
1136    }
1137
1138    /// Read a child's sidecar (active or archived). `None` when this
1139    /// `(parent_name, child_id)` pair was never saved.
1140    pub fn load_subagent_transcript(
1141        &self,
1142        parent_name: &str,
1143        child_id: &str,
1144    ) -> Result<Option<String>> {
1145        Self::validate_name(parent_name)?;
1146        Self::validate_name(child_id)?;
1147        let active = self.subagent_transcript_path(parent_name, child_id, false);
1148        let path = if active.exists() {
1149            active
1150        } else {
1151            self.subagent_transcript_path(parent_name, child_id, true)
1152        };
1153        if !path.exists() {
1154            return Ok(None);
1155        }
1156        Ok(Some(std::fs::read_to_string(path)?))
1157    }
1158
1159    /// Read a child sidecar from exactly the selected parent family.
1160    pub fn load_subagent_transcript_from(
1161        &self,
1162        parent_name: &str,
1163        child_id: &str,
1164        archived: bool,
1165    ) -> Result<Option<String>> {
1166        Self::validate_name(parent_name)?;
1167        Self::validate_name(child_id)?;
1168        let path = self.subagent_transcript_path(parent_name, child_id, archived);
1169        if !path.exists() {
1170            return Ok(None);
1171        }
1172        Ok(Some(std::fs::read_to_string(path)?))
1173    }
1174
1175    /// P5-3: persist a child's typed [`crate::subagents::SubagentLineage`]
1176    /// record at `<parent_name>.subagents/<child_id>.lineage.json` —
1177    /// overwrite semantics, like [`Self::save_reduction_log`].
1178    pub fn save_subagent_lineage(
1179        &self,
1180        parent_name: &str,
1181        child_id: &str,
1182        record: &crate::subagents::SubagentLineage,
1183    ) -> Result<()> {
1184        Self::validate_name(parent_name)?;
1185        Self::validate_name(child_id)?;
1186        std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
1187        let json = serde_json::to_string(record).map_err(Error::Decode)?;
1188        std::fs::write(
1189            self.subagent_lineage_path(parent_name, child_id, false),
1190            json,
1191        )?;
1192        Ok(())
1193    }
1194
1195    /// Read a child's lineage record (active or archived). `None` when this
1196    /// `(parent_name, child_id)` pair was never saved.
1197    pub fn load_subagent_lineage(
1198        &self,
1199        parent_name: &str,
1200        child_id: &str,
1201    ) -> Result<Option<crate::subagents::SubagentLineage>> {
1202        Self::validate_name(parent_name)?;
1203        Self::validate_name(child_id)?;
1204        let active = self.subagent_lineage_path(parent_name, child_id, false);
1205        let path = if active.exists() {
1206            active
1207        } else {
1208            self.subagent_lineage_path(parent_name, child_id, true)
1209        };
1210        if !path.exists() {
1211            return Ok(None);
1212        }
1213        Ok(Some(
1214            serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1215        ))
1216    }
1217
1218    /// P5-3: every child id natively spawned under `parent_name` (active AND
1219    /// archived, deduped and sorted) — discovered from the `.sidecar.jsonl`
1220    /// members of `Self::subagents_dir`, the same "list what's on disk"
1221    /// posture [`Self::list`] uses for top-level sessions.
1222    pub fn list_subagent_ids(&self, parent_name: &str) -> Result<Vec<String>> {
1223        Self::validate_name(parent_name)?;
1224        let mut ids = std::collections::BTreeSet::new();
1225        for archived in [false, true] {
1226            let dir = self.subagents_dir(parent_name, archived);
1227            let Ok(rd) = std::fs::read_dir(&dir) else {
1228                continue;
1229            };
1230            for entry in rd.flatten() {
1231                let p = entry.path();
1232                if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1233                    if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1234                        ids.insert(id.to_string());
1235                    }
1236                }
1237            }
1238        }
1239        Ok(ids.into_iter().collect())
1240    }
1241
1242    /// List child ids from exactly the selected active/archive family.
1243    pub fn list_subagent_ids_from(&self, parent_name: &str, archived: bool) -> Result<Vec<String>> {
1244        Self::validate_name(parent_name)?;
1245        let mut ids = std::collections::BTreeSet::new();
1246        let dir = self.subagents_dir(parent_name, archived);
1247        let Ok(rd) = std::fs::read_dir(&dir) else {
1248            return Ok(Vec::new());
1249        };
1250        for entry in rd.flatten() {
1251            let p = entry.path();
1252            if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1253                if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1254                    ids.insert(id.to_string());
1255                }
1256            }
1257        }
1258        Ok(ids.into_iter().collect())
1259    }
1260
1261    // ---- BP-8: the append-only journal (catalog:150/154/156) ------------
1262
1263    /// The journal path for `name` — `<root>/<name>.journal.jsonl`.
1264    pub fn journal_path(&self, name: &str) -> Result<PathBuf> {
1265        Self::validate_name(name)?;
1266        Ok(self.journal_path_in(name, false))
1267    }
1268
1269    /// Open (creating if absent) `name`'s append-only journal for writing.
1270    pub fn open_journal(&self, name: &str) -> Result<crate::session_journal::SessionJournal> {
1271        Self::validate_name(name)?;
1272        crate::session_journal::SessionJournal::open_append(&self.journal_path_in(name, false))
1273    }
1274
1275    /// Replay `name`'s journal (active or archived). `None` when the
1276    /// session never had one — every pre-BP-8 session, and every session
1277    /// whose config left `[core.session] append_only` off.
1278    pub fn load_journal(&self, name: &str) -> Result<Option<crate::session_journal::JournalState>> {
1279        Self::validate_name(name)?;
1280        for archived in [false, true] {
1281            let path = self.journal_path_in(name, archived);
1282            if path.exists() {
1283                return crate::session_journal::replay(&path);
1284            }
1285        }
1286        Ok(None)
1287    }
1288
1289    /// Write `name`'s current plan (the folded head of the journal's `plan`
1290    /// records) as `<name>.plan.json`.
1291    pub fn save_plan(&self, name: &str, plan: &[crate::session_journal::PlanEntry]) -> Result<()> {
1292        Self::validate_name(name)?;
1293        std::fs::create_dir_all(self.dir(false))?;
1294        std::fs::write(
1295            self.plan_path_in(name, false),
1296            serde_json::to_string(plan).map_err(Error::Decode)?,
1297        )?;
1298        Ok(())
1299    }
1300
1301    /// Read `name`'s persisted plan (active or archived), if any.
1302    pub fn load_plan(&self, name: &str) -> Result<Option<Vec<crate::session_journal::PlanEntry>>> {
1303        Self::validate_name(name)?;
1304        for archived in [false, true] {
1305            let path = self.plan_path_in(name, archived);
1306            if path.exists() {
1307                let text = std::fs::read_to_string(path)?;
1308                return Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?));
1309            }
1310        }
1311        Ok(None)
1312    }
1313
1314    // ---- BP-8: rename (catalog:152 "Session naming/rename") -------------
1315
1316    /// Change a session's RESUME HANDLE — the name every resume door takes
1317    /// — by moving its whole `<name>.*` family (D1) to the new stem, in
1318    /// whichever of the active/archived directories it lives in.
1319    ///
1320    /// This is the half [`Self::set_title`] is not: `set_title` changes the
1321    /// display string only, and a session found by its old handle after a
1322    /// title change is the same session. A rename moves the handle itself,
1323    /// so it must move every family member atomically enough that a
1324    /// half-renamed session is never left behind — hence the up-front
1325    /// collision check (any `<to>.*` member existing at all refuses) rather
1326    /// than discovering the clash halfway through the moves.
1327    pub fn rename(&self, from: &str, to: &str) -> Result<()> {
1328        Self::validate_name(from)?;
1329        Self::validate_name(to)?;
1330        if from == to {
1331            return Ok(());
1332        }
1333        let mut moves: Vec<(PathBuf, PathBuf)> = Vec::new();
1334        let mut found = false;
1335        for archived in [false, true] {
1336            let dir = self.dir(archived);
1337            let Ok(rd) = std::fs::read_dir(&dir) else {
1338                continue;
1339            };
1340            for entry in rd.flatten() {
1341                let file = entry.file_name();
1342                let Some(file) = file.to_str() else { continue };
1343                // `<stem>.` prefix: session stems never contain a `.`, so
1344                // this can only match this session's own family members
1345                // (`<name>.jsonl`, `<name>.meta.json`, `<name>.subagents/`, …).
1346                let Some(suffix) = file.strip_prefix(&format!("{from}.")) else {
1347                    continue;
1348                };
1349                found = true;
1350                let target = dir.join(format!("{to}.{suffix}"));
1351                if target.exists() {
1352                    return Err(Error::Other(format!(
1353                        "cannot rename `{from}` to `{to}`: `{}` already exists",
1354                        target.display()
1355                    )));
1356                }
1357                moves.push((entry.path(), target));
1358            }
1359        }
1360        if !found {
1361            return Err(Error::Other(format!("no session named `{from}`")));
1362        }
1363        for (src, dst) in &moves {
1364            std::fs::rename(src, dst)?;
1365        }
1366        // The meta carries the name as data too; a renamed session that
1367        // still reported its old name to `list` would be a split brain.
1368        for archived in [false, true] {
1369            let mp = self.meta_path(to, archived);
1370            if mp.exists() {
1371                if let Some(mut info) = std::fs::read_to_string(&mp)
1372                    .ok()
1373                    .and_then(|t| serde_json::from_str::<SessionInfo>(&t).ok())
1374                {
1375                    info.name = to.to_string();
1376                    info.archived = archived;
1377                    std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
1378                }
1379            }
1380        }
1381        // The rename is itself a session event: record it in the (moved)
1382        // journal so the handle's history is recoverable from the log.
1383        if self.journal_path_in(to, false).exists() {
1384            let mut journal = self.open_journal(to)?;
1385            journal.append(crate::session_journal::JournalOp::Rename {
1386                from: from.to_string(),
1387                to: to.to_string(),
1388            })?;
1389        }
1390        self.invalidate_index();
1391        Ok(())
1392    }
1393
1394    // ---- BP-8: format versioning + in-place upgrade (catalog:153) --------
1395
1396    /// The generation THIS build writes. A session whose meta carries this
1397    /// value is already in the current on-disk shape and
1398    /// [`Self::upgrade_in_place`] does nothing for it.
1399    pub const FORMAT_VERSION: u32 = 2;
1400
1401    /// The generation `name`'s stored family is at. `0` means the meta
1402    /// carries no marker at all — every session written before BP-8.
1403    pub fn format_version(&self, name: &str) -> u32 {
1404        self.list()
1405            .into_iter()
1406            .find(|s| s.name == name)
1407            .map(|s| s.format_version)
1408            .unwrap_or(0)
1409    }
1410
1411    /// Upgrade `name`'s stored transcript IN PLACE to
1412    /// [`Self::FORMAT_VERSION`], and stamp the marker so no later read
1413    /// repeats the work.
1414    ///
1415    /// **Reversible.** The original bytes are copied verbatim to
1416    /// `<name>.v<old>.jsonl` BEFORE anything is rewritten, so the
1417    /// pre-upgrade file is always recoverable; the rewrite itself is a
1418    /// per-line [`crate::ChatMessage`] round-trip, which drops keys this
1419    /// build does not model and normalizes the ones it does — never
1420    /// collapsing multimodal parts into text or otherwise changing what the
1421    /// line MEANS.
1422    ///
1423    /// Returns `None` when the session is already current (or has no
1424    /// transcript) — that `None` is the whole point of the marker: the
1425    /// tolerant path is paid once, not on every read.
1426    pub fn upgrade_in_place(&self, name: &str) -> Result<Option<FormatUpgrade>> {
1427        Self::validate_name(name)?;
1428        let current = self.format_version(name);
1429        if current >= Self::FORMAT_VERSION {
1430            return Ok(None);
1431        }
1432        let (path, archived) = {
1433            let active = self.transcript_path(name, false);
1434            if active.exists() {
1435                (active, false)
1436            } else {
1437                let arch = self.transcript_path(name, true);
1438                if !arch.exists() {
1439                    return Ok(None);
1440                }
1441                (arch, true)
1442            }
1443        };
1444        let original = std::fs::read_to_string(&path)?;
1445        let mut upgraded = String::with_capacity(original.len());
1446        let mut lines = 0usize;
1447        for line in original.lines() {
1448            if line.trim().is_empty() {
1449                continue;
1450            }
1451            let msg: crate::ChatMessage =
1452                serde_json::from_str(line.trim()).map_err(Error::Decode)?;
1453            if lines > 0 {
1454                upgraded.push('\n');
1455            }
1456            upgraded.push_str(&serde_json::to_string(&msg).map_err(Error::Decode)?);
1457            lines += 1;
1458        }
1459        let backup_name = format!("{name}.v{current}.jsonl");
1460        let backup = self.dir(archived).join(&backup_name);
1461        // Preserve first, rewrite second: an interruption between the two
1462        // leaves the original intact under both names, never neither.
1463        std::fs::write(&backup, original.as_bytes())?;
1464        let rewritten = upgraded != original;
1465        if rewritten {
1466            std::fs::write(&path, upgraded.as_bytes())?;
1467        }
1468        self.set_format_version(name, Self::FORMAT_VERSION)?;
1469        if self.journal_path_in(name, false).exists() {
1470            let mut journal = self.open_journal(name)?;
1471            journal.append(crate::session_journal::JournalOp::Upgrade {
1472                from_version: current,
1473                to_version: Self::FORMAT_VERSION,
1474                original: backup_name.clone(),
1475            })?;
1476        }
1477        self.invalidate_index();
1478        Ok(Some(FormatUpgrade {
1479            from_version: current,
1480            to_version: Self::FORMAT_VERSION,
1481            original: backup_name,
1482            messages: lines,
1483            rewritten,
1484        }))
1485    }
1486
1487    /// Stamp the format marker on `name`'s meta, preserving every other
1488    /// recorded field (same posture as [`Self::set_title`]).
1489    pub fn set_format_version(&self, name: &str, version: u32) -> Result<()> {
1490        Self::validate_name(name)?;
1491        for archived in [false, true] {
1492            let mp = self.meta_path(name, archived);
1493            if mp.exists() {
1494                let mut info: SessionInfo = std::fs::read_to_string(&mp)
1495                    .ok()
1496                    .and_then(|t| serde_json::from_str(&t).ok())
1497                    .unwrap_or_else(|| SessionInfo {
1498                        name: name.to_string(),
1499                        ..Default::default()
1500                    });
1501                info.format_version = version;
1502                info.archived = archived;
1503                std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
1504                return Ok(());
1505            }
1506        }
1507        Err(Error::Other(format!("no session named `{name}`")))
1508    }
1509
1510    // ---- BP-8: the derived index cache (catalog:151) ---------------------
1511
1512    /// `<root>/.session-index.json` — the derived listing cache. Never a
1513    /// session name (it starts with a dot, which [`Self::validate_name`]
1514    /// rejects) and never a `*.meta.json`, so [`Self::list`] cannot see it.
1515    pub fn index_path(&self) -> PathBuf {
1516        self.root.join(".session-index.json")
1517    }
1518
1519    /// Delete the derived index. Purely a cache drop: the next
1520    /// [`Self::index`] rebuilds an identical answer from the transcripts,
1521    /// which remain the only record.
1522    pub fn invalidate_index(&self) {
1523        let _ = std::fs::remove_file(self.index_path());
1524    }
1525
1526    /// The fast listing: title, age-ordering time, message count and a
1527    /// first-user-line preview for every session, WITHOUT reading each
1528    /// transcript on every call.
1529    ///
1530    /// The transcripts stay authoritative. Each cached row carries the
1531    /// `(mtime, size)` of the transcript it was derived from; a row whose
1532    /// validator still matches is reused, and any row that does not (or is
1533    /// missing) is re-derived by reading that one file. The cache is then
1534    /// written back. Deleting [`Self::index_path`] therefore changes
1535    /// nothing except how much work the next call does — which is exactly
1536    /// what makes it a cache and not a second source of truth.
1537    pub fn index(&self) -> SessionIndex {
1538        let cached: std::collections::HashMap<String, SessionIndexEntry> =
1539            std::fs::read_to_string(self.index_path())
1540                .ok()
1541                .and_then(|t| serde_json::from_str::<SessionIndexFile>(&t).ok())
1542                .filter(|f| f.version == SESSION_INDEX_VERSION)
1543                .map(|f| f.entries.into_iter().map(|e| (e.name.clone(), e)).collect())
1544                .unwrap_or_default();
1545        let mut out = SessionIndex::default();
1546        for info in self.list() {
1547            let path = self.transcript_path(&info.name, info.archived);
1548            let (mtime_nanos, size) = match std::fs::metadata(&path) {
1549                Ok(m) => (
1550                    m.modified()
1551                        .ok()
1552                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1553                        .map(|d| d.as_nanos() as u64)
1554                        .unwrap_or(0),
1555                    m.len(),
1556                ),
1557                Err(_) => (0, 0),
1558            };
1559            match cached.get(&info.name) {
1560                Some(hit)
1561                    if hit.mtime_nanos == mtime_nanos
1562                        && hit.size == size
1563                        && hit.archived == info.archived
1564                        && hit.title == info.title =>
1565                {
1566                    out.reused += 1;
1567                    out.entries.push(hit.clone());
1568                }
1569                _ => {
1570                    out.rederived += 1;
1571                    out.entries
1572                        .push(self.derive_index_entry(&info, mtime_nanos, size));
1573                }
1574            }
1575        }
1576        out.entries
1577            .sort_by(|a, b| b.time_secs.cmp(&a.time_secs).then(a.name.cmp(&b.name)));
1578        let file = SessionIndexFile {
1579            version: SESSION_INDEX_VERSION,
1580            entries: out.entries.clone(),
1581        };
1582        // Never CREATES the root: addressing a store must not bring one
1583        // into existence (see `SessionStore::at`), and a listing is a read.
1584        if self.root.is_dir() {
1585            if let Ok(text) = serde_json::to_string(&file) {
1586                let _ = std::fs::write(self.index_path(), text);
1587            }
1588        }
1589        out
1590    }
1591
1592    fn derive_index_entry(
1593        &self,
1594        info: &SessionInfo,
1595        mtime_nanos: u64,
1596        size: u64,
1597    ) -> SessionIndexEntry {
1598        let text = self
1599            .load_if_present_from(&info.name, info.archived)
1600            .ok()
1601            .flatten()
1602            .unwrap_or_default();
1603        let mut messages = 0usize;
1604        let mut preview = String::new();
1605        for line in text.lines() {
1606            if line.trim().is_empty() {
1607                continue;
1608            }
1609            messages += 1;
1610            if preview.is_empty() {
1611                if let Ok(msg) = serde_json::from_str::<crate::ChatMessage>(line.trim()) {
1612                    if msg.role == crate::Role::User {
1613                        if let Some(c) = msg.content.as_deref() {
1614                            preview = preview_line(c);
1615                        }
1616                    }
1617                }
1618            }
1619        }
1620        SessionIndexEntry {
1621            name: info.name.clone(),
1622            title: info.title.clone(),
1623            archived: info.archived,
1624            time_secs: derive_session_time(&info.name, mtime_nanos / 1_000_000),
1625            preview,
1626            messages,
1627            mtime_nanos,
1628            size,
1629        }
1630    }
1631
1632    /// P4e (§1.6/§3.1 `core.session.retention_days`): permanently delete
1633    /// every ARCHIVED session (never an active one — retention is a
1634    /// post-archive concern, matching every peer harness) whose transcript
1635    /// is older than `retention_days` days as of `now`. Returns the names
1636    /// deleted (empty if nothing was old enough, or `retention_days == 0`
1637    /// which this treats as "prune nothing" rather than "prune
1638    /// everything" -- an explicit, non-surprising floor).
1639    pub fn prune_expired(
1640        &self,
1641        retention_days: u32,
1642        now: std::time::SystemTime,
1643    ) -> Result<Vec<String>> {
1644        if retention_days == 0 {
1645            return Ok(Vec::new());
1646        }
1647        let Some(cutoff) = now.checked_sub(std::time::Duration::from_secs(
1648            retention_days as u64 * 86_400,
1649        )) else {
1650            return Ok(Vec::new());
1651        };
1652        let mut pruned = Vec::new();
1653        for info in self.list() {
1654            if !info.archived {
1655                continue;
1656            }
1657            let Some(mtime) = self.transcript_mtime(&info.name) else {
1658                continue;
1659            };
1660            if mtime < cutoff {
1661                self.delete(&info.name)?;
1662                pruned.push(info.name);
1663            }
1664        }
1665        Ok(pruned)
1666    }
1667}
1668
1669/// BP-8: what [`SessionStore::upgrade_in_place`] did.
1670#[derive(Debug, Clone, PartialEq, Eq)]
1671pub struct FormatUpgrade {
1672    /// The generation the file was at (`0` = unmarked).
1673    pub from_version: u32,
1674    /// The generation it is at now.
1675    pub to_version: u32,
1676    /// Store-relative file holding the ORIGINAL bytes verbatim.
1677    pub original: String,
1678    /// Messages in the upgraded transcript.
1679    pub messages: usize,
1680    /// Whether the transcript bytes actually changed (a file already in
1681    /// today's shape is only STAMPED, never rewritten).
1682    pub rewritten: bool,
1683}
1684
1685/// BP-8: the derived-index cache format version.
1686pub const SESSION_INDEX_VERSION: u32 = 1;
1687
1688/// One cached listing row — see [`SessionStore::index`].
1689#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1690pub struct SessionIndexEntry {
1691    /// Session name (the resume handle).
1692    pub name: String,
1693    /// Display title.
1694    pub title: String,
1695    /// Whether the session is archived.
1696    pub archived: bool,
1697    /// Seconds since the epoch used to order "newest first".
1698    pub time_secs: u64,
1699    /// First user line, truncated — the picker's preview.
1700    pub preview: String,
1701    /// Messages in the transcript.
1702    pub messages: usize,
1703    /// Validator: transcript mtime in unix-NANOSECONDS at derivation time.
1704    /// Nanoseconds, not milliseconds: two writes inside one millisecond
1705    /// that happen to produce the same byte count would otherwise validate
1706    /// a stale row.
1707    pub mtime_nanos: u64,
1708    /// Validator: transcript size in bytes at derivation time.
1709    pub size: u64,
1710}
1711
1712/// The listing plus how it was obtained (how many rows came from the cache
1713/// and how many had to be re-derived) — the counters make "this really is
1714/// read-repaired, not re-read from scratch" a checkable claim.
1715#[derive(Debug, Clone, Default, PartialEq, Eq)]
1716pub struct SessionIndex {
1717    /// Rows, newest first.
1718    pub entries: Vec<SessionIndexEntry>,
1719    /// Rows served from the cache without opening the transcript.
1720    pub reused: usize,
1721    /// Rows re-derived from the transcript this call.
1722    pub rederived: usize,
1723}
1724
1725#[derive(Serialize, Deserialize)]
1726struct SessionIndexFile {
1727    version: u32,
1728    entries: Vec<SessionIndexEntry>,
1729}
1730
1731/// The "when was this session last active" rule, in one place so the store
1732/// and its callers cannot drift: a legacy `<tag>-<micros>` name carries its
1733/// own creation time, and everything else falls back to the transcript's
1734/// mtime.
1735pub fn derive_session_time(name: &str, mtime_ms: u64) -> u64 {
1736    if let Some(micros) = name.rsplit('-').next().and_then(|s| s.parse::<u128>().ok()) {
1737        return (micros / 1_000_000) as u64;
1738    }
1739    mtime_ms / 1000
1740}
1741
1742/// One-line, length-capped preview of a message body.
1743fn preview_line(content: &str) -> String {
1744    let line = content.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
1745    let line = line.trim();
1746    if line.chars().count() <= 80 {
1747        return line.to_string();
1748    }
1749    let truncated: String = line.chars().take(79).collect();
1750    format!("{truncated}…")
1751}
1752
1753/// P4e (§1.6 obligation-6 "fork-to-new-file WITH provenance") — see
1754/// [`SessionStore::fork`].
1755#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1756pub struct ForkProvenance {
1757    /// The session name this fork was copied from.
1758    pub forked_from: String,
1759    /// If the fork was truncated, how many leading messages it kept.
1760    /// `None` means a full, untruncated copy.
1761    #[serde(default)]
1762    pub forked_at_message: Option<usize>,
1763    /// Unix-ms wall-clock time the fork was created.
1764    #[serde(default)]
1765    pub timestamp_ms: i64,
1766}
1767
1768#[cfg(test)]
1769mod tests {
1770    use super::*;
1771
1772    #[test]
1773    fn addressing_a_store_does_not_create_its_root() {
1774        let tmp = std::env::temp_dir().join(format!(
1775            "sc-store-address-only-{}-{}",
1776            std::process::id(),
1777            std::time::SystemTime::now()
1778                .duration_since(std::time::UNIX_EPOCH)
1779                .unwrap()
1780                .as_nanos()
1781        ));
1782        let store = SessionStore::at(&tmp);
1783        assert_eq!(store.root(), tmp);
1784        assert!(!tmp.exists());
1785        assert!(store.list().is_empty());
1786        assert!(!tmp.exists());
1787    }
1788
1789    #[test]
1790    fn rejects_path_traversal_names() {
1791        let tmp = std::env::temp_dir().join(format!("sc-store-test-{}", std::process::id()));
1792        let store = SessionStore::open(&tmp).unwrap();
1793        for bad in ["../escape", "..", "a/b", "/abs", "", "  ", ".", "x\0y"] {
1794            assert!(store.save(bad, "t", "{}").is_err(), "should reject `{bad}`");
1795            assert!(store.load(bad).is_err(), "should reject load `{bad}`");
1796            assert!(store.delete(bad).is_err(), "should reject delete `{bad}`");
1797        }
1798        // A normal name still works and stays inside the root.
1799        store.save("ok-name", "t", "{}").unwrap();
1800        assert!(tmp.join("ok-name.jsonl").exists());
1801        // Nothing escaped the root.
1802        assert!(!tmp.parent().unwrap().join("escape.jsonl").exists());
1803        let _ = std::fs::remove_dir_all(&tmp);
1804    }
1805
1806    fn temp_store() -> (SessionStore, std::path::PathBuf) {
1807        use std::sync::atomic::{AtomicU64, Ordering};
1808        static N: AtomicU64 = AtomicU64::new(0);
1809        let tmp = std::env::temp_dir().join(format!(
1810            "sc-store-model-change-{}-{}",
1811            std::process::id(),
1812            N.fetch_add(1, Ordering::SeqCst)
1813        ));
1814        (SessionStore::open(&tmp).unwrap(), tmp)
1815    }
1816
1817    /// P4c (S1.10, "round-trip losslessly through the store" — the explicit
1818    /// item-9 proof requirement): a saved `model_change` log survives a
1819    /// save/load round trip byte-for-byte in every field.
1820    #[test]
1821    fn model_change_log_round_trips_losslessly_through_the_store() {
1822        let (store, tmp) = temp_store();
1823        store.save("sess", "t", "[]").unwrap();
1824        let records = vec![
1825            crate::model_change::ModelChangeRecord::new(
1826                0,
1827                "vendor/model-a",
1828                "vendor/model-b",
1829                true,
1830                4,
1831                1_700_000_000_000,
1832            ),
1833            crate::model_change::ModelChangeRecord::new(
1834                5,
1835                "vendor/model-b",
1836                "vendor/model-c",
1837                true,
1838                0,
1839                1_700_000_050_000,
1840            ),
1841        ];
1842        store.save_model_change_log("sess", &records).unwrap();
1843        let loaded = store.load_model_change_log("sess").unwrap();
1844        assert_eq!(loaded, records);
1845        let _ = std::fs::remove_dir_all(&tmp);
1846    }
1847
1848    /// A session that never switched models has an empty (not missing/error)
1849    /// model-change log — the overwhelmingly common case.
1850    #[test]
1851    fn model_change_log_is_empty_when_never_saved() {
1852        let (store, tmp) = temp_store();
1853        store.save("sess", "t", "[]").unwrap();
1854        assert_eq!(store.load_model_change_log("sess").unwrap(), Vec::new());
1855        let _ = std::fs::remove_dir_all(&tmp);
1856    }
1857
1858    /// The `<name>.model_change.jsonl` sidecar is part of the `<name>.*`
1859    /// family: `archive`/`delete` move/remove it exactly like every other
1860    /// member (usage log, reduction log, sidecar, events).
1861    #[test]
1862    fn model_change_log_travels_with_archive_and_is_removed_by_delete() {
1863        let (store, tmp) = temp_store();
1864        store.save("sess", "t", "[]").unwrap();
1865        let records = vec![crate::model_change::ModelChangeRecord::new(
1866            0, "a", "b", true, 1, 1,
1867        )];
1868        store.save_model_change_log("sess", &records).unwrap();
1869        assert!(tmp.join("sess.model_change.jsonl").exists());
1870
1871        store.archive("sess").unwrap();
1872        assert!(!tmp.join("sess.model_change.jsonl").exists());
1873        assert!(tmp.join("archived/sess.model_change.jsonl").exists());
1874        // Still readable after archiving.
1875        assert_eq!(store.load_model_change_log("sess").unwrap(), records);
1876
1877        store.delete("sess").unwrap();
1878        assert!(!tmp.join("archived/sess.model_change.jsonl").exists());
1879        assert_eq!(store.load_model_change_log("sess").unwrap(), Vec::new());
1880        let _ = std::fs::remove_dir_all(&tmp);
1881    }
1882
1883    /// P4e (§1.13 "round-trip losslessly through the store"): a saved
1884    /// `GitMetadataRecord` survives a save/load round trip byte-for-byte.
1885    #[test]
1886    fn git_metadata_round_trips_losslessly_through_the_store() {
1887        let (store, tmp) = temp_store();
1888        store.save("sess", "t", "[]").unwrap();
1889        let record = crate::git_metadata::GitMetadataRecord {
1890            branch: Some("main".to_string()),
1891            sha: Some("deadbeef".to_string()),
1892            dirty: true,
1893            captured_at_ms: 1_700_000_000_000,
1894        };
1895        store.save_git_metadata("sess", &record).unwrap();
1896        assert_eq!(store.load_git_metadata("sess").unwrap(), Some(record));
1897        let _ = std::fs::remove_dir_all(&tmp);
1898    }
1899
1900    /// A session with `session_git_metadata` off (the default) never gets a
1901    /// `.git.json` file, and loading it back is `None`, not an error.
1902    #[test]
1903    fn git_metadata_is_none_when_never_saved() {
1904        let (store, tmp) = temp_store();
1905        store.save("sess", "t", "[]").unwrap();
1906        assert_eq!(store.load_git_metadata("sess").unwrap(), None);
1907        let _ = std::fs::remove_dir_all(&tmp);
1908    }
1909
1910    /// The `<name>.git.json` sidecar travels with `archive`/is removed by
1911    /// `delete`, exactly like every other `<name>.*` family member.
1912    #[test]
1913    fn git_metadata_travels_with_archive_and_is_removed_by_delete() {
1914        let (store, tmp) = temp_store();
1915        store.save("sess", "t", "[]").unwrap();
1916        let record = crate::git_metadata::GitMetadataRecord {
1917            branch: Some("main".to_string()),
1918            sha: None,
1919            dirty: false,
1920            captured_at_ms: 1,
1921        };
1922        store.save_git_metadata("sess", &record).unwrap();
1923        assert!(tmp.join("sess.git.json").exists());
1924
1925        store.archive("sess").unwrap();
1926        assert!(!tmp.join("sess.git.json").exists());
1927        assert!(tmp.join("archived/sess.git.json").exists());
1928        assert_eq!(store.load_git_metadata("sess").unwrap(), Some(record));
1929
1930        store.delete("sess").unwrap();
1931        assert!(!tmp.join("archived/sess.git.json").exists());
1932        assert_eq!(store.load_git_metadata("sess").unwrap(), None);
1933        let _ = std::fs::remove_dir_all(&tmp);
1934    }
1935
1936    // -------------------------------------------------------------------
1937    // P5-3 (design §2 module 9 D5 "subagent transcripts"): the
1938    // `<name>.subagents/` family member.
1939    // -------------------------------------------------------------------
1940
1941    fn sample_lineage(child_id: &str) -> crate::subagents::SubagentLineage {
1942        crate::subagents::SubagentLineage {
1943            child_agent_id: child_id.to_string(),
1944            parent_session_id: Some("parent-sess".to_string()),
1945            parent_tool_use_id: "call_1".to_string(),
1946            depth: 1,
1947            agent_type: Some("researcher".to_string()),
1948            task: "investigate the flaky test".to_string(),
1949            background: false,
1950            spawned_at_ms: 1_700_000_000_000,
1951            model: "vendor/model-a".to_string(),
1952        }
1953    }
1954
1955    /// §1.13 lossless round trip: a saved [`crate::subagents::SubagentLineage`]
1956    /// survives a save/load cycle byte-for-byte in every field.
1957    #[test]
1958    fn subagent_lineage_round_trips_losslessly_through_the_store() {
1959        let (store, tmp) = temp_store();
1960        store.save("parent-sess", "t", "[]").unwrap();
1961        let record = sample_lineage("agent-1");
1962        store
1963            .save_subagent_lineage("parent-sess", "agent-1", &record)
1964            .unwrap();
1965        assert_eq!(
1966            store
1967                .load_subagent_lineage("parent-sess", "agent-1")
1968                .unwrap(),
1969            Some(record)
1970        );
1971        let _ = std::fs::remove_dir_all(&tmp);
1972    }
1973
1974    /// A child transcript saved via `save_subagent_transcript` round-trips
1975    /// byte-for-byte AND parses back through `Session::from_native_str`
1976    /// (proving the whole native-write pipeline — lineage header included —
1977    /// not just the store's own byte plumbing).
1978    #[test]
1979    fn subagent_transcript_round_trips_and_parses_back_with_lineage() {
1980        let (store, tmp) = temp_store();
1981        store.save("parent-sess", "t", "[]").unwrap();
1982
1983        let mut session = crate::session::Session::from_claude_code_str("").unwrap();
1984        session.meta.agent_id = Some("agent-1".to_string());
1985        session.meta.parent_tool_use_id = Some("call_1".to_string());
1986        session
1987            .meta
1988            .lineage
1989            .insert("parent_thread_id".to_string(), "parent-sess".to_string());
1990        session
1991            .meta
1992            .lineage
1993            .insert("depth".to_string(), "1".to_string());
1994        let appended = vec![crate::message::ChatMessage::user("hello from the child")];
1995        let sidecar_jsonl = session.to_native_jsonl_v2(&appended);
1996
1997        store
1998            .save_subagent_transcript("parent-sess", "agent-1", &sidecar_jsonl)
1999            .unwrap();
2000        let loaded = store
2001            .load_subagent_transcript("parent-sess", "agent-1")
2002            .unwrap()
2003            .expect("just saved");
2004        assert_eq!(loaded, sidecar_jsonl);
2005
2006        let parsed = crate::session::Session::from_native_str(&loaded).unwrap();
2007        assert_eq!(parsed.meta.agent_id.as_deref(), Some("agent-1"));
2008        assert_eq!(parsed.meta.parent_tool_use_id.as_deref(), Some("call_1"));
2009        assert_eq!(
2010            parsed.meta.lineage.get("parent_thread_id"),
2011            Some(&"parent-sess".to_string())
2012        );
2013        assert_eq!(
2014            parsed.messages.last().and_then(|m| m.content.as_deref()),
2015            Some("hello from the child")
2016        );
2017
2018        assert_eq!(
2019            store.list_subagent_ids("parent-sess").unwrap(),
2020            vec!["agent-1".to_string()]
2021        );
2022        let _ = std::fs::remove_dir_all(&tmp);
2023    }
2024
2025    /// Imported Claude child logs use the same store family as native
2026    /// children, but retain their foreign raw body inside native-v2. Prove
2027    /// the real process boundary: close/reopen the store, parse the wrapper,
2028    /// and recover CRLF/trailing-whitespace source bytes exactly.
2029    #[test]
2030    fn imported_subagents_survive_disk_reload_with_verbatim_source_bytes() {
2031        let (store, tmp) = temp_store();
2032        let original = concat!(
2033            "{\"type\":\"user\",\"sessionId\":\"parent\",\"agentId\":\"child-7\",\"uuid\":\"u1\",\"parentUuid\":null,\"message\":{\"role\":\"user\",\"content\":\"inspect it\"}}  \r\n",
2034            "{\"type\":\"queue-operation\",\"operation\":\"dequeue\"}"
2035        );
2036        let mut child = crate::session::Session::from_claude_code_str(original).unwrap();
2037        child.meta.agent_id = Some("child-7".to_string());
2038        child.meta.parent_tool_use_id = Some("toolu_task_7".to_string());
2039
2040        assert_eq!(
2041            store
2042                .save_imported_subagents("parent-sess", &[child])
2043                .unwrap(),
2044            1
2045        );
2046        drop(store);
2047
2048        let reopened = SessionStore::open(&tmp).unwrap();
2049        let native = reopened
2050            .load_subagent_transcript("parent-sess", "child-7")
2051            .unwrap()
2052            .expect("imported child persisted");
2053        let loaded = crate::session::Session::from_sidecar_str(&native).unwrap();
2054        assert_eq!(
2055            loaded.meta.source,
2056            crate::session::SessionSource::ClaudeCode
2057        );
2058        assert_eq!(loaded.meta.agent_id.as_deref(), Some("child-7"));
2059        assert_eq!(
2060            loaded.meta.parent_tool_use_id.as_deref(),
2061            Some("toolu_task_7")
2062        );
2063        assert_eq!(loaded.raw_verbatim(), original);
2064        assert!(loaded.raw_is_verbatim);
2065
2066        let _ = std::fs::remove_dir_all(&tmp);
2067    }
2068
2069    /// Validate the complete imported child id set before writing anything,
2070    /// so a duplicate cannot silently overwrite the first transcript.
2071    #[test]
2072    fn imported_subagent_duplicate_ids_fail_before_any_write() {
2073        let (store, tmp) = temp_store();
2074        let mut first = crate::session::Session::from_claude_code_str("{}").unwrap();
2075        first.meta.agent_id = Some("same".to_string());
2076        let second = first.clone();
2077
2078        assert!(store
2079            .save_imported_subagents("parent-sess", &[first, second])
2080            .unwrap_err()
2081            .to_string()
2082            .contains("duplicate imported subagent id"));
2083        assert!(!tmp.join("parent-sess.subagents").exists());
2084
2085        let _ = std::fs::remove_dir_all(&tmp);
2086    }
2087
2088    /// The whole `<name>.subagents/` directory travels with `archive` and is
2089    /// removed by `delete`, exactly like every other `<name>.*` family
2090    /// member (D5 "folded into archive/delete/list").
2091    #[test]
2092    fn subagent_family_travels_with_archive_and_is_removed_by_delete() {
2093        let (store, tmp) = temp_store();
2094        store.save("parent-sess", "t", "[]").unwrap();
2095        store
2096            .save_subagent_lineage("parent-sess", "agent-1", &sample_lineage("agent-1"))
2097            .unwrap();
2098        store
2099            .save_subagent_transcript("parent-sess", "agent-1", "{}\n")
2100            .unwrap();
2101        assert!(tmp
2102            .join("parent-sess.subagents/agent-1.lineage.json")
2103            .exists());
2104        assert!(tmp
2105            .join("parent-sess.subagents/agent-1.sidecar.jsonl")
2106            .exists());
2107
2108        store.archive("parent-sess").unwrap();
2109        assert!(!tmp.join("parent-sess.subagents").exists());
2110        assert!(tmp
2111            .join("archived/parent-sess.subagents/agent-1.lineage.json")
2112            .exists());
2113        // Still readable (active-or-archived fallback) after archiving.
2114        assert_eq!(
2115            store.list_subagent_ids("parent-sess").unwrap(),
2116            vec!["agent-1".to_string()]
2117        );
2118        assert!(store
2119            .load_subagent_lineage("parent-sess", "agent-1")
2120            .unwrap()
2121            .is_some());
2122
2123        store.delete("parent-sess").unwrap();
2124        assert!(!tmp.join("archived/parent-sess.subagents").exists());
2125        assert_eq!(
2126            store
2127                .load_subagent_lineage("parent-sess", "agent-1")
2128                .unwrap(),
2129            None
2130        );
2131        assert!(store.list_subagent_ids("parent-sess").unwrap().is_empty());
2132        let _ = std::fs::remove_dir_all(&tmp);
2133    }
2134
2135    /// A session that never spawned any subagents has no `.subagents/`
2136    /// directory at all, and every accessor reports the empty/`None` case
2137    /// rather than erroring — the default-off, zero-cost posture.
2138    #[test]
2139    fn subagent_family_is_absent_by_default() {
2140        let (store, tmp) = temp_store();
2141        store.save("sess", "t", "[]").unwrap();
2142        assert_eq!(
2143            store.load_subagent_lineage("sess", "agent-1").unwrap(),
2144            None
2145        );
2146        assert_eq!(
2147            store.load_subagent_transcript("sess", "agent-1").unwrap(),
2148            None
2149        );
2150        assert!(store.list_subagent_ids("sess").unwrap().is_empty());
2151        assert!(!tmp.join("sess.subagents").exists());
2152        let _ = std::fs::remove_dir_all(&tmp);
2153    }
2154
2155    /// Happy path: a full (untruncated) fork is a byte-identical copy of
2156    /// the source transcript, with a provenance record naming the source
2157    /// and `forked_at_message = None`.
2158    #[test]
2159    fn fork_full_copy_is_byte_identical_with_provenance() {
2160        let (store, tmp) = temp_store();
2161        let transcript = "{\"role\":\"user\"}\n{\"role\":\"assistant\"}\n";
2162        store.save("orig", "t", transcript).unwrap();
2163        let provenance = store
2164            .fork("orig", "copy", "fork of t", None, 1_700_000_000_000)
2165            .unwrap();
2166        assert_eq!(store.load("copy").unwrap(), transcript);
2167        assert_eq!(provenance.forked_from, "orig");
2168        assert_eq!(provenance.forked_at_message, None);
2169        assert_eq!(
2170            store.load_fork_provenance("copy").unwrap(),
2171            Some(provenance)
2172        );
2173        // The source is untouched.
2174        assert_eq!(store.load("orig").unwrap(), transcript);
2175        let _ = std::fs::remove_dir_all(&tmp);
2176    }
2177
2178    /// Boundary: a truncated fork (CX shape: copy + truncation) keeps only
2179    /// the first N messages, and the provenance record honestly reports the
2180    /// truncation point so a reader can tell it's a partial fork.
2181    #[test]
2182    fn fork_with_truncation_keeps_only_leading_messages() {
2183        let (store, tmp) = temp_store();
2184        let transcript = "{\"role\":\"system\"}\n{\"role\":\"user\"}\n{\"role\":\"assistant\"}\n{\"role\":\"tool\"}\n";
2185        store.save("orig", "t", transcript).unwrap();
2186        let provenance = store
2187            .fork("orig", "partial", "partial fork", Some(2), 42)
2188            .unwrap();
2189        assert_eq!(
2190            store.load("partial").unwrap(),
2191            "{\"role\":\"system\"}\n{\"role\":\"user\"}\n"
2192        );
2193        assert_eq!(provenance.forked_at_message, Some(2));
2194        // The source retains every message — truncation only affects the
2195        // NEW fork, never the original.
2196        assert_eq!(store.load("orig").unwrap(), transcript);
2197        let _ = std::fs::remove_dir_all(&tmp);
2198    }
2199
2200    /// A session never created via `fork` has no provenance record.
2201    #[test]
2202    fn fork_provenance_is_none_for_a_plain_session() {
2203        let (store, tmp) = temp_store();
2204        store.save("sess", "t", "[]").unwrap();
2205        assert_eq!(store.load_fork_provenance("sess").unwrap(), None);
2206        let _ = std::fs::remove_dir_all(&tmp);
2207    }
2208
2209    /// `prune_expired` deletes only ARCHIVED sessions older than the
2210    /// retention window, leaving active sessions and fresh archives alone.
2211    #[test]
2212    fn prune_expired_deletes_only_old_archived_sessions() {
2213        let (store, tmp) = temp_store();
2214        store.save("old-archived", "t", "[]").unwrap();
2215        store.archive("old-archived").unwrap();
2216        store.save("fresh-archived", "t", "[]").unwrap();
2217        store.archive("fresh-archived").unwrap();
2218        store.save("active", "t", "[]").unwrap();
2219
2220        // Back-date the old archived session's mtime well past any
2221        // reasonable retention window.
2222        let old_path = tmp.join("archived/old-archived.jsonl");
2223        let ancient = std::time::SystemTime::now() - std::time::Duration::from_secs(400 * 86_400);
2224        std::fs::OpenOptions::new()
2225            .write(true)
2226            .open(&old_path)
2227            .unwrap()
2228            .set_modified(ancient)
2229            .unwrap();
2230
2231        let pruned = store
2232            .prune_expired(30, std::time::SystemTime::now())
2233            .unwrap();
2234        assert_eq!(pruned, vec!["old-archived".to_string()]);
2235        assert!(!old_path.exists());
2236        assert!(tmp.join("archived/fresh-archived.jsonl").exists());
2237        assert!(tmp.join("active.jsonl").exists());
2238        let _ = std::fs::remove_dir_all(&tmp);
2239    }
2240
2241    /// `retention_days == 0` is an explicit "prune nothing" floor, not
2242    /// "prune everything" — a config typo must never nuke every archive.
2243    #[test]
2244    fn prune_expired_zero_days_prunes_nothing() {
2245        let (store, tmp) = temp_store();
2246        store.save("sess", "t", "[]").unwrap();
2247        store.archive("sess").unwrap();
2248        let pruned = store
2249            .prune_expired(0, std::time::SystemTime::now())
2250            .unwrap();
2251        assert!(pruned.is_empty());
2252        assert!(tmp.join("archived/sess.jsonl").exists());
2253        let _ = std::fs::remove_dir_all(&tmp);
2254    }
2255
2256    /// Default-unchanged: an active (never-archived) session is never
2257    /// pruned, regardless of age.
2258    #[test]
2259    fn prune_expired_never_touches_active_sessions() {
2260        let (store, tmp) = temp_store();
2261        store.save("active", "t", "[]").unwrap();
2262        let ancient = std::time::SystemTime::now() - std::time::Duration::from_secs(400 * 86_400);
2263        std::fs::OpenOptions::new()
2264            .write(true)
2265            .open(tmp.join("active.jsonl"))
2266            .unwrap()
2267            .set_modified(ancient)
2268            .unwrap();
2269        let pruned = store
2270            .prune_expired(30, std::time::SystemTime::now())
2271            .unwrap();
2272        assert!(pruned.is_empty());
2273        assert!(tmp.join("active.jsonl").exists());
2274        let _ = std::fs::remove_dir_all(&tmp);
2275    }
2276
2277    // -------------------------------------------------------------------
2278    // P5-5 (design §2 module 21 `session.tree`): the `<name>.tree.json`
2279    // family member.
2280    // -------------------------------------------------------------------
2281
2282    fn sample_tree() -> crate::session_tree::SessionTree {
2283        let mut tree = crate::session_tree::SessionTree::from_linear(
2284            &[
2285                crate::message::ChatMessage::user("hello"),
2286                crate::message::ChatMessage::assistant("hi"),
2287            ],
2288            1_700_000_000_000,
2289        );
2290        tree.branch("n0", Some("side".to_string()), 1_700_000_001_000)
2291            .unwrap();
2292        tree.append_message(
2293            crate::message::ChatMessage::user("side turn"),
2294            1_700_000_002_000,
2295        );
2296        tree
2297    }
2298
2299    /// §1.13 lossless round trip: a saved [`crate::session_tree::SessionTree`]
2300    /// — nodes, branches, and the active-branch pointer — survives a
2301    /// save/load cycle with every branch's linear projection intact.
2302    #[test]
2303    fn session_tree_round_trips_losslessly_through_the_store() {
2304        let (store, tmp) = temp_store();
2305        store.save("sess", "t", "[]").unwrap();
2306        let tree = sample_tree();
2307        store.save_tree("sess", &tree).unwrap();
2308        let loaded = store.load_tree("sess").unwrap().expect("just saved");
2309
2310        assert_eq!(loaded.root, tree.root);
2311        assert_eq!(loaded.active_branch, tree.active_branch);
2312        assert_eq!(loaded.nodes.len(), tree.nodes.len());
2313        assert_eq!(
2314            loaded.linear_projection_of("main").unwrap().len(),
2315            tree.linear_projection_of("main").unwrap().len()
2316        );
2317        assert_eq!(
2318            loaded.linear_projection().unwrap().len(),
2319            tree.linear_projection().unwrap().len()
2320        );
2321        let _ = std::fs::remove_dir_all(&tmp);
2322    }
2323
2324    /// F1 (HIGH, ported from the Fable-5 adversarial review's
2325    /// `attack_tree_sidecar_drops_message_metadata`): a rewound-past branch
2326    /// — whose messages have NO backing beyond the `.tree.json` sidecar — must
2327    /// keep `ChatMessage::metadata` through a save/load cycle. Before the
2328    /// fix, `TreeNode` persisted via `ChatMessage`'s custom wire `Serialize`
2329    /// (`message.rs:49-79`), which deliberately OMITS `metadata` — so this
2330    /// assertion FAILING (metadata empty) would mean the sidecar reverted to
2331    /// that lossy behavior.
2332    #[test]
2333    fn attack_tree_sidecar_preserves_message_metadata() {
2334        let (store, tmp) = temp_store();
2335        let mut m = crate::message::ChatMessage::user("turn with provenance");
2336        m.metadata
2337            .insert("phase".to_string(), "commentary".to_string());
2338        m.metadata
2339            .insert("turn_id".to_string(), "cx-turn-42".to_string());
2340        m.metadata
2341            .insert("pi_entry_id".to_string(), "entry-7".to_string());
2342        let mut tree = crate::session_tree::SessionTree::from_linear(
2343            &[m.clone(), crate::message::ChatMessage::assistant("ok")],
2344            1,
2345        );
2346        // Rewind so n0..n1 becomes an off-path preserved branch whose ONLY
2347        // persistent record is the .tree.json sidecar.
2348        let preserved = tree.rewind("n0", 2).unwrap().unwrap();
2349        store.save("s", "t", "").unwrap();
2350        store.save_tree("s", &tree).unwrap();
2351        let loaded = store.load_tree("s").unwrap().unwrap();
2352        let recovered = loaded.linear_projection_of(&preserved).unwrap();
2353        assert_eq!(recovered.len(), 2);
2354        // The metadata must have survived the sidecar round trip in full.
2355        assert_eq!(
2356            recovered[0].metadata.get("phase"),
2357            Some(&"commentary".to_string())
2358        );
2359        assert_eq!(
2360            recovered[0].metadata.get("turn_id"),
2361            Some(&"cx-turn-42".to_string())
2362        );
2363        assert_eq!(
2364            recovered[0].metadata.get("pi_entry_id"),
2365            Some(&"entry-7".to_string())
2366        );
2367        let _ = std::fs::remove_dir_all(&tmp);
2368    }
2369
2370    /// F1 (HIGH, ported from the Fable-5 review's
2371    /// `attack_message_with_both_content_and_parts_loses_content_through_sidecar`):
2372    /// `ChatMessage`'s wire `Serialize` collapses `content` whenever
2373    /// `content_parts` is also set (parts win, plain string dropped) — the
2374    /// correct behavior for an OUTBOUND provider request, but wrong for this
2375    /// sidecar, which must keep both independently since it's the only
2376    /// durable record of an off-path branch. Before the fix this assertion
2377    /// (`content` surviving) would fail.
2378    #[test]
2379    fn attack_tree_sidecar_preserves_content_alongside_content_parts() {
2380        let (store, tmp) = temp_store();
2381        let mut m = crate::message::ChatMessage::user("plain content");
2382        m.content_parts = Some(vec![serde_json::json!({"type":"text","text":"part"})]);
2383        let tree = crate::session_tree::SessionTree::from_linear(&[m], 1);
2384        store.save("s", "t", "").unwrap();
2385        store.save_tree("s", &tree).unwrap();
2386        let loaded = store.load_tree("s").unwrap().unwrap();
2387        let got = &loaded.node("n0").unwrap().message;
2388        assert_eq!(got.content.as_deref(), Some("plain content"));
2389        assert_eq!(
2390            got.content_parts.as_ref().unwrap()[0]["text"],
2391            serde_json::json!("part")
2392        );
2393        let _ = std::fs::remove_dir_all(&tmp);
2394    }
2395
2396    /// F1: a FULL-FIDELITY round trip, not just length/content/role (the gap
2397    /// that hid the original defect — `session_tree_round_trips_losslessly_through_the_store`
2398    /// above only ever compared `.len()`). Asserts `metadata` and
2399    /// `content_parts` field-for-field equality on every node after a
2400    /// save/load cycle.
2401    #[test]
2402    fn session_tree_round_trip_preserves_full_message_fidelity_not_just_length() {
2403        let (store, tmp) = temp_store();
2404        let mut m0 = crate::message::ChatMessage::user("hello");
2405        m0.metadata
2406            .insert("promptSource".to_string(), "cli".to_string());
2407        m0.metadata.insert("isMeta".to_string(), "true".to_string());
2408        let mut m1 = crate::message::ChatMessage::assistant("hi");
2409        m1.content_parts = Some(vec![
2410            serde_json::json!({"type": "text", "text": "hi"}),
2411            serde_json::json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}),
2412        ]);
2413        m1.metadata
2414            .insert("review_findings".to_string(), "none".to_string());
2415        let tree = crate::session_tree::SessionTree::from_linear(
2416            &[m0.clone(), m1.clone()],
2417            1_700_000_000_000,
2418        );
2419        store.save("s", "t", "").unwrap();
2420        store.save_tree("s", &tree).unwrap();
2421        let loaded = store.load_tree("s").unwrap().unwrap();
2422
2423        let got0 = &loaded.node("n0").unwrap().message;
2424        assert_eq!(got0.content, m0.content);
2425        assert_eq!(got0.metadata, m0.metadata);
2426        assert_eq!(got0.content_parts, m0.content_parts);
2427
2428        let got1 = &loaded.node("n1").unwrap().message;
2429        assert_eq!(got1.content, m1.content);
2430        assert_eq!(got1.metadata, m1.metadata);
2431        assert_eq!(got1.content_parts, m1.content_parts);
2432
2433        let _ = std::fs::remove_dir_all(&tmp);
2434    }
2435
2436    /// Re-verification (Fable-5 review's `attack_tree_sidecar_save_load_save_is_byte_identical`):
2437    /// F1's serialization change (routing `TreeNode.message` through
2438    /// [`crate::sidecar::NativeTurn`] instead of `ChatMessage`'s own wire
2439    /// serde) must not break the sidecar's save→load→save byte-identity —
2440    /// in particular, `TreeNodeWire`'s `ts` field must be derived
2441    /// deterministically from the node's `created_at_ms`, NOT from
2442    /// wall-clock `now()` (which `NativeTurn::from(&ChatMessage)` normally
2443    /// stamps), or every reload-then-resave would produce different bytes.
2444    #[test]
2445    fn tree_sidecar_save_load_save_is_byte_identical_after_f1() {
2446        let (store, tmp) = temp_store();
2447        let mut tree = crate::session_tree::SessionTree::from_linear(
2448            &[
2449                crate::message::ChatMessage::user("a"),
2450                crate::message::ChatMessage::assistant("b"),
2451            ],
2452            1_700_000_000_000,
2453        );
2454        tree.branch("n0", Some("side".to_string()), 2).unwrap();
2455        tree.append_message(crate::message::ChatMessage::user("c"), 3);
2456        tree.label("n1", "checkpoint").unwrap();
2457        store.save("s", "t", "").unwrap();
2458        store.save_tree("s", &tree).unwrap();
2459        let bytes1 = std::fs::read(tmp.join("s.tree.json")).unwrap();
2460        let loaded = store.load_tree("s").unwrap().unwrap();
2461        store.save_tree("s", &loaded).unwrap();
2462        let bytes2 = std::fs::read(tmp.join("s.tree.json")).unwrap();
2463        assert_eq!(bytes1, bytes2);
2464        let _ = std::fs::remove_dir_all(&tmp);
2465    }
2466
2467    /// Re-verification (Fable-5 review's `attack_corrupt_tree_json_errors_on_load`):
2468    /// unchanged by F1 — a corrupt or empty `.tree.json` must still ERROR
2469    /// on load, never panic or silently return `None`.
2470    #[test]
2471    fn corrupt_tree_json_still_errors_on_load_after_the_fixes() {
2472        let (store, tmp) = temp_store();
2473        store.save("s", "t", "").unwrap();
2474        std::fs::write(tmp.join("s.tree.json"), "{not json").unwrap();
2475        assert!(store.load_tree("s").is_err());
2476        std::fs::write(tmp.join("s.tree.json"), "").unwrap();
2477        assert!(store.load_tree("s").is_err());
2478        let _ = std::fs::remove_dir_all(&tmp);
2479    }
2480
2481    /// A session that never invoked a tree operation has no `.tree.json`
2482    /// sidecar, and loading it back is `None`, not an error — the
2483    /// degenerate-single-path default.
2484    #[test]
2485    fn session_tree_is_absent_by_default() {
2486        let (store, tmp) = temp_store();
2487        store.save("sess", "t", "[]").unwrap();
2488        assert!(store.load_tree("sess").unwrap().is_none());
2489        assert!(!tmp.join("sess.tree.json").exists());
2490        let _ = std::fs::remove_dir_all(&tmp);
2491    }
2492
2493    /// The `<name>.tree.json` sidecar travels with `archive`/is removed by
2494    /// `delete`, exactly like every other `<name>.*` family member (D5
2495    /// "folded into archive/delete/list").
2496    #[test]
2497    fn session_tree_travels_with_archive_and_is_removed_by_delete() {
2498        let (store, tmp) = temp_store();
2499        store.save("sess", "t", "[]").unwrap();
2500        store.save_tree("sess", &sample_tree()).unwrap();
2501        assert!(tmp.join("sess.tree.json").exists());
2502
2503        store.archive("sess").unwrap();
2504        assert!(!tmp.join("sess.tree.json").exists());
2505        assert!(tmp.join("archived/sess.tree.json").exists());
2506        // Still readable after archiving.
2507        assert!(store.load_tree("sess").unwrap().is_some());
2508
2509        store.delete("sess").unwrap();
2510        assert!(!tmp.join("archived/sess.tree.json").exists());
2511        assert!(store.load_tree("sess").unwrap().is_none());
2512        let _ = std::fs::remove_dir_all(&tmp);
2513    }
2514
2515    /// A fork copies the source session's `.tree.json` sidecar whole (like
2516    /// the sidecar/reduction-log/usage/model-change/git-metadata members) —
2517    /// a fork of a branched session stays fully tree-addressable, not
2518    /// silently downgraded to linear-only.
2519    #[test]
2520    fn fork_copies_the_session_tree_sidecar() {
2521        let (store, tmp) = temp_store();
2522        let transcript = "{\"role\":\"user\"}\n";
2523        store.save("orig", "t", transcript).unwrap();
2524        store.save_tree("orig", &sample_tree()).unwrap();
2525
2526        store
2527            .fork("orig", "copy", "fork of t", None, 1_700_000_000_000)
2528            .unwrap();
2529        let copied = store.load_tree("copy").unwrap().expect("copied");
2530        let orig = store.load_tree("orig").unwrap().unwrap();
2531        assert_eq!(copied.nodes.len(), orig.nodes.len());
2532        assert_eq!(copied.branches.len(), orig.branches.len());
2533        let _ = std::fs::remove_dir_all(&tmp);
2534    }
2535
2536    #[test]
2537    fn claude_runtime_manifest_roundtrips_and_travels_with_family_lifecycle() {
2538        let (store, tmp) = temp_store();
2539        store.save("sess", "t", "[]").unwrap();
2540        let source = concat!(
2541            "{\"type\":\"permission-mode\",\"permissionMode\":\"bypassPermissions\",",
2542            "\"timestamp\":\"2026-07-14T00:00:00Z\"}\n"
2543        );
2544        let session = crate::Session::from_claude_code_str(source).unwrap();
2545        let manifest =
2546            crate::claude_runtime_state::ClaudeRuntimeManifest::from_session(&session).unwrap();
2547        store
2548            .save_claude_runtime_manifest("sess", &manifest)
2549            .unwrap();
2550        assert_eq!(
2551            store.load_claude_runtime_manifest("sess").unwrap(),
2552            Some(manifest.clone())
2553        );
2554
2555        store
2556            .fork("sess", "copy", "copy", None, 1_700_000_000_000)
2557            .unwrap();
2558        assert_eq!(
2559            store.load_claude_runtime_manifest("copy").unwrap(),
2560            Some(manifest.clone())
2561        );
2562        store.archive("sess").unwrap();
2563        assert!(!tmp.join("sess.claude-runtime.json").exists());
2564        assert!(tmp.join("archived/sess.claude-runtime.json").exists());
2565        assert_eq!(
2566            store.load_claude_runtime_manifest("sess").unwrap(),
2567            Some(manifest)
2568        );
2569        store.delete("sess").unwrap();
2570        assert!(store
2571            .load_claude_runtime_manifest("sess")
2572            .unwrap()
2573            .is_none());
2574        let _ = std::fs::remove_dir_all(&tmp);
2575    }
2576}