Skip to main content

lean_ctx/core/context_snapshot/
mod.rs

1//! Context Snapshot (`CONTEXT_SNAPSHOT_V1`) — the git-anchored, signed, temporal
2//! artifact behind the Context Time Machine (GL epic #1022, Phase 0 #1023).
3//!
4//! A snapshot is a distilled, typed, signed projection of the live context
5//! stores at a point in time:
6//!
7//! - **git anchor** — commit / branch / dirty state ([`GitAnchorV1`])
8//! - **lineage** — what entered the window, from Context IR ([`SnapshotLineageV1`])
9//! - **ledger** — why it was there, with Φ-scores ([`SnapshotLedgerV1`])
10//! - **ROI** — token savings at that moment ([`SnapshotRoiV1`])
11//! - **session** — the task/decisions behind it ([`SnapshotSessionV1`])
12//!
13//! Snapshots chain via [`ContextSnapshotV1::parent_id`] into an append-only
14//! timeline. The id is content-addressed (BLAKE3 of the canonical body, see
15//! [`digest`]) and the signature (ed25519, see [`signing`]) is computed over it.
16//!
17//! This module is the **contract** (Phase 0): the types, the deterministic
18//! id/signing semantics, and their tests. The builder that fills snapshots from
19//! live stores and the append-only timeline index land in Phase 1 (#1024).
20
21pub mod builder;
22pub mod digest;
23pub mod publish;
24pub mod restore;
25pub mod signing;
26pub mod timeline;
27pub mod types;
28
29pub use builder::{SnapshotOptions, build, create};
30pub use digest::{canonical_body, compute_id, finalize_id};
31pub use publish::{ImportOutcome, PublishOptions, PublishOutcome, import, publish};
32pub use restore::{GitRestore, RestoreOptions, RestoreOutcome, SessionMerge, restore};
33pub use signing::{sign_snapshot, verify_snapshot};
34pub use timeline::{
35    TimelineEntry, head_id, load_entries, read_snapshot, resolve_id, snapshots_dir, write_snapshot,
36};
37pub use types::{
38    ContextSnapshotV1, GitAnchorV1, MAX_SNAPSHOT_LEDGER_ITEMS, MAX_SNAPSHOT_LINEAGE_ITEMS,
39    MAX_SNAPSHOT_SESSION_LIST, SnapshotLedgerItemV1, SnapshotLedgerV1, SnapshotLineageItemV1,
40    SnapshotLineageV1, SnapshotProjectV1, SnapshotRoiV1, SnapshotSessionV1, SnapshotSignatureV1,
41};
42
43#[cfg(test)]
44mod tests {
45    use super::types::*;
46    use crate::core::contracts::CONTEXT_SNAPSHOT_V1_SCHEMA_VERSION;
47
48    /// A fully-populated snapshot exercising every field for roundtrip coverage.
49    fn full_snapshot() -> ContextSnapshotV1 {
50        ContextSnapshotV1 {
51            schema_version: CONTEXT_SNAPSHOT_V1_SCHEMA_VERSION,
52            snapshot_id: "id".repeat(32),
53            parent_id: Some("p".repeat(64)),
54            created_at: "2026-06-28T12:00:00Z".into(),
55            lean_ctx_version: "3.9.0".into(),
56            git: GitAnchorV1 {
57                commit: Some("abc1234".into()),
58                branch: Some("feat/context-time-machine".into()),
59                dirty: true,
60            },
61            project: SnapshotProjectV1 {
62                root_hash: Some("r".repeat(64)),
63                identity_hash: Some("i".repeat(64)),
64            },
65            roi: SnapshotRoiV1 {
66                input_tokens: 1000,
67                output_tokens: 200,
68                tokens_saved: 800,
69                compression_rate: 0.444_44,
70            },
71            lineage: SnapshotLineageV1 {
72                items_recorded: 42,
73                items: vec![SnapshotLineageItemV1 {
74                    seq: 1,
75                    kind: "read".into(),
76                    tool: "ctx_read".into(),
77                    path: Some("src/main.rs".into()),
78                    input_tokens: 500,
79                    output_tokens: 13,
80                    compression_ratio: 0.974,
81                    content_hash: Some("c".repeat(64)),
82                }],
83            },
84            ledger: SnapshotLedgerV1 {
85                window_size: 8000,
86                total_tokens_sent: 1200,
87                total_tokens_saved: 800,
88                items: vec![SnapshotLedgerItemV1 {
89                    path: "src/main.rs".into(),
90                    state: "pinned".into(),
91                    phi: Some(0.91),
92                    sent_tokens: 13,
93                    original_tokens: 500,
94                }],
95            },
96            session: Some(SnapshotSessionV1 {
97                session_id: Some("sess-1".into()),
98                task: Some("Implement Context Time Machine".into()),
99                decisions: vec!["Use ed25519 for snapshot signing".into()],
100                files_touched: vec!["rust/src/core/context_snapshot/mod.rs".into()],
101                progress_pct: Some(20),
102            }),
103            signature: Some(SnapshotSignatureV1 {
104                algorithm: "ed25519".into(),
105                public_key: "a".repeat(64),
106                value: "b".repeat(128),
107            }),
108        }
109    }
110
111    #[test]
112    fn serde_roundtrip_preserves_every_field() {
113        let original = full_snapshot();
114        let json = serde_json::to_string(&original).expect("serialize");
115        let restored: ContextSnapshotV1 = serde_json::from_str(&json).expect("deserialize");
116        assert_eq!(original, restored);
117    }
118
119    #[test]
120    fn new_uses_current_schema_version_and_empty_slices() {
121        let snap = ContextSnapshotV1::new("2026-06-28T12:00:00Z".into(), "3.9.0".into());
122        assert_eq!(snap.schema_version, CONTEXT_SNAPSHOT_V1_SCHEMA_VERSION);
123        assert!(snap.snapshot_id.is_empty());
124        assert!(snap.signature.is_none());
125        assert!(snap.lineage.items.is_empty());
126        assert!(snap.ledger.items.is_empty());
127    }
128
129    #[test]
130    fn sign_then_serde_roundtrip_still_verifies() {
131        use ed25519_dalek::SigningKey;
132        let mut snap = ContextSnapshotV1::new("2026-06-28T12:00:00Z".into(), "3.9.0".into());
133        super::sign_snapshot(&mut snap, &SigningKey::from_bytes(&[9u8; 32])).expect("sign");
134
135        let json = serde_json::to_string(&snap).expect("serialize");
136        let restored: ContextSnapshotV1 = serde_json::from_str(&json).expect("deserialize");
137        assert_eq!(snap, restored);
138        assert!(super::verify_snapshot(&restored).expect("verify"));
139    }
140}