use std::{
collections::BTreeMap,
fmt, fs,
io::{self, Write},
path::{Path, PathBuf},
process::ExitCode,
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::cli;
pub const MACHINE_LEDGER_FILE: &str = "ledger.jsonl";
pub const MARKDOWN_LEDGER_FILE: &str = "ledger.md";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct LedgerEntry {
pub commit_sha: String,
pub verdict: Verdict,
pub disposition: Disposition,
pub claim: String,
pub evidence: Vec<String>,
pub reviewer: ReviewerConfig,
pub findings: Vec<String>,
pub resolution: Option<Resolution>,
pub created_at_unix: u64,
pub updated_at_unix: u64,
}
impl LedgerEntry {
pub fn new(
commit_sha: impl Into<String>,
verdict: Verdict,
claim: impl Into<String>,
evidence: Vec<String>,
reviewer: ReviewerConfig,
findings: Vec<String>,
) -> Self {
Self::new_at(
commit_sha,
verdict,
claim,
evidence,
reviewer,
findings,
unix_now(),
)
}
pub fn new_at(
commit_sha: impl Into<String>,
verdict: Verdict,
claim: impl Into<String>,
evidence: Vec<String>,
reviewer: ReviewerConfig,
findings: Vec<String>,
timestamp: u64,
) -> Self {
let disposition = match verdict {
Verdict::Pass => Disposition::Resolved,
Verdict::Reject => Disposition::Open,
};
Self {
commit_sha: commit_sha.into(),
verdict,
disposition,
claim: claim.into(),
evidence,
reviewer,
findings,
resolution: None,
created_at_unix: timestamp,
updated_at_unix: timestamp,
}
}
pub fn is_unresolved_rejection(&self) -> bool {
self.verdict == Verdict::Reject && self.disposition == Disposition::Open
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Verdict {
Pass,
Reject,
}
impl fmt::Display for Verdict {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Pass => formatter.write_str("PASS"),
Self::Reject => formatter.write_str("REJECT"),
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Disposition {
Open,
Resolved,
Waived,
}
impl fmt::Display for Disposition {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Open => formatter.write_str("open"),
Self::Resolved => formatter.write_str("resolved"),
Self::Waived => formatter.write_str("waived"),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewerConfig {
pub harness: String,
pub model: String,
pub allow_same_model: bool,
}
impl ReviewerConfig {
pub fn new(
harness: impl Into<String>,
model: impl Into<String>,
allow_same_model: bool,
) -> Self {
Self {
harness: harness.into(),
model: model.into(),
allow_same_model,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Resolution {
pub kind: ResolutionKind,
pub reason: String,
pub resolved_at_unix: u64,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ResolutionKind {
Resolved,
Waived,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct LedgerStats {
pub total: usize,
pub pass: usize,
pub reject: usize,
pub unresolved: usize,
pub resolved: usize,
pub waived: usize,
}
#[derive(Clone, Debug)]
pub struct LedgerStore {
root: PathBuf,
}
impl LedgerStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn machine_path(&self) -> PathBuf {
self.root.join(MACHINE_LEDGER_FILE)
}
pub fn markdown_path(&self) -> PathBuf {
self.root.join(MARKDOWN_LEDGER_FILE)
}
pub fn append_entry(&self, entry: &LedgerEntry) -> Result<(), LedgerError> {
fs::create_dir_all(&self.root)?;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.machine_path())?;
serde_json::to_writer(&mut file, entry)?;
writeln!(file)?;
self.render_markdown_mirror()?;
Ok(())
}
pub fn read_history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
let path = self.machine_path();
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
contents
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).map_err(LedgerError::from))
.collect()
}
pub fn latest_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
let mut by_sha = BTreeMap::new();
for entry in self.read_history()? {
by_sha.insert(entry.commit_sha.clone(), entry);
}
Ok(by_sha.into_values().collect())
}
pub fn show(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
self.latest_entries()?
.into_iter()
.find(|entry| entry.commit_sha == sha)
.ok_or_else(|| LedgerError::NotFound {
sha: sha.to_owned(),
})
}
pub fn unresolved_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
Ok(self
.latest_entries()?
.into_iter()
.filter(LedgerEntry::is_unresolved_rejection)
.collect())
}
pub fn resolve(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
self.transition_rejection(
sha,
Disposition::Resolved,
ResolutionKind::Resolved,
"resolved",
)
}
pub fn waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
if reason.trim().is_empty() {
return Err(LedgerError::EmptyWaiverReason);
}
self.transition_rejection(sha, Disposition::Waived, ResolutionKind::Waived, reason)
}
pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
let mut stats = LedgerStats::default();
for entry in self.latest_entries()? {
stats.total += 1;
match entry.verdict {
Verdict::Pass => stats.pass += 1,
Verdict::Reject => stats.reject += 1,
}
match entry.disposition {
Disposition::Open => {
if entry.verdict == Verdict::Reject {
stats.unresolved += 1;
}
}
Disposition::Resolved => stats.resolved += 1,
Disposition::Waived => stats.waived += 1,
}
}
Ok(stats)
}
pub fn render_markdown_mirror(&self) -> Result<(), LedgerError> {
fs::create_dir_all(&self.root)?;
let markdown = render_markdown(&self.latest_entries()?, &self.stats()?);
fs::write(self.markdown_path(), markdown)?;
Ok(())
}
fn transition_rejection(
&self,
sha: &str,
disposition: Disposition,
kind: ResolutionKind,
reason: &str,
) -> Result<LedgerEntry, LedgerError> {
let mut entry = self.show(sha)?;
if !entry.is_unresolved_rejection() {
return Err(LedgerError::NoOpenRejection {
sha: sha.to_owned(),
});
}
let timestamp = unix_now();
entry.disposition = disposition;
entry.resolution = Some(Resolution {
kind,
reason: reason.trim().to_owned(),
resolved_at_unix: timestamp,
});
entry.updated_at_unix = timestamp;
self.append_entry(&entry)?;
Ok(entry)
}
}
#[derive(Debug, Error)]
pub enum LedgerError {
#[error("ledger IO failed: {0}")]
Io(#[from] io::Error),
#[error("ledger JSON failed: {0}")]
Json(#[from] serde_json::Error),
#[error("ledger entry not found for commit {sha}")]
NotFound { sha: String },
#[error("commit {sha} has no open rejection")]
NoOpenRejection { sha: String },
#[error("waive requires a non-empty reason")]
EmptyWaiverReason,
}
pub fn run(args: cli::LedgerArgs, state_dir: &Path) -> Result<ExitCode> {
let store = LedgerStore::new(state_dir);
match args.command {
cli::LedgerCommand::List => {
print_unresolved(&store.unresolved_rejections()?);
}
cli::LedgerCommand::Show { sha } => {
let entry = store.show(&sha)?;
print_entry(&entry);
}
cli::LedgerCommand::Resolve { sha } => {
let entry = store.resolve(&sha)?;
println!("resolved {}", entry.commit_sha);
}
cli::LedgerCommand::Waive { sha, reason } => {
let entry = store.waive(&sha, &reason)?;
println!("waived {}", entry.commit_sha);
}
cli::LedgerCommand::Stats => print_stats(&store.stats()?),
}
Ok(ExitCode::SUCCESS)
}
fn render_markdown(entries: &[LedgerEntry], stats: &LedgerStats) -> String {
let mut output = String::new();
output.push_str("# Truth Mirror Ledger\n\n");
output.push_str("## Summary\n\n");
output.push_str(&format!("- Total: {}\n", stats.total));
output.push_str(&format!("- PASS: {}\n", stats.pass));
output.push_str(&format!("- REJECT: {}\n", stats.reject));
output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
output.push_str(&format!("- Resolved: {}\n", stats.resolved));
output.push_str(&format!("- Waived: {}\n\n", stats.waived));
output.push_str("## Entries\n");
if entries.is_empty() {
output.push_str("\nNo ledger entries.\n");
return output;
}
for entry in entries {
output.push_str(&format!(
"\n### {} - {} - {}\n\n",
entry.commit_sha, entry.verdict, entry.disposition
));
output.push_str(&format!("- Claim: {}\n", entry.claim));
output.push_str(&format!(
"- Evidence: {}\n",
if entry.evidence.is_empty() {
"none".to_owned()
} else {
entry.evidence.join(", ")
}
));
output.push_str(&format!(
"- Reviewer: {}/{} (allow_same_model={})\n",
entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
));
if entry.findings.is_empty() {
output.push_str("- Findings: none\n");
} else {
output.push_str("- Findings:\n");
for finding in &entry.findings {
output.push_str(&format!(" - {}\n", finding));
}
}
if let Some(resolution) = &entry.resolution {
output.push_str(&format!(
"- Resolution: {:?} - {}\n",
resolution.kind, resolution.reason
));
}
}
output
}
fn print_unresolved(entries: &[LedgerEntry]) {
if entries.is_empty() {
println!("No unresolved rejected commits.");
return;
}
for entry in entries {
println!(
"{} {} {} {}",
entry.commit_sha, entry.verdict, entry.disposition, entry.claim
);
}
}
fn print_entry(entry: &LedgerEntry) {
println!("commit: {}", entry.commit_sha);
println!("verdict: {}", entry.verdict);
println!("disposition: {}", entry.disposition);
println!("claim: {}", entry.claim);
println!("evidence: {}", entry.evidence.join(", "));
println!(
"reviewer: {}/{} allow_same_model={}",
entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
);
if entry.findings.is_empty() {
println!("findings: none");
} else {
println!("findings:");
for finding in &entry.findings {
println!("- {finding}");
}
}
}
fn print_stats(stats: &LedgerStats) {
println!("total={}", stats.total);
println!("pass={}", stats.pass);
println!("reject={}", stats.reject);
println!("unresolved={}", stats.unresolved);
println!("resolved={}", stats.resolved);
println!("waived={}", stats.waived);
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs())
}
#[cfg(test)]
mod tests {
use std::fs;
use proptest::prelude::*;
use super::{Disposition, LedgerEntry, LedgerStore, ResolutionKind, ReviewerConfig, Verdict};
fn reviewer() -> ReviewerConfig {
ReviewerConfig::new("claude", "claude-opus-4-1", false)
}
fn rejected_entry(sha: &str) -> LedgerEntry {
LedgerEntry::new_at(
sha,
Verdict::Reject,
"CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
vec!["claim was unsupported".to_owned()],
100,
)
}
#[test]
fn append_and_read_latest_entries() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("abc123")).unwrap();
let entries = store.latest_entries().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].commit_sha, "abc123");
assert!(entries[0].is_unresolved_rejection());
}
#[test]
fn markdown_mirror_renders_summary_and_findings() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("abc123")).unwrap();
let markdown = fs::read_to_string(store.markdown_path()).unwrap();
assert!(markdown.contains("# Truth Mirror Ledger"));
assert!(markdown.contains("Unresolved rejections: 1"));
assert!(markdown.contains("claim was unsupported"));
}
#[test]
fn resolve_clears_unresolved_rejection() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("abc123")).unwrap();
let resolved = store.resolve("abc123").unwrap();
assert_eq!(resolved.disposition, Disposition::Resolved);
assert_eq!(
resolved.resolution.as_ref().unwrap().kind,
ResolutionKind::Resolved
);
assert!(store.unresolved_rejections().unwrap().is_empty());
assert_eq!(store.read_history().unwrap().len(), 2);
}
#[test]
fn waive_records_reason_and_clears_unresolved_rejection() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("abc123")).unwrap();
let waived = store.waive("abc123", "Ramiro approved exception").unwrap();
assert_eq!(waived.disposition, Disposition::Waived);
assert_eq!(
waived.resolution.as_ref().unwrap().reason,
"Ramiro approved exception"
);
assert!(store.unresolved_rejections().unwrap().is_empty());
}
#[test]
fn stats_counts_latest_dispositions() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("abc123")).unwrap();
store
.append_entry(&LedgerEntry::new_at(
"def456",
Verdict::Pass,
"CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
Vec::new(),
100,
))
.unwrap();
store.waive("abc123", "accepted risk").unwrap();
let stats = store.stats().unwrap();
assert_eq!(stats.total, 2);
assert_eq!(stats.pass, 1);
assert_eq!(stats.reject, 1);
assert_eq!(stats.unresolved, 0);
assert_eq!(stats.waived, 1);
}
proptest! {
#[test]
fn append_read_preserves_unresolved_rejection_semantics(sha in "[a-f0-9]{7,40}") {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry(&sha)).unwrap();
let entries = store.latest_entries().unwrap();
prop_assert_eq!(entries.len(), 1);
prop_assert_eq!(entries[0].commit_sha.as_str(), sha.as_str());
prop_assert!(entries[0].is_unresolved_rejection());
}
}
}