Skip to main content

truth_mirror/
ledger.rs

1//! Machine-readable ledger and markdown mirror.
2
3use std::{
4    collections::BTreeMap,
5    fmt, fs,
6    io::{self, Write},
7    path::{Path, PathBuf},
8    process::ExitCode,
9    time::{SystemTime, UNIX_EPOCH},
10};
11
12use anyhow::Result;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use crate::cli;
17
18pub const MACHINE_LEDGER_FILE: &str = "ledger.jsonl";
19pub const MARKDOWN_LEDGER_FILE: &str = "ledger.md";
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22pub struct LedgerEntry {
23    pub commit_sha: String,
24    pub verdict: Verdict,
25    pub disposition: Disposition,
26    pub claim: String,
27    pub evidence: Vec<String>,
28    pub reviewer: ReviewerConfig,
29    pub findings: Vec<String>,
30    pub resolution: Option<Resolution>,
31    pub created_at_unix: u64,
32    pub updated_at_unix: u64,
33}
34
35impl LedgerEntry {
36    pub fn new(
37        commit_sha: impl Into<String>,
38        verdict: Verdict,
39        claim: impl Into<String>,
40        evidence: Vec<String>,
41        reviewer: ReviewerConfig,
42        findings: Vec<String>,
43    ) -> Self {
44        Self::new_at(
45            commit_sha,
46            verdict,
47            claim,
48            evidence,
49            reviewer,
50            findings,
51            unix_now(),
52        )
53    }
54
55    pub fn new_at(
56        commit_sha: impl Into<String>,
57        verdict: Verdict,
58        claim: impl Into<String>,
59        evidence: Vec<String>,
60        reviewer: ReviewerConfig,
61        findings: Vec<String>,
62        timestamp: u64,
63    ) -> Self {
64        let disposition = match verdict {
65            Verdict::Pass => Disposition::Resolved,
66            Verdict::Reject => Disposition::Open,
67        };
68
69        Self {
70            commit_sha: commit_sha.into(),
71            verdict,
72            disposition,
73            claim: claim.into(),
74            evidence,
75            reviewer,
76            findings,
77            resolution: None,
78            created_at_unix: timestamp,
79            updated_at_unix: timestamp,
80        }
81    }
82
83    pub fn is_unresolved_rejection(&self) -> bool {
84        self.verdict == Verdict::Reject && self.disposition == Disposition::Open
85    }
86}
87
88#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
89#[serde(rename_all = "UPPERCASE")]
90pub enum Verdict {
91    Pass,
92    Reject,
93}
94
95impl fmt::Display for Verdict {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self {
98            Self::Pass => formatter.write_str("PASS"),
99            Self::Reject => formatter.write_str("REJECT"),
100        }
101    }
102}
103
104#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
105#[serde(rename_all = "kebab-case")]
106pub enum Disposition {
107    Open,
108    Resolved,
109    Waived,
110}
111
112impl fmt::Display for Disposition {
113    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114        match self {
115            Self::Open => formatter.write_str("open"),
116            Self::Resolved => formatter.write_str("resolved"),
117            Self::Waived => formatter.write_str("waived"),
118        }
119    }
120}
121
122#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
123pub struct ReviewerConfig {
124    pub harness: String,
125    pub model: String,
126    pub allow_same_model: bool,
127}
128
129impl ReviewerConfig {
130    pub fn new(
131        harness: impl Into<String>,
132        model: impl Into<String>,
133        allow_same_model: bool,
134    ) -> Self {
135        Self {
136            harness: harness.into(),
137            model: model.into(),
138            allow_same_model,
139        }
140    }
141}
142
143#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
144pub struct Resolution {
145    pub kind: ResolutionKind,
146    pub reason: String,
147    pub resolved_at_unix: u64,
148}
149
150#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
151#[serde(rename_all = "kebab-case")]
152pub enum ResolutionKind {
153    Resolved,
154    Waived,
155}
156
157#[derive(Clone, Debug, Default, Eq, PartialEq)]
158pub struct LedgerStats {
159    pub total: usize,
160    pub pass: usize,
161    pub reject: usize,
162    pub unresolved: usize,
163    pub resolved: usize,
164    pub waived: usize,
165}
166
167#[derive(Clone, Debug)]
168pub struct LedgerStore {
169    root: PathBuf,
170}
171
172impl LedgerStore {
173    pub fn new(root: impl Into<PathBuf>) -> Self {
174        Self { root: root.into() }
175    }
176
177    pub fn machine_path(&self) -> PathBuf {
178        self.root.join(MACHINE_LEDGER_FILE)
179    }
180
181    pub fn markdown_path(&self) -> PathBuf {
182        self.root.join(MARKDOWN_LEDGER_FILE)
183    }
184
185    pub fn append_entry(&self, entry: &LedgerEntry) -> Result<(), LedgerError> {
186        fs::create_dir_all(&self.root)?;
187        let mut file = fs::OpenOptions::new()
188            .create(true)
189            .append(true)
190            .open(self.machine_path())?;
191        serde_json::to_writer(&mut file, entry)?;
192        writeln!(file)?;
193        self.render_markdown_mirror()?;
194        Ok(())
195    }
196
197    pub fn read_history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
198        let path = self.machine_path();
199        let contents = match fs::read_to_string(&path) {
200            Ok(contents) => contents,
201            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
202            Err(error) => return Err(error.into()),
203        };
204
205        contents
206            .lines()
207            .filter(|line| !line.trim().is_empty())
208            .map(|line| serde_json::from_str(line).map_err(LedgerError::from))
209            .collect()
210    }
211
212    pub fn latest_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
213        let mut by_sha = BTreeMap::new();
214        for entry in self.read_history()? {
215            by_sha.insert(entry.commit_sha.clone(), entry);
216        }
217        Ok(by_sha.into_values().collect())
218    }
219
220    pub fn show(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
221        self.latest_entries()?
222            .into_iter()
223            .find(|entry| entry.commit_sha == sha)
224            .ok_or_else(|| LedgerError::NotFound {
225                sha: sha.to_owned(),
226            })
227    }
228
229    pub fn unresolved_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
230        Ok(self
231            .latest_entries()?
232            .into_iter()
233            .filter(LedgerEntry::is_unresolved_rejection)
234            .collect())
235    }
236
237    pub fn resolve(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
238        self.transition_rejection(
239            sha,
240            Disposition::Resolved,
241            ResolutionKind::Resolved,
242            "resolved",
243        )
244    }
245
246    pub fn waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
247        if reason.trim().is_empty() {
248            return Err(LedgerError::EmptyWaiverReason);
249        }
250
251        self.transition_rejection(sha, Disposition::Waived, ResolutionKind::Waived, reason)
252    }
253
254    pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
255        let mut stats = LedgerStats::default();
256        for entry in self.latest_entries()? {
257            stats.total += 1;
258            match entry.verdict {
259                Verdict::Pass => stats.pass += 1,
260                Verdict::Reject => stats.reject += 1,
261            }
262            match entry.disposition {
263                Disposition::Open => {
264                    if entry.verdict == Verdict::Reject {
265                        stats.unresolved += 1;
266                    }
267                }
268                Disposition::Resolved => stats.resolved += 1,
269                Disposition::Waived => stats.waived += 1,
270            }
271        }
272        Ok(stats)
273    }
274
275    pub fn render_markdown_mirror(&self) -> Result<(), LedgerError> {
276        fs::create_dir_all(&self.root)?;
277        let markdown = render_markdown(&self.latest_entries()?, &self.stats()?);
278        fs::write(self.markdown_path(), markdown)?;
279        Ok(())
280    }
281
282    fn transition_rejection(
283        &self,
284        sha: &str,
285        disposition: Disposition,
286        kind: ResolutionKind,
287        reason: &str,
288    ) -> Result<LedgerEntry, LedgerError> {
289        let mut entry = self.show(sha)?;
290        if !entry.is_unresolved_rejection() {
291            return Err(LedgerError::NoOpenRejection {
292                sha: sha.to_owned(),
293            });
294        }
295
296        let timestamp = unix_now();
297        entry.disposition = disposition;
298        entry.resolution = Some(Resolution {
299            kind,
300            reason: reason.trim().to_owned(),
301            resolved_at_unix: timestamp,
302        });
303        entry.updated_at_unix = timestamp;
304        self.append_entry(&entry)?;
305        Ok(entry)
306    }
307}
308
309#[derive(Debug, Error)]
310pub enum LedgerError {
311    #[error("ledger IO failed: {0}")]
312    Io(#[from] io::Error),
313    #[error("ledger JSON failed: {0}")]
314    Json(#[from] serde_json::Error),
315    #[error("ledger entry not found for commit {sha}")]
316    NotFound { sha: String },
317    #[error("commit {sha} has no open rejection")]
318    NoOpenRejection { sha: String },
319    #[error("waive requires a non-empty reason")]
320    EmptyWaiverReason,
321}
322
323pub fn run(args: cli::LedgerArgs, state_dir: &Path) -> Result<ExitCode> {
324    let store = LedgerStore::new(state_dir);
325    match args.command {
326        cli::LedgerCommand::List => {
327            print_unresolved(&store.unresolved_rejections()?);
328        }
329        cli::LedgerCommand::Show { sha } => {
330            let entry = store.show(&sha)?;
331            print_entry(&entry);
332        }
333        cli::LedgerCommand::Resolve { sha } => {
334            let entry = store.resolve(&sha)?;
335            println!("resolved {}", entry.commit_sha);
336        }
337        cli::LedgerCommand::Waive { sha, reason } => {
338            let entry = store.waive(&sha, &reason)?;
339            println!("waived {}", entry.commit_sha);
340        }
341        cli::LedgerCommand::Stats => print_stats(&store.stats()?),
342    }
343
344    Ok(ExitCode::SUCCESS)
345}
346
347fn render_markdown(entries: &[LedgerEntry], stats: &LedgerStats) -> String {
348    let mut output = String::new();
349    output.push_str("# Truth Mirror Ledger\n\n");
350    output.push_str("## Summary\n\n");
351    output.push_str(&format!("- Total: {}\n", stats.total));
352    output.push_str(&format!("- PASS: {}\n", stats.pass));
353    output.push_str(&format!("- REJECT: {}\n", stats.reject));
354    output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
355    output.push_str(&format!("- Resolved: {}\n", stats.resolved));
356    output.push_str(&format!("- Waived: {}\n\n", stats.waived));
357    output.push_str("## Entries\n");
358
359    if entries.is_empty() {
360        output.push_str("\nNo ledger entries.\n");
361        return output;
362    }
363
364    for entry in entries {
365        output.push_str(&format!(
366            "\n### {} - {} - {}\n\n",
367            entry.commit_sha, entry.verdict, entry.disposition
368        ));
369        output.push_str(&format!("- Claim: {}\n", entry.claim));
370        output.push_str(&format!(
371            "- Evidence: {}\n",
372            if entry.evidence.is_empty() {
373                "none".to_owned()
374            } else {
375                entry.evidence.join(", ")
376            }
377        ));
378        output.push_str(&format!(
379            "- Reviewer: {}/{} (allow_same_model={})\n",
380            entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
381        ));
382
383        if entry.findings.is_empty() {
384            output.push_str("- Findings: none\n");
385        } else {
386            output.push_str("- Findings:\n");
387            for finding in &entry.findings {
388                output.push_str(&format!("  - {}\n", finding));
389            }
390        }
391
392        if let Some(resolution) = &entry.resolution {
393            output.push_str(&format!(
394                "- Resolution: {:?} - {}\n",
395                resolution.kind, resolution.reason
396            ));
397        }
398    }
399
400    output
401}
402
403fn print_unresolved(entries: &[LedgerEntry]) {
404    if entries.is_empty() {
405        println!("No unresolved rejected commits.");
406        return;
407    }
408
409    for entry in entries {
410        println!(
411            "{} {} {} {}",
412            entry.commit_sha, entry.verdict, entry.disposition, entry.claim
413        );
414    }
415}
416
417fn print_entry(entry: &LedgerEntry) {
418    println!("commit: {}", entry.commit_sha);
419    println!("verdict: {}", entry.verdict);
420    println!("disposition: {}", entry.disposition);
421    println!("claim: {}", entry.claim);
422    println!("evidence: {}", entry.evidence.join(", "));
423    println!(
424        "reviewer: {}/{} allow_same_model={}",
425        entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
426    );
427    if entry.findings.is_empty() {
428        println!("findings: none");
429    } else {
430        println!("findings:");
431        for finding in &entry.findings {
432            println!("- {finding}");
433        }
434    }
435}
436
437fn print_stats(stats: &LedgerStats) {
438    println!("total={}", stats.total);
439    println!("pass={}", stats.pass);
440    println!("reject={}", stats.reject);
441    println!("unresolved={}", stats.unresolved);
442    println!("resolved={}", stats.resolved);
443    println!("waived={}", stats.waived);
444}
445
446fn unix_now() -> u64 {
447    SystemTime::now()
448        .duration_since(UNIX_EPOCH)
449        .map_or(0, |duration| duration.as_secs())
450}
451
452#[cfg(test)]
453mod tests {
454    use std::fs;
455
456    use proptest::prelude::*;
457
458    use super::{Disposition, LedgerEntry, LedgerStore, ResolutionKind, ReviewerConfig, Verdict};
459
460    fn reviewer() -> ReviewerConfig {
461        ReviewerConfig::new("claude", "claude-opus-4-1", false)
462    }
463
464    fn rejected_entry(sha: &str) -> LedgerEntry {
465        LedgerEntry::new_at(
466            sha,
467            Verdict::Reject,
468            "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
469            vec!["tests:cargo-test".to_owned()],
470            reviewer(),
471            vec!["claim was unsupported".to_owned()],
472            100,
473        )
474    }
475
476    #[test]
477    fn append_and_read_latest_entries() {
478        let temp = tempfile::tempdir().unwrap();
479        let store = LedgerStore::new(temp.path());
480        store.append_entry(&rejected_entry("abc123")).unwrap();
481
482        let entries = store.latest_entries().unwrap();
483
484        assert_eq!(entries.len(), 1);
485        assert_eq!(entries[0].commit_sha, "abc123");
486        assert!(entries[0].is_unresolved_rejection());
487    }
488
489    #[test]
490    fn markdown_mirror_renders_summary_and_findings() {
491        let temp = tempfile::tempdir().unwrap();
492        let store = LedgerStore::new(temp.path());
493        store.append_entry(&rejected_entry("abc123")).unwrap();
494
495        let markdown = fs::read_to_string(store.markdown_path()).unwrap();
496
497        assert!(markdown.contains("# Truth Mirror Ledger"));
498        assert!(markdown.contains("Unresolved rejections: 1"));
499        assert!(markdown.contains("claim was unsupported"));
500    }
501
502    #[test]
503    fn resolve_clears_unresolved_rejection() {
504        let temp = tempfile::tempdir().unwrap();
505        let store = LedgerStore::new(temp.path());
506        store.append_entry(&rejected_entry("abc123")).unwrap();
507
508        let resolved = store.resolve("abc123").unwrap();
509
510        assert_eq!(resolved.disposition, Disposition::Resolved);
511        assert_eq!(
512            resolved.resolution.as_ref().unwrap().kind,
513            ResolutionKind::Resolved
514        );
515        assert!(store.unresolved_rejections().unwrap().is_empty());
516        assert_eq!(store.read_history().unwrap().len(), 2);
517    }
518
519    #[test]
520    fn waive_records_reason_and_clears_unresolved_rejection() {
521        let temp = tempfile::tempdir().unwrap();
522        let store = LedgerStore::new(temp.path());
523        store.append_entry(&rejected_entry("abc123")).unwrap();
524
525        let waived = store.waive("abc123", "Ramiro approved exception").unwrap();
526
527        assert_eq!(waived.disposition, Disposition::Waived);
528        assert_eq!(
529            waived.resolution.as_ref().unwrap().reason,
530            "Ramiro approved exception"
531        );
532        assert!(store.unresolved_rejections().unwrap().is_empty());
533    }
534
535    #[test]
536    fn stats_counts_latest_dispositions() {
537        let temp = tempfile::tempdir().unwrap();
538        let store = LedgerStore::new(temp.path());
539        store.append_entry(&rejected_entry("abc123")).unwrap();
540        store
541            .append_entry(&LedgerEntry::new_at(
542                "def456",
543                Verdict::Pass,
544                "CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
545                vec!["tests:cargo-test".to_owned()],
546                reviewer(),
547                Vec::new(),
548                100,
549            ))
550            .unwrap();
551        store.waive("abc123", "accepted risk").unwrap();
552
553        let stats = store.stats().unwrap();
554
555        assert_eq!(stats.total, 2);
556        assert_eq!(stats.pass, 1);
557        assert_eq!(stats.reject, 1);
558        assert_eq!(stats.unresolved, 0);
559        assert_eq!(stats.waived, 1);
560    }
561
562    proptest! {
563        #[test]
564        fn append_read_preserves_unresolved_rejection_semantics(sha in "[a-f0-9]{7,40}") {
565            let temp = tempfile::tempdir().unwrap();
566            let store = LedgerStore::new(temp.path());
567            store.append_entry(&rejected_entry(&sha)).unwrap();
568
569            let entries = store.latest_entries().unwrap();
570            prop_assert_eq!(entries.len(), 1);
571            prop_assert_eq!(entries[0].commit_sha.as_str(), sha.as_str());
572            prop_assert!(entries[0].is_unresolved_rejection());
573        }
574    }
575}