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(crate) mod builder;
22pub(crate) mod digest;
23#[allow(dead_code)]
24pub mod publish;
25pub mod restore;
26pub(crate) mod signing;
27pub(crate) mod timeline;
28pub mod types;
29
30pub use builder::{SnapshotOptions, create};
31pub use publish::{ImportOutcome, PublishOptions, PublishOutcome, import, publish};
32pub use restore::{GitRestore, RestoreOptions, RestoreOutcome, restore};
33pub use signing::verify_snapshot;
34pub use timeline::{load_entries, read_snapshot, resolve_id};
35pub use types::ContextSnapshotV1;
36
37#[cfg(test)]
38mod tests {
39    use super::types::*;
40    use crate::core::contracts::CONTEXT_SNAPSHOT_V1_SCHEMA_VERSION;
41
42    /// A fully-populated snapshot exercising every field for roundtrip coverage.
43    fn full_snapshot() -> ContextSnapshotV1 {
44        ContextSnapshotV1 {
45            schema_version: CONTEXT_SNAPSHOT_V1_SCHEMA_VERSION,
46            snapshot_id: "id".repeat(32),
47            parent_id: Some("p".repeat(64)),
48            created_at: "2026-06-28T12:00:00Z".into(),
49            lean_ctx_version: "3.9.0".into(),
50            git: GitAnchorV1 {
51                commit: Some("abc1234".into()),
52                branch: Some("feat/context-time-machine".into()),
53                dirty: true,
54            },
55            project: SnapshotProjectV1 {
56                root_hash: Some("r".repeat(64)),
57                identity_hash: Some("i".repeat(64)),
58            },
59            roi: SnapshotRoiV1 {
60                input_tokens: 1000,
61                output_tokens: 200,
62                tokens_saved: 800,
63                compression_rate: 0.444_44,
64            },
65            lineage: SnapshotLineageV1 {
66                items_recorded: 42,
67                items: vec![SnapshotLineageItemV1 {
68                    seq: 1,
69                    kind: "read".into(),
70                    tool: "ctx_read".into(),
71                    path: Some("src/main.rs".into()),
72                    input_tokens: 500,
73                    output_tokens: 13,
74                    compression_ratio: 0.974,
75                    content_hash: Some("c".repeat(64)),
76                }],
77            },
78            ledger: SnapshotLedgerV1 {
79                window_size: 8000,
80                total_tokens_sent: 1200,
81                total_tokens_saved: 800,
82                items: vec![SnapshotLedgerItemV1 {
83                    path: "src/main.rs".into(),
84                    state: "pinned".into(),
85                    phi: Some(0.91),
86                    sent_tokens: 13,
87                    original_tokens: 500,
88                }],
89            },
90            session: Some(SnapshotSessionV1 {
91                session_id: Some("sess-1".into()),
92                task: Some("Implement Context Time Machine".into()),
93                decisions: vec!["Use ed25519 for snapshot signing".into()],
94                files_touched: vec!["rust/src/core/context_snapshot/mod.rs".into()],
95                progress_pct: Some(20),
96            }),
97            signature: Some(SnapshotSignatureV1 {
98                algorithm: "ed25519".into(),
99                public_key: "a".repeat(64),
100                value: "b".repeat(128),
101            }),
102        }
103    }
104
105    #[test]
106    fn serde_roundtrip_preserves_every_field() {
107        let original = full_snapshot();
108        let json = serde_json::to_string(&original).expect("serialize");
109        let restored: ContextSnapshotV1 = serde_json::from_str(&json).expect("deserialize");
110        assert_eq!(original, restored);
111    }
112
113    #[test]
114    fn new_uses_current_schema_version_and_empty_slices() {
115        let snap = ContextSnapshotV1::new("2026-06-28T12:00:00Z".into(), "3.9.0".into());
116        assert_eq!(snap.schema_version, CONTEXT_SNAPSHOT_V1_SCHEMA_VERSION);
117        assert!(snap.snapshot_id.is_empty(), "{:?}", snap.snapshot_id);
118        assert!(snap.signature.is_none());
119        assert!(snap.lineage.items.is_empty(), "{:?}", snap.lineage.items);
120        assert!(snap.ledger.items.is_empty(), "{:?}", snap.ledger.items);
121    }
122
123    #[test]
124    fn sign_then_serde_roundtrip_still_verifies() {
125        use ed25519_dalek::SigningKey;
126        let mut snap = ContextSnapshotV1::new("2026-06-28T12:00:00Z".into(), "3.9.0".into());
127        super::signing::sign_snapshot(&mut snap, &SigningKey::from_bytes(&[9u8; 32]))
128            .expect("sign");
129
130        let json = serde_json::to_string(&snap).expect("serialize");
131        let restored: ContextSnapshotV1 = serde_json::from_str(&json).expect("deserialize");
132        assert_eq!(snap, restored);
133        assert!(super::verify_snapshot(&restored).expect("verify"));
134    }
135}