1use 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#[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 PetitionEnqueued,
39 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 #[serde(default)]
69 pub petition_attempts: u32,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub petition_for: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub transition_provenance: Option<TransitionProvenance>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub review_run_id: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub batch_id: Option<String>,
86 #[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 pub fn is_open_flag(&self) -> bool {
183 self.verdict == Verdict::Flag && self.disposition == Disposition::Open
184 }
185
186 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 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 (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
346fn 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 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 Ok(value.trim().parse::<f64>().map_or(0, confidence_from_f64))
397 }
398 }
399
400 deserializer.deserialize_any(ConfidenceVisitor)
401}
402
403fn clamp_percent(value: u64) -> u8 {
405 value.min(100) as u8
406}
407
408fn 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 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 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 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 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 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 pub fn history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
653 self.read_history()
654 }
655
656 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 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 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 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 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 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 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 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 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 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 valid_len = bytes.len();
1031 }
1032 }
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 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 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, "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 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 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 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); 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); }
1853
1854 proptest! {
1855 #[test]
1856 fn confidence_deserialization_is_total_over_all_finite_floats(value in -1000.0f64..1000.0) {
1857 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 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 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 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 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 store.append_entry(&rejected_entry("fix456")).unwrap();
1992 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 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 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 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 assert_eq!(store.show("fix456").unwrap().verdict, Verdict::Pass);
2047 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 store.append_entry(&rejected_entry("fix456")).unwrap();
2059 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}