1use std::collections::BTreeSet;
15use std::path::Path;
16
17use chrono::{DateTime, Utc};
18use serde::Serialize;
19
20use crate::core::audit_trail::{self, AuditEntry};
21use crate::core::savings_ledger::{self, SavingsEvent};
22use crate::core::security_events::{SecurityCounts, parse_action};
23use crate::core::wrapped::format_tokens;
24
25use super::format::{Style, security_phrases};
26use super::snapshot::ValueSnapshot;
27
28#[derive(Debug, Clone, Serialize)]
29pub struct ChainCheck {
30 pub name: &'static str,
31 pub path: Option<String>,
32 pub entries: usize,
33 pub intact: bool,
34 pub first_invalid_at: Option<usize>,
35}
36
37#[derive(Debug, Clone, Default, Serialize)]
40pub struct Evidence {
41 pub entries: u64,
42 pub first_hash: Option<String>,
43 pub last_hash: Option<String>,
44}
45
46impl Evidence {
47 fn push(&mut self, hash: &str) {
48 self.entries += 1;
49 if self.first_hash.is_none() {
50 self.first_hash = Some(hash.to_string());
51 }
52 self.last_hash = Some(hash.to_string());
53 }
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct Proof {
58 pub session_id: Option<String>,
60 pub ledger: ChainCheck,
61 pub audit: ChainCheck,
62 pub tokens_baseline: u64,
64 pub tokens_saved: u64,
66 pub tokens_bounced: u64,
68 pub ledger_evidence: Evidence,
69 pub security: SecurityCounts,
71 pub audit_evidence: Evidence,
72 pub live: Option<ValueSnapshot>,
74 #[serde(skip)]
77 pub active_days: BTreeSet<String>,
78}
79
80impl Proof {
81 pub fn tampered(&self) -> bool {
82 !self.ledger.intact || !self.audit.intact
83 }
84
85 pub fn net_saved(&self) -> u64 {
86 self.tokens_saved.saturating_sub(self.tokens_bounced)
87 }
88
89 pub fn saved_pct(&self) -> Option<f64> {
91 (self.tokens_baseline > 0)
92 .then(|| self.net_saved() as f64 * 100.0 / self.tokens_baseline as f64)
93 }
94}
95
96fn in_session(entry_session: Option<&str>, wanted: Option<&str>) -> bool {
97 wanted.is_none_or(|id| entry_session == Some(id))
98}
99
100pub fn build_from(
103 ledger_path: Option<&Path>,
104 trail_path: Option<&Path>,
105 session: Option<&str>,
106 live: Option<ValueSnapshot>,
107) -> Proof {
108 build_scoped(ledger_path, trail_path, session, live, None)
109}
110
111pub fn build_scoped(
115 ledger_path: Option<&Path>,
116 trail_path: Option<&Path>,
117 session: Option<&str>,
118 live: Option<ValueSnapshot>,
119 scope: Option<&savings_ledger::ProjectScope>,
120) -> Proof {
121 let ledger_verify = ledger_path.map_or_else(savings_ledger::VerifyResult::empty, |p| {
122 savings_ledger::store::verify(p)
123 });
124 let events: Vec<SavingsEvent> = ledger_path
125 .map(savings_ledger::store::load)
126 .unwrap_or_default();
127
128 let mut proof = Proof {
129 session_id: session.map(str::to_string),
130 ledger: ChainCheck {
131 name: "savings ledger",
132 path: ledger_path.map(|p| p.display().to_string()),
133 entries: ledger_verify.total.max(events.len()),
134 intact: ledger_verify.valid,
135 first_invalid_at: ledger_verify.first_invalid_at,
136 },
137 audit: ChainCheck {
138 name: "audit trail",
139 path: trail_path.map(|p| p.display().to_string()),
140 entries: 0,
141 intact: true,
142 first_invalid_at: None,
143 },
144 tokens_baseline: 0,
145 tokens_saved: 0,
146 tokens_bounced: 0,
147 ledger_evidence: Evidence::default(),
148 security: SecurityCounts::default(),
149 audit_evidence: Evidence::default(),
150 live,
151 active_days: BTreeSet::new(),
152 };
153
154 for ev in &events {
155 if !in_session(ev.session_id.as_deref(), session)
156 || scope.is_some_and(|scope| !scope.holds_event(ev))
157 {
158 continue;
159 }
160 if ev.saved_tokens == 0 && ev.bounce_adjustment == 0 {
161 continue;
162 }
163 proof.tokens_baseline = proof.tokens_baseline.saturating_add(ev.baseline_tokens);
164 proof.tokens_saved = proof.tokens_saved.saturating_add(ev.saved_tokens);
165 proof.tokens_bounced = proof.tokens_bounced.saturating_add(ev.bounce_adjustment);
166 proof.ledger_evidence.push(&ev.entry_hash);
167 if let Some(day) = ev.ts.get(..10) {
168 proof.active_days.insert(day.to_string());
169 }
170 }
171
172 if let Some(path) = trail_path {
173 let (verify, evidence) = audit_trail::verified_security_at(path);
177 if let (None, None, Some(evidence)) = (session, scope, evidence) {
178 proof.audit.entries = verify.total_entries;
179 proof.audit.intact = verify.valid;
180 proof.audit.first_invalid_at = verify.first_invalid_at;
181 proof.security = evidence.counts;
182 proof.audit_evidence = Evidence {
183 entries: evidence.entries,
184 first_hash: evidence.first_hash,
185 last_hash: evidence.last_hash,
186 };
187 return proof;
188 }
189 let entries: Vec<AuditEntry> = audit_trail::load_all_at(path);
190 proof.audit.entries = verify.total_entries.max(entries.len());
191 proof.audit.intact = verify.valid;
192 proof.audit.first_invalid_at = verify.first_invalid_at;
193 for entry in &entries {
194 let Some((kind, n, entry_session)) = entry.action.as_deref().and_then(parse_action)
195 else {
196 continue;
197 };
198 if !in_session(entry_session, session)
199 || scope.is_some_and(|scope| !scope.holds_session(entry_session))
200 {
201 continue;
202 }
203 proof.security.add(kind, n);
204 proof.audit_evidence.push(&entry.entry_hash);
205 }
206 }
207 proof
208}
209
210pub fn build(session: Option<&str>, lifetime: bool) -> Proof {
213 let live = if lifetime {
214 None
215 } else {
216 super::snapshot::load(session)
217 };
218 let session = if lifetime {
219 None
220 } else {
221 session
222 .map(str::to_string)
223 .or_else(|| live.as_ref().map(|s| s.session_id.clone()))
224 .filter(|id| !id.is_empty())
225 };
226 let ledger = savings_ledger::store::default_path();
227 let trail = audit_trail::default_trail_path();
228 build_from(
229 ledger.as_deref(),
230 trail.as_deref(),
231 session.as_deref(),
232 live,
233 )
234}
235
236pub fn build_lifetime_for(scope: &savings_ledger::ProjectScope) -> Proof {
238 let ledger = savings_ledger::store::default_path();
239 let trail = audit_trail::default_trail_path();
240 build_scoped(ledger.as_deref(), trail.as_deref(), None, None, Some(scope))
241}
242
243pub fn verified_security_since(cutoff: Option<DateTime<Utc>>) -> Option<SecurityCounts> {
247 security_since_at(&audit_trail::default_trail_path()?, cutoff)
248}
249
250pub fn verified_security_evidence() -> Option<(SecurityCounts, Evidence)> {
256 security_evidence_at(&audit_trail::default_trail_path()?)
257}
258
259fn security_evidence_at(path: &Path) -> Option<(SecurityCounts, Evidence)> {
262 let tally = audit_trail::verified_signed_tally_at(path)?;
263 Some((
264 tally.counts,
265 Evidence {
266 entries: tally.entries,
267 first_hash: tally.first_hash,
268 last_hash: tally.last_hash,
269 },
270 ))
271}
272
273fn security_since_at(path: &Path, cutoff: Option<DateTime<Utc>>) -> Option<SecurityCounts> {
274 if !audit_trail::verify_chain_at(path).valid {
275 return None;
276 }
277 let mut counts = SecurityCounts::default();
278 for entry in audit_trail::load_all_at(path) {
279 if let Some(cutoff) = cutoff {
280 let Ok(ts) = DateTime::parse_from_rfc3339(&entry.timestamp) else {
281 continue;
282 };
283 if ts.with_timezone(&Utc) < cutoff {
284 continue;
285 }
286 }
287 if let Some((kind, n, _)) = entry.action.as_deref().and_then(parse_action) {
288 counts.add(kind, n);
289 }
290 }
291 Some(counts)
292}
293
294fn short(hash: Option<&String>) -> String {
295 hash.map_or_else(|| "—".to_string(), |h| h.chars().take(12).collect())
296}
297
298struct Marks {
299 measured: String,
300 derived: String,
301 ok: String,
302 bad: String,
303}
304
305impl Marks {
306 fn new(style: Style) -> Self {
307 let paint = |code: &str, text: &str| {
308 if style.color {
309 format!("\x1b[{code}m{text}\x1b[0m")
310 } else {
311 text.to_string()
312 }
313 };
314 let (check, approx, cross) = if style.unicode {
315 ("✓", "≈", "✗")
316 } else {
317 ("OK", "~", "FAIL")
318 };
319 Self {
320 measured: paint("32", &format!("{check} measured")),
321 derived: paint("2", &format!("{approx} derived ")),
322 ok: paint("32", check),
323 bad: paint("31", cross),
324 }
325 }
326}
327
328fn chain_line(check: &ChainCheck, marks: &Marks) -> String {
329 if check.intact {
330 format!(
331 "{} {} intact ({} entries)",
332 marks.ok, check.name, check.entries
333 )
334 } else {
335 format!(
336 "{} {} TAMPERED at entry {}",
337 marks.bad,
338 check.name,
339 check.first_invalid_at.unwrap_or(0)
340 )
341 }
342}
343
344pub fn render(proof: &Proof, style: Style) -> String {
346 let marks = Marks::new(style);
347 let mut out = String::new();
348 let scope = proof
349 .session_id
350 .as_deref()
351 .map_or_else(|| "all sessions".to_string(), |id| format!("session {id}"));
352 out.push_str(&format!("lean-ctx value · {scope}\n\n"));
353
354 if proof.ledger_evidence.entries == 0 && proof.security.is_empty() {
355 out.push_str(" nothing recorded for this scope yet\n");
356 }
357 if proof.ledger_evidence.entries > 0 {
358 out.push_str(&format!(
359 " {} {} tokens kept out of context savings ledger, {} events\n",
360 marks.measured,
361 format_tokens(proof.net_saved()),
362 proof.ledger_evidence.entries
363 ));
364 if proof.tokens_bounced > 0 {
365 out.push_str(&format!(
366 " (net of {} tokens re-read by the agent)\n",
367 format_tokens(proof.tokens_bounced)
368 ));
369 }
370 if let Some(pct) = proof.saved_pct() {
371 out.push_str(&format!(
372 " {} {pct:.0}% of {} tool-input tokens net saved ÷ baseline\n",
373 marks.derived,
374 format_tokens(proof.tokens_baseline)
375 ));
376 }
377 }
378 for phrase in security_phrases(&proof.security) {
379 out.push_str(&format!(" {} {phrase} audit trail\n", marks.measured));
380 }
381
382 out.push('\n');
383 if proof.ledger_evidence.entries > 0 {
384 out.push_str(&format!(
385 " evidence ledger {} … {}\n",
386 short(proof.ledger_evidence.first_hash.as_ref()),
387 short(proof.ledger_evidence.last_hash.as_ref())
388 ));
389 }
390 if proof.audit_evidence.entries > 0 {
391 out.push_str(&format!(
392 " evidence audit {} … {}\n",
393 short(proof.audit_evidence.first_hash.as_ref()),
394 short(proof.audit_evidence.last_hash.as_ref())
395 ));
396 }
397 out.push_str(&format!(
398 " chains {}\n",
399 chain_line(&proof.ledger, &marks)
400 ));
401 out.push_str(&format!(
402 " {}\n",
403 chain_line(&proof.audit, &marks)
404 ));
405
406 if let Some(line) = proof.live.as_ref().and_then(|snap| {
407 super::format::one_line(
408 snap,
409 Style {
410 color: false,
411 ..style
412 },
413 )
414 }) {
415 out.push_str(&format!(
416 "\n live {line}\n (display counters; the chains above are the proof)\n"
417 ));
418 }
419 if proof.tampered() {
420 out.push_str(
421 "\n A chain failed verification: the numbers above are NOT proof.\n\
422 \x20 Inspect the file at the reported entry; `lean-ctx savings verify` shows details.\n",
423 );
424 }
425 out
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use crate::core::audit_trail::{AuditEntryData, AuditEventType};
432
433 fn ledger_event(saved: u64, session: Option<&str>) -> SavingsEvent {
434 let mut json = serde_json::json!({
435 "ts": "2026-09-25T10:00:00+00:00",
436 "tool": "ctx_read",
437 "mechanism": "compression",
438 "model_id": "m",
439 "tokenizer": "o200k_base",
440 "baseline_tokens": saved + 100,
441 "actual_tokens": 100,
442 "saved_tokens": saved,
443 "bounce_adjustment": 0,
444 "unit_price_per_m_usd": 3.0,
445 "saved_usd": 0.0,
446 "repo_hash": "r",
447 "agent_id": "a",
448 "prev_hash": "",
449 "entry_hash": "",
450 "version": "3.10.3",
451 });
452 if let Some(id) = session {
453 json["session_id"] = id.into();
454 }
455 serde_json::from_value(json).unwrap()
456 }
457
458 fn security(path: &Path, action: &str) {
459 audit_trail::record_at(
460 path,
461 AuditEntryData {
462 agent_id: "a".into(),
463 tool: "ctx_shell".into(),
464 action: Some(action.into()),
465 input_hash: String::new(),
466 output_tokens: 0,
467 role: "coder".into(),
468 event_type: AuditEventType::SecretDetected,
469 },
470 );
471 }
472
473 fn fixture() -> (
477 crate::core::data_dir::IsolatedDataDir,
478 std::path::PathBuf,
479 std::path::PathBuf,
480 ) {
481 let dir = crate::core::data_dir::isolated_data_dir();
482 let ledger = dir.path().join("ledger.jsonl");
483 let trail = dir.path().join("trail.jsonl");
484 savings_ledger::store::append(&ledger, ledger_event(1_000, Some("s1"))).unwrap();
485 savings_ledger::store::append(&ledger, ledger_event(500, Some("s2"))).unwrap();
486 savings_ledger::store::append(&ledger, ledger_event(250, None)).unwrap();
487 security(&trail, "secret_redacted:2|session=s1");
488 security(&trail, "shell_blocked:1|session=s2");
489 security(&trail, "tool_call");
490 (dir, ledger, trail)
491 }
492
493 #[test]
494 fn session_scope_counts_only_that_session() {
495 let (_dir, ledger, trail) = fixture();
496 let proof = build_from(Some(&ledger), Some(&trail), Some("s1"), None);
497 assert!(!proof.tampered());
498 assert_eq!(proof.tokens_saved, 1_000);
499 assert_eq!(proof.ledger_evidence.entries, 1);
500 assert_eq!(proof.security.secrets_redacted, 2);
501 assert_eq!(proof.security.shell_blocked, 0);
502 assert_eq!(proof.audit_evidence.entries, 1);
503 assert_eq!(proof.ledger.entries, 3);
504 assert_eq!(proof.audit.entries, 3);
505 }
506
507 #[test]
510 fn project_scope_counts_only_that_projects_events_and_sessions() {
511 let (dir, ledger, trail) = fixture();
512 let project = dir.path().join("project");
513 std::fs::create_dir_all(project.join(".git")).unwrap();
514 let root = project.to_string_lossy().into_owned();
515 let other = dir.path().join("other");
516 std::fs::create_dir_all(other.join(".git")).unwrap();
517 let other_root = other.to_string_lossy().into_owned();
518 let mut folder_event = ledger_event(40, None);
519 folder_event.repo_hash = savings_ledger::hash_folder(&root);
520 savings_ledger::store::append(&ledger, folder_event).unwrap();
521 let scope = savings_ledger::ProjectScope::from_sessions(
522 &project,
523 [("s1", root.as_str()), ("s2", other_root.as_str())].into_iter(),
524 );
525
526 let proof = build_scoped(Some(&ledger), Some(&trail), None, None, Some(&scope));
527 assert!(!proof.tampered());
528 assert_eq!(
529 proof.tokens_saved, 1_040,
530 "session s1 plus the project's folder"
531 );
532 assert_eq!(proof.ledger_evidence.entries, 2);
533 assert_eq!(proof.security.secrets_redacted, 2);
534 assert_eq!(
535 proof.security.shell_blocked, 0,
536 "s2 worked in another project"
537 );
538 assert_eq!(proof.security.total(), 2);
539 assert_eq!(
540 proof.ledger.entries, 4,
541 "the chain check still covers the whole ledger"
542 );
543 }
544
545 #[test]
546 fn lifetime_scope_counts_everything() {
547 let (_dir, ledger, trail) = fixture();
548 let proof = build_from(Some(&ledger), Some(&trail), None, None);
549 assert_eq!(proof.tokens_saved, 1_750);
550 assert_eq!(proof.security.total(), 3);
551 let text = render(&proof, Style::PLAIN);
552 assert!(text.contains("✓ measured"), "{text}");
553 assert!(text.contains("2 secrets kept out of context"), "{text}");
554 assert!(text.contains("savings ledger intact (3 entries)"), "{text}");
555 }
556
557 #[test]
558 fn tampered_ledger_is_reported() {
559 let (_dir, ledger, trail) = fixture();
560 let raw = std::fs::read_to_string(&ledger).unwrap();
561 std::fs::write(
562 &ledger,
563 raw.replacen("\"saved_tokens\":1000", "\"saved_tokens\":9000", 1),
564 )
565 .unwrap();
566 let proof = build_from(Some(&ledger), Some(&trail), Some("s1"), None);
567 assert!(proof.tampered());
568 assert!(!proof.ledger.intact);
569 let text = render(&proof, Style::PLAIN);
570 assert!(text.contains("TAMPERED"), "{text}");
571 assert!(text.contains("NOT proof"), "{text}");
572 }
573
574 #[test]
575 fn tampered_trail_is_reported() {
576 let (_dir, ledger, trail) = fixture();
577 let raw = std::fs::read_to_string(&trail).unwrap();
578 std::fs::write(
579 &trail,
580 raw.replacen("secret_redacted:2", "secret_redacted:9", 1),
581 )
582 .unwrap();
583 let proof = build_from(Some(&ledger), Some(&trail), None, None);
584 assert!(proof.tampered());
585 assert!(!proof.audit.intact);
586 }
587
588 #[test]
589 fn missing_chains_are_empty_and_intact() {
590 let dir = tempfile::tempdir().unwrap();
591 let proof = build_from(
592 Some(&dir.path().join("none.jsonl")),
593 Some(&dir.path().join("none2.jsonl")),
594 Some("x"),
595 None,
596 );
597 assert!(!proof.tampered());
598 assert!(render(&proof, Style::PLAIN).contains("nothing recorded"));
599 }
600
601 #[test]
602 fn security_since_filters_by_time_and_refuses_a_tampered_trail() {
603 let (_dir, _ledger, trail) = fixture();
604 let all = security_since_at(&trail, None).unwrap();
605 assert_eq!(all.total(), 3);
606 let future = Utc::now() + chrono::Duration::days(1);
607 assert!(security_since_at(&trail, Some(future)).unwrap().is_empty());
608 let past = Utc::now() - chrono::Duration::days(7);
609 assert_eq!(security_since_at(&trail, Some(past)).unwrap(), all);
610
611 let raw = std::fs::read_to_string(&trail).unwrap();
612 std::fs::write(
613 &trail,
614 raw.replacen("secret_redacted:2", "secret_redacted:9", 1),
615 )
616 .unwrap();
617 assert_eq!(security_since_at(&trail, None), None);
618 }
619
620 #[test]
621 fn security_evidence_carries_the_hashes_and_refuses_a_tampered_trail() {
622 let (_dir, _ledger, trail) = fixture();
623 let (counts, evidence) = security_evidence_at(&trail).unwrap();
624 assert_eq!(counts.total(), 3);
625 assert_eq!(evidence.entries, 2);
626 assert!(evidence.first_hash.is_some() && evidence.last_hash.is_some());
627
628 let raw = std::fs::read_to_string(&trail).unwrap();
629 std::fs::write(
630 &trail,
631 raw.replacen("shell_blocked:1", "shell_blocked:5", 1),
632 )
633 .unwrap();
634 assert!(security_evidence_at(&trail).is_none());
635 }
636
637 fn full_parse_evidence(path: &Path) -> (SecurityCounts, Evidence) {
640 let mut counts = SecurityCounts::default();
641 let mut evidence = Evidence::default();
642 for entry in audit_trail::load_all_at(path) {
643 if let Some((kind, n, _)) = entry.action.as_deref().and_then(parse_action) {
644 if kind.in_signed_tally() {
645 counts.add(kind, n);
646 evidence.push(&entry.entry_hash);
647 }
648 }
649 }
650 (counts, evidence)
651 }
652
653 #[test]
657 fn resumed_security_evidence_matches_a_full_parse_after_appends() {
658 let iso = crate::core::data_dir::isolated_data_dir();
659 let trail = audit_trail::default_trail_path().unwrap();
660 let bytes = |(counts, evidence): &(SecurityCounts, Evidence)| {
661 serde_json::to_string(&(counts, evidence)).unwrap()
662 };
663 security(&trail, "pii_redacted:4");
664 assert_eq!(
665 bytes(&security_evidence_at(&trail).unwrap()),
666 bytes(&full_parse_evidence(&trail))
667 );
668 let checkpoints = iso.path().join("audit").join("verified");
669 assert!(
670 std::fs::read_dir(&checkpoints).unwrap().next().is_some(),
671 "the first pass leaves a checkpoint to resume from"
672 );
673 for action in [
674 "secret_redacted:2|session=s1",
675 "tool_call",
676 "shell_blocked:1",
677 "path_blocked:3|session=s2",
678 ] {
679 security(&trail, action);
680 assert_eq!(
681 bytes(&security_evidence_at(&trail).unwrap()),
682 bytes(&full_parse_evidence(&trail)),
683 "after appending {action}"
684 );
685 }
686 assert_eq!(security_evidence_at(&trail).unwrap().1.entries, 3);
687 }
688
689 #[test]
693 fn lifetime_proof_from_the_verified_state_matches_a_full_parse() {
694 let _iso = crate::core::data_dir::isolated_data_dir();
695 let trail = audit_trail::default_trail_path().unwrap();
696 let bytes = |counts: &SecurityCounts, evidence: &Evidence| {
697 serde_json::to_string(&(counts, evidence)).unwrap()
698 };
699 for action in [
700 "pii_redacted:4",
701 "secret_redacted:2|session=s1",
702 "tool_call",
703 "shell_blocked:1",
704 "path_blocked:3|session=s2",
705 ] {
706 security(&trail, action);
707 let proof = build_from(None, Some(&trail), None, None);
708 let mut counts = SecurityCounts::default();
709 let mut evidence = Evidence::default();
710 for entry in audit_trail::load_all_at(&trail) {
711 if let Some((kind, n, _)) = entry.action.as_deref().and_then(parse_action) {
712 counts.add(kind, n);
713 evidence.push(&entry.entry_hash);
714 }
715 }
716 assert_eq!(
717 bytes(&proof.security, &proof.audit_evidence),
718 bytes(&counts, &evidence),
719 "after appending {action}"
720 );
721 assert_eq!(proof.audit.entries, audit_trail::load_all_at(&trail).len());
722 assert!(!proof.tampered());
723 }
724 }
725
726 #[test]
727 fn json_has_labels_tooling_needs() {
728 let (_dir, ledger, trail) = fixture();
729 let proof = build_from(Some(&ledger), Some(&trail), Some("s2"), None);
730 let json = serde_json::to_value(&proof).unwrap();
731 assert_eq!(json["tokens_saved"], 500);
732 assert_eq!(json["security"]["shell_blocked"], 1);
733 assert_eq!(json["ledger"]["intact"], true);
734 assert!(json["ledger_evidence"]["last_hash"].is_string());
735 }
736}