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    #[serde(default)]
30    pub summary: String,
31    pub findings: Vec<String>,
32    #[serde(default)]
33    pub structured_findings: Vec<StructuredFinding>,
34    #[serde(default)]
35    pub next_steps: Vec<String>,
36    #[serde(default)]
37    pub raw_reviewer_output: String,
38    pub resolution: Option<Resolution>,
39    pub created_at_unix: u64,
40    pub updated_at_unix: u64,
41}
42
43impl LedgerEntry {
44    pub fn new(
45        commit_sha: impl Into<String>,
46        verdict: Verdict,
47        claim: impl Into<String>,
48        evidence: Vec<String>,
49        reviewer: ReviewerConfig,
50        findings: Vec<String>,
51    ) -> Self {
52        Self::new_at(
53            commit_sha,
54            verdict,
55            claim,
56            evidence,
57            reviewer,
58            findings,
59            unix_now(),
60        )
61    }
62
63    pub fn new_at(
64        commit_sha: impl Into<String>,
65        verdict: Verdict,
66        claim: impl Into<String>,
67        evidence: Vec<String>,
68        reviewer: ReviewerConfig,
69        findings: Vec<String>,
70        timestamp: u64,
71    ) -> Self {
72        let disposition = match verdict {
73            Verdict::Pass => Disposition::Resolved,
74            Verdict::Reject => Disposition::Open,
75        };
76
77        Self {
78            commit_sha: commit_sha.into(),
79            verdict,
80            disposition,
81            claim: claim.into(),
82            evidence,
83            reviewer,
84            summary: String::new(),
85            findings,
86            structured_findings: Vec::new(),
87            next_steps: Vec::new(),
88            raw_reviewer_output: String::new(),
89            resolution: None,
90            created_at_unix: timestamp,
91            updated_at_unix: timestamp,
92        }
93    }
94
95    pub fn with_structured_review(
96        mut self,
97        summary: impl Into<String>,
98        structured_findings: Vec<StructuredFinding>,
99        next_steps: Vec<String>,
100        raw_reviewer_output: impl Into<String>,
101    ) -> Self {
102        self.summary = summary.into();
103        self.structured_findings = structured_findings;
104        self.next_steps = next_steps;
105        self.raw_reviewer_output = raw_reviewer_output.into();
106        self
107    }
108
109    pub fn is_unresolved_rejection(&self) -> bool {
110        self.verdict == Verdict::Reject && self.disposition == Disposition::Open
111    }
112}
113
114#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
115#[serde(rename_all = "UPPERCASE")]
116pub enum Verdict {
117    Pass,
118    Reject,
119}
120
121impl fmt::Display for Verdict {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::Pass => formatter.write_str("PASS"),
125            Self::Reject => formatter.write_str("REJECT"),
126        }
127    }
128}
129
130#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
131#[serde(rename_all = "kebab-case")]
132pub enum FindingSeverity {
133    Critical,
134    High,
135    Medium,
136    Low,
137}
138
139impl fmt::Display for FindingSeverity {
140    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141        match self {
142            Self::Critical => formatter.write_str("critical"),
143            Self::High => formatter.write_str("high"),
144            Self::Medium => formatter.write_str("medium"),
145            Self::Low => formatter.write_str("low"),
146        }
147    }
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151pub struct StructuredFinding {
152    pub severity: FindingSeverity,
153    pub title: String,
154    pub body: String,
155    pub file: String,
156    pub line_start: u32,
157    pub line_end: u32,
158    pub confidence: u8,
159    pub recommendation: String,
160}
161
162impl StructuredFinding {
163    pub fn display_line(&self) -> String {
164        format!(
165            "{} [{}:{}-{}] {}: {} Recommendation: {} Confidence: {}%",
166            self.severity,
167            self.file,
168            self.line_start,
169            self.line_end,
170            self.title,
171            self.body,
172            self.recommendation,
173            self.confidence
174        )
175    }
176}
177
178#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
179#[serde(rename_all = "kebab-case")]
180pub enum Disposition {
181    Open,
182    Resolved,
183    Waived,
184}
185
186impl fmt::Display for Disposition {
187    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match self {
189            Self::Open => formatter.write_str("open"),
190            Self::Resolved => formatter.write_str("resolved"),
191            Self::Waived => formatter.write_str("waived"),
192        }
193    }
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
197pub struct ReviewerConfig {
198    pub harness: String,
199    pub model: String,
200    pub allow_same_model: bool,
201}
202
203impl ReviewerConfig {
204    pub fn new(
205        harness: impl Into<String>,
206        model: impl Into<String>,
207        allow_same_model: bool,
208    ) -> Self {
209        Self {
210            harness: harness.into(),
211            model: model.into(),
212            allow_same_model,
213        }
214    }
215}
216
217#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
218pub struct Resolution {
219    pub kind: ResolutionKind,
220    pub reason: String,
221    pub resolved_at_unix: u64,
222}
223
224#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
225#[serde(rename_all = "kebab-case")]
226pub enum ResolutionKind {
227    Resolved,
228    Waived,
229}
230
231#[derive(Clone, Debug, Default, Eq, PartialEq)]
232pub struct LedgerStats {
233    pub total: usize,
234    pub pass: usize,
235    pub reject: usize,
236    pub unresolved: usize,
237    pub resolved: usize,
238    pub waived: usize,
239}
240
241#[derive(Clone, Debug)]
242pub struct LedgerStore {
243    root: PathBuf,
244}
245
246impl LedgerStore {
247    pub fn new(root: impl Into<PathBuf>) -> Self {
248        Self { root: root.into() }
249    }
250
251    pub fn machine_path(&self) -> PathBuf {
252        self.root.join(MACHINE_LEDGER_FILE)
253    }
254
255    pub fn markdown_path(&self) -> PathBuf {
256        self.root.join(MARKDOWN_LEDGER_FILE)
257    }
258
259    pub fn append_entry(&self, entry: &LedgerEntry) -> Result<(), LedgerError> {
260        fs::create_dir_all(&self.root)?;
261        let mut file = fs::OpenOptions::new()
262            .create(true)
263            .append(true)
264            .open(self.machine_path())?;
265        serde_json::to_writer(&mut file, entry)?;
266        writeln!(file)?;
267        self.render_markdown_mirror()?;
268        Ok(())
269    }
270
271    pub fn read_history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
272        let path = self.machine_path();
273        let contents = match fs::read_to_string(&path) {
274            Ok(contents) => contents,
275            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
276            Err(error) => return Err(error.into()),
277        };
278
279        contents
280            .lines()
281            .filter(|line| !line.trim().is_empty())
282            .map(|line| serde_json::from_str(line).map_err(LedgerError::from))
283            .collect()
284    }
285
286    pub fn latest_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
287        let mut by_sha = BTreeMap::new();
288        for entry in self.read_history()? {
289            by_sha.insert(entry.commit_sha.clone(), entry);
290        }
291        Ok(by_sha.into_values().collect())
292    }
293
294    pub fn show(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
295        self.latest_entries()?
296            .into_iter()
297            .find(|entry| entry.commit_sha == sha)
298            .ok_or_else(|| LedgerError::NotFound {
299                sha: sha.to_owned(),
300            })
301    }
302
303    pub fn unresolved_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
304        Ok(self
305            .latest_entries()?
306            .into_iter()
307            .filter(LedgerEntry::is_unresolved_rejection)
308            .collect())
309    }
310
311    pub fn resolve(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
312        self.transition_rejection(
313            sha,
314            Disposition::Resolved,
315            ResolutionKind::Resolved,
316            "resolved",
317        )
318    }
319
320    pub fn waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
321        if reason.trim().is_empty() {
322            return Err(LedgerError::EmptyWaiverReason);
323        }
324
325        self.transition_rejection(sha, Disposition::Waived, ResolutionKind::Waived, reason)
326    }
327
328    pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
329        let mut stats = LedgerStats::default();
330        for entry in self.latest_entries()? {
331            stats.total += 1;
332            match entry.verdict {
333                Verdict::Pass => stats.pass += 1,
334                Verdict::Reject => stats.reject += 1,
335            }
336            match entry.disposition {
337                Disposition::Open => {
338                    if entry.verdict == Verdict::Reject {
339                        stats.unresolved += 1;
340                    }
341                }
342                Disposition::Resolved => stats.resolved += 1,
343                Disposition::Waived => stats.waived += 1,
344            }
345        }
346        Ok(stats)
347    }
348
349    pub fn render_markdown_mirror(&self) -> Result<(), LedgerError> {
350        fs::create_dir_all(&self.root)?;
351        let markdown = render_markdown(&self.latest_entries()?, &self.stats()?);
352        fs::write(self.markdown_path(), markdown)?;
353        Ok(())
354    }
355
356    fn transition_rejection(
357        &self,
358        sha: &str,
359        disposition: Disposition,
360        kind: ResolutionKind,
361        reason: &str,
362    ) -> Result<LedgerEntry, LedgerError> {
363        let mut entry = self.show(sha)?;
364        if !entry.is_unresolved_rejection() {
365            return Err(LedgerError::NoOpenRejection {
366                sha: sha.to_owned(),
367            });
368        }
369
370        let timestamp = unix_now();
371        entry.disposition = disposition;
372        entry.resolution = Some(Resolution {
373            kind,
374            reason: reason.trim().to_owned(),
375            resolved_at_unix: timestamp,
376        });
377        entry.updated_at_unix = timestamp;
378        self.append_entry(&entry)?;
379        Ok(entry)
380    }
381}
382
383#[derive(Debug, Error)]
384pub enum LedgerError {
385    #[error("ledger IO failed: {0}")]
386    Io(#[from] io::Error),
387    #[error("ledger JSON failed: {0}")]
388    Json(#[from] serde_json::Error),
389    #[error("ledger entry not found for commit {sha}")]
390    NotFound { sha: String },
391    #[error("commit {sha} has no open rejection")]
392    NoOpenRejection { sha: String },
393    #[error("waive requires a non-empty reason")]
394    EmptyWaiverReason,
395}
396
397pub fn run(args: cli::LedgerArgs, state_dir: &Path) -> Result<ExitCode> {
398    let store = LedgerStore::new(state_dir);
399    match args.command {
400        cli::LedgerCommand::List => {
401            print_unresolved(&store.unresolved_rejections()?);
402        }
403        cli::LedgerCommand::Show { sha } => {
404            let entry = store.show(&sha)?;
405            print_entry(&entry);
406        }
407        cli::LedgerCommand::Resolve { sha } => {
408            let entry = store.resolve(&sha)?;
409            println!("resolved {}", entry.commit_sha);
410        }
411        cli::LedgerCommand::Waive { sha, reason } => {
412            let entry = store.waive(&sha, &reason)?;
413            println!("waived {}", entry.commit_sha);
414        }
415        cli::LedgerCommand::Stats => print_stats(&store.stats()?),
416    }
417
418    Ok(ExitCode::SUCCESS)
419}
420
421fn render_markdown(entries: &[LedgerEntry], stats: &LedgerStats) -> String {
422    let mut output = String::new();
423    output.push_str("# Truth Mirror Ledger\n\n");
424    output.push_str("## Summary\n\n");
425    output.push_str(&format!("- Total: {}\n", stats.total));
426    output.push_str(&format!("- PASS: {}\n", stats.pass));
427    output.push_str(&format!("- REJECT: {}\n", stats.reject));
428    output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
429    output.push_str(&format!("- Resolved: {}\n", stats.resolved));
430    output.push_str(&format!("- Waived: {}\n\n", stats.waived));
431    output.push_str("## Entries\n");
432
433    if entries.is_empty() {
434        output.push_str("\nNo ledger entries.\n");
435        return output;
436    }
437
438    for entry in entries {
439        output.push_str(&format!(
440            "\n### {} - {} - {}\n\n",
441            entry.commit_sha, entry.verdict, entry.disposition
442        ));
443        output.push_str(&format!("- Claim: {}\n", entry.claim));
444        output.push_str(&format!(
445            "- Evidence: {}\n",
446            if entry.evidence.is_empty() {
447                "none".to_owned()
448            } else {
449                entry.evidence.join(", ")
450            }
451        ));
452        output.push_str(&format!(
453            "- Reviewer: {}/{} (allow_same_model={})\n",
454            entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
455        ));
456        if !entry.summary.trim().is_empty() {
457            output.push_str(&format!("- Summary: {}\n", entry.summary));
458        }
459
460        if entry.findings.is_empty() {
461            output.push_str("- Findings: none\n");
462        } else if !entry.structured_findings.is_empty() {
463            output.push_str("- Findings:\n");
464            for finding in &entry.structured_findings {
465                output.push_str(&format!("  - {}\n", finding.display_line()));
466            }
467        } else {
468            output.push_str("- Findings:\n");
469            for finding in &entry.findings {
470                output.push_str(&format!("  - {}\n", finding));
471            }
472        }
473
474        if !entry.next_steps.is_empty() {
475            output.push_str("- Next steps:\n");
476            for step in &entry.next_steps {
477                output.push_str(&format!("  - {}\n", step));
478            }
479        }
480
481        if !entry.raw_reviewer_output.trim().is_empty() {
482            output.push_str("- Raw reviewer output:\n\n```json\n");
483            output.push_str(entry.raw_reviewer_output.trim());
484            output.push_str("\n```\n");
485        }
486
487        if let Some(resolution) = &entry.resolution {
488            output.push_str(&format!(
489                "- Resolution: {:?} - {}\n",
490                resolution.kind, resolution.reason
491            ));
492        }
493    }
494
495    output
496}
497
498fn print_unresolved(entries: &[LedgerEntry]) {
499    if entries.is_empty() {
500        println!("No unresolved rejected commits.");
501        return;
502    }
503
504    for entry in entries {
505        println!(
506            "{} {} {} {}",
507            entry.commit_sha, entry.verdict, entry.disposition, entry.claim
508        );
509    }
510}
511
512fn print_entry(entry: &LedgerEntry) {
513    println!("commit: {}", entry.commit_sha);
514    println!("verdict: {}", entry.verdict);
515    println!("disposition: {}", entry.disposition);
516    println!("claim: {}", entry.claim);
517    println!("evidence: {}", entry.evidence.join(", "));
518    println!(
519        "reviewer: {}/{} allow_same_model={}",
520        entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
521    );
522    if !entry.summary.trim().is_empty() {
523        println!("summary: {}", entry.summary);
524    }
525    if entry.findings.is_empty() {
526        println!("findings: none");
527    } else if !entry.structured_findings.is_empty() {
528        println!("findings:");
529        for finding in &entry.structured_findings {
530            println!("- {}", finding.display_line());
531        }
532    } else {
533        println!("findings:");
534        for finding in &entry.findings {
535            println!("- {finding}");
536        }
537    }
538    if !entry.next_steps.is_empty() {
539        println!("next_steps:");
540        for step in &entry.next_steps {
541            println!("- {step}");
542        }
543    }
544    if !entry.raw_reviewer_output.trim().is_empty() {
545        println!("raw_reviewer_output:");
546        println!("{}", entry.raw_reviewer_output.trim());
547    }
548}
549
550fn print_stats(stats: &LedgerStats) {
551    println!("total={}", stats.total);
552    println!("pass={}", stats.pass);
553    println!("reject={}", stats.reject);
554    println!("unresolved={}", stats.unresolved);
555    println!("resolved={}", stats.resolved);
556    println!("waived={}", stats.waived);
557}
558
559fn unix_now() -> u64 {
560    SystemTime::now()
561        .duration_since(UNIX_EPOCH)
562        .map_or(0, |duration| duration.as_secs())
563}
564
565#[cfg(test)]
566mod tests {
567    use std::fs;
568
569    use proptest::prelude::*;
570
571    use super::{
572        Disposition, FindingSeverity, LedgerEntry, LedgerStore, ResolutionKind, ReviewerConfig,
573        StructuredFinding, Verdict,
574    };
575
576    fn reviewer() -> ReviewerConfig {
577        ReviewerConfig::new("claude", "claude-opus-4-1", false)
578    }
579
580    fn rejected_entry(sha: &str) -> LedgerEntry {
581        LedgerEntry::new_at(
582            sha,
583            Verdict::Reject,
584            "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
585            vec!["tests:cargo-test".to_owned()],
586            reviewer(),
587            vec!["claim was unsupported".to_owned()],
588            100,
589        )
590    }
591
592    fn structured_finding() -> StructuredFinding {
593        StructuredFinding {
594            severity: FindingSeverity::High,
595            title: "missing proof".to_owned(),
596            body: "The evidence pointer does not prove the claim.".to_owned(),
597            file: "src/lib.rs".to_owned(),
598            line_start: 7,
599            line_end: 9,
600            confidence: 95,
601            recommendation: "Add executable evidence before claiming completion.".to_owned(),
602        }
603    }
604
605    #[test]
606    fn append_and_read_latest_entries() {
607        let temp = tempfile::tempdir().unwrap();
608        let store = LedgerStore::new(temp.path());
609        store.append_entry(&rejected_entry("abc123")).unwrap();
610
611        let entries = store.latest_entries().unwrap();
612
613        assert_eq!(entries.len(), 1);
614        assert_eq!(entries[0].commit_sha, "abc123");
615        assert!(entries[0].is_unresolved_rejection());
616    }
617
618    #[test]
619    fn markdown_mirror_renders_summary_and_findings() {
620        let temp = tempfile::tempdir().unwrap();
621        let store = LedgerStore::new(temp.path());
622        store.append_entry(&rejected_entry("abc123")).unwrap();
623
624        let markdown = fs::read_to_string(store.markdown_path()).unwrap();
625
626        assert!(markdown.contains("# Truth Mirror Ledger"));
627        assert!(markdown.contains("Unresolved rejections: 1"));
628        assert!(markdown.contains("claim was unsupported"));
629    }
630
631    #[test]
632    fn markdown_mirror_renders_structured_review_provenance() {
633        let temp = tempfile::tempdir().unwrap();
634        let store = LedgerStore::new(temp.path());
635        let entry = rejected_entry("abc123").with_structured_review(
636            "The claim is not proven.",
637            vec![structured_finding()],
638            vec!["Run cargo test.".to_owned()],
639            r#"{"verdict":"REJECT"}"#,
640        );
641        store.append_entry(&entry).unwrap();
642
643        let markdown = fs::read_to_string(store.markdown_path()).unwrap();
644
645        assert!(markdown.contains("The claim is not proven."));
646        assert!(markdown.contains("high [src/lib.rs:7-9] missing proof"));
647        assert!(markdown.contains("Run cargo test."));
648        assert!(markdown.contains(r#"{"verdict":"REJECT"}"#));
649    }
650
651    #[test]
652    fn resolve_clears_unresolved_rejection() {
653        let temp = tempfile::tempdir().unwrap();
654        let store = LedgerStore::new(temp.path());
655        store.append_entry(&rejected_entry("abc123")).unwrap();
656
657        let resolved = store.resolve("abc123").unwrap();
658
659        assert_eq!(resolved.disposition, Disposition::Resolved);
660        assert_eq!(
661            resolved.resolution.as_ref().unwrap().kind,
662            ResolutionKind::Resolved
663        );
664        assert!(store.unresolved_rejections().unwrap().is_empty());
665        assert_eq!(store.read_history().unwrap().len(), 2);
666    }
667
668    #[test]
669    fn waive_records_reason_and_clears_unresolved_rejection() {
670        let temp = tempfile::tempdir().unwrap();
671        let store = LedgerStore::new(temp.path());
672        store.append_entry(&rejected_entry("abc123")).unwrap();
673
674        let waived = store.waive("abc123", "Ramiro approved exception").unwrap();
675
676        assert_eq!(waived.disposition, Disposition::Waived);
677        assert_eq!(
678            waived.resolution.as_ref().unwrap().reason,
679            "Ramiro approved exception"
680        );
681        assert!(store.unresolved_rejections().unwrap().is_empty());
682    }
683
684    #[test]
685    fn stats_counts_latest_dispositions() {
686        let temp = tempfile::tempdir().unwrap();
687        let store = LedgerStore::new(temp.path());
688        store.append_entry(&rejected_entry("abc123")).unwrap();
689        store
690            .append_entry(&LedgerEntry::new_at(
691                "def456",
692                Verdict::Pass,
693                "CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
694                vec!["tests:cargo-test".to_owned()],
695                reviewer(),
696                Vec::new(),
697                100,
698            ))
699            .unwrap();
700        store.waive("abc123", "accepted risk").unwrap();
701
702        let stats = store.stats().unwrap();
703
704        assert_eq!(stats.total, 2);
705        assert_eq!(stats.pass, 1);
706        assert_eq!(stats.reject, 1);
707        assert_eq!(stats.unresolved, 0);
708        assert_eq!(stats.waived, 1);
709    }
710
711    proptest! {
712        #[test]
713        fn append_read_preserves_unresolved_rejection_semantics(sha in "[a-f0-9]{7,40}") {
714            let temp = tempfile::tempdir().unwrap();
715            let store = LedgerStore::new(temp.path());
716            store.append_entry(&rejected_entry(&sha)).unwrap();
717
718            let entries = store.latest_entries().unwrap();
719            prop_assert_eq!(entries.len(), 1);
720            prop_assert_eq!(entries[0].commit_sha.as_str(), sha.as_str());
721            prop_assert!(entries[0].is_unresolved_rejection());
722        }
723    }
724}