Skip to main content

lean_ctx/core/ocla/
ledger_export.rs

1//! Unified ledger export and offline verification.
2
3use std::fs::File;
4use std::io::{BufRead, BufReader, ErrorKind};
5
6use serde::{Deserialize, Serialize};
7
8use super::unified_ledger::UnifiedSavingsEventV2;
9
10/// A self-contained export bundle for offline verification.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct LedgerExportBundle {
13    pub schema_version: u32,
14    pub exported_at: String,
15    pub event_count: usize,
16    pub hash_chain_valid: bool,
17    pub total_saved_tokens: u64,
18    pub events: Vec<UnifiedSavingsEventV2>,
19}
20
21/// Result of offline verification.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct VerificationResult {
24    pub valid: bool,
25    pub event_count: usize,
26    pub chain_breaks: Vec<usize>,
27    pub total_saved_tokens: u64,
28    pub errors: Vec<String>,
29}
30
31/// Export the unified ledger as a verifiable bundle.
32pub fn export_unified_ledger() -> Result<LedgerExportBundle, String> {
33    let events = load_unified_events()?;
34    let hash_chain_valid = verify_hash_chain(&events);
35    let total_saved_tokens = events.iter().map(|event| event.saved_tokens).sum();
36
37    Ok(LedgerExportBundle {
38        schema_version: 2,
39        exported_at: chrono::Utc::now().to_rfc3339(),
40        event_count: events.len(),
41        hash_chain_valid,
42        total_saved_tokens,
43        events,
44    })
45}
46
47/// Verify an exported bundle offline without reading a ledger file.
48pub fn verify_export_bundle(bundle: &LedgerExportBundle) -> VerificationResult {
49    let chain_breaks = bundle
50        .events
51        .windows(2)
52        .enumerate()
53        .filter_map(|(index, window)| {
54            (window[1].prev_hash != window[0].event_hash).then_some(index + 1)
55        })
56        .collect::<Vec<_>>();
57    let mut errors = Vec::new();
58
59    let total_saved_tokens = bundle.events.iter().map(|event| event.saved_tokens).sum();
60    if total_saved_tokens != bundle.total_saved_tokens {
61        errors.push(format!(
62            "token total mismatch: header={}, computed={}",
63            bundle.total_saved_tokens, total_saved_tokens
64        ));
65    }
66
67    if bundle.events.len() != bundle.event_count {
68        errors.push(format!(
69            "event count mismatch: header={}, actual={}",
70            bundle.event_count,
71            bundle.events.len()
72        ));
73    }
74
75    VerificationResult {
76        valid: chain_breaks.is_empty() && errors.is_empty(),
77        event_count: bundle.events.len(),
78        chain_breaks,
79        total_saved_tokens,
80        errors,
81    }
82}
83
84fn load_unified_events() -> Result<Vec<UnifiedSavingsEventV2>, String> {
85    let path = crate::core::data_dir::lean_ctx_data_dir()
86        .map_err(|error| format!("failed to load unified ledger: {error}"))?
87        .join("ledger")
88        .join("unified_events.jsonl");
89    let file = match File::open(&path) {
90        Ok(file) => file,
91        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
92        Err(error) => {
93            return Err(format!(
94                "failed to load unified ledger {}: {error}",
95                path.display()
96            ));
97        }
98    };
99
100    Ok(BufReader::new(file)
101        .lines()
102        .map_while(Result::ok)
103        .filter_map(|line| serde_json::from_str(&line).ok())
104        .collect())
105}
106
107fn verify_hash_chain(events: &[UnifiedSavingsEventV2]) -> bool {
108    events
109        .windows(2)
110        .all(|window| window[1].prev_hash == window[0].event_hash)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn event(prev_hash: &str, event_hash: &str, saved_tokens: u64) -> UnifiedSavingsEventV2 {
118        UnifiedSavingsEventV2 {
119            tool_name: "ctx_read".to_owned(),
120            mode: "auto".to_owned(),
121            original_tokens: saved_tokens + 10,
122            compressed_tokens: 10,
123            saved_tokens,
124            content_hash: "content".to_owned(),
125            timestamp_epoch_ms: 1,
126            prev_hash: prev_hash.to_owned(),
127            event_hash: event_hash.to_owned(),
128            intent: None,
129            outcome: None,
130            routing_decision: None,
131            agent_id: None,
132            efficiency_etpao: None,
133            attribution_id: "attribution".to_owned(),
134            trace_id: None,
135            request_id: None,
136            session_id: None,
137            quality_ref: None,
138        }
139    }
140
141    fn bundle(events: Vec<UnifiedSavingsEventV2>) -> LedgerExportBundle {
142        LedgerExportBundle {
143            schema_version: 2,
144            exported_at: "2026-01-01T00:00:00Z".to_owned(),
145            event_count: events.len(),
146            hash_chain_valid: verify_hash_chain(&events),
147            total_saved_tokens: events.iter().map(|event| event.saved_tokens).sum(),
148            events,
149        }
150    }
151
152    #[test]
153    fn test_export_empty_ledger() {
154        let _data_dir = crate::core::data_dir::isolated_data_dir();
155
156        let exported = export_unified_ledger().expect("empty ledger should export");
157
158        assert_eq!(exported.event_count, 0);
159        assert!(exported.events.is_empty());
160        assert!(exported.hash_chain_valid);
161        assert_eq!(exported.total_saved_tokens, 0);
162    }
163
164    #[test]
165    fn test_verify_valid_bundle() {
166        let exported = bundle(vec![event("", "first", 5), event("first", "second", 7)]);
167
168        let result = verify_export_bundle(&exported);
169
170        assert!(result.valid);
171        assert!(result.chain_breaks.is_empty());
172        assert_eq!(result.total_saved_tokens, 12);
173    }
174
175    #[test]
176    fn test_verify_broken_chain() {
177        let exported = bundle(vec![event("", "first", 5), event("tampered", "second", 7)]);
178
179        let result = verify_export_bundle(&exported);
180
181        assert!(!result.valid);
182        assert_eq!(result.chain_breaks, vec![1]);
183    }
184
185    #[test]
186    fn test_verify_token_mismatch() {
187        let mut exported = bundle(vec![event("", "first", 5)]);
188        exported.total_saved_tokens = 99;
189
190        let result = verify_export_bundle(&exported);
191
192        assert!(!result.valid);
193        assert_eq!(
194            result.errors[0],
195            "token total mismatch: header=99, computed=5"
196        );
197    }
198
199    #[test]
200    fn test_verify_count_mismatch() {
201        let mut exported = bundle(vec![event("", "first", 5)]);
202        exported.event_count = 2;
203
204        let result = verify_export_bundle(&exported);
205
206        assert!(!result.valid);
207        assert_eq!(result.errors[0], "event count mismatch: header=2, actual=1");
208    }
209}