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, Deserializer, Serialize, de};
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    #[serde(deserialize_with = "deserialize_confidence")]
159    pub confidence: u8,
160    pub recommendation: String,
161}
162
163impl StructuredFinding {
164    pub fn display_line(&self) -> String {
165        format!(
166            "{} [{}:{}-{}] {}: {} Recommendation: {} Confidence: {}%",
167            self.severity,
168            self.file,
169            self.line_start,
170            self.line_end,
171            self.title,
172            self.body,
173            self.recommendation,
174            self.confidence
175        )
176    }
177}
178
179/// Deserialize a reviewer finding's `confidence`, tolerating every numeric shape a
180/// reviewer harness emits and normalizing to an integer percent in `0..=100`.
181///
182/// Codex (gpt-5.5) emits a normalized float such as `0.95`; other harnesses emit an
183/// integer percent (`86`) or a decimal percent (`95.5`). A malformed or out-of-range
184/// value is **clamped**, never rejected: dropping the whole verdict because the
185/// confidence was formatted unusually is exactly how six REJECT verdicts were silently
186/// swallowed. A verdict must never be lost to confidence formatting.
187fn deserialize_confidence<'de, D>(deserializer: D) -> Result<u8, D::Error>
188where
189    D: Deserializer<'de>,
190{
191    struct ConfidenceVisitor;
192
193    impl<'de> de::Visitor<'de> for ConfidenceVisitor {
194        type Value = u8;
195
196        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197            formatter.write_str("an integer percent 0..=100 or a normalized float 0.0..=1.0")
198        }
199
200        fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
201        where
202            E: de::Error,
203        {
204            Ok(clamp_percent(value))
205        }
206
207        fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
208        where
209            E: de::Error,
210        {
211            // A negative confidence is meaningless; floor it at zero rather than error.
212            Ok(u64::try_from(value).map_or(0, clamp_percent))
213        }
214
215        fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
216        where
217            E: de::Error,
218        {
219            Ok(confidence_from_f64(value))
220        }
221
222        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
223        where
224            E: de::Error,
225        {
226            // Some harnesses quote the number (`"0.95"` / `"86"`). Parse it on the
227            // float path; an unparseable string floors to zero rather than dropping
228            // the whole verdict over a stray confidence format.
229            Ok(value.trim().parse::<f64>().map_or(0, confidence_from_f64))
230        }
231    }
232
233    deserializer.deserialize_any(ConfidenceVisitor)
234}
235
236/// Clamp an integer confidence to the `0..=100` percent range.
237fn clamp_percent(value: u64) -> u8 {
238    value.min(100) as u8
239}
240
241/// Normalize a floating-point confidence to an integer percent in `0..=100`.
242///
243/// A value in `0.0..=1.0` is read as a normalized fraction and scaled to a percent;
244/// anything larger is treated as already being on the percent scale. Non-finite or
245/// out-of-range inputs are clamped so a stray value never drops the verdict.
246fn confidence_from_f64(value: f64) -> u8 {
247    if value.is_nan() {
248        return 0;
249    }
250
251    let percent = if (0.0..=1.0).contains(&value) {
252        value * 100.0
253    } else {
254        value
255    };
256
257    if percent <= 0.0 {
258        0
259    } else if percent >= 100.0 {
260        100
261    } else {
262        percent.round() as u8
263    }
264}
265
266#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
267#[serde(rename_all = "kebab-case")]
268pub enum Disposition {
269    Open,
270    Resolved,
271    Waived,
272}
273
274impl fmt::Display for Disposition {
275    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Self::Open => formatter.write_str("open"),
278            Self::Resolved => formatter.write_str("resolved"),
279            Self::Waived => formatter.write_str("waived"),
280        }
281    }
282}
283
284#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
285pub struct ReviewerConfig {
286    pub harness: String,
287    pub model: String,
288    pub allow_same_model: bool,
289}
290
291impl ReviewerConfig {
292    pub fn new(
293        harness: impl Into<String>,
294        model: impl Into<String>,
295        allow_same_model: bool,
296    ) -> Self {
297        Self {
298            harness: harness.into(),
299            model: model.into(),
300            allow_same_model,
301        }
302    }
303}
304
305#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
306pub struct Resolution {
307    pub kind: ResolutionKind,
308    pub reason: String,
309    pub resolved_at_unix: u64,
310}
311
312#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
313#[serde(rename_all = "kebab-case")]
314pub enum ResolutionKind {
315    Resolved,
316    Waived,
317}
318
319#[derive(Clone, Debug, Default, Eq, PartialEq)]
320pub struct LedgerStats {
321    pub total: usize,
322    pub pass: usize,
323    pub reject: usize,
324    pub unresolved: usize,
325    pub resolved: usize,
326    pub waived: usize,
327}
328
329#[derive(Clone, Debug)]
330pub struct LedgerStore {
331    root: PathBuf,
332}
333
334impl LedgerStore {
335    pub fn new(root: impl Into<PathBuf>) -> Self {
336        Self { root: root.into() }
337    }
338
339    pub fn machine_path(&self) -> PathBuf {
340        self.root.join(MACHINE_LEDGER_FILE)
341    }
342
343    pub fn markdown_path(&self) -> PathBuf {
344        self.root.join(MARKDOWN_LEDGER_FILE)
345    }
346
347    pub fn append_entry(&self, entry: &LedgerEntry) -> Result<(), LedgerError> {
348        fs::create_dir_all(&self.root)?;
349        let mut file = fs::OpenOptions::new()
350            .create(true)
351            .append(true)
352            .open(self.machine_path())?;
353        serde_json::to_writer(&mut file, entry)?;
354        writeln!(file)?;
355        self.render_markdown_mirror()?;
356        Ok(())
357    }
358
359    pub fn read_history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
360        let path = self.machine_path();
361        let contents = match fs::read_to_string(&path) {
362            Ok(contents) => contents,
363            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
364            Err(error) => return Err(error.into()),
365        };
366
367        contents
368            .lines()
369            .filter(|line| !line.trim().is_empty())
370            .map(|line| serde_json::from_str(line).map_err(LedgerError::from))
371            .collect()
372    }
373
374    pub fn latest_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
375        let mut by_sha = BTreeMap::new();
376        for entry in self.read_history()? {
377            by_sha.insert(entry.commit_sha.clone(), entry);
378        }
379        Ok(by_sha.into_values().collect())
380    }
381
382    pub fn show(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
383        self.latest_entries()?
384            .into_iter()
385            .find(|entry| entry.commit_sha == sha)
386            .ok_or_else(|| LedgerError::NotFound {
387                sha: sha.to_owned(),
388            })
389    }
390
391    pub fn unresolved_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
392        Ok(self
393            .latest_entries()?
394            .into_iter()
395            .filter(LedgerEntry::is_unresolved_rejection)
396            .collect())
397    }
398
399    pub fn resolve(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
400        self.transition_rejection(
401            sha,
402            Disposition::Resolved,
403            ResolutionKind::Resolved,
404            "resolved",
405        )
406    }
407
408    pub fn waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
409        if reason.trim().is_empty() {
410            return Err(LedgerError::EmptyWaiverReason);
411        }
412
413        self.transition_rejection(sha, Disposition::Waived, ResolutionKind::Waived, reason)
414    }
415
416    pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
417        let mut stats = LedgerStats::default();
418        for entry in self.latest_entries()? {
419            stats.total += 1;
420            match entry.verdict {
421                Verdict::Pass => stats.pass += 1,
422                Verdict::Reject => stats.reject += 1,
423            }
424            match entry.disposition {
425                Disposition::Open => {
426                    if entry.verdict == Verdict::Reject {
427                        stats.unresolved += 1;
428                    }
429                }
430                Disposition::Resolved => stats.resolved += 1,
431                Disposition::Waived => stats.waived += 1,
432            }
433        }
434        Ok(stats)
435    }
436
437    pub fn render_markdown_mirror(&self) -> Result<(), LedgerError> {
438        fs::create_dir_all(&self.root)?;
439        let markdown = render_markdown(&self.latest_entries()?, &self.stats()?);
440        fs::write(self.markdown_path(), markdown)?;
441        Ok(())
442    }
443
444    fn transition_rejection(
445        &self,
446        sha: &str,
447        disposition: Disposition,
448        kind: ResolutionKind,
449        reason: &str,
450    ) -> Result<LedgerEntry, LedgerError> {
451        let mut entry = self.show(sha)?;
452        if !entry.is_unresolved_rejection() {
453            return Err(LedgerError::NoOpenRejection {
454                sha: sha.to_owned(),
455            });
456        }
457
458        let timestamp = unix_now();
459        entry.disposition = disposition;
460        entry.resolution = Some(Resolution {
461            kind,
462            reason: reason.trim().to_owned(),
463            resolved_at_unix: timestamp,
464        });
465        entry.updated_at_unix = timestamp;
466        self.append_entry(&entry)?;
467        Ok(entry)
468    }
469}
470
471#[derive(Debug, Error)]
472pub enum LedgerError {
473    #[error("ledger IO failed: {0}")]
474    Io(#[from] io::Error),
475    #[error("ledger JSON failed: {0}")]
476    Json(#[from] serde_json::Error),
477    #[error("ledger entry not found for commit {sha}")]
478    NotFound { sha: String },
479    #[error("commit {sha} has no open rejection")]
480    NoOpenRejection { sha: String },
481    #[error("waive requires a non-empty reason")]
482    EmptyWaiverReason,
483}
484
485pub fn run(args: cli::LedgerArgs, state_dir: &Path) -> Result<ExitCode> {
486    let store = LedgerStore::new(state_dir);
487    match args.command {
488        cli::LedgerCommand::List => {
489            print_unresolved(&store.unresolved_rejections()?);
490        }
491        cli::LedgerCommand::Show { sha } => {
492            let entry = store.show(&sha)?;
493            print_entry(&entry);
494        }
495        cli::LedgerCommand::Resolve { sha } => {
496            let entry = store.resolve(&sha)?;
497            println!("resolved {}", entry.commit_sha);
498        }
499        cli::LedgerCommand::Waive { sha, reason } => {
500            let entry = store.waive(&sha, &reason)?;
501            println!("waived {}", entry.commit_sha);
502        }
503        cli::LedgerCommand::Stats => print_stats(&store.stats()?),
504    }
505
506    Ok(ExitCode::SUCCESS)
507}
508
509fn render_markdown(entries: &[LedgerEntry], stats: &LedgerStats) -> String {
510    let mut output = String::new();
511    output.push_str("# Truth Mirror Ledger\n\n");
512    output.push_str("## Summary\n\n");
513    output.push_str(&format!("- Total: {}\n", stats.total));
514    output.push_str(&format!("- PASS: {}\n", stats.pass));
515    output.push_str(&format!("- REJECT: {}\n", stats.reject));
516    output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
517    output.push_str(&format!("- Resolved: {}\n", stats.resolved));
518    output.push_str(&format!("- Waived: {}\n\n", stats.waived));
519    output.push_str("## Entries\n");
520
521    if entries.is_empty() {
522        output.push_str("\nNo ledger entries.\n");
523        return output;
524    }
525
526    for entry in entries {
527        output.push_str(&format!(
528            "\n### {} - {} - {}\n\n",
529            entry.commit_sha, entry.verdict, entry.disposition
530        ));
531        output.push_str(&format!("- Claim: {}\n", entry.claim));
532        output.push_str(&format!(
533            "- Evidence: {}\n",
534            if entry.evidence.is_empty() {
535                "none".to_owned()
536            } else {
537                entry.evidence.join(", ")
538            }
539        ));
540        output.push_str(&format!(
541            "- Reviewer: {}/{} (allow_same_model={})\n",
542            entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
543        ));
544        if !entry.summary.trim().is_empty() {
545            output.push_str(&format!("- Summary: {}\n", entry.summary));
546        }
547
548        if entry.findings.is_empty() {
549            output.push_str("- Findings: none\n");
550        } else if !entry.structured_findings.is_empty() {
551            output.push_str("- Findings:\n");
552            for finding in &entry.structured_findings {
553                output.push_str(&format!("  - {}\n", finding.display_line()));
554            }
555        } else {
556            output.push_str("- Findings:\n");
557            for finding in &entry.findings {
558                output.push_str(&format!("  - {}\n", finding));
559            }
560        }
561
562        if !entry.next_steps.is_empty() {
563            output.push_str("- Next steps:\n");
564            for step in &entry.next_steps {
565                output.push_str(&format!("  - {}\n", step));
566            }
567        }
568
569        if !entry.raw_reviewer_output.trim().is_empty() {
570            output.push_str("- Raw reviewer output:\n\n```json\n");
571            output.push_str(entry.raw_reviewer_output.trim());
572            output.push_str("\n```\n");
573        }
574
575        if let Some(resolution) = &entry.resolution {
576            output.push_str(&format!(
577                "- Resolution: {:?} - {}\n",
578                resolution.kind, resolution.reason
579            ));
580        }
581    }
582
583    output
584}
585
586fn print_unresolved(entries: &[LedgerEntry]) {
587    if entries.is_empty() {
588        println!("No unresolved rejected commits.");
589        return;
590    }
591
592    for entry in entries {
593        println!(
594            "{} {} {} {}",
595            entry.commit_sha, entry.verdict, entry.disposition, entry.claim
596        );
597    }
598}
599
600fn print_entry(entry: &LedgerEntry) {
601    println!("commit: {}", entry.commit_sha);
602    println!("verdict: {}", entry.verdict);
603    println!("disposition: {}", entry.disposition);
604    println!("claim: {}", entry.claim);
605    println!("evidence: {}", entry.evidence.join(", "));
606    println!(
607        "reviewer: {}/{} allow_same_model={}",
608        entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
609    );
610    if !entry.summary.trim().is_empty() {
611        println!("summary: {}", entry.summary);
612    }
613    if entry.findings.is_empty() {
614        println!("findings: none");
615    } else if !entry.structured_findings.is_empty() {
616        println!("findings:");
617        for finding in &entry.structured_findings {
618            println!("- {}", finding.display_line());
619        }
620    } else {
621        println!("findings:");
622        for finding in &entry.findings {
623            println!("- {finding}");
624        }
625    }
626    if !entry.next_steps.is_empty() {
627        println!("next_steps:");
628        for step in &entry.next_steps {
629            println!("- {step}");
630        }
631    }
632    if !entry.raw_reviewer_output.trim().is_empty() {
633        println!("raw_reviewer_output:");
634        println!("{}", entry.raw_reviewer_output.trim());
635    }
636}
637
638fn print_stats(stats: &LedgerStats) {
639    println!("total={}", stats.total);
640    println!("pass={}", stats.pass);
641    println!("reject={}", stats.reject);
642    println!("unresolved={}", stats.unresolved);
643    println!("resolved={}", stats.resolved);
644    println!("waived={}", stats.waived);
645}
646
647fn unix_now() -> u64 {
648    SystemTime::now()
649        .duration_since(UNIX_EPOCH)
650        .map_or(0, |duration| duration.as_secs())
651}
652
653#[cfg(test)]
654mod tests {
655    use std::fs;
656
657    use proptest::prelude::*;
658
659    use super::{
660        Disposition, FindingSeverity, LedgerEntry, LedgerStore, ResolutionKind, ReviewerConfig,
661        StructuredFinding, Verdict,
662    };
663
664    fn reviewer() -> ReviewerConfig {
665        ReviewerConfig::new("claude", "claude-opus-4-1", false)
666    }
667
668    fn rejected_entry(sha: &str) -> LedgerEntry {
669        LedgerEntry::new_at(
670            sha,
671            Verdict::Reject,
672            "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
673            vec!["tests:cargo-test".to_owned()],
674            reviewer(),
675            vec!["claim was unsupported".to_owned()],
676            100,
677        )
678    }
679
680    fn structured_finding() -> StructuredFinding {
681        StructuredFinding {
682            severity: FindingSeverity::High,
683            title: "missing proof".to_owned(),
684            body: "The evidence pointer does not prove the claim.".to_owned(),
685            file: "src/lib.rs".to_owned(),
686            line_start: 7,
687            line_end: 9,
688            confidence: 95,
689            recommendation: "Add executable evidence before claiming completion.".to_owned(),
690        }
691    }
692
693    #[test]
694    fn append_and_read_latest_entries() {
695        let temp = tempfile::tempdir().unwrap();
696        let store = LedgerStore::new(temp.path());
697        store.append_entry(&rejected_entry("abc123")).unwrap();
698
699        let entries = store.latest_entries().unwrap();
700
701        assert_eq!(entries.len(), 1);
702        assert_eq!(entries[0].commit_sha, "abc123");
703        assert!(entries[0].is_unresolved_rejection());
704    }
705
706    #[test]
707    fn markdown_mirror_renders_summary_and_findings() {
708        let temp = tempfile::tempdir().unwrap();
709        let store = LedgerStore::new(temp.path());
710        store.append_entry(&rejected_entry("abc123")).unwrap();
711
712        let markdown = fs::read_to_string(store.markdown_path()).unwrap();
713
714        assert!(markdown.contains("# Truth Mirror Ledger"));
715        assert!(markdown.contains("Unresolved rejections: 1"));
716        assert!(markdown.contains("claim was unsupported"));
717    }
718
719    #[test]
720    fn markdown_mirror_renders_structured_review_provenance() {
721        let temp = tempfile::tempdir().unwrap();
722        let store = LedgerStore::new(temp.path());
723        let entry = rejected_entry("abc123").with_structured_review(
724            "The claim is not proven.",
725            vec![structured_finding()],
726            vec!["Run cargo test.".to_owned()],
727            r#"{"verdict":"REJECT"}"#,
728        );
729        store.append_entry(&entry).unwrap();
730
731        let markdown = fs::read_to_string(store.markdown_path()).unwrap();
732
733        assert!(markdown.contains("The claim is not proven."));
734        assert!(markdown.contains("high [src/lib.rs:7-9] missing proof"));
735        assert!(markdown.contains("Run cargo test."));
736        assert!(markdown.contains(r#"{"verdict":"REJECT"}"#));
737    }
738
739    #[test]
740    fn resolve_clears_unresolved_rejection() {
741        let temp = tempfile::tempdir().unwrap();
742        let store = LedgerStore::new(temp.path());
743        store.append_entry(&rejected_entry("abc123")).unwrap();
744
745        let resolved = store.resolve("abc123").unwrap();
746
747        assert_eq!(resolved.disposition, Disposition::Resolved);
748        assert_eq!(
749            resolved.resolution.as_ref().unwrap().kind,
750            ResolutionKind::Resolved
751        );
752        assert!(store.unresolved_rejections().unwrap().is_empty());
753        assert_eq!(store.read_history().unwrap().len(), 2);
754    }
755
756    #[test]
757    fn waive_records_reason_and_clears_unresolved_rejection() {
758        let temp = tempfile::tempdir().unwrap();
759        let store = LedgerStore::new(temp.path());
760        store.append_entry(&rejected_entry("abc123")).unwrap();
761
762        let waived = store.waive("abc123", "Ramiro approved exception").unwrap();
763
764        assert_eq!(waived.disposition, Disposition::Waived);
765        assert_eq!(
766            waived.resolution.as_ref().unwrap().reason,
767            "Ramiro approved exception"
768        );
769        assert!(store.unresolved_rejections().unwrap().is_empty());
770    }
771
772    #[test]
773    fn stats_counts_latest_dispositions() {
774        let temp = tempfile::tempdir().unwrap();
775        let store = LedgerStore::new(temp.path());
776        store.append_entry(&rejected_entry("abc123")).unwrap();
777        store
778            .append_entry(&LedgerEntry::new_at(
779                "def456",
780                Verdict::Pass,
781                "CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
782                vec!["tests:cargo-test".to_owned()],
783                reviewer(),
784                Vec::new(),
785                100,
786            ))
787            .unwrap();
788        store.waive("abc123", "accepted risk").unwrap();
789
790        let stats = store.stats().unwrap();
791
792        assert_eq!(stats.total, 2);
793        assert_eq!(stats.pass, 1);
794        assert_eq!(stats.reject, 1);
795        assert_eq!(stats.unresolved, 0);
796        assert_eq!(stats.waived, 1);
797    }
798
799    /// Deserialize a finding whose only varying field is `confidence`, exercising the
800    /// real `#[serde(deserialize_with = "deserialize_confidence")]` wiring end to end.
801    fn confidence_of(confidence: serde_json::Value) -> u8 {
802        let value = serde_json::json!({
803            "severity": "high",
804            "title": "missing proof",
805            "body": "The cited evidence does not prove the claim.",
806            "file": "src/lib.rs",
807            "line_start": 1,
808            "line_end": 1,
809            "confidence": confidence,
810            "recommendation": "Provide executable evidence.",
811        });
812        serde_json::from_value::<StructuredFinding>(value)
813            .expect("a finding must never fail to deserialize over confidence formatting")
814            .confidence
815    }
816
817    #[test]
818    fn confidence_accepts_codex_normalized_floats() {
819        // The exact payload shapes that silently swallowed six REJECT verdicts.
820        assert_eq!(confidence_of(serde_json::json!(0.95)), 95);
821        assert_eq!(confidence_of(serde_json::json!(0.9)), 90);
822        assert_eq!(confidence_of(serde_json::json!(0.86)), 86);
823    }
824
825    #[test]
826    fn confidence_accepts_integer_percent() {
827        assert_eq!(confidence_of(serde_json::json!(86)), 86);
828        assert_eq!(confidence_of(serde_json::json!(0)), 0);
829        assert_eq!(confidence_of(serde_json::json!(100)), 100);
830    }
831
832    #[test]
833    fn confidence_normalizes_or_clamps_but_never_drops_the_verdict() {
834        assert_eq!(confidence_of(serde_json::json!(95.5)), 96); // decimal percent rounds
835        assert_eq!(confidence_of(serde_json::json!(140)), 100); // percent over 100 clamps
836        assert_eq!(confidence_of(serde_json::json!(-3)), 0); // negative int floors
837        assert_eq!(confidence_of(serde_json::json!(-0.2)), 0); // negative float floors
838        assert_eq!(confidence_of(serde_json::json!("0.86")), 86); // quoted number
839        assert_eq!(confidence_of(serde_json::json!("nonsense")), 0); // junk floors, no drop
840    }
841
842    proptest! {
843        #[test]
844        fn confidence_deserialization_is_total_over_all_finite_floats(value in -1000.0f64..1000.0) {
845            // No finite confidence float may ever error out of deserialization.
846            let confidence = confidence_of(serde_json::json!(value));
847            prop_assert!(confidence <= 100);
848        }
849
850        #[test]
851        fn append_read_preserves_unresolved_rejection_semantics(sha in "[a-f0-9]{7,40}") {
852            let temp = tempfile::tempdir().unwrap();
853            let store = LedgerStore::new(temp.path());
854            store.append_entry(&rejected_entry(&sha)).unwrap();
855
856            let entries = store.latest_entries().unwrap();
857            prop_assert_eq!(entries.len(), 1);
858            prop_assert_eq!(entries[0].commit_sha.as_str(), sha.as_str());
859            prop_assert!(entries[0].is_unresolved_rejection());
860        }
861    }
862}