Skip to main content

truth_mirror/
ledger.rs

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