Skip to main content

lean_ctx/core/value/
proof.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `lean-ctx value`: every number the value surface shows, re-derived from the
3//! two tamper-evident chains instead of the display counters.
4//!
5//! * Tokens come from the savings ledger (SHA-256 chain, `session_id`
6//!   committed since canonical v7).
7//! * Security events come from the audit trail (SHA-256 chain, Ed25519
8//!   signed; kind, count and session committed in the hashed `action`).
9//!
10//! Both chains are re-walked from genesis first. A broken chain makes the
11//! report *tampered*: the numbers are still printed, but flagged as not proof,
12//! and the CLI exits non-zero.
13
14use 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/// First and last `entry_hash` of the chain entries a claim rests on —
38/// anyone can look them up in the JSONL file.
39#[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    /// `None` = lifetime (every session).
59    pub session_id: Option<String>,
60    pub ledger: ChainCheck,
61    pub audit: ChainCheck,
62    /// ✓ measured: counterfactual input tokens of the attributed tool results.
63    pub tokens_baseline: u64,
64    /// ✓ measured: tokens kept out of the model's context.
65    pub tokens_saved: u64,
66    /// ✓ measured: tokens the agent spent re-reading (netted out).
67    pub tokens_bounced: u64,
68    pub ledger_evidence: Evidence,
69    /// ✓ measured: security events committed to the audit trail.
70    pub security: SecurityCounts,
71    pub audit_evidence: Evidence,
72    /// The live display counters, for comparison only — not proof.
73    pub live: Option<ValueSnapshot>,
74    /// UTC days (`YYYY-MM-DD`) with at least one attributed ledger event —
75    /// the evidence behind streak milestones.
76    #[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    /// ≈ derived: share of the attributed input kept out of context.
90    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
100/// Builds the proof for `session` (or lifetime) from the chains at the given
101/// paths. A missing file is an empty, intact chain.
102pub 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
111/// [`build_from`] narrowed to one project's ledger events and audit entries
112/// (`None`: every project). An audit entry without a session belongs to no
113/// one project, so a project's proof leaves it out.
114pub 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        // The lifetime proof over an intact chain comes from the verifier's
174        // (incremental) state; only a session view or a broken chain reads
175        // every entry again.
176        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
210/// The proof over the local chains. `session = None` and `lifetime = false`
211/// picks the most recently active session (from `value/current.json`).
212pub 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
236/// The lifetime proof over the local chains for one project.
237pub 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
243/// ✓ measured security events since `cutoff` (lifetime when `None`), from the
244/// local audit trail. `None` when the trail fails verification, so a tampered
245/// chain never reaches a report or share card.
246pub fn verified_security_since(cutoff: Option<DateTime<Utc>>) -> Option<SecurityCounts> {
247    security_since_at(&audit_trail::default_trail_path()?, cutoff)
248}
249
250/// ✓ measured lifetime security events together with the audit-trail entry
251/// hashes they rest on, for a signed savings-batch tally: only the kinds that
252/// tally carries (see `SecurityCounts::signed_tally_projection`), so the
253/// evidence names exactly the entries the counts rest on. `None` when there is
254/// no trail or it fails verification.
255pub fn verified_security_evidence() -> Option<(SecurityCounts, Evidence)> {
256    security_evidence_at(&audit_trail::default_trail_path()?)
257}
258
259/// Aggregated by the verifying pass itself, so a resumed (checkpointed)
260/// verification reads only the appended entries for the evidence too.
261fn 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
344/// Human report. Every value line carries its label and source.
345pub 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    /// Chains in an isolated data dir, held for the test: the trail's entries
474    /// are signed with this data dir's `lean-ctx` key and verified against
475    /// it, so a parallel test switching the data dir cannot swap the key.
476    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    /// A project's proof counts the events recorded in its folders or its
508    /// sessions, over its whole lifetime; entries without a session stay out.
509    #[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    /// The evidence a full parse of the trail yields (the pre-checkpoint
638    /// algorithm): the reference the incremental aggregate must reproduce.
639    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    /// In the data dir the evidence comes from the checkpointed pass, which
654    /// reads only appended entries; after every append it must serialize
655    /// byte-identically to a full parse.
656    #[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    /// The lifetime proof takes its security counts and evidence from the
690    /// verifier's (resumed) state instead of reading the trail again; it must
691    /// equal a full parse after every append, every kind included.
692    #[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}