1use 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 #[serde(default)]
45 pub petition_attempts: u32,
46 #[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 pub fn is_open_flag(&self) -> bool {
142 self.verdict == Verdict::Flag && self.disposition == Disposition::Open
143 }
144
145 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 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 (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
305fn 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 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 Ok(value.trim().parse::<f64>().map_or(0, confidence_from_f64))
356 }
357 }
358
359 deserializer.deserialize_any(ConfidenceVisitor)
360}
361
362fn clamp_percent(value: u64) -> u8 {
364 value.min(100) as u8
365}
366
367fn 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 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 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 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 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 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 pub fn history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
593 self.read_history()
594 }
595
596 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 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 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 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 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 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 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 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 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 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 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 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 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, "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 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 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 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); assert_eq!(confidence_of(serde_json::json!(140)), 100); assert_eq!(confidence_of(serde_json::json!(-3)), 0); assert_eq!(confidence_of(serde_json::json!(-0.2)), 0); assert_eq!(confidence_of(serde_json::json!("0.86")), 86); assert_eq!(confidence_of(serde_json::json!("nonsense")), 0); }
1601
1602 proptest! {
1603 #[test]
1604 fn confidence_deserialization_is_total_over_all_finite_floats(value in -1000.0f64..1000.0) {
1605 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 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 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 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 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 store.append_entry(&rejected_entry("fix456")).unwrap();
1740 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 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 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 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 assert_eq!(store.show("fix456").unwrap().verdict, Verdict::Pass);
1795 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 store.append_entry(&rejected_entry("fix456")).unwrap();
1807 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}