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 pub findings: Vec<String>,
30 pub resolution: Option<Resolution>,
31 pub created_at_unix: u64,
32 pub updated_at_unix: u64,
33}
34
35impl LedgerEntry {
36 pub fn new(
37 commit_sha: impl Into<String>,
38 verdict: Verdict,
39 claim: impl Into<String>,
40 evidence: Vec<String>,
41 reviewer: ReviewerConfig,
42 findings: Vec<String>,
43 ) -> Self {
44 Self::new_at(
45 commit_sha,
46 verdict,
47 claim,
48 evidence,
49 reviewer,
50 findings,
51 unix_now(),
52 )
53 }
54
55 pub fn new_at(
56 commit_sha: impl Into<String>,
57 verdict: Verdict,
58 claim: impl Into<String>,
59 evidence: Vec<String>,
60 reviewer: ReviewerConfig,
61 findings: Vec<String>,
62 timestamp: u64,
63 ) -> Self {
64 let disposition = match verdict {
65 Verdict::Pass => Disposition::Resolved,
66 Verdict::Reject => Disposition::Open,
67 };
68
69 Self {
70 commit_sha: commit_sha.into(),
71 verdict,
72 disposition,
73 claim: claim.into(),
74 evidence,
75 reviewer,
76 findings,
77 resolution: None,
78 created_at_unix: timestamp,
79 updated_at_unix: timestamp,
80 }
81 }
82
83 pub fn is_unresolved_rejection(&self) -> bool {
84 self.verdict == Verdict::Reject && self.disposition == Disposition::Open
85 }
86}
87
88#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
89#[serde(rename_all = "UPPERCASE")]
90pub enum Verdict {
91 Pass,
92 Reject,
93}
94
95impl fmt::Display for Verdict {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 match self {
98 Self::Pass => formatter.write_str("PASS"),
99 Self::Reject => formatter.write_str("REJECT"),
100 }
101 }
102}
103
104#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
105#[serde(rename_all = "kebab-case")]
106pub enum Disposition {
107 Open,
108 Resolved,
109 Waived,
110}
111
112impl fmt::Display for Disposition {
113 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114 match self {
115 Self::Open => formatter.write_str("open"),
116 Self::Resolved => formatter.write_str("resolved"),
117 Self::Waived => formatter.write_str("waived"),
118 }
119 }
120}
121
122#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
123pub struct ReviewerConfig {
124 pub harness: String,
125 pub model: String,
126 pub allow_same_model: bool,
127}
128
129impl ReviewerConfig {
130 pub fn new(
131 harness: impl Into<String>,
132 model: impl Into<String>,
133 allow_same_model: bool,
134 ) -> Self {
135 Self {
136 harness: harness.into(),
137 model: model.into(),
138 allow_same_model,
139 }
140 }
141}
142
143#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
144pub struct Resolution {
145 pub kind: ResolutionKind,
146 pub reason: String,
147 pub resolved_at_unix: u64,
148}
149
150#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
151#[serde(rename_all = "kebab-case")]
152pub enum ResolutionKind {
153 Resolved,
154 Waived,
155}
156
157#[derive(Clone, Debug, Default, Eq, PartialEq)]
158pub struct LedgerStats {
159 pub total: usize,
160 pub pass: usize,
161 pub reject: usize,
162 pub unresolved: usize,
163 pub resolved: usize,
164 pub waived: usize,
165}
166
167#[derive(Clone, Debug)]
168pub struct LedgerStore {
169 root: PathBuf,
170}
171
172impl LedgerStore {
173 pub fn new(root: impl Into<PathBuf>) -> Self {
174 Self { root: root.into() }
175 }
176
177 pub fn machine_path(&self) -> PathBuf {
178 self.root.join(MACHINE_LEDGER_FILE)
179 }
180
181 pub fn markdown_path(&self) -> PathBuf {
182 self.root.join(MARKDOWN_LEDGER_FILE)
183 }
184
185 pub fn append_entry(&self, entry: &LedgerEntry) -> Result<(), LedgerError> {
186 fs::create_dir_all(&self.root)?;
187 let mut file = fs::OpenOptions::new()
188 .create(true)
189 .append(true)
190 .open(self.machine_path())?;
191 serde_json::to_writer(&mut file, entry)?;
192 writeln!(file)?;
193 self.render_markdown_mirror()?;
194 Ok(())
195 }
196
197 pub fn read_history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
198 let path = self.machine_path();
199 let contents = match fs::read_to_string(&path) {
200 Ok(contents) => contents,
201 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
202 Err(error) => return Err(error.into()),
203 };
204
205 contents
206 .lines()
207 .filter(|line| !line.trim().is_empty())
208 .map(|line| serde_json::from_str(line).map_err(LedgerError::from))
209 .collect()
210 }
211
212 pub fn latest_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
213 let mut by_sha = BTreeMap::new();
214 for entry in self.read_history()? {
215 by_sha.insert(entry.commit_sha.clone(), entry);
216 }
217 Ok(by_sha.into_values().collect())
218 }
219
220 pub fn show(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
221 self.latest_entries()?
222 .into_iter()
223 .find(|entry| entry.commit_sha == sha)
224 .ok_or_else(|| LedgerError::NotFound {
225 sha: sha.to_owned(),
226 })
227 }
228
229 pub fn unresolved_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
230 Ok(self
231 .latest_entries()?
232 .into_iter()
233 .filter(LedgerEntry::is_unresolved_rejection)
234 .collect())
235 }
236
237 pub fn resolve(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
238 self.transition_rejection(
239 sha,
240 Disposition::Resolved,
241 ResolutionKind::Resolved,
242 "resolved",
243 )
244 }
245
246 pub fn waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
247 if reason.trim().is_empty() {
248 return Err(LedgerError::EmptyWaiverReason);
249 }
250
251 self.transition_rejection(sha, Disposition::Waived, ResolutionKind::Waived, reason)
252 }
253
254 pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
255 let mut stats = LedgerStats::default();
256 for entry in self.latest_entries()? {
257 stats.total += 1;
258 match entry.verdict {
259 Verdict::Pass => stats.pass += 1,
260 Verdict::Reject => stats.reject += 1,
261 }
262 match entry.disposition {
263 Disposition::Open => {
264 if entry.verdict == Verdict::Reject {
265 stats.unresolved += 1;
266 }
267 }
268 Disposition::Resolved => stats.resolved += 1,
269 Disposition::Waived => stats.waived += 1,
270 }
271 }
272 Ok(stats)
273 }
274
275 pub fn render_markdown_mirror(&self) -> Result<(), LedgerError> {
276 fs::create_dir_all(&self.root)?;
277 let markdown = render_markdown(&self.latest_entries()?, &self.stats()?);
278 fs::write(self.markdown_path(), markdown)?;
279 Ok(())
280 }
281
282 fn transition_rejection(
283 &self,
284 sha: &str,
285 disposition: Disposition,
286 kind: ResolutionKind,
287 reason: &str,
288 ) -> Result<LedgerEntry, LedgerError> {
289 let mut entry = self.show(sha)?;
290 if !entry.is_unresolved_rejection() {
291 return Err(LedgerError::NoOpenRejection {
292 sha: sha.to_owned(),
293 });
294 }
295
296 let timestamp = unix_now();
297 entry.disposition = disposition;
298 entry.resolution = Some(Resolution {
299 kind,
300 reason: reason.trim().to_owned(),
301 resolved_at_unix: timestamp,
302 });
303 entry.updated_at_unix = timestamp;
304 self.append_entry(&entry)?;
305 Ok(entry)
306 }
307}
308
309#[derive(Debug, Error)]
310pub enum LedgerError {
311 #[error("ledger IO failed: {0}")]
312 Io(#[from] io::Error),
313 #[error("ledger JSON failed: {0}")]
314 Json(#[from] serde_json::Error),
315 #[error("ledger entry not found for commit {sha}")]
316 NotFound { sha: String },
317 #[error("commit {sha} has no open rejection")]
318 NoOpenRejection { sha: String },
319 #[error("waive requires a non-empty reason")]
320 EmptyWaiverReason,
321}
322
323pub fn run(args: cli::LedgerArgs, state_dir: &Path) -> Result<ExitCode> {
324 let store = LedgerStore::new(state_dir);
325 match args.command {
326 cli::LedgerCommand::List => {
327 print_unresolved(&store.unresolved_rejections()?);
328 }
329 cli::LedgerCommand::Show { sha } => {
330 let entry = store.show(&sha)?;
331 print_entry(&entry);
332 }
333 cli::LedgerCommand::Resolve { sha } => {
334 let entry = store.resolve(&sha)?;
335 println!("resolved {}", entry.commit_sha);
336 }
337 cli::LedgerCommand::Waive { sha, reason } => {
338 let entry = store.waive(&sha, &reason)?;
339 println!("waived {}", entry.commit_sha);
340 }
341 cli::LedgerCommand::Stats => print_stats(&store.stats()?),
342 }
343
344 Ok(ExitCode::SUCCESS)
345}
346
347fn render_markdown(entries: &[LedgerEntry], stats: &LedgerStats) -> String {
348 let mut output = String::new();
349 output.push_str("# Truth Mirror Ledger\n\n");
350 output.push_str("## Summary\n\n");
351 output.push_str(&format!("- Total: {}\n", stats.total));
352 output.push_str(&format!("- PASS: {}\n", stats.pass));
353 output.push_str(&format!("- REJECT: {}\n", stats.reject));
354 output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
355 output.push_str(&format!("- Resolved: {}\n", stats.resolved));
356 output.push_str(&format!("- Waived: {}\n\n", stats.waived));
357 output.push_str("## Entries\n");
358
359 if entries.is_empty() {
360 output.push_str("\nNo ledger entries.\n");
361 return output;
362 }
363
364 for entry in entries {
365 output.push_str(&format!(
366 "\n### {} - {} - {}\n\n",
367 entry.commit_sha, entry.verdict, entry.disposition
368 ));
369 output.push_str(&format!("- Claim: {}\n", entry.claim));
370 output.push_str(&format!(
371 "- Evidence: {}\n",
372 if entry.evidence.is_empty() {
373 "none".to_owned()
374 } else {
375 entry.evidence.join(", ")
376 }
377 ));
378 output.push_str(&format!(
379 "- Reviewer: {}/{} (allow_same_model={})\n",
380 entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
381 ));
382
383 if entry.findings.is_empty() {
384 output.push_str("- Findings: none\n");
385 } else {
386 output.push_str("- Findings:\n");
387 for finding in &entry.findings {
388 output.push_str(&format!(" - {}\n", finding));
389 }
390 }
391
392 if let Some(resolution) = &entry.resolution {
393 output.push_str(&format!(
394 "- Resolution: {:?} - {}\n",
395 resolution.kind, resolution.reason
396 ));
397 }
398 }
399
400 output
401}
402
403fn print_unresolved(entries: &[LedgerEntry]) {
404 if entries.is_empty() {
405 println!("No unresolved rejected commits.");
406 return;
407 }
408
409 for entry in entries {
410 println!(
411 "{} {} {} {}",
412 entry.commit_sha, entry.verdict, entry.disposition, entry.claim
413 );
414 }
415}
416
417fn print_entry(entry: &LedgerEntry) {
418 println!("commit: {}", entry.commit_sha);
419 println!("verdict: {}", entry.verdict);
420 println!("disposition: {}", entry.disposition);
421 println!("claim: {}", entry.claim);
422 println!("evidence: {}", entry.evidence.join(", "));
423 println!(
424 "reviewer: {}/{} allow_same_model={}",
425 entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
426 );
427 if entry.findings.is_empty() {
428 println!("findings: none");
429 } else {
430 println!("findings:");
431 for finding in &entry.findings {
432 println!("- {finding}");
433 }
434 }
435}
436
437fn print_stats(stats: &LedgerStats) {
438 println!("total={}", stats.total);
439 println!("pass={}", stats.pass);
440 println!("reject={}", stats.reject);
441 println!("unresolved={}", stats.unresolved);
442 println!("resolved={}", stats.resolved);
443 println!("waived={}", stats.waived);
444}
445
446fn unix_now() -> u64 {
447 SystemTime::now()
448 .duration_since(UNIX_EPOCH)
449 .map_or(0, |duration| duration.as_secs())
450}
451
452#[cfg(test)]
453mod tests {
454 use std::fs;
455
456 use proptest::prelude::*;
457
458 use super::{Disposition, LedgerEntry, LedgerStore, ResolutionKind, ReviewerConfig, Verdict};
459
460 fn reviewer() -> ReviewerConfig {
461 ReviewerConfig::new("claude", "claude-opus-4-1", false)
462 }
463
464 fn rejected_entry(sha: &str) -> LedgerEntry {
465 LedgerEntry::new_at(
466 sha,
467 Verdict::Reject,
468 "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
469 vec!["tests:cargo-test".to_owned()],
470 reviewer(),
471 vec!["claim was unsupported".to_owned()],
472 100,
473 )
474 }
475
476 #[test]
477 fn append_and_read_latest_entries() {
478 let temp = tempfile::tempdir().unwrap();
479 let store = LedgerStore::new(temp.path());
480 store.append_entry(&rejected_entry("abc123")).unwrap();
481
482 let entries = store.latest_entries().unwrap();
483
484 assert_eq!(entries.len(), 1);
485 assert_eq!(entries[0].commit_sha, "abc123");
486 assert!(entries[0].is_unresolved_rejection());
487 }
488
489 #[test]
490 fn markdown_mirror_renders_summary_and_findings() {
491 let temp = tempfile::tempdir().unwrap();
492 let store = LedgerStore::new(temp.path());
493 store.append_entry(&rejected_entry("abc123")).unwrap();
494
495 let markdown = fs::read_to_string(store.markdown_path()).unwrap();
496
497 assert!(markdown.contains("# Truth Mirror Ledger"));
498 assert!(markdown.contains("Unresolved rejections: 1"));
499 assert!(markdown.contains("claim was unsupported"));
500 }
501
502 #[test]
503 fn resolve_clears_unresolved_rejection() {
504 let temp = tempfile::tempdir().unwrap();
505 let store = LedgerStore::new(temp.path());
506 store.append_entry(&rejected_entry("abc123")).unwrap();
507
508 let resolved = store.resolve("abc123").unwrap();
509
510 assert_eq!(resolved.disposition, Disposition::Resolved);
511 assert_eq!(
512 resolved.resolution.as_ref().unwrap().kind,
513 ResolutionKind::Resolved
514 );
515 assert!(store.unresolved_rejections().unwrap().is_empty());
516 assert_eq!(store.read_history().unwrap().len(), 2);
517 }
518
519 #[test]
520 fn waive_records_reason_and_clears_unresolved_rejection() {
521 let temp = tempfile::tempdir().unwrap();
522 let store = LedgerStore::new(temp.path());
523 store.append_entry(&rejected_entry("abc123")).unwrap();
524
525 let waived = store.waive("abc123", "Ramiro approved exception").unwrap();
526
527 assert_eq!(waived.disposition, Disposition::Waived);
528 assert_eq!(
529 waived.resolution.as_ref().unwrap().reason,
530 "Ramiro approved exception"
531 );
532 assert!(store.unresolved_rejections().unwrap().is_empty());
533 }
534
535 #[test]
536 fn stats_counts_latest_dispositions() {
537 let temp = tempfile::tempdir().unwrap();
538 let store = LedgerStore::new(temp.path());
539 store.append_entry(&rejected_entry("abc123")).unwrap();
540 store
541 .append_entry(&LedgerEntry::new_at(
542 "def456",
543 Verdict::Pass,
544 "CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
545 vec!["tests:cargo-test".to_owned()],
546 reviewer(),
547 Vec::new(),
548 100,
549 ))
550 .unwrap();
551 store.waive("abc123", "accepted risk").unwrap();
552
553 let stats = store.stats().unwrap();
554
555 assert_eq!(stats.total, 2);
556 assert_eq!(stats.pass, 1);
557 assert_eq!(stats.reject, 1);
558 assert_eq!(stats.unresolved, 0);
559 assert_eq!(stats.waived, 1);
560 }
561
562 proptest! {
563 #[test]
564 fn append_read_preserves_unresolved_rejection_semantics(sha in "[a-f0-9]{7,40}") {
565 let temp = tempfile::tempdir().unwrap();
566 let store = LedgerStore::new(temp.path());
567 store.append_entry(&rejected_entry(&sha)).unwrap();
568
569 let entries = store.latest_entries().unwrap();
570 prop_assert_eq!(entries.len(), 1);
571 prop_assert_eq!(entries[0].commit_sha.as_str(), sha.as_str());
572 prop_assert!(entries[0].is_unresolved_rejection());
573 }
574 }
575}