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;
21use crate::core::savings_ledger;
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 mut proof = Proof {
122        session_id: session.map(str::to_string),
123        ledger: ChainCheck {
124            name: "savings ledger",
125            path: ledger_path.map(|p| p.display().to_string()),
126            entries: 0,
127            intact: true,
128            first_invalid_at: None,
129        },
130        audit: ChainCheck {
131            name: "audit trail",
132            path: trail_path.map(|p| p.display().to_string()),
133            entries: 0,
134            intact: true,
135            first_invalid_at: None,
136        },
137        tokens_baseline: 0,
138        tokens_saved: 0,
139        tokens_bounced: 0,
140        ledger_evidence: Evidence::default(),
141        security: SecurityCounts::default(),
142        audit_evidence: Evidence::default(),
143        live,
144        active_days: BTreeSet::new(),
145    };
146
147    // One streaming pass checks the chain and sums the events (#2240): the
148    // ledger is never held in memory.
149    if let Some(path) = ledger_path {
150        let (verify, visited) = savings_ledger::store::verify_each(path, |ev| {
151            if !in_session(ev.session_id.as_deref(), session)
152                || scope.is_some_and(|scope| !scope.holds_event(ev))
153                || (ev.saved_tokens == 0 && ev.bounce_adjustment == 0)
154            {
155                return;
156            }
157            proof.tokens_baseline = proof.tokens_baseline.saturating_add(ev.baseline_tokens);
158            proof.tokens_saved = proof.tokens_saved.saturating_add(ev.saved_tokens);
159            proof.tokens_bounced = proof.tokens_bounced.saturating_add(ev.bounce_adjustment);
160            proof.ledger_evidence.push(&ev.entry_hash);
161            if let Some(day) = ev.ts.get(..10) {
162                proof.active_days.insert(day.to_string());
163            }
164        });
165        proof.ledger.entries = verify.total.max(visited);
166        proof.ledger.intact = verify.valid;
167        proof.ledger.first_invalid_at = verify.first_invalid_at;
168    }
169
170    if let Some(path) = trail_path {
171        // The lifetime proof over an intact chain comes from the verifier's
172        // (incremental) state; only a session view or a broken chain reads
173        // the entries again.
174        let (verify, evidence) = audit_trail::verified_security_at(path);
175        proof.audit.entries = verify.total_entries;
176        proof.audit.intact = verify.valid;
177        proof.audit.first_invalid_at = verify.first_invalid_at;
178        if let (None, None, Some(evidence)) = (session, scope, evidence) {
179            proof.security = evidence.counts;
180            proof.audit_evidence = Evidence {
181                entries: evidence.entries,
182                first_hash: evidence.first_hash,
183                last_hash: evidence.last_hash,
184            };
185            return proof;
186        }
187        // A session's entries are not older than the session and carry its
188        // id in the hashed action: segments sealed before it started and
189        // lines without its id cannot hold one of them (#2240).
190        let since = session.and_then(session_started_at);
191        let needle = session.filter(|id| appears_verbatim_in_json(id));
192        let visited = audit_trail::for_each_entry_at(path, since, needle, |entry| {
193            let Some((kind, n, entry_session)) = entry.action.as_deref().and_then(parse_action)
194            else {
195                return;
196            };
197            if !in_session(entry_session, session)
198                || scope.is_some_and(|scope| !scope.holds_session(entry_session))
199            {
200                return;
201            }
202            proof.security.add(kind, n);
203            proof.audit_evidence.push(&entry.entry_hash);
204        });
205        if since.is_none() && needle.is_none() {
206            proof.audit.entries = proof.audit.entries.max(visited);
207        } else if !verify.valid {
208            // A broken or unreadable chain reports every entry still on disk,
209            // as the full read did: rare, so it may read the trail once more.
210            let all = audit_trail::for_each_entry_at(path, None, None, |_| {});
211            proof.audit.entries = proof.audit.entries.max(all);
212        }
213    }
214    proof
215}
216
217/// Slack before an engine session's start time (UTC, from its id) within
218/// which the audit trail is still read for it, against clock steps.
219const SESSION_START_SLACK_HOURS: i64 = 24;
220
221/// When the engine session `id` (`YYYYMMDD-HHMMSS…`, UTC; see
222/// `generate_session_id`) started, less [`SESSION_START_SLACK_HOURS`].
223/// `None` for an id in another format: then every segment is read.
224fn session_started_at(id: &str) -> Option<DateTime<Utc>> {
225    let stamp = id.get(..15)?;
226    let started = chrono::NaiveDateTime::parse_from_str(stamp, "%Y%m%d-%H%M%S").ok()?;
227    Some(started.and_utc() - chrono::Duration::hours(SESSION_START_SLACK_HOURS))
228}
229
230/// Whether `text` is written as-is inside a JSON string, so a line holding
231/// it in a field must contain it byte for byte.
232fn appears_verbatim_in_json(text: &str) -> bool {
233    !text.is_empty()
234        && text
235            .chars()
236            .all(|c| c.is_ascii_graphic() && c != '"' && c != '\\' && c != '/')
237}
238
239/// The proof over the local chains. `session = None` and `lifetime = false`
240/// picks the most recently active session (from `value/current.json`).
241pub fn build(session: Option<&str>, lifetime: bool) -> Proof {
242    let live = if lifetime {
243        None
244    } else {
245        super::snapshot::load(session)
246    };
247    let session = if lifetime {
248        None
249    } else {
250        session
251            .map(str::to_string)
252            .or_else(|| live.as_ref().map(|s| s.session_id.clone()))
253            .filter(|id| !id.is_empty())
254    };
255    let ledger = savings_ledger::store::default_path();
256    let trail = audit_trail::default_trail_path();
257    build_from(
258        ledger.as_deref(),
259        trail.as_deref(),
260        session.as_deref(),
261        live,
262    )
263}
264
265/// The lifetime proof over the local chains for one project.
266pub fn build_lifetime_for(scope: &savings_ledger::ProjectScope) -> Proof {
267    let ledger = savings_ledger::store::default_path();
268    let trail = audit_trail::default_trail_path();
269    build_scoped(ledger.as_deref(), trail.as_deref(), None, None, Some(scope))
270}
271
272/// ✓ measured security events since `cutoff` (lifetime when `None`), from the
273/// local audit trail. `None` when the trail fails verification, so a tampered
274/// chain never reaches a report or share card.
275pub fn verified_security_since(cutoff: Option<DateTime<Utc>>) -> Option<SecurityCounts> {
276    security_since_at(&audit_trail::default_trail_path()?, cutoff)
277}
278
279/// ✓ measured lifetime security events together with the audit-trail entry
280/// hashes they rest on, for a signed savings-batch tally: only the kinds that
281/// tally carries (see `SecurityCounts::signed_tally_projection`), so the
282/// evidence names exactly the entries the counts rest on. `None` when there is
283/// no trail or it fails verification.
284pub fn verified_security_evidence() -> Option<(SecurityCounts, Evidence)> {
285    security_evidence_at(&audit_trail::default_trail_path()?)
286}
287
288/// Aggregated by the verifying pass itself, so a resumed (checkpointed)
289/// verification reads only the appended entries for the evidence too.
290fn security_evidence_at(path: &Path) -> Option<(SecurityCounts, Evidence)> {
291    let tally = audit_trail::verified_signed_tally_at(path)?;
292    Some((
293        tally.counts,
294        Evidence {
295            entries: tally.entries,
296            first_hash: tally.first_hash,
297            last_hash: tally.last_hash,
298        },
299    ))
300}
301
302fn security_since_at(path: &Path, cutoff: Option<DateTime<Utc>>) -> Option<SecurityCounts> {
303    if !audit_trail::verify_chain_at(path).valid {
304        return None;
305    }
306    let mut counts = SecurityCounts::default();
307    for entry in audit_trail::load_all_at(path) {
308        if let Some(cutoff) = cutoff {
309            let Ok(ts) = DateTime::parse_from_rfc3339(&entry.timestamp) else {
310                continue;
311            };
312            if ts.with_timezone(&Utc) < cutoff {
313                continue;
314            }
315        }
316        if let Some((kind, n, _)) = entry.action.as_deref().and_then(parse_action) {
317            counts.add(kind, n);
318        }
319    }
320    Some(counts)
321}
322
323fn short(hash: Option<&String>) -> String {
324    hash.map_or_else(|| "—".to_string(), |h| h.chars().take(12).collect())
325}
326
327struct Marks {
328    measured: String,
329    derived: String,
330    ok: String,
331    bad: String,
332}
333
334impl Marks {
335    fn new(style: Style) -> Self {
336        let paint = |code: &str, text: &str| {
337            if style.color {
338                format!("\x1b[{code}m{text}\x1b[0m")
339            } else {
340                text.to_string()
341            }
342        };
343        let (check, approx, cross) = if style.unicode {
344            ("✓", "≈", "✗")
345        } else {
346            ("OK", "~", "FAIL")
347        };
348        Self {
349            measured: paint("32", &format!("{check} measured")),
350            derived: paint("2", &format!("{approx} derived ")),
351            ok: paint("32", check),
352            bad: paint("31", cross),
353        }
354    }
355}
356
357fn chain_line(check: &ChainCheck, marks: &Marks) -> String {
358    if check.intact {
359        format!(
360            "{} {} intact ({} entries)",
361            marks.ok, check.name, check.entries
362        )
363    } else {
364        format!(
365            "{} {} TAMPERED at entry {}",
366            marks.bad,
367            check.name,
368            check.first_invalid_at.unwrap_or(0)
369        )
370    }
371}
372
373/// Human report. Every value line carries its label and source.
374pub fn render(proof: &Proof, style: Style) -> String {
375    let marks = Marks::new(style);
376    let mut out = String::new();
377    let scope = proof
378        .session_id
379        .as_deref()
380        .map_or_else(|| "all sessions".to_string(), |id| format!("session {id}"));
381    out.push_str(&format!("lean-ctx value · {scope}\n\n"));
382
383    if proof.ledger_evidence.entries == 0 && proof.security.is_empty() {
384        out.push_str("  nothing recorded for this scope yet\n");
385    }
386    if proof.ledger_evidence.entries > 0 {
387        out.push_str(&format!(
388            "  {}  {} tokens kept out of context   savings ledger, {} events\n",
389            marks.measured,
390            format_tokens(proof.net_saved()),
391            proof.ledger_evidence.entries
392        ));
393        if proof.tokens_bounced > 0 {
394            out.push_str(&format!(
395                "                 (net of {} tokens re-read by the agent)\n",
396                format_tokens(proof.tokens_bounced)
397            ));
398        }
399        if let Some(pct) = proof.saved_pct() {
400            out.push_str(&format!(
401                "  {}  {pct:.0}% of {} tool-input tokens   net saved ÷ baseline\n",
402                marks.derived,
403                format_tokens(proof.tokens_baseline)
404            ));
405        }
406    }
407    for phrase in security_phrases(&proof.security) {
408        out.push_str(&format!("  {}  {phrase}   audit trail\n", marks.measured));
409    }
410
411    out.push('\n');
412    if proof.ledger_evidence.entries > 0 {
413        out.push_str(&format!(
414            "  evidence  ledger {} … {}\n",
415            short(proof.ledger_evidence.first_hash.as_ref()),
416            short(proof.ledger_evidence.last_hash.as_ref())
417        ));
418    }
419    if proof.audit_evidence.entries > 0 {
420        out.push_str(&format!(
421            "  evidence  audit  {} … {}\n",
422            short(proof.audit_evidence.first_hash.as_ref()),
423            short(proof.audit_evidence.last_hash.as_ref())
424        ));
425    }
426    out.push_str(&format!(
427        "  chains    {}\n",
428        chain_line(&proof.ledger, &marks)
429    ));
430    out.push_str(&format!(
431        "            {}\n",
432        chain_line(&proof.audit, &marks)
433    ));
434
435    if let Some(line) = proof.live.as_ref().and_then(|snap| {
436        super::format::one_line(
437            snap,
438            Style {
439                color: false,
440                ..style
441            },
442        )
443    }) {
444        out.push_str(&format!(
445            "\n  live      {line}\n            (display counters; the chains above are the proof)\n"
446        ));
447    }
448    if proof.tampered() {
449        out.push_str(
450            "\n  A chain failed verification: the numbers above are NOT proof.\n\
451             \x20 Inspect the file at the reported entry; `lean-ctx savings verify` shows details.\n",
452        );
453    }
454    out
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::core::audit_trail::{AuditEntryData, AuditEventType};
461    use crate::core::savings_ledger::SavingsEvent;
462
463    fn ledger_event(saved: u64, session: Option<&str>) -> SavingsEvent {
464        let mut json = serde_json::json!({
465            "ts": "2026-09-25T10:00:00+00:00",
466            "tool": "ctx_read",
467            "mechanism": "compression",
468            "model_id": "m",
469            "tokenizer": "o200k_base",
470            "baseline_tokens": saved + 100,
471            "actual_tokens": 100,
472            "saved_tokens": saved,
473            "bounce_adjustment": 0,
474            "unit_price_per_m_usd": 3.0,
475            "saved_usd": 0.0,
476            "repo_hash": "r",
477            "agent_id": "a",
478            "prev_hash": "",
479            "entry_hash": "",
480            "version": "3.10.3",
481        });
482        if let Some(id) = session {
483            json["session_id"] = id.into();
484        }
485        serde_json::from_value(json).unwrap()
486    }
487
488    fn security(path: &Path, action: &str) {
489        audit_trail::record_at(
490            path,
491            AuditEntryData {
492                agent_id: "a".into(),
493                tool: "ctx_shell".into(),
494                action: Some(action.into()),
495                input_hash: String::new(),
496                output_tokens: 0,
497                role: "coder".into(),
498                event_type: AuditEventType::SecretDetected,
499            },
500        );
501    }
502
503    /// Chains in an isolated data dir, held for the test: the trail's entries
504    /// are signed with this data dir's `lean-ctx` key and verified against
505    /// it, so a parallel test switching the data dir cannot swap the key.
506    fn fixture() -> (
507        crate::core::data_dir::IsolatedDataDir,
508        std::path::PathBuf,
509        std::path::PathBuf,
510    ) {
511        let dir = crate::core::data_dir::isolated_data_dir();
512        let ledger = dir.path().join("ledger.jsonl");
513        let trail = dir.path().join("trail.jsonl");
514        savings_ledger::store::append(&ledger, ledger_event(1_000, Some("s1"))).unwrap();
515        savings_ledger::store::append(&ledger, ledger_event(500, Some("s2"))).unwrap();
516        savings_ledger::store::append(&ledger, ledger_event(250, None)).unwrap();
517        security(&trail, "secret_redacted:2|session=s1");
518        security(&trail, "shell_blocked:1|session=s2");
519        security(&trail, "tool_call");
520        (dir, ledger, trail)
521    }
522
523    #[test]
524    fn session_scope_counts_only_that_session() {
525        let (_dir, ledger, trail) = fixture();
526        let proof = build_from(Some(&ledger), Some(&trail), Some("s1"), None);
527        assert!(!proof.tampered());
528        assert_eq!(proof.tokens_saved, 1_000);
529        assert_eq!(proof.ledger_evidence.entries, 1);
530        assert_eq!(proof.security.secrets_redacted, 2);
531        assert_eq!(proof.security.shell_blocked, 0);
532        assert_eq!(proof.audit_evidence.entries, 1);
533        assert_eq!(proof.ledger.entries, 3);
534        assert_eq!(proof.audit.entries, 3);
535    }
536
537    /// An engine session id carries its UTC start: the session view reads
538    /// only what was sealed after it (less a day of slack) and still counts
539    /// every entry of the session (#2240).
540    #[test]
541    fn engine_session_view_counts_the_same_as_a_full_read() {
542        let (_dir, ledger, trail) = fixture();
543        let id = crate::core::session::SessionState::new().id;
544        savings_ledger::store::append(&ledger, ledger_event(70, Some(&id))).unwrap();
545        security(&trail, &format!("secret_redacted:3|session={id}"));
546        security(&trail, &format!("shell_blocked:1|session={id}x"));
547
548        let proof = build_from(Some(&ledger), Some(&trail), Some(&id), None);
549        assert!(!proof.tampered());
550        assert_eq!(proof.tokens_saved, 70);
551        assert_eq!(proof.ledger_evidence.entries, 1);
552        assert_eq!(proof.ledger.entries, 4);
553        assert_eq!(proof.security.secrets_redacted, 3);
554        assert_eq!(
555            proof.security.shell_blocked, 0,
556            "a longer id is another session"
557        );
558        assert_eq!(proof.audit_evidence.entries, 1);
559        assert_eq!(proof.audit.entries, 5);
560    }
561
562    #[test]
563    fn session_start_comes_from_an_engine_id_less_the_slack() {
564        let started = session_started_at("20261111-195640u478865p2526s0").unwrap();
565        assert_eq!(
566            started,
567            "2026-11-10T19:56:40Z".parse::<DateTime<Utc>>().unwrap()
568        );
569        assert_eq!(
570            session_started_at("20261111-195640-478865-2526").map(|t| t.to_rfc3339()),
571            Some("2026-11-10T19:56:40+00:00".into()),
572            "the legacy id format"
573        );
574        assert_eq!(session_started_at("s1"), None);
575        assert_eq!(session_started_at("claude-7f3e-…"), None);
576    }
577
578    #[test]
579    fn only_ids_json_leaves_unescaped_filter_lines() {
580        assert!(appears_verbatim_in_json("20261111-195640u478865p2526s0"));
581        assert!(!appears_verbatim_in_json(""));
582        for escaped in ["a\"b", "a\\b", "a/b", "a b", "naïve"] {
583            assert!(!appears_verbatim_in_json(escaped), "{escaped}");
584        }
585    }
586
587    /// The streaming pass reports the same chain check as `verify` and still
588    /// sums the events past a break, like the numbers `load` returned.
589    #[test]
590    fn a_broken_ledger_is_flagged_and_still_summed() {
591        let (_dir, ledger, trail) = fixture();
592        let text = std::fs::read_to_string(&ledger).unwrap();
593        std::fs::write(
594            &ledger,
595            text.replacen("\"saved_tokens\":500", "\"saved_tokens\":900", 1),
596        )
597        .unwrap();
598        let expected = savings_ledger::store::verify(&ledger);
599        assert!(!expected.valid);
600
601        let proof = build_from(Some(&ledger), Some(&trail), None, None);
602        assert!(proof.tampered());
603        assert!(!proof.ledger.intact);
604        assert_eq!(proof.ledger.first_invalid_at, expected.first_invalid_at);
605        assert_eq!(proof.ledger.entries, 3);
606        assert_eq!(proof.tokens_saved, 1_000 + 900 + 250);
607    }
608
609    /// A project's proof counts the events recorded in its folders or its
610    /// sessions, over its whole lifetime; entries without a session stay out.
611    #[test]
612    fn project_scope_counts_only_that_projects_events_and_sessions() {
613        let (dir, ledger, trail) = fixture();
614        let project = dir.path().join("project");
615        std::fs::create_dir_all(project.join(".git")).unwrap();
616        let root = project.to_string_lossy().into_owned();
617        let other = dir.path().join("other");
618        std::fs::create_dir_all(other.join(".git")).unwrap();
619        let other_root = other.to_string_lossy().into_owned();
620        let mut folder_event = ledger_event(40, None);
621        folder_event.repo_hash = savings_ledger::hash_folder(&root);
622        savings_ledger::store::append(&ledger, folder_event).unwrap();
623        let scope = savings_ledger::ProjectScope::from_sessions(
624            &project,
625            [("s1", root.as_str()), ("s2", other_root.as_str())].into_iter(),
626        );
627
628        let proof = build_scoped(Some(&ledger), Some(&trail), None, None, Some(&scope));
629        assert!(!proof.tampered());
630        assert_eq!(
631            proof.tokens_saved, 1_040,
632            "session s1 plus the project's folder"
633        );
634        assert_eq!(proof.ledger_evidence.entries, 2);
635        assert_eq!(proof.security.secrets_redacted, 2);
636        assert_eq!(
637            proof.security.shell_blocked, 0,
638            "s2 worked in another project"
639        );
640        assert_eq!(proof.security.total(), 2);
641        assert_eq!(
642            proof.ledger.entries, 4,
643            "the chain check still covers the whole ledger"
644        );
645    }
646
647    #[test]
648    fn lifetime_scope_counts_everything() {
649        let (_dir, ledger, trail) = fixture();
650        let proof = build_from(Some(&ledger), Some(&trail), None, None);
651        assert_eq!(proof.tokens_saved, 1_750);
652        assert_eq!(proof.security.total(), 3);
653        let text = render(&proof, Style::PLAIN);
654        assert!(text.contains("✓ measured"), "{text}");
655        assert!(text.contains("2 secrets kept out of context"), "{text}");
656        assert!(text.contains("savings ledger intact (3 entries)"), "{text}");
657    }
658
659    #[test]
660    fn tampered_ledger_is_reported() {
661        let (_dir, ledger, trail) = fixture();
662        let raw = std::fs::read_to_string(&ledger).unwrap();
663        std::fs::write(
664            &ledger,
665            raw.replacen("\"saved_tokens\":1000", "\"saved_tokens\":9000", 1),
666        )
667        .unwrap();
668        let proof = build_from(Some(&ledger), Some(&trail), Some("s1"), None);
669        assert!(proof.tampered());
670        assert!(!proof.ledger.intact);
671        let text = render(&proof, Style::PLAIN);
672        assert!(text.contains("TAMPERED"), "{text}");
673        assert!(text.contains("NOT proof"), "{text}");
674    }
675
676    #[test]
677    fn tampered_trail_is_reported() {
678        let (_dir, ledger, trail) = fixture();
679        let raw = std::fs::read_to_string(&trail).unwrap();
680        std::fs::write(
681            &trail,
682            raw.replacen("secret_redacted:2", "secret_redacted:9", 1),
683        )
684        .unwrap();
685        let proof = build_from(Some(&ledger), Some(&trail), None, None);
686        assert!(proof.tampered());
687        assert!(!proof.audit.intact);
688    }
689
690    /// A session view of a broken trail reports every entry on disk, as the
691    /// lifetime view and the full read before #2240 did, not the break index.
692    #[test]
693    fn a_session_view_of_a_tampered_trail_counts_every_entry() {
694        let (_dir, ledger, trail) = fixture();
695        let raw = std::fs::read_to_string(&trail).unwrap();
696        std::fs::write(
697            &trail,
698            raw.replacen("secret_redacted:2", "secret_redacted:9", 1),
699        )
700        .unwrap();
701        let lifetime = build_from(Some(&ledger), Some(&trail), None, None);
702        let session = build_from(Some(&ledger), Some(&trail), Some("s2"), None);
703        assert!(!session.audit.intact);
704        assert_eq!(
705            session.audit.first_invalid_at,
706            lifetime.audit.first_invalid_at
707        );
708        assert_eq!(session.audit.entries, lifetime.audit.entries);
709        assert_eq!(session.audit.entries, 3);
710    }
711
712    #[test]
713    fn missing_chains_are_empty_and_intact() {
714        let dir = tempfile::tempdir().unwrap();
715        let proof = build_from(
716            Some(&dir.path().join("none.jsonl")),
717            Some(&dir.path().join("none2.jsonl")),
718            Some("x"),
719            None,
720        );
721        assert!(!proof.tampered());
722        assert!(render(&proof, Style::PLAIN).contains("nothing recorded"));
723    }
724
725    #[test]
726    fn security_since_filters_by_time_and_refuses_a_tampered_trail() {
727        let (_dir, _ledger, trail) = fixture();
728        let all = security_since_at(&trail, None).unwrap();
729        assert_eq!(all.total(), 3);
730        let future = Utc::now() + chrono::Duration::days(1);
731        assert!(security_since_at(&trail, Some(future)).unwrap().is_empty());
732        let past = Utc::now() - chrono::Duration::days(7);
733        assert_eq!(security_since_at(&trail, Some(past)).unwrap(), all);
734
735        let raw = std::fs::read_to_string(&trail).unwrap();
736        std::fs::write(
737            &trail,
738            raw.replacen("secret_redacted:2", "secret_redacted:9", 1),
739        )
740        .unwrap();
741        assert_eq!(security_since_at(&trail, None), None);
742    }
743
744    #[test]
745    fn security_evidence_carries_the_hashes_and_refuses_a_tampered_trail() {
746        let (_dir, _ledger, trail) = fixture();
747        let (counts, evidence) = security_evidence_at(&trail).unwrap();
748        assert_eq!(counts.total(), 3);
749        assert_eq!(evidence.entries, 2);
750        assert!(evidence.first_hash.is_some() && evidence.last_hash.is_some());
751
752        let raw = std::fs::read_to_string(&trail).unwrap();
753        std::fs::write(
754            &trail,
755            raw.replacen("shell_blocked:1", "shell_blocked:5", 1),
756        )
757        .unwrap();
758        assert!(security_evidence_at(&trail).is_none());
759    }
760
761    /// The evidence a full parse of the trail yields (the pre-checkpoint
762    /// algorithm): the reference the incremental aggregate must reproduce.
763    fn full_parse_evidence(path: &Path) -> (SecurityCounts, Evidence) {
764        let mut counts = SecurityCounts::default();
765        let mut evidence = Evidence::default();
766        for entry in audit_trail::load_all_at(path) {
767            if let Some((kind, n, _)) = entry.action.as_deref().and_then(parse_action) {
768                if kind.in_signed_tally() {
769                    counts.add(kind, n);
770                    evidence.push(&entry.entry_hash);
771                }
772            }
773        }
774        (counts, evidence)
775    }
776
777    /// In the data dir the evidence comes from the checkpointed pass, which
778    /// reads only appended entries; after every append it must serialize
779    /// byte-identically to a full parse.
780    #[test]
781    fn resumed_security_evidence_matches_a_full_parse_after_appends() {
782        let iso = crate::core::data_dir::isolated_data_dir();
783        let trail = audit_trail::default_trail_path().unwrap();
784        let bytes = |(counts, evidence): &(SecurityCounts, Evidence)| {
785            serde_json::to_string(&(counts, evidence)).unwrap()
786        };
787        security(&trail, "pii_redacted:4");
788        assert_eq!(
789            bytes(&security_evidence_at(&trail).unwrap()),
790            bytes(&full_parse_evidence(&trail))
791        );
792        let checkpoints = iso.path().join("audit").join("verified");
793        assert!(
794            std::fs::read_dir(&checkpoints).unwrap().next().is_some(),
795            "the first pass leaves a checkpoint to resume from"
796        );
797        for action in [
798            "secret_redacted:2|session=s1",
799            "tool_call",
800            "shell_blocked:1",
801            "path_blocked:3|session=s2",
802        ] {
803            security(&trail, action);
804            assert_eq!(
805                bytes(&security_evidence_at(&trail).unwrap()),
806                bytes(&full_parse_evidence(&trail)),
807                "after appending {action}"
808            );
809        }
810        assert_eq!(security_evidence_at(&trail).unwrap().1.entries, 3);
811    }
812
813    /// The lifetime proof takes its security counts and evidence from the
814    /// verifier's (resumed) state instead of reading the trail again; it must
815    /// equal a full parse after every append, every kind included.
816    #[test]
817    fn lifetime_proof_from_the_verified_state_matches_a_full_parse() {
818        let _iso = crate::core::data_dir::isolated_data_dir();
819        let trail = audit_trail::default_trail_path().unwrap();
820        let bytes = |counts: &SecurityCounts, evidence: &Evidence| {
821            serde_json::to_string(&(counts, evidence)).unwrap()
822        };
823        for action in [
824            "pii_redacted:4",
825            "secret_redacted:2|session=s1",
826            "tool_call",
827            "shell_blocked:1",
828            "path_blocked:3|session=s2",
829        ] {
830            security(&trail, action);
831            let proof = build_from(None, Some(&trail), None, None);
832            let mut counts = SecurityCounts::default();
833            let mut evidence = Evidence::default();
834            for entry in audit_trail::load_all_at(&trail) {
835                if let Some((kind, n, _)) = entry.action.as_deref().and_then(parse_action) {
836                    counts.add(kind, n);
837                    evidence.push(&entry.entry_hash);
838                }
839            }
840            assert_eq!(
841                bytes(&proof.security, &proof.audit_evidence),
842                bytes(&counts, &evidence),
843                "after appending {action}"
844            );
845            assert_eq!(proof.audit.entries, audit_trail::load_all_at(&trail).len());
846            assert!(!proof.tampered());
847        }
848    }
849
850    #[test]
851    fn json_has_labels_tooling_needs() {
852        let (_dir, ledger, trail) = fixture();
853        let proof = build_from(Some(&ledger), Some(&trail), Some("s2"), None);
854        let json = serde_json::to_value(&proof).unwrap();
855        assert_eq!(json["tokens_saved"], 500);
856        assert_eq!(json["security"]["shell_blocked"], 1);
857        assert_eq!(json["ledger"]["intact"], true);
858        assert!(json["ledger_evidence"]["last_hash"].is_string());
859    }
860}