1use std::{
4 collections::BTreeMap,
5 fmt, fs,
6 io::{self, Write},
7 path::{Path, PathBuf},
8 process::ExitCode,
9 time::{SystemTime, UNIX_EPOCH},
10};
11
12use anyhow::Result;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use crate::cli;
17
18pub const MACHINE_LEDGER_FILE: &str = "ledger.jsonl";
19pub const MARKDOWN_LEDGER_FILE: &str = "ledger.md";
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22pub struct LedgerEntry {
23 pub commit_sha: String,
24 pub verdict: Verdict,
25 pub disposition: Disposition,
26 pub claim: String,
27 pub evidence: Vec<String>,
28 pub reviewer: ReviewerConfig,
29 #[serde(default)]
30 pub summary: String,
31 pub findings: Vec<String>,
32 #[serde(default)]
33 pub structured_findings: Vec<StructuredFinding>,
34 #[serde(default)]
35 pub next_steps: Vec<String>,
36 #[serde(default)]
37 pub raw_reviewer_output: String,
38 pub resolution: Option<Resolution>,
39 pub created_at_unix: u64,
40 pub updated_at_unix: u64,
41}
42
43impl LedgerEntry {
44 pub fn new(
45 commit_sha: impl Into<String>,
46 verdict: Verdict,
47 claim: impl Into<String>,
48 evidence: Vec<String>,
49 reviewer: ReviewerConfig,
50 findings: Vec<String>,
51 ) -> Self {
52 Self::new_at(
53 commit_sha,
54 verdict,
55 claim,
56 evidence,
57 reviewer,
58 findings,
59 unix_now(),
60 )
61 }
62
63 pub fn new_at(
64 commit_sha: impl Into<String>,
65 verdict: Verdict,
66 claim: impl Into<String>,
67 evidence: Vec<String>,
68 reviewer: ReviewerConfig,
69 findings: Vec<String>,
70 timestamp: u64,
71 ) -> Self {
72 let disposition = match verdict {
73 Verdict::Pass => Disposition::Resolved,
74 Verdict::Reject => Disposition::Open,
75 };
76
77 Self {
78 commit_sha: commit_sha.into(),
79 verdict,
80 disposition,
81 claim: claim.into(),
82 evidence,
83 reviewer,
84 summary: String::new(),
85 findings,
86 structured_findings: Vec::new(),
87 next_steps: Vec::new(),
88 raw_reviewer_output: String::new(),
89 resolution: None,
90 created_at_unix: timestamp,
91 updated_at_unix: timestamp,
92 }
93 }
94
95 pub fn with_structured_review(
96 mut self,
97 summary: impl Into<String>,
98 structured_findings: Vec<StructuredFinding>,
99 next_steps: Vec<String>,
100 raw_reviewer_output: impl Into<String>,
101 ) -> Self {
102 self.summary = summary.into();
103 self.structured_findings = structured_findings;
104 self.next_steps = next_steps;
105 self.raw_reviewer_output = raw_reviewer_output.into();
106 self
107 }
108
109 pub fn is_unresolved_rejection(&self) -> bool {
110 self.verdict == Verdict::Reject && self.disposition == Disposition::Open
111 }
112}
113
114#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
115#[serde(rename_all = "UPPERCASE")]
116pub enum Verdict {
117 Pass,
118 Reject,
119}
120
121impl fmt::Display for Verdict {
122 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123 match self {
124 Self::Pass => formatter.write_str("PASS"),
125 Self::Reject => formatter.write_str("REJECT"),
126 }
127 }
128}
129
130#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
131#[serde(rename_all = "kebab-case")]
132pub enum FindingSeverity {
133 Critical,
134 High,
135 Medium,
136 Low,
137}
138
139impl fmt::Display for FindingSeverity {
140 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141 match self {
142 Self::Critical => formatter.write_str("critical"),
143 Self::High => formatter.write_str("high"),
144 Self::Medium => formatter.write_str("medium"),
145 Self::Low => formatter.write_str("low"),
146 }
147 }
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151pub struct StructuredFinding {
152 pub severity: FindingSeverity,
153 pub title: String,
154 pub body: String,
155 pub file: String,
156 pub line_start: u32,
157 pub line_end: u32,
158 pub confidence: u8,
159 pub recommendation: String,
160}
161
162impl StructuredFinding {
163 pub fn display_line(&self) -> String {
164 format!(
165 "{} [{}:{}-{}] {}: {} Recommendation: {} Confidence: {}%",
166 self.severity,
167 self.file,
168 self.line_start,
169 self.line_end,
170 self.title,
171 self.body,
172 self.recommendation,
173 self.confidence
174 )
175 }
176}
177
178#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
179#[serde(rename_all = "kebab-case")]
180pub enum Disposition {
181 Open,
182 Resolved,
183 Waived,
184}
185
186impl fmt::Display for Disposition {
187 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
188 match self {
189 Self::Open => formatter.write_str("open"),
190 Self::Resolved => formatter.write_str("resolved"),
191 Self::Waived => formatter.write_str("waived"),
192 }
193 }
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
197pub struct ReviewerConfig {
198 pub harness: String,
199 pub model: String,
200 pub allow_same_model: bool,
201}
202
203impl ReviewerConfig {
204 pub fn new(
205 harness: impl Into<String>,
206 model: impl Into<String>,
207 allow_same_model: bool,
208 ) -> Self {
209 Self {
210 harness: harness.into(),
211 model: model.into(),
212 allow_same_model,
213 }
214 }
215}
216
217#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
218pub struct Resolution {
219 pub kind: ResolutionKind,
220 pub reason: String,
221 pub resolved_at_unix: u64,
222}
223
224#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
225#[serde(rename_all = "kebab-case")]
226pub enum ResolutionKind {
227 Resolved,
228 Waived,
229}
230
231#[derive(Clone, Debug, Default, Eq, PartialEq)]
232pub struct LedgerStats {
233 pub total: usize,
234 pub pass: usize,
235 pub reject: usize,
236 pub unresolved: usize,
237 pub resolved: usize,
238 pub waived: usize,
239}
240
241#[derive(Clone, Debug)]
242pub struct LedgerStore {
243 root: PathBuf,
244}
245
246impl LedgerStore {
247 pub fn new(root: impl Into<PathBuf>) -> Self {
248 Self { root: root.into() }
249 }
250
251 pub fn machine_path(&self) -> PathBuf {
252 self.root.join(MACHINE_LEDGER_FILE)
253 }
254
255 pub fn markdown_path(&self) -> PathBuf {
256 self.root.join(MARKDOWN_LEDGER_FILE)
257 }
258
259 pub fn append_entry(&self, entry: &LedgerEntry) -> Result<(), LedgerError> {
260 fs::create_dir_all(&self.root)?;
261 let mut file = fs::OpenOptions::new()
262 .create(true)
263 .append(true)
264 .open(self.machine_path())?;
265 serde_json::to_writer(&mut file, entry)?;
266 writeln!(file)?;
267 self.render_markdown_mirror()?;
268 Ok(())
269 }
270
271 pub fn read_history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
272 let path = self.machine_path();
273 let contents = match fs::read_to_string(&path) {
274 Ok(contents) => contents,
275 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
276 Err(error) => return Err(error.into()),
277 };
278
279 contents
280 .lines()
281 .filter(|line| !line.trim().is_empty())
282 .map(|line| serde_json::from_str(line).map_err(LedgerError::from))
283 .collect()
284 }
285
286 pub fn latest_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
287 let mut by_sha = BTreeMap::new();
288 for entry in self.read_history()? {
289 by_sha.insert(entry.commit_sha.clone(), entry);
290 }
291 Ok(by_sha.into_values().collect())
292 }
293
294 pub fn show(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
295 self.latest_entries()?
296 .into_iter()
297 .find(|entry| entry.commit_sha == sha)
298 .ok_or_else(|| LedgerError::NotFound {
299 sha: sha.to_owned(),
300 })
301 }
302
303 pub fn unresolved_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
304 Ok(self
305 .latest_entries()?
306 .into_iter()
307 .filter(LedgerEntry::is_unresolved_rejection)
308 .collect())
309 }
310
311 pub fn resolve(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
312 self.transition_rejection(
313 sha,
314 Disposition::Resolved,
315 ResolutionKind::Resolved,
316 "resolved",
317 )
318 }
319
320 pub fn waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
321 if reason.trim().is_empty() {
322 return Err(LedgerError::EmptyWaiverReason);
323 }
324
325 self.transition_rejection(sha, Disposition::Waived, ResolutionKind::Waived, reason)
326 }
327
328 pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
329 let mut stats = LedgerStats::default();
330 for entry in self.latest_entries()? {
331 stats.total += 1;
332 match entry.verdict {
333 Verdict::Pass => stats.pass += 1,
334 Verdict::Reject => stats.reject += 1,
335 }
336 match entry.disposition {
337 Disposition::Open => {
338 if entry.verdict == Verdict::Reject {
339 stats.unresolved += 1;
340 }
341 }
342 Disposition::Resolved => stats.resolved += 1,
343 Disposition::Waived => stats.waived += 1,
344 }
345 }
346 Ok(stats)
347 }
348
349 pub fn render_markdown_mirror(&self) -> Result<(), LedgerError> {
350 fs::create_dir_all(&self.root)?;
351 let markdown = render_markdown(&self.latest_entries()?, &self.stats()?);
352 fs::write(self.markdown_path(), markdown)?;
353 Ok(())
354 }
355
356 fn transition_rejection(
357 &self,
358 sha: &str,
359 disposition: Disposition,
360 kind: ResolutionKind,
361 reason: &str,
362 ) -> Result<LedgerEntry, LedgerError> {
363 let mut entry = self.show(sha)?;
364 if !entry.is_unresolved_rejection() {
365 return Err(LedgerError::NoOpenRejection {
366 sha: sha.to_owned(),
367 });
368 }
369
370 let timestamp = unix_now();
371 entry.disposition = disposition;
372 entry.resolution = Some(Resolution {
373 kind,
374 reason: reason.trim().to_owned(),
375 resolved_at_unix: timestamp,
376 });
377 entry.updated_at_unix = timestamp;
378 self.append_entry(&entry)?;
379 Ok(entry)
380 }
381}
382
383#[derive(Debug, Error)]
384pub enum LedgerError {
385 #[error("ledger IO failed: {0}")]
386 Io(#[from] io::Error),
387 #[error("ledger JSON failed: {0}")]
388 Json(#[from] serde_json::Error),
389 #[error("ledger entry not found for commit {sha}")]
390 NotFound { sha: String },
391 #[error("commit {sha} has no open rejection")]
392 NoOpenRejection { sha: String },
393 #[error("waive requires a non-empty reason")]
394 EmptyWaiverReason,
395}
396
397pub fn run(args: cli::LedgerArgs, state_dir: &Path) -> Result<ExitCode> {
398 let store = LedgerStore::new(state_dir);
399 match args.command {
400 cli::LedgerCommand::List => {
401 print_unresolved(&store.unresolved_rejections()?);
402 }
403 cli::LedgerCommand::Show { sha } => {
404 let entry = store.show(&sha)?;
405 print_entry(&entry);
406 }
407 cli::LedgerCommand::Resolve { sha } => {
408 let entry = store.resolve(&sha)?;
409 println!("resolved {}", entry.commit_sha);
410 }
411 cli::LedgerCommand::Waive { sha, reason } => {
412 let entry = store.waive(&sha, &reason)?;
413 println!("waived {}", entry.commit_sha);
414 }
415 cli::LedgerCommand::Stats => print_stats(&store.stats()?),
416 }
417
418 Ok(ExitCode::SUCCESS)
419}
420
421fn render_markdown(entries: &[LedgerEntry], stats: &LedgerStats) -> String {
422 let mut output = String::new();
423 output.push_str("# Truth Mirror Ledger\n\n");
424 output.push_str("## Summary\n\n");
425 output.push_str(&format!("- Total: {}\n", stats.total));
426 output.push_str(&format!("- PASS: {}\n", stats.pass));
427 output.push_str(&format!("- REJECT: {}\n", stats.reject));
428 output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
429 output.push_str(&format!("- Resolved: {}\n", stats.resolved));
430 output.push_str(&format!("- Waived: {}\n\n", stats.waived));
431 output.push_str("## Entries\n");
432
433 if entries.is_empty() {
434 output.push_str("\nNo ledger entries.\n");
435 return output;
436 }
437
438 for entry in entries {
439 output.push_str(&format!(
440 "\n### {} - {} - {}\n\n",
441 entry.commit_sha, entry.verdict, entry.disposition
442 ));
443 output.push_str(&format!("- Claim: {}\n", entry.claim));
444 output.push_str(&format!(
445 "- Evidence: {}\n",
446 if entry.evidence.is_empty() {
447 "none".to_owned()
448 } else {
449 entry.evidence.join(", ")
450 }
451 ));
452 output.push_str(&format!(
453 "- Reviewer: {}/{} (allow_same_model={})\n",
454 entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
455 ));
456 if !entry.summary.trim().is_empty() {
457 output.push_str(&format!("- Summary: {}\n", entry.summary));
458 }
459
460 if entry.findings.is_empty() {
461 output.push_str("- Findings: none\n");
462 } else if !entry.structured_findings.is_empty() {
463 output.push_str("- Findings:\n");
464 for finding in &entry.structured_findings {
465 output.push_str(&format!(" - {}\n", finding.display_line()));
466 }
467 } else {
468 output.push_str("- Findings:\n");
469 for finding in &entry.findings {
470 output.push_str(&format!(" - {}\n", finding));
471 }
472 }
473
474 if !entry.next_steps.is_empty() {
475 output.push_str("- Next steps:\n");
476 for step in &entry.next_steps {
477 output.push_str(&format!(" - {}\n", step));
478 }
479 }
480
481 if !entry.raw_reviewer_output.trim().is_empty() {
482 output.push_str("- Raw reviewer output:\n\n```json\n");
483 output.push_str(entry.raw_reviewer_output.trim());
484 output.push_str("\n```\n");
485 }
486
487 if let Some(resolution) = &entry.resolution {
488 output.push_str(&format!(
489 "- Resolution: {:?} - {}\n",
490 resolution.kind, resolution.reason
491 ));
492 }
493 }
494
495 output
496}
497
498fn print_unresolved(entries: &[LedgerEntry]) {
499 if entries.is_empty() {
500 println!("No unresolved rejected commits.");
501 return;
502 }
503
504 for entry in entries {
505 println!(
506 "{} {} {} {}",
507 entry.commit_sha, entry.verdict, entry.disposition, entry.claim
508 );
509 }
510}
511
512fn print_entry(entry: &LedgerEntry) {
513 println!("commit: {}", entry.commit_sha);
514 println!("verdict: {}", entry.verdict);
515 println!("disposition: {}", entry.disposition);
516 println!("claim: {}", entry.claim);
517 println!("evidence: {}", entry.evidence.join(", "));
518 println!(
519 "reviewer: {}/{} allow_same_model={}",
520 entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
521 );
522 if !entry.summary.trim().is_empty() {
523 println!("summary: {}", entry.summary);
524 }
525 if entry.findings.is_empty() {
526 println!("findings: none");
527 } else if !entry.structured_findings.is_empty() {
528 println!("findings:");
529 for finding in &entry.structured_findings {
530 println!("- {}", finding.display_line());
531 }
532 } else {
533 println!("findings:");
534 for finding in &entry.findings {
535 println!("- {finding}");
536 }
537 }
538 if !entry.next_steps.is_empty() {
539 println!("next_steps:");
540 for step in &entry.next_steps {
541 println!("- {step}");
542 }
543 }
544 if !entry.raw_reviewer_output.trim().is_empty() {
545 println!("raw_reviewer_output:");
546 println!("{}", entry.raw_reviewer_output.trim());
547 }
548}
549
550fn print_stats(stats: &LedgerStats) {
551 println!("total={}", stats.total);
552 println!("pass={}", stats.pass);
553 println!("reject={}", stats.reject);
554 println!("unresolved={}", stats.unresolved);
555 println!("resolved={}", stats.resolved);
556 println!("waived={}", stats.waived);
557}
558
559fn unix_now() -> u64 {
560 SystemTime::now()
561 .duration_since(UNIX_EPOCH)
562 .map_or(0, |duration| duration.as_secs())
563}
564
565#[cfg(test)]
566mod tests {
567 use std::fs;
568
569 use proptest::prelude::*;
570
571 use super::{
572 Disposition, FindingSeverity, LedgerEntry, LedgerStore, ResolutionKind, ReviewerConfig,
573 StructuredFinding, Verdict,
574 };
575
576 fn reviewer() -> ReviewerConfig {
577 ReviewerConfig::new("claude", "claude-opus-4-1", false)
578 }
579
580 fn rejected_entry(sha: &str) -> LedgerEntry {
581 LedgerEntry::new_at(
582 sha,
583 Verdict::Reject,
584 "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
585 vec!["tests:cargo-test".to_owned()],
586 reviewer(),
587 vec!["claim was unsupported".to_owned()],
588 100,
589 )
590 }
591
592 fn structured_finding() -> StructuredFinding {
593 StructuredFinding {
594 severity: FindingSeverity::High,
595 title: "missing proof".to_owned(),
596 body: "The evidence pointer does not prove the claim.".to_owned(),
597 file: "src/lib.rs".to_owned(),
598 line_start: 7,
599 line_end: 9,
600 confidence: 95,
601 recommendation: "Add executable evidence before claiming completion.".to_owned(),
602 }
603 }
604
605 #[test]
606 fn append_and_read_latest_entries() {
607 let temp = tempfile::tempdir().unwrap();
608 let store = LedgerStore::new(temp.path());
609 store.append_entry(&rejected_entry("abc123")).unwrap();
610
611 let entries = store.latest_entries().unwrap();
612
613 assert_eq!(entries.len(), 1);
614 assert_eq!(entries[0].commit_sha, "abc123");
615 assert!(entries[0].is_unresolved_rejection());
616 }
617
618 #[test]
619 fn markdown_mirror_renders_summary_and_findings() {
620 let temp = tempfile::tempdir().unwrap();
621 let store = LedgerStore::new(temp.path());
622 store.append_entry(&rejected_entry("abc123")).unwrap();
623
624 let markdown = fs::read_to_string(store.markdown_path()).unwrap();
625
626 assert!(markdown.contains("# Truth Mirror Ledger"));
627 assert!(markdown.contains("Unresolved rejections: 1"));
628 assert!(markdown.contains("claim was unsupported"));
629 }
630
631 #[test]
632 fn markdown_mirror_renders_structured_review_provenance() {
633 let temp = tempfile::tempdir().unwrap();
634 let store = LedgerStore::new(temp.path());
635 let entry = rejected_entry("abc123").with_structured_review(
636 "The claim is not proven.",
637 vec![structured_finding()],
638 vec!["Run cargo test.".to_owned()],
639 r#"{"verdict":"REJECT"}"#,
640 );
641 store.append_entry(&entry).unwrap();
642
643 let markdown = fs::read_to_string(store.markdown_path()).unwrap();
644
645 assert!(markdown.contains("The claim is not proven."));
646 assert!(markdown.contains("high [src/lib.rs:7-9] missing proof"));
647 assert!(markdown.contains("Run cargo test."));
648 assert!(markdown.contains(r#"{"verdict":"REJECT"}"#));
649 }
650
651 #[test]
652 fn resolve_clears_unresolved_rejection() {
653 let temp = tempfile::tempdir().unwrap();
654 let store = LedgerStore::new(temp.path());
655 store.append_entry(&rejected_entry("abc123")).unwrap();
656
657 let resolved = store.resolve("abc123").unwrap();
658
659 assert_eq!(resolved.disposition, Disposition::Resolved);
660 assert_eq!(
661 resolved.resolution.as_ref().unwrap().kind,
662 ResolutionKind::Resolved
663 );
664 assert!(store.unresolved_rejections().unwrap().is_empty());
665 assert_eq!(store.read_history().unwrap().len(), 2);
666 }
667
668 #[test]
669 fn waive_records_reason_and_clears_unresolved_rejection() {
670 let temp = tempfile::tempdir().unwrap();
671 let store = LedgerStore::new(temp.path());
672 store.append_entry(&rejected_entry("abc123")).unwrap();
673
674 let waived = store.waive("abc123", "Ramiro approved exception").unwrap();
675
676 assert_eq!(waived.disposition, Disposition::Waived);
677 assert_eq!(
678 waived.resolution.as_ref().unwrap().reason,
679 "Ramiro approved exception"
680 );
681 assert!(store.unresolved_rejections().unwrap().is_empty());
682 }
683
684 #[test]
685 fn stats_counts_latest_dispositions() {
686 let temp = tempfile::tempdir().unwrap();
687 let store = LedgerStore::new(temp.path());
688 store.append_entry(&rejected_entry("abc123")).unwrap();
689 store
690 .append_entry(&LedgerEntry::new_at(
691 "def456",
692 Verdict::Pass,
693 "CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
694 vec!["tests:cargo-test".to_owned()],
695 reviewer(),
696 Vec::new(),
697 100,
698 ))
699 .unwrap();
700 store.waive("abc123", "accepted risk").unwrap();
701
702 let stats = store.stats().unwrap();
703
704 assert_eq!(stats.total, 2);
705 assert_eq!(stats.pass, 1);
706 assert_eq!(stats.reject, 1);
707 assert_eq!(stats.unresolved, 0);
708 assert_eq!(stats.waived, 1);
709 }
710
711 proptest! {
712 #[test]
713 fn append_read_preserves_unresolved_rejection_semantics(sha in "[a-f0-9]{7,40}") {
714 let temp = tempfile::tempdir().unwrap();
715 let store = LedgerStore::new(temp.path());
716 store.append_entry(&rejected_entry(&sha)).unwrap();
717
718 let entries = store.latest_entries().unwrap();
719 prop_assert_eq!(entries.len(), 1);
720 prop_assert_eq!(entries[0].commit_sha.as_str(), sha.as_str());
721 prop_assert!(entries[0].is_unresolved_rejection());
722 }
723 }
724}