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};
10
11use anyhow::Result;
12use serde::{Deserialize, Deserializer, Serialize, de};
13use thiserror::Error;
14
15use crate::{cli, config::MemorySkillCandidateKind, time::unix_now};
16
17pub const MACHINE_LEDGER_FILE: &str = "ledger.jsonl";
18pub const MARKDOWN_LEDGER_FILE: &str = "ledger.md";
19
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
21pub struct LedgerEntry {
22    pub commit_sha: String,
23    pub verdict: Verdict,
24    pub disposition: Disposition,
25    pub claim: String,
26    pub evidence: Vec<String>,
27    pub reviewer: ReviewerConfig,
28    #[serde(default)]
29    pub summary: String,
30    pub findings: Vec<String>,
31    #[serde(default)]
32    pub structured_findings: Vec<StructuredFinding>,
33    #[serde(default)]
34    pub next_steps: Vec<String>,
35    #[serde(default)]
36    pub memory_skill_classification: MemorySkillClassification,
37    #[serde(default)]
38    pub raw_reviewer_output: String,
39    pub resolution: Option<Resolution>,
40    /// Number of petition cycles spent attempting to resolve this rejection.
41    /// Incremented every time `truth-mirror resolve --fixed-by` runs, regardless of
42    /// outcome. Backward-compatible: records written before this field existed
43    /// deserialize as `0` via the `#[serde(default)]`.
44    #[serde(default)]
45    pub petition_attempts: u32,
46    /// When set, this entry is a petition *review* for the listed original rejection
47    /// SHA, not the rejection itself. Petition reviews are normal verdicts; the
48    /// petition plumbing uses this to decide whether to transition the original
49    /// rejection to `resolved`/`needs-human` based on the verdict.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub petition_for: Option<String>,
52    pub created_at_unix: u64,
53    pub updated_at_unix: u64,
54}
55
56impl LedgerEntry {
57    pub fn new(
58        commit_sha: impl Into<String>,
59        verdict: Verdict,
60        claim: impl Into<String>,
61        evidence: Vec<String>,
62        reviewer: ReviewerConfig,
63        findings: Vec<String>,
64    ) -> Self {
65        Self::new_at(
66            commit_sha,
67            verdict,
68            claim,
69            evidence,
70            reviewer,
71            findings,
72            unix_now(),
73        )
74    }
75
76    pub fn new_at(
77        commit_sha: impl Into<String>,
78        verdict: Verdict,
79        claim: impl Into<String>,
80        evidence: Vec<String>,
81        reviewer: ReviewerConfig,
82        findings: Vec<String>,
83        timestamp: u64,
84    ) -> Self {
85        let disposition = match verdict {
86            Verdict::Pass => Disposition::Resolved,
87            Verdict::Reject => Disposition::Open,
88            Verdict::Flag => Disposition::Open,
89        };
90
91        Self {
92            commit_sha: commit_sha.into(),
93            verdict,
94            disposition,
95            claim: claim.into(),
96            evidence,
97            reviewer,
98            summary: String::new(),
99            findings,
100            structured_findings: Vec::new(),
101            next_steps: Vec::new(),
102            memory_skill_classification: MemorySkillClassification::default(),
103            raw_reviewer_output: String::new(),
104            resolution: None,
105            petition_attempts: 0,
106            petition_for: None,
107            created_at_unix: timestamp,
108            updated_at_unix: timestamp,
109        }
110    }
111
112    pub fn with_structured_review(
113        mut self,
114        summary: impl Into<String>,
115        structured_findings: Vec<StructuredFinding>,
116        next_steps: Vec<String>,
117        raw_reviewer_output: impl Into<String>,
118    ) -> Self {
119        self.summary = summary.into();
120        self.structured_findings = structured_findings;
121        self.next_steps = next_steps;
122        self.raw_reviewer_output = raw_reviewer_output.into();
123        self
124    }
125
126    pub fn with_memory_skill_classification(
127        mut self,
128        classification: MemorySkillClassification,
129    ) -> Self {
130        self.memory_skill_classification = classification;
131        self
132    }
133
134    pub fn is_unresolved_rejection(&self) -> bool {
135        self.verdict == Verdict::Reject && self.disposition == Disposition::Open
136    }
137
138    /// Whether this entry is a `Flag` verdict that has been recorded but not yet
139    /// acted on. FLAGs accumulate silently in the ledger; the `truth-mirror debt`
140    /// subcommand lists them for the human. They never block reinject or push.
141    pub fn is_open_flag(&self) -> bool {
142        self.verdict == Verdict::Flag && self.disposition == Disposition::Open
143    }
144
145    /// Whether this entry marks a rejection that escalated to `needs-human`
146    /// after exhausting the bounded petition attempts.
147    pub fn is_needs_human(&self) -> bool {
148        self.disposition == Disposition::NeedsHuman
149    }
150}
151
152#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
153#[serde(rename_all = "UPPERCASE")]
154pub enum Verdict {
155    Pass,
156    Reject,
157    /// Claim substantiated, but the reviewer flagged evidence/provenance/process
158    /// debt that should be tracked. Never blocks push or reinject; surfaces via
159    /// `truth-mirror debt` for the human.
160    Flag,
161}
162
163impl fmt::Display for Verdict {
164    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
165        match self {
166            Self::Pass => formatter.write_str("PASS"),
167            Self::Reject => formatter.write_str("REJECT"),
168            Self::Flag => formatter.write_str("FLAG"),
169        }
170    }
171}
172
173#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
174#[serde(rename_all = "kebab-case")]
175pub enum FindingSeverity {
176    Critical,
177    High,
178    Medium,
179    Low,
180}
181
182impl fmt::Display for FindingSeverity {
183    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184        match self {
185            Self::Critical => formatter.write_str("critical"),
186            Self::High => formatter.write_str("high"),
187            Self::Medium => formatter.write_str("medium"),
188            Self::Low => formatter.write_str("low"),
189        }
190    }
191}
192
193#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
194pub struct StructuredFinding {
195    pub severity: FindingSeverity,
196    pub title: String,
197    pub body: String,
198    pub file: String,
199    pub line_start: u32,
200    pub line_end: u32,
201    #[serde(deserialize_with = "deserialize_confidence")]
202    pub confidence: u8,
203    pub recommendation: String,
204}
205
206impl StructuredFinding {
207    pub fn display_line(&self) -> String {
208        format!(
209            "{} [{}:{}-{}] {}: {} Recommendation: {} Confidence: {}%",
210            self.severity,
211            self.file,
212            self.line_start,
213            self.line_end,
214            self.title,
215            self.body,
216            self.recommendation,
217            self.confidence
218        )
219    }
220}
221
222#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
223pub struct MemorySkillClassification {
224    pub kind: MemorySkillClassificationKind,
225    pub learning_source: String,
226    pub reasoning: String,
227}
228
229impl Default for MemorySkillClassification {
230    fn default() -> Self {
231        Self {
232            kind: MemorySkillClassificationKind::None,
233            learning_source: String::new(),
234            reasoning: String::new(),
235        }
236    }
237}
238
239impl MemorySkillClassification {
240    pub fn candidate_kind(&self) -> Option<MemorySkillCandidateKind> {
241        match self.kind {
242            MemorySkillClassificationKind::None => None,
243            MemorySkillClassificationKind::HowToSkill => Some(MemorySkillCandidateKind::HowToSkill),
244            MemorySkillClassificationKind::AntiPatternSkill => {
245                Some(MemorySkillCandidateKind::AntiPatternSkill)
246            }
247            MemorySkillClassificationKind::RemediationSkill => {
248                Some(MemorySkillCandidateKind::RemediationSkill)
249            }
250        }
251    }
252
253    pub fn validate_for_verdict(&self, verdict: Verdict) -> Result<(), String> {
254        if self.reasoning.trim().is_empty() {
255            return Err("memory_skill.reasoning must not be empty".to_owned());
256        }
257        if self.kind != MemorySkillClassificationKind::None
258            && self.learning_source.trim().is_empty()
259        {
260            return Err(
261                "memory_skill.learning_source must not be empty when kind is not none".to_owned(),
262            );
263        }
264        match (verdict, self.kind) {
265            (Verdict::Pass, MemorySkillClassificationKind::AntiPatternSkill)
266            | (Verdict::Pass, MemorySkillClassificationKind::RemediationSkill) => Err(
267                "PASS verdict cannot propose anti_pattern_skill or remediation_skill".to_owned(),
268            ),
269            (Verdict::Reject, MemorySkillClassificationKind::HowToSkill) => {
270                Err("REJECT verdict cannot propose how_to_skill".to_owned())
271            }
272            // FLAG deliberately accepts every kind: the claim holds (so a
273            // how_to_skill for the verified procedure is legitimate) AND the
274            // reviewer surfaced debt (so anti_pattern/remediation skills for
275            // the flagged pattern are too). Note extraction currently seeds
276            // candidates only from PASS/REJECT entries (`seed_from_entry`),
277            // so FLAG classifications are recorded but not auto-seeded.
278            (Verdict::Flag, _) => Ok(()),
279            _ => Ok(()),
280        }
281    }
282}
283
284#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
285#[serde(rename_all = "snake_case")]
286pub enum MemorySkillClassificationKind {
287    None,
288    HowToSkill,
289    AntiPatternSkill,
290    RemediationSkill,
291}
292
293impl std::fmt::Display for MemorySkillClassificationKind {
294    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
295        let value = match self {
296            Self::None => "none",
297            Self::HowToSkill => "how_to_skill",
298            Self::AntiPatternSkill => "anti_pattern_skill",
299            Self::RemediationSkill => "remediation_skill",
300        };
301        formatter.write_str(value)
302    }
303}
304
305/// Deserialize a reviewer finding's `confidence`, tolerating every numeric shape a
306/// reviewer harness emits and normalizing to an integer percent in `0..=100`.
307///
308/// Codex (gpt-5.5) emits a normalized float such as `0.95`; other harnesses emit an
309/// integer percent (`86`) or a decimal percent (`95.5`). A malformed or out-of-range
310/// value is **clamped**, never rejected: dropping the whole verdict because the
311/// confidence was formatted unusually is exactly how six REJECT verdicts were silently
312/// swallowed. A verdict must never be lost to confidence formatting.
313fn deserialize_confidence<'de, D>(deserializer: D) -> Result<u8, D::Error>
314where
315    D: Deserializer<'de>,
316{
317    struct ConfidenceVisitor;
318
319    impl<'de> de::Visitor<'de> for ConfidenceVisitor {
320        type Value = u8;
321
322        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
323            formatter.write_str("an integer percent 0..=100 or a normalized float 0.0..=1.0")
324        }
325
326        fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
327        where
328            E: de::Error,
329        {
330            Ok(clamp_percent(value))
331        }
332
333        fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
334        where
335            E: de::Error,
336        {
337            // A negative confidence is meaningless; floor it at zero rather than error.
338            Ok(u64::try_from(value).map_or(0, clamp_percent))
339        }
340
341        fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
342        where
343            E: de::Error,
344        {
345            Ok(confidence_from_f64(value))
346        }
347
348        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
349        where
350            E: de::Error,
351        {
352            // Some harnesses quote the number (`"0.95"` / `"86"`). Parse it on the
353            // float path; an unparseable string floors to zero rather than dropping
354            // the whole verdict over a stray confidence format.
355            Ok(value.trim().parse::<f64>().map_or(0, confidence_from_f64))
356        }
357    }
358
359    deserializer.deserialize_any(ConfidenceVisitor)
360}
361
362/// Clamp an integer confidence to the `0..=100` percent range.
363fn clamp_percent(value: u64) -> u8 {
364    value.min(100) as u8
365}
366
367/// Normalize a floating-point confidence to an integer percent in `0..=100`.
368///
369/// A value in `0.0..=1.0` is read as a normalized fraction and scaled to a percent;
370/// anything larger is treated as already being on the percent scale. Non-finite or
371/// out-of-range inputs are clamped so a stray value never drops the verdict.
372fn confidence_from_f64(value: f64) -> u8 {
373    if value.is_nan() {
374        return 0;
375    }
376
377    let percent = if (0.0..=1.0).contains(&value) {
378        value * 100.0
379    } else {
380        value
381    };
382
383    if percent <= 0.0 {
384        0
385    } else if percent >= 100.0 {
386        100
387    } else {
388        percent.round() as u8
389    }
390}
391
392#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
393#[serde(rename_all = "kebab-case")]
394pub enum Disposition {
395    Open,
396    Resolved,
397    Waived,
398    /// Petition attempts exhausted (default 2). Reinjection still surfaces these
399    /// so the agent cannot ignore them, but they are visibly non-actionable for
400    /// the agent — a human must resolve or waive.
401    NeedsHuman,
402}
403
404impl fmt::Display for Disposition {
405    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
406        match self {
407            Self::Open => formatter.write_str("open"),
408            Self::Resolved => formatter.write_str("resolved"),
409            Self::Waived => formatter.write_str("waived"),
410            Self::NeedsHuman => formatter.write_str("needs-human"),
411        }
412    }
413}
414
415#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
416pub struct ReviewerConfig {
417    pub harness: String,
418    pub model: String,
419    pub allow_same_model: bool,
420}
421
422impl ReviewerConfig {
423    pub fn new(
424        harness: impl Into<String>,
425        model: impl Into<String>,
426        allow_same_model: bool,
427    ) -> Self {
428        Self {
429            harness: harness.into(),
430            model: model.into(),
431            allow_same_model,
432        }
433    }
434}
435
436#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
437pub struct Resolution {
438    pub kind: ResolutionKind,
439    pub reason: String,
440    pub resolved_at_unix: u64,
441}
442
443#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
444#[serde(rename_all = "kebab-case")]
445pub enum ResolutionKind {
446    Resolved,
447    Waived,
448    NeedsHuman,
449}
450
451#[derive(Clone, Debug, Default, Eq, PartialEq)]
452pub struct LedgerStats {
453    pub total: usize,
454    pub pass: usize,
455    pub reject: usize,
456    pub flag: usize,
457    pub unresolved: usize,
458    pub resolved: usize,
459    pub waived: usize,
460    pub needs_human: usize,
461    pub petition_attempts: u32,
462}
463
464#[derive(Clone, Debug)]
465pub struct LedgerStore {
466    root: PathBuf,
467}
468
469impl LedgerStore {
470    pub fn new(root: impl Into<PathBuf>) -> Self {
471        Self { root: root.into() }
472    }
473
474    pub fn machine_path(&self) -> PathBuf {
475        self.root.join(MACHINE_LEDGER_FILE)
476    }
477
478    pub fn markdown_path(&self) -> PathBuf {
479        self.root.join(MARKDOWN_LEDGER_FILE)
480    }
481
482    pub fn append_entry(&self, entry: &LedgerEntry) -> Result<(), LedgerError> {
483        fs::create_dir_all(&self.root)?;
484        let mut file = fs::OpenOptions::new()
485            .create(true)
486            .append(true)
487            .open(self.machine_path())?;
488        serde_json::to_writer(&mut file, entry)?;
489        writeln!(file)?;
490        self.render_markdown_mirror()?;
491        Ok(())
492    }
493
494    pub fn read_history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
495        let path = self.machine_path();
496        let contents = match fs::read_to_string(&path) {
497            Ok(contents) => contents,
498            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
499            Err(error) => return Err(error.into()),
500        };
501
502        contents
503            .lines()
504            .filter(|line| !line.trim().is_empty())
505            .map(|line| serde_json::from_str(line).map_err(LedgerError::from))
506            .collect()
507    }
508
509    pub fn latest_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
510        let mut by_sha: BTreeMap<String, LedgerEntry> = BTreeMap::new();
511        for entry in self.read_history()? {
512            match by_sha.get(&entry.commit_sha) {
513                // A petition REVIEW (stored under the fix commit's SHA with
514                // `petition_for` set) must never shadow the fix commit's own
515                // review state — otherwise a PASS petition verdict would
516                // overwrite the fix commit's own REJECT in every consumer of
517                // the latest view. Petition entries only represent a SHA that
518                // has no non-petition state of its own.
519                Some(current) if current.petition_for.is_none() && entry.petition_for.is_some() => {
520                }
521                _ => {
522                    by_sha.insert(entry.commit_sha.clone(), entry);
523                }
524            }
525        }
526        Ok(by_sha.into_values().collect())
527    }
528
529    pub fn show(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
530        self.latest_entries()?
531            .into_iter()
532            .find(|entry| entry.commit_sha == sha)
533            .ok_or_else(|| LedgerError::NotFound {
534                sha: sha.to_owned(),
535            })
536    }
537
538    pub fn unresolved_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
539        Ok(self
540            .latest_entries()?
541            .into_iter()
542            .filter(LedgerEntry::is_unresolved_rejection)
543            .collect())
544    }
545
546    /// Entries that must surface in reinjection and block the pre-push gate: open
547    /// `REJECT` verdicts and any commit escalated to `needs-human` after exhausting
548    /// the bounded petition attempts.
549    pub fn blocking_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
550        Ok(self
551            .latest_entries()?
552            .into_iter()
553            .filter(|entry| entry.is_unresolved_rejection() || entry.is_needs_human())
554            .collect())
555    }
556
557    /// FLAG entries — the reviewer accepted the claim but flagged evidence,
558    /// provenance, or process debt. Never blocks push or reinject; surfaces only
559    /// via `truth-mirror debt`.
560    pub fn flagged_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
561        let mut flagged: Vec<LedgerEntry> = self
562            .latest_entries()?
563            .into_iter()
564            .filter(LedgerEntry::is_open_flag)
565            .collect();
566        // Petition FLAG reviews are accept-with-debt records. latest_entries
567        // rightly refuses to let them shadow the fix commit's own state, so
568        // collect them from history separately (latest per fix+rejection
569        // pair) or their debt would vanish from `truth-mirror debt`.
570        let mut petition_flags: BTreeMap<(String, String), LedgerEntry> = BTreeMap::new();
571        for entry in self.read_history()? {
572            if let Some(original) = entry.petition_for.clone() {
573                petition_flags.insert((entry.commit_sha.clone(), original), entry);
574            }
575        }
576        let already: std::collections::BTreeSet<(String, Option<String>)> = flagged
577            .iter()
578            .map(|entry| (entry.commit_sha.clone(), entry.petition_for.clone()))
579            .collect();
580        for entry in petition_flags.into_values() {
581            if entry.is_open_flag()
582                && !already.contains(&(entry.commit_sha.clone(), entry.petition_for.clone()))
583            {
584                flagged.push(entry);
585            }
586        }
587        Ok(flagged)
588    }
589
590    /// All entries (across every SHA) — full history flattened for
591    /// debt-by-finding-class grouping.
592    pub fn history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
593        self.read_history()
594    }
595
596    /// Look up the latest petition attempt count for a commit's rejection.
597    /// Returns 0 if no entry has ever recorded an attempt.
598    pub fn petition_attempts_for(&self, sha: &str) -> Result<u32, LedgerError> {
599        let mut max_attempts = 0u32;
600        for entry in self.read_history()? {
601            // Petition REVIEW rows live under the FIX commit's SHA and carry
602            // the counter of the rejection they petition — counting them here
603            // would make a fix commit's OWN rejection inherit attempts spent
604            // on some other rejection it happened to fix.
605            if entry.commit_sha != sha || entry.petition_for.is_some() {
606                continue;
607            }
608            max_attempts = max_attempts.max(entry.petition_attempts);
609        }
610        Ok(max_attempts)
611    }
612
613    /// Append a `resolution = Some(_)` ledger event for an existing rejection,
614    /// keeping the same `petition_attempts` counter (use
615    /// [`append_petition_transition`] to increment it).
616    pub fn resolve(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
617        self.transition_rejection(
618            sha,
619            Disposition::Resolved,
620            ResolutionKind::Resolved,
621            "resolved",
622        )
623    }
624
625    /// Waive an open rejection or a needs-human escalation. Waiving a
626    /// needs-human escalation is the canonical human-side close after the
627    /// petition attempts have been exhausted.
628    pub fn waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
629        if reason.trim().is_empty() {
630            return Err(LedgerError::EmptyWaiverReason);
631        }
632
633        self.transition_waive(sha, reason)
634    }
635
636    /// Mark a rejection as escalated to `needs-human` (e.g. after the bounded
637    /// petition attempts have been exhausted). The latest known attempt count
638    /// is preserved on the new entry by default; pass `attempts` to override
639    /// (e.g. the petition plumbing knows the new count after the reviewer
640    /// verdict is recorded).
641    pub fn escalate_to_needs_human(
642        &self,
643        sha: &str,
644        reason: &str,
645    ) -> Result<LedgerEntry, LedgerError> {
646        let attempts = self.petition_attempts_for(sha)?;
647        self.append_petition_transition(
648            sha,
649            Disposition::NeedsHuman,
650            ResolutionKind::NeedsHuman,
651            reason,
652            attempts,
653        )
654    }
655
656    /// Escalate to `needs-human` and record a specific attempt count on the new
657    /// entry. Used by the petition plumbing after it has the authoritative new
658    /// count from the reviewer verdict.
659    pub fn escalate_to_needs_human_with_attempts(
660        &self,
661        sha: &str,
662        reason: &str,
663        attempts: u32,
664    ) -> Result<LedgerEntry, LedgerError> {
665        self.append_petition_transition(
666            sha,
667            Disposition::NeedsHuman,
668            ResolutionKind::NeedsHuman,
669            reason,
670            attempts,
671        )
672    }
673
674    /// Append a transition event for a rejection, preserving the latest known
675    /// `petition_attempts` counter by default (no increment). The caller passes
676    /// `attempts_override` only when it has authoritative new knowledge (the
677    /// petition plumbing increments this after the reviewer verdict is recorded).
678    pub fn append_petition_transition(
679        &self,
680        sha: &str,
681        disposition: Disposition,
682        kind: ResolutionKind,
683        reason: &str,
684        attempts: u32,
685    ) -> Result<LedgerEntry, LedgerError> {
686        let mut entry = self.show(sha)?;
687        if !entry.is_unresolved_rejection() && !entry.is_needs_human() {
688            return Err(LedgerError::NoOpenRejection {
689                sha: sha.to_owned(),
690            });
691        }
692        let timestamp = unix_now();
693        entry.disposition = disposition;
694        // A rejection that stays `Open` (petition enqueued, or a failed attempt
695        // that hasn't exhausted the bound) is NOT resolved — writing a
696        // `Resolution` for it would make `ledger show` and the markdown mirror
697        // claim resolution while the rejection still blocks. The appended
698        // history entry plus the bumped `petition_attempts` counter are the
699        // audit trail for open transitions.
700        entry.resolution = if disposition == Disposition::Open {
701            None
702        } else {
703            Some(Resolution {
704                kind,
705                reason: reason.trim().to_owned(),
706                resolved_at_unix: timestamp,
707            })
708        };
709        entry.petition_attempts = attempts;
710        entry.updated_at_unix = timestamp;
711        self.append_entry(&entry)?;
712        Ok(entry)
713    }
714
715    pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
716        let mut stats = LedgerStats::default();
717        // Count totals over the latest entry per SHA so resolution transitions don't
718        // inflate the count. Walk the full history once just to compute the sum of
719        // per-SHA peak petition attempts (useful surface for the `debt` view).
720        // Petition REVIEW entries (petition_for set) carry the same counter under
721        // the FIX commit's SHA — skip them so one attempt isn't summed twice.
722        let mut max_attempts_by_sha = BTreeMap::<String, u32>::new();
723        for entry in self.read_history()? {
724            if entry.petition_for.is_some() {
725                continue;
726            }
727            let slot = max_attempts_by_sha
728                .entry(entry.commit_sha.clone())
729                .or_insert(0);
730            if entry.petition_attempts > *slot {
731                *slot = entry.petition_attempts;
732            }
733        }
734        for entry in self.latest_entries()? {
735            stats.total += 1;
736            match entry.verdict {
737                Verdict::Pass => stats.pass += 1,
738                Verdict::Reject => stats.reject += 1,
739                Verdict::Flag => stats.flag += 1,
740            }
741            match entry.disposition {
742                Disposition::Open => {
743                    if entry.verdict == Verdict::Reject {
744                        stats.unresolved += 1;
745                    }
746                }
747                Disposition::Resolved => stats.resolved += 1,
748                Disposition::Waived => stats.waived += 1,
749                Disposition::NeedsHuman => stats.needs_human += 1,
750            }
751        }
752        stats.petition_attempts = max_attempts_by_sha.values().copied().sum();
753        Ok(stats)
754    }
755
756    pub fn render_markdown_mirror(&self) -> Result<(), LedgerError> {
757        fs::create_dir_all(&self.root)?;
758        let markdown = render_markdown(&self.latest_entries()?, &self.stats()?);
759        fs::write(self.markdown_path(), markdown)?;
760        Ok(())
761    }
762
763    fn transition_rejection(
764        &self,
765        sha: &str,
766        disposition: Disposition,
767        kind: ResolutionKind,
768        reason: &str,
769    ) -> Result<LedgerEntry, LedgerError> {
770        let mut entry = self.show(sha)?;
771        if !entry.is_unresolved_rejection() {
772            return Err(LedgerError::NoOpenRejection {
773                sha: sha.to_owned(),
774            });
775        }
776
777        let timestamp = unix_now();
778        entry.disposition = disposition;
779        entry.resolution = Some(Resolution {
780            kind,
781            reason: reason.trim().to_owned(),
782            resolved_at_unix: timestamp,
783        });
784        entry.updated_at_unix = timestamp;
785        self.append_entry(&entry)?;
786        Ok(entry)
787    }
788
789    fn transition_waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
790        let mut entry = self.show(sha)?;
791        if !entry.is_unresolved_rejection() && !entry.is_needs_human() {
792            return Err(LedgerError::NoOpenRejection {
793                sha: sha.to_owned(),
794            });
795        }
796
797        let timestamp = unix_now();
798        entry.disposition = Disposition::Waived;
799        entry.resolution = Some(Resolution {
800            kind: ResolutionKind::Waived,
801            reason: reason.trim().to_owned(),
802            resolved_at_unix: timestamp,
803        });
804        entry.updated_at_unix = timestamp;
805        self.append_entry(&entry)?;
806        Ok(entry)
807    }
808}
809
810#[derive(Debug, Error)]
811pub enum LedgerError {
812    #[error("ledger IO failed: {0}")]
813    Io(#[from] io::Error),
814    #[error("ledger JSON failed: {0}")]
815    Json(#[from] serde_json::Error),
816    #[error("ledger entry not found for commit {sha}")]
817    NotFound { sha: String },
818    #[error("commit {sha} has no open rejection")]
819    NoOpenRejection { sha: String },
820    #[error("waive requires a non-empty reason")]
821    EmptyWaiverReason,
822}
823
824pub fn run(args: cli::LedgerArgs, state_dir: &Path) -> Result<ExitCode> {
825    let store = LedgerStore::new(state_dir);
826    match args.command {
827        cli::LedgerCommand::List => {
828            // The human-facing list must match what actually blocks push and
829            // reinjection: open REJECTs *plus* needs-human escalations. Using
830            // the narrower unresolved set here let the CLI say "nothing
831            // unresolved" while the pre-push gate still refused.
832            print_unresolved(&store.blocking_rejections()?);
833        }
834        cli::LedgerCommand::Show { sha } => {
835            let entry = store.show(&sha)?;
836            print_entry(&entry);
837        }
838        cli::LedgerCommand::History { sha } => {
839            // A rejection's audit trail includes the petition REVIEWS run
840            // against it — those land under the FIX commit's SHA, linked back
841            // via `petition_for`, and were previously invisible here.
842            let entries = store
843                .history()?
844                .into_iter()
845                .filter(|entry| {
846                    entry.commit_sha == sha || entry.petition_for.as_deref() == Some(sha.as_str())
847                })
848                .collect::<Vec<_>>();
849            print_history(&entries);
850        }
851        cli::LedgerCommand::Stats => print_stats(&store.stats()?),
852    }
853
854    Ok(ExitCode::SUCCESS)
855}
856
857fn render_markdown(entries: &[LedgerEntry], stats: &LedgerStats) -> String {
858    let mut output = String::new();
859    output.push_str("# Truth Mirror Ledger\n\n");
860    output.push_str("## Summary\n\n");
861    output.push_str(&format!("- Total: {}\n", stats.total));
862    output.push_str(&format!("- PASS: {}\n", stats.pass));
863    output.push_str(&format!("- REJECT: {}\n", stats.reject));
864    output.push_str(&format!("- FLAG: {}\n", stats.flag));
865    output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
866    output.push_str(&format!("- Resolved: {}\n", stats.resolved));
867    output.push_str(&format!("- Waived: {}\n", stats.waived));
868    output.push_str(&format!(
869        "- Needs-human (escalated): {}\n",
870        stats.needs_human
871    ));
872    output.push_str(&format!(
873        "- Petition attempts (cumulative): {}\n\n",
874        stats.petition_attempts
875    ));
876    output.push_str("## Entries\n");
877
878    if entries.is_empty() {
879        output.push_str("\nNo ledger entries.\n");
880        return output;
881    }
882
883    for entry in entries {
884        output.push_str(&format!(
885            "\n### {} - {} - {}\n\n",
886            entry.commit_sha, entry.verdict, entry.disposition
887        ));
888        output.push_str(&format!("- Claim: {}\n", entry.claim));
889        output.push_str(&format!(
890            "- Evidence: {}\n",
891            if entry.evidence.is_empty() {
892                "none".to_owned()
893            } else {
894                entry.evidence.join(", ")
895            }
896        ));
897        output.push_str(&format!(
898            "- Reviewer: {}/{} (allow_same_model={})\n",
899            entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
900        ));
901        if !entry.summary.trim().is_empty() {
902            output.push_str(&format!("- Summary: {}\n", entry.summary));
903        }
904
905        if entry.findings.is_empty() {
906            output.push_str("- Findings: none\n");
907        } else if !entry.structured_findings.is_empty() {
908            output.push_str("- Findings:\n");
909            for finding in &entry.structured_findings {
910                output.push_str(&format!("  - {}\n", finding.display_line()));
911            }
912        } else {
913            output.push_str("- Findings:\n");
914            for finding in &entry.findings {
915                output.push_str(&format!("  - {}\n", finding));
916            }
917        }
918
919        if !entry.next_steps.is_empty() {
920            output.push_str("- Next steps:\n");
921            for step in &entry.next_steps {
922                output.push_str(&format!("  - {}\n", step));
923            }
924        }
925
926        if !entry
927            .memory_skill_classification
928            .reasoning
929            .trim()
930            .is_empty()
931        {
932            let classification = &entry.memory_skill_classification;
933            output.push_str(&format!("- Memory skill: {}", classification.kind));
934            if !classification.learning_source.trim().is_empty() {
935                output.push_str(&format!(" - {}", classification.learning_source));
936            }
937            output.push_str(&format!(" - {}\n", classification.reasoning));
938        }
939
940        if !entry.raw_reviewer_output.trim().is_empty() {
941            output.push_str("- Raw reviewer output:\n\n```json\n");
942            output.push_str(entry.raw_reviewer_output.trim());
943            output.push_str("\n```\n");
944        }
945
946        if let Some(resolution) = &entry.resolution {
947            // Open entries no longer get a `Resolution` written, but ledgers
948            // recorded before that fix may still carry one — label those as
949            // petition bookkeeping so the mirror never reads "resolved" for
950            // an entry that still blocks.
951            if entry.disposition == Disposition::Open {
952                output.push_str(&format!(
953                    "- Petition event (rejection still open): {:?} - {}\n",
954                    resolution.kind, resolution.reason
955                ));
956            } else {
957                output.push_str(&format!(
958                    "- Resolution: {:?} - {}\n",
959                    resolution.kind, resolution.reason
960                ));
961            }
962        }
963
964        if entry.petition_attempts > 0 || entry.petition_for.is_some() {
965            output.push_str(&format!(
966                "- Petition attempts: {}\n",
967                entry.petition_attempts
968            ));
969        }
970        if let Some(original) = &entry.petition_for {
971            output.push_str(&format!("- Petition for: {original}\n"));
972        }
973    }
974
975    output
976}
977
978fn print_unresolved(entries: &[LedgerEntry]) {
979    if entries.is_empty() {
980        println!("No blocking commits (no open rejections, no needs-human escalations).");
981        return;
982    }
983
984    for entry in entries {
985        println!(
986            "{} {} {} {}",
987            entry.commit_sha, entry.verdict, entry.disposition, entry.claim
988        );
989    }
990}
991
992fn print_entry(entry: &LedgerEntry) {
993    println!("commit: {}", entry.commit_sha);
994    println!("verdict: {}", entry.verdict);
995    println!("disposition: {}", entry.disposition);
996    println!("claim: {}", entry.claim);
997    println!("evidence: {}", entry.evidence.join(", "));
998    println!(
999        "reviewer: {}/{} allow_same_model={}",
1000        entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
1001    );
1002    if !entry.summary.trim().is_empty() {
1003        println!("summary: {}", entry.summary);
1004    }
1005    if entry.findings.is_empty() {
1006        println!("findings: none");
1007    } else if !entry.structured_findings.is_empty() {
1008        println!("findings:");
1009        for finding in &entry.structured_findings {
1010            println!("- {}", finding.display_line());
1011        }
1012    } else {
1013        println!("findings:");
1014        for finding in &entry.findings {
1015            println!("- {finding}");
1016        }
1017    }
1018    if !entry.next_steps.is_empty() {
1019        println!("next_steps:");
1020        for step in &entry.next_steps {
1021            println!("- {step}");
1022        }
1023    }
1024    if !entry
1025        .memory_skill_classification
1026        .reasoning
1027        .trim()
1028        .is_empty()
1029    {
1030        let classification = &entry.memory_skill_classification;
1031        println!("memory_skill_kind: {}", classification.kind);
1032        if !classification.learning_source.trim().is_empty() {
1033            println!(
1034                "memory_skill_learning_source: {}",
1035                classification.learning_source
1036            );
1037        }
1038        println!("memory_skill_reasoning: {}", classification.reasoning);
1039    }
1040    if !entry.raw_reviewer_output.trim().is_empty() {
1041        println!("raw_reviewer_output:");
1042        println!("{}", entry.raw_reviewer_output.trim());
1043    }
1044    if entry.petition_attempts > 0 || entry.petition_for.is_some() {
1045        println!("petition_attempts: {}", entry.petition_attempts);
1046    }
1047    if let Some(original) = &entry.petition_for {
1048        println!("petition_for: {original}");
1049    }
1050}
1051
1052fn print_history(entries: &[LedgerEntry]) {
1053    if entries.is_empty() {
1054        println!("No ledger entries for that commit.");
1055        return;
1056    }
1057    println!("audit trail ({} entries):", entries.len());
1058    for entry in entries {
1059        let resolution = entry
1060            .resolution
1061            .as_ref()
1062            .map(|resolution| format!("{:?}", resolution.kind))
1063            .unwrap_or_else(|| "-".to_owned());
1064        // updated_at, not created_at: transition events inherit the base
1065        // entry's creation stamp, so created_at renders every transition on a
1066        // rejection with the same timestamp.
1067        println!(
1068            "  {} {} {} {} (attempts={}, petition_for={:?}, resolution={})",
1069            entry.updated_at_unix,
1070            entry.commit_sha,
1071            entry.verdict,
1072            entry.disposition,
1073            entry.petition_attempts,
1074            entry.petition_for,
1075            resolution
1076        );
1077    }
1078}
1079
1080fn print_stats(stats: &LedgerStats) {
1081    println!("total={}", stats.total);
1082    println!("pass={}", stats.pass);
1083    println!("reject={}", stats.reject);
1084    println!("flag={}", stats.flag);
1085    println!("unresolved={}", stats.unresolved);
1086    println!("resolved={}", stats.resolved);
1087    println!("waived={}", stats.waived);
1088    println!("needs_human={}", stats.needs_human);
1089    println!("petition_attempts={}", stats.petition_attempts);
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use std::fs;
1095
1096    use proptest::prelude::*;
1097
1098    use super::{
1099        Disposition, FindingSeverity, LedgerEntry, LedgerError, LedgerStore,
1100        MemorySkillClassification, MemorySkillClassificationKind, ResolutionKind, ReviewerConfig,
1101        StructuredFinding, Verdict,
1102    };
1103
1104    fn reviewer() -> ReviewerConfig {
1105        ReviewerConfig::new("claude", "claude-opus-4-1", false)
1106    }
1107
1108    fn rejected_entry(sha: &str) -> LedgerEntry {
1109        LedgerEntry::new_at(
1110            sha,
1111            Verdict::Reject,
1112            "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
1113            vec!["tests:cargo-test".to_owned()],
1114            reviewer(),
1115            vec!["claim was unsupported".to_owned()],
1116            100,
1117        )
1118    }
1119
1120    fn structured_finding() -> StructuredFinding {
1121        StructuredFinding {
1122            severity: FindingSeverity::High,
1123            title: "missing proof".to_owned(),
1124            body: "The evidence pointer does not prove the claim.".to_owned(),
1125            file: "src/lib.rs".to_owned(),
1126            line_start: 7,
1127            line_end: 9,
1128            confidence: 95,
1129            recommendation: "Add executable evidence before claiming completion.".to_owned(),
1130        }
1131    }
1132
1133    #[test]
1134    fn append_and_read_latest_entries() {
1135        let temp = tempfile::tempdir().unwrap();
1136        let store = LedgerStore::new(temp.path());
1137        store.append_entry(&rejected_entry("abc123")).unwrap();
1138
1139        let entries = store.latest_entries().unwrap();
1140
1141        assert_eq!(entries.len(), 1);
1142        assert_eq!(entries[0].commit_sha, "abc123");
1143        assert!(entries[0].is_unresolved_rejection());
1144    }
1145
1146    #[test]
1147    fn markdown_mirror_renders_summary_and_findings() {
1148        let temp = tempfile::tempdir().unwrap();
1149        let store = LedgerStore::new(temp.path());
1150        store.append_entry(&rejected_entry("abc123")).unwrap();
1151
1152        let markdown = fs::read_to_string(store.markdown_path()).unwrap();
1153
1154        assert!(markdown.contains("# Truth Mirror Ledger"));
1155        assert!(markdown.contains("Unresolved rejections: 1"));
1156        assert!(markdown.contains("claim was unsupported"));
1157    }
1158
1159    #[test]
1160    fn markdown_mirror_renders_structured_review_provenance() {
1161        let temp = tempfile::tempdir().unwrap();
1162        let store = LedgerStore::new(temp.path());
1163        let entry = rejected_entry("abc123")
1164            .with_structured_review(
1165                "The claim is not proven.",
1166                vec![structured_finding()],
1167                vec!["Run cargo test.".to_owned()],
1168                r#"{"verdict":"REJECT"}"#,
1169            )
1170            .with_memory_skill_classification(MemorySkillClassification {
1171                kind: MemorySkillClassificationKind::AntiPatternSkill,
1172                learning_source: "missing proof".to_owned(),
1173                reasoning: "The reviewer proposed an anti-pattern candidate.".to_owned(),
1174            });
1175        store.append_entry(&entry).unwrap();
1176
1177        let markdown = fs::read_to_string(store.markdown_path()).unwrap();
1178
1179        assert!(markdown.contains("The claim is not proven."));
1180        assert!(markdown.contains("high [src/lib.rs:7-9] missing proof"));
1181        assert!(markdown.contains("Run cargo test."));
1182        assert!(markdown.contains("Memory skill: anti_pattern_skill - missing proof"));
1183        assert!(markdown.contains(r#"{"verdict":"REJECT"}"#));
1184    }
1185
1186    #[test]
1187    fn old_ledger_entry_without_memory_skill_classification_reads_as_none() {
1188        let entry: LedgerEntry = serde_json::from_value(serde_json::json!({
1189            "commit_sha": "abc123",
1190            "verdict": "REJECT",
1191            "disposition": "open",
1192            "claim": "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
1193            "evidence": ["tests:cargo-test"],
1194            "reviewer": {
1195                "harness": "claude",
1196                "model": "claude-opus-4-1",
1197                "allow_same_model": false
1198            },
1199            "summary": "The claim is not proven.",
1200            "findings": ["missing proof"],
1201            "structured_findings": [],
1202            "next_steps": [],
1203            "raw_reviewer_output": "{}",
1204            "resolution": null,
1205            "created_at_unix": 100,
1206            "updated_at_unix": 100
1207        }))
1208        .unwrap();
1209
1210        assert_eq!(
1211            entry.memory_skill_classification.kind,
1212            MemorySkillClassificationKind::None
1213        );
1214    }
1215
1216    #[test]
1217    fn resolve_clears_unresolved_rejection() {
1218        let temp = tempfile::tempdir().unwrap();
1219        let store = LedgerStore::new(temp.path());
1220        store.append_entry(&rejected_entry("abc123")).unwrap();
1221
1222        let resolved = store.resolve("abc123").unwrap();
1223
1224        assert_eq!(resolved.disposition, Disposition::Resolved);
1225        assert_eq!(
1226            resolved.resolution.as_ref().unwrap().kind,
1227            ResolutionKind::Resolved
1228        );
1229        assert!(store.unresolved_rejections().unwrap().is_empty());
1230        assert_eq!(store.read_history().unwrap().len(), 2);
1231    }
1232
1233    #[test]
1234    fn waive_records_reason_and_clears_unresolved_rejection() {
1235        let temp = tempfile::tempdir().unwrap();
1236        let store = LedgerStore::new(temp.path());
1237        store.append_entry(&rejected_entry("abc123")).unwrap();
1238
1239        let waived = store.waive("abc123", "Ramiro approved exception").unwrap();
1240
1241        assert_eq!(waived.disposition, Disposition::Waived);
1242        assert_eq!(
1243            waived.resolution.as_ref().unwrap().reason,
1244            "Ramiro approved exception"
1245        );
1246        assert!(store.unresolved_rejections().unwrap().is_empty());
1247    }
1248
1249    #[test]
1250    fn stats_counts_latest_dispositions() {
1251        let temp = tempfile::tempdir().unwrap();
1252        let store = LedgerStore::new(temp.path());
1253        store.append_entry(&rejected_entry("abc123")).unwrap();
1254        store
1255            .append_entry(&LedgerEntry::new_at(
1256                "def456",
1257                Verdict::Pass,
1258                "CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
1259                vec!["tests:cargo-test".to_owned()],
1260                reviewer(),
1261                Vec::new(),
1262                100,
1263            ))
1264            .unwrap();
1265        store.waive("abc123", "accepted risk").unwrap();
1266
1267        let stats = store.stats().unwrap();
1268
1269        assert_eq!(stats.total, 2);
1270        assert_eq!(stats.pass, 1);
1271        assert_eq!(stats.reject, 1);
1272        assert_eq!(stats.unresolved, 0);
1273        assert_eq!(stats.waived, 1);
1274    }
1275
1276    fn flagged_entry(sha: &str) -> LedgerEntry {
1277        LedgerEntry::new_at(
1278            sha,
1279            Verdict::Flag,
1280            "CLAIM: holds | verified: cargo test | evidence: tests:cargo-test",
1281            vec!["tests:cargo-test".to_owned()],
1282            reviewer(),
1283            vec!["evidence is thin".to_owned()],
1284            100,
1285        )
1286        .with_structured_review(
1287            "Claim substantiated, evidence is thin.",
1288            vec![StructuredFinding {
1289                severity: FindingSeverity::Medium,
1290                title: "thin evidence".to_owned(),
1291                body: "Claim holds but the only evidence is a passing test.".to_owned(),
1292                file: "src/lib.rs".to_owned(),
1293                line_start: 1,
1294                line_end: 1,
1295                confidence: 70,
1296                recommendation: "Provide stronger evidence.".to_owned(),
1297            }],
1298            vec![],
1299            r#"{"verdict":"FLAG"}"#,
1300        )
1301    }
1302
1303    #[test]
1304    fn flag_is_recorded_as_open_flag_and_not_unresolved_rejection() {
1305        let temp = tempfile::tempdir().unwrap();
1306        let store = LedgerStore::new(temp.path());
1307        store.append_entry(&flagged_entry("flag123")).unwrap();
1308
1309        let latest = store.show("flag123").unwrap();
1310        assert_eq!(latest.verdict, Verdict::Flag);
1311        assert_eq!(latest.disposition, Disposition::Open);
1312        assert!(latest.is_open_flag());
1313        assert!(!latest.is_unresolved_rejection());
1314
1315        assert!(store.unresolved_rejections().unwrap().is_empty());
1316        assert!(store.blocking_rejections().unwrap().is_empty());
1317        let flagged = store.flagged_entries().unwrap();
1318        assert_eq!(flagged.len(), 1);
1319        assert_eq!(flagged[0].commit_sha, "flag123");
1320    }
1321
1322    #[test]
1323    fn stats_separates_flag_from_unresolved() {
1324        let temp = tempfile::tempdir().unwrap();
1325        let store = LedgerStore::new(temp.path());
1326        store.append_entry(&rejected_entry("rej123")).unwrap();
1327        store.append_entry(&flagged_entry("flag123")).unwrap();
1328
1329        let stats = store.stats().unwrap();
1330        assert_eq!(stats.total, 2);
1331        assert_eq!(stats.reject, 1);
1332        assert_eq!(stats.flag, 1);
1333        assert_eq!(stats.unresolved, 1);
1334    }
1335
1336    #[test]
1337    fn old_ledger_entry_without_petition_attempts_reads_as_zero() {
1338        let entry: LedgerEntry = serde_json::from_value(serde_json::json!({
1339            "commit_sha": "abc123",
1340            "verdict": "REJECT",
1341            "disposition": "open",
1342            "claim": "CLAIM: legacy | verified: cargo test | evidence: tests:cargo-test",
1343            "evidence": ["tests:cargo-test"],
1344            "reviewer": {
1345                "harness": "claude",
1346                "model": "claude-opus-4-1",
1347                "allow_same_model": false
1348            },
1349            "summary": "The claim is not proven.",
1350            "findings": ["missing proof"],
1351            "structured_findings": [],
1352            "next_steps": [],
1353            "raw_reviewer_output": "{}",
1354            "resolution": null,
1355            "created_at_unix": 100,
1356            "updated_at_unix": 100
1357        }))
1358        .unwrap();
1359
1360        assert_eq!(entry.petition_attempts, 0);
1361        assert!(entry.petition_for.is_none());
1362    }
1363
1364    #[test]
1365    fn petition_attempts_increment_via_append_petition_transition() {
1366        let temp = tempfile::tempdir().unwrap();
1367        let store = LedgerStore::new(temp.path());
1368        store.append_entry(&rejected_entry("abc123")).unwrap();
1369
1370        let first = store
1371            .append_petition_transition(
1372                "abc123",
1373                Disposition::Open,
1374                ResolutionKind::Resolved, // kind is informational here; disposition stays Open
1375                "first petition attempt recorded",
1376                1,
1377            )
1378            .unwrap();
1379        assert_eq!(first.petition_attempts, 1);
1380        assert_eq!(first.disposition, Disposition::Open);
1381
1382        assert_eq!(store.petition_attempts_for("abc123").unwrap(), 1);
1383
1384        let second = store
1385            .append_petition_transition(
1386                "abc123",
1387                Disposition::Open,
1388                ResolutionKind::Resolved,
1389                "second petition attempt recorded",
1390                2,
1391            )
1392            .unwrap();
1393        assert_eq!(second.petition_attempts, 2);
1394        assert_eq!(store.petition_attempts_for("abc123").unwrap(), 2);
1395    }
1396
1397    #[test]
1398    fn append_petition_transition_refuses_when_no_open_rejection() {
1399        let temp = tempfile::tempdir().unwrap();
1400        let store = LedgerStore::new(temp.path());
1401        store.append_entry(&rejected_entry("abc123")).unwrap();
1402        store.resolve("abc123").unwrap();
1403
1404        let error = store
1405            .append_petition_transition(
1406                "abc123",
1407                Disposition::Open,
1408                ResolutionKind::Resolved,
1409                "stale",
1410                1,
1411            )
1412            .unwrap_err();
1413
1414        assert!(matches!(error, LedgerError::NoOpenRejection { .. }));
1415    }
1416
1417    #[test]
1418    fn blocking_rejections_includes_open_rejects_and_needs_human_only() {
1419        let temp = tempfile::tempdir().unwrap();
1420        let store = LedgerStore::new(temp.path());
1421        store.append_entry(&rejected_entry("rej1")).unwrap();
1422        store.append_entry(&flagged_entry("flag1")).unwrap();
1423        store.append_entry(&rejected_entry("rej2")).unwrap();
1424        store.escalate_to_needs_human("rej2", "exhausted").unwrap();
1425        store.resolve("rej1").unwrap();
1426
1427        let blocking = store.blocking_rejections().unwrap();
1428        assert_eq!(blocking.len(), 1);
1429        assert_eq!(blocking[0].commit_sha, "rej2");
1430        assert_eq!(blocking[0].disposition, Disposition::NeedsHuman);
1431
1432        let flagged = store.flagged_entries().unwrap();
1433        assert_eq!(flagged.len(), 1);
1434        assert_eq!(flagged[0].commit_sha, "flag1");
1435    }
1436
1437    #[test]
1438    fn escalate_to_needs_human_uses_needs_human_disposition() {
1439        let temp = tempfile::tempdir().unwrap();
1440        let store = LedgerStore::new(temp.path());
1441        store.append_entry(&rejected_entry("abc123")).unwrap();
1442
1443        let escalated = store
1444            .escalate_to_needs_human("abc123", "third petition attempt failed")
1445            .unwrap();
1446        assert_eq!(escalated.disposition, Disposition::NeedsHuman);
1447        assert_eq!(
1448            escalated.resolution.as_ref().unwrap().kind,
1449            ResolutionKind::NeedsHuman
1450        );
1451        assert_eq!(
1452            escalated.resolution.as_ref().unwrap().reason,
1453            "third petition attempt failed"
1454        );
1455        assert!(escalated.is_needs_human());
1456    }
1457
1458    #[test]
1459    fn resolve_after_needs_human_is_refused() {
1460        let temp = tempfile::tempdir().unwrap();
1461        let store = LedgerStore::new(temp.path());
1462        store.append_entry(&rejected_entry("abc123")).unwrap();
1463        store
1464            .escalate_to_needs_human("abc123", "exhausted")
1465            .unwrap();
1466
1467        // needs-human entries may be further escalated (e.g. another petition succeeds
1468        // via an out-of-band review) but require explicit append_petition_transition.
1469        let error = store.resolve("abc123").unwrap_err();
1470        assert!(matches!(error, LedgerError::NoOpenRejection { .. }));
1471    }
1472
1473    #[test]
1474    fn ledger_round_trips_petition_for_marker() {
1475        let temp = tempfile::tempdir().unwrap();
1476        let store = LedgerStore::new(temp.path());
1477        let mut entry = LedgerEntry::new_at(
1478            "def456",
1479            Verdict::Pass,
1480            "CLAIM: resolves abc123 | verified: cargo test | evidence: tests:cargo-test",
1481            vec!["tests:cargo-test".to_owned()],
1482            reviewer(),
1483            Vec::new(),
1484            100,
1485        );
1486        entry.petition_for = Some("abc123".to_owned());
1487        entry.petition_attempts = 1;
1488        store.append_entry(&entry).unwrap();
1489
1490        let history = store.history().unwrap();
1491        let recovered = history
1492            .iter()
1493            .find(|item| item.commit_sha == "def456")
1494            .expect("entry should be persisted");
1495        assert_eq!(recovered.petition_for.as_deref(), Some("abc123"));
1496        assert_eq!(recovered.petition_attempts, 1);
1497    }
1498
1499    #[test]
1500    fn ledger_round_trips_disposition_needs_human() {
1501        let entry: LedgerEntry = serde_json::from_value(serde_json::json!({
1502            "commit_sha": "abc123",
1503            "verdict": "REJECT",
1504            "disposition": "needs-human",
1505            "claim": "CLAIM: x | verified: cargo test | evidence: tests:x",
1506            "evidence": ["tests:x"],
1507            "reviewer": {
1508                "harness": "claude",
1509                "model": "opus",
1510                "allow_same_model": false
1511            },
1512            "summary": "x",
1513            "findings": [],
1514            "structured_findings": [],
1515            "next_steps": [],
1516            "raw_reviewer_output": "{}",
1517            "resolution": {
1518                "kind": "needs-human",
1519                "reason": "third petition attempt failed",
1520                "resolved_at_unix": 200
1521            },
1522            "petition_attempts": 2,
1523            "created_at_unix": 100,
1524            "updated_at_unix": 200
1525        }))
1526        .unwrap();
1527
1528        assert_eq!(entry.disposition, Disposition::NeedsHuman);
1529        assert_eq!(
1530            entry.resolution.as_ref().unwrap().kind,
1531            ResolutionKind::NeedsHuman
1532        );
1533        assert_eq!(entry.petition_attempts, 2);
1534        assert!(entry.is_needs_human());
1535    }
1536
1537    #[test]
1538    fn markdown_mirror_renders_flag_summary_and_petition_attempts() {
1539        let temp = tempfile::tempdir().unwrap();
1540        let store = LedgerStore::new(temp.path());
1541        store.append_entry(&flagged_entry("flag1")).unwrap();
1542        store.append_entry(&rejected_entry("rej1")).unwrap();
1543        store
1544            .append_petition_transition(
1545                "rej1",
1546                Disposition::Open,
1547                ResolutionKind::Resolved,
1548                "first petition attempt recorded",
1549                1,
1550            )
1551            .unwrap();
1552
1553        let markdown = fs::read_to_string(store.markdown_path()).unwrap();
1554        assert!(markdown.contains("FLAG: 1"));
1555        assert!(markdown.contains("Petition attempts (cumulative): 1"));
1556        assert!(markdown.contains("Petition attempts: 1"));
1557    }
1558
1559    /// Deserialize a finding whose only varying field is `confidence`, exercising the
1560    /// real `#[serde(deserialize_with = "deserialize_confidence")]` wiring end to end.
1561    fn confidence_of(confidence: serde_json::Value) -> u8 {
1562        let value = serde_json::json!({
1563            "severity": "high",
1564            "title": "missing proof",
1565            "body": "The cited evidence does not prove the claim.",
1566            "file": "src/lib.rs",
1567            "line_start": 1,
1568            "line_end": 1,
1569            "confidence": confidence,
1570            "recommendation": "Provide executable evidence.",
1571        });
1572        serde_json::from_value::<StructuredFinding>(value)
1573            .expect("a finding must never fail to deserialize over confidence formatting")
1574            .confidence
1575    }
1576
1577    #[test]
1578    fn confidence_accepts_codex_normalized_floats() {
1579        // The exact payload shapes that silently swallowed six REJECT verdicts.
1580        assert_eq!(confidence_of(serde_json::json!(0.95)), 95);
1581        assert_eq!(confidence_of(serde_json::json!(0.9)), 90);
1582        assert_eq!(confidence_of(serde_json::json!(0.86)), 86);
1583    }
1584
1585    #[test]
1586    fn confidence_accepts_integer_percent() {
1587        assert_eq!(confidence_of(serde_json::json!(86)), 86);
1588        assert_eq!(confidence_of(serde_json::json!(0)), 0);
1589        assert_eq!(confidence_of(serde_json::json!(100)), 100);
1590    }
1591
1592    #[test]
1593    fn confidence_normalizes_or_clamps_but_never_drops_the_verdict() {
1594        assert_eq!(confidence_of(serde_json::json!(95.5)), 96); // decimal percent rounds
1595        assert_eq!(confidence_of(serde_json::json!(140)), 100); // percent over 100 clamps
1596        assert_eq!(confidence_of(serde_json::json!(-3)), 0); // negative int floors
1597        assert_eq!(confidence_of(serde_json::json!(-0.2)), 0); // negative float floors
1598        assert_eq!(confidence_of(serde_json::json!("0.86")), 86); // quoted number
1599        assert_eq!(confidence_of(serde_json::json!("nonsense")), 0); // junk floors, no drop
1600    }
1601
1602    proptest! {
1603        #[test]
1604        fn confidence_deserialization_is_total_over_all_finite_floats(value in -1000.0f64..1000.0) {
1605            // No finite confidence float may ever error out of deserialization.
1606            let confidence = confidence_of(serde_json::json!(value));
1607            prop_assert!(confidence <= 100);
1608        }
1609
1610        #[test]
1611        fn append_read_preserves_unresolved_rejection_semantics(sha in "[a-f0-9]{7,40}") {
1612            let temp = tempfile::tempdir().unwrap();
1613            let store = LedgerStore::new(temp.path());
1614            store.append_entry(&rejected_entry(&sha)).unwrap();
1615
1616            let entries = store.latest_entries().unwrap();
1617            prop_assert_eq!(entries.len(), 1);
1618            prop_assert_eq!(entries[0].commit_sha.as_str(), sha.as_str());
1619            prop_assert!(entries[0].is_unresolved_rejection());
1620        }
1621    }
1622
1623    #[test]
1624    fn open_petition_transition_writes_no_resolution() {
1625        let temp = tempfile::tempdir().unwrap();
1626        let store = LedgerStore::new(temp.path());
1627        store.append_entry(&rejected_entry("abc123")).unwrap();
1628
1629        let entry = store
1630            .append_petition_transition(
1631                "abc123",
1632                Disposition::Open,
1633                ResolutionKind::Resolved,
1634                "petition review enqueued: fix=def456, attempts=1/2",
1635                1,
1636            )
1637            .unwrap();
1638
1639        // Still open — a `Resolution` here would make `ledger show` / the
1640        // markdown mirror claim resolution for a rejection that still blocks.
1641        assert_eq!(entry.disposition, Disposition::Open);
1642        assert!(entry.resolution.is_none());
1643        assert_eq!(entry.petition_attempts, 1);
1644        assert!(entry.is_unresolved_rejection());
1645
1646        // Non-open transitions still record the resolution.
1647        let resolved = store
1648            .append_petition_transition(
1649                "abc123",
1650                Disposition::Resolved,
1651                ResolutionKind::Resolved,
1652                "petition accepted",
1653                1,
1654            )
1655            .unwrap();
1656        assert_eq!(
1657            resolved.resolution.as_ref().unwrap().kind,
1658            ResolutionKind::Resolved
1659        );
1660    }
1661
1662    #[test]
1663    fn stats_petition_attempts_skip_petition_review_entries() {
1664        let temp = tempfile::tempdir().unwrap();
1665        let store = LedgerStore::new(temp.path());
1666        store.append_entry(&rejected_entry("orig123")).unwrap();
1667        // CLI bookkeeping on the rejection: attempts = 1.
1668        store
1669            .append_petition_transition(
1670                "orig123",
1671                Disposition::Open,
1672                ResolutionKind::Resolved,
1673                "petition review enqueued: fix=fix456, attempts=1/2",
1674                1,
1675            )
1676            .unwrap();
1677        // The petition REVIEW entry lands under the FIX sha carrying the same
1678        // counter — it must not be summed as a second attempt.
1679        let mut review = LedgerEntry::new_at(
1680            "fix456",
1681            Verdict::Pass,
1682            "CLAIM: fix | verified: cargo test | evidence: tests:cargo-test",
1683            vec!["tests:cargo-test".to_owned()],
1684            reviewer(),
1685            Vec::new(),
1686            200,
1687        );
1688        review.petition_for = Some("orig123".to_owned());
1689        review.petition_attempts = 1;
1690        store.append_entry(&review).unwrap();
1691
1692        let stats = store.stats().unwrap();
1693        assert_eq!(stats.petition_attempts, 1);
1694    }
1695
1696    #[test]
1697    fn is_open_flag_requires_open_disposition() {
1698        let mut entry = LedgerEntry::new_at(
1699            "flag1",
1700            Verdict::Flag,
1701            "CLAIM: holds | verified: cargo test | evidence: tests:cargo-test",
1702            vec!["tests:cargo-test".to_owned()],
1703            reviewer(),
1704            vec!["thin evidence".to_owned()],
1705            100,
1706        );
1707        assert!(entry.is_open_flag());
1708        entry.disposition = Disposition::Resolved;
1709        assert!(!entry.is_open_flag());
1710        entry.disposition = Disposition::Waived;
1711        assert!(!entry.is_open_flag());
1712    }
1713
1714    #[test]
1715    fn flag_verdict_accepts_every_memory_skill_kind() {
1716        for kind in [
1717            MemorySkillClassificationKind::None,
1718            MemorySkillClassificationKind::HowToSkill,
1719            MemorySkillClassificationKind::AntiPatternSkill,
1720            MemorySkillClassificationKind::RemediationSkill,
1721        ] {
1722            let classification = MemorySkillClassification {
1723                kind,
1724                learning_source: "some reusable pattern".to_owned(),
1725                reasoning: "flag carries debt context".to_owned(),
1726            };
1727            assert!(
1728                classification.validate_for_verdict(Verdict::Flag).is_ok(),
1729                "FLAG should accept {kind:?}"
1730            );
1731        }
1732    }
1733
1734    #[test]
1735    fn petition_review_entries_do_not_shadow_the_fix_commits_own_state() {
1736        let temp = tempfile::tempdir().unwrap();
1737        let store = LedgerStore::new(temp.path());
1738        // The fix commit has its own REJECT from its post-commit review...
1739        store.append_entry(&rejected_entry("fix456")).unwrap();
1740        // ...and a LATER petition review entry (PASS) lands under the same
1741        // SHA, tagged with the original rejection it petitions.
1742        let mut petition_review = LedgerEntry::new_at(
1743            "fix456",
1744            Verdict::Pass,
1745            "CLAIM: fix | verified: cargo test | evidence: tests:cargo-test",
1746            vec!["tests:cargo-test".to_owned()],
1747            reviewer(),
1748            Vec::new(),
1749            200,
1750        );
1751        petition_review.petition_for = Some("orig123".to_owned());
1752        petition_review.petition_attempts = 1;
1753        store.append_entry(&petition_review).unwrap();
1754
1755        // The fix commit's OWN latest state stays the REJECT — the petition
1756        // verdict must not overwrite it in show()/latest_entries()/blocking.
1757        let latest = store.show("fix456").unwrap();
1758        assert_eq!(latest.verdict, Verdict::Reject);
1759        assert!(latest.petition_for.is_none());
1760        assert_eq!(store.blocking_rejections().unwrap().len(), 1);
1761    }
1762
1763    #[test]
1764    fn petition_flag_debt_survives_latest_entry_shadow_protection() {
1765        let temp = tempfile::tempdir().unwrap();
1766        let store = LedgerStore::new(temp.path());
1767        // The fix commit has its own PASS state...
1768        store
1769            .append_entry(&LedgerEntry::new_at(
1770                "fix456",
1771                Verdict::Pass,
1772                "CLAIM: fix | verified: cargo test | evidence: tests:cargo-test",
1773                vec!["tests:cargo-test".to_owned()],
1774                reviewer(),
1775                Vec::new(),
1776                100,
1777            ))
1778            .unwrap();
1779        // ...and a petition FLAG review (accept-with-debt) under the same SHA.
1780        let mut petition_flag = LedgerEntry::new_at(
1781            "fix456",
1782            Verdict::Flag,
1783            "CLAIM: fix | verified: cargo test | evidence: tests:cargo-test",
1784            vec!["tests:cargo-test".to_owned()],
1785            reviewer(),
1786            vec!["evidence could be tighter".to_owned()],
1787            200,
1788        );
1789        petition_flag.petition_for = Some("orig123".to_owned());
1790        petition_flag.petition_attempts = 1;
1791        store.append_entry(&petition_flag).unwrap();
1792
1793        // latest state of the fix commit stays its own PASS...
1794        assert_eq!(store.show("fix456").unwrap().verdict, Verdict::Pass);
1795        // ...but the petition FLAG debt still surfaces in the debt view.
1796        let flagged = store.flagged_entries().unwrap();
1797        assert_eq!(flagged.len(), 1);
1798        assert_eq!(flagged[0].petition_for.as_deref(), Some("orig123"));
1799    }
1800
1801    #[test]
1802    fn petition_attempts_ignore_petition_review_rows_under_the_same_sha() {
1803        let temp = tempfile::tempdir().unwrap();
1804        let store = LedgerStore::new(temp.path());
1805        // The fix commit has its OWN open rejection (0 attempts spent on it)...
1806        store.append_entry(&rejected_entry("fix456")).unwrap();
1807        // ...plus a petition review row for ANOTHER rejection carrying that
1808        // rejection's counter. The fix commit must not inherit it.
1809        let mut petition_review = LedgerEntry::new_at(
1810            "fix456",
1811            Verdict::Reject,
1812            "CLAIM: fix | verified: cargo test | evidence: tests:cargo-test",
1813            vec!["tests:cargo-test".to_owned()],
1814            reviewer(),
1815            vec!["still unaddressed".to_owned()],
1816            200,
1817        );
1818        petition_review.petition_for = Some("orig123".to_owned());
1819        petition_review.petition_attempts = 2;
1820        store.append_entry(&petition_review).unwrap();
1821
1822        assert_eq!(store.petition_attempts_for("fix456").unwrap(), 0);
1823    }
1824}