use std::{
collections::BTreeMap,
fmt, fs,
io::{self, Write},
path::{Path, PathBuf},
process::ExitCode,
};
use anyhow::Result;
use serde::{Deserialize, Deserializer, Serialize, de};
use thiserror::Error;
use crate::{cli, config::MemorySkillCandidateKind, time::unix_now};
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,
#[serde(default)]
pub summary: String,
pub findings: Vec<String>,
#[serde(default)]
pub structured_findings: Vec<StructuredFinding>,
#[serde(default)]
pub next_steps: Vec<String>,
#[serde(default)]
pub memory_skill_classification: MemorySkillClassification,
#[serde(default)]
pub raw_reviewer_output: String,
pub resolution: Option<Resolution>,
#[serde(default)]
pub petition_attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub petition_for: Option<String>,
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,
Verdict::Flag => Disposition::Open,
};
Self {
commit_sha: commit_sha.into(),
verdict,
disposition,
claim: claim.into(),
evidence,
reviewer,
summary: String::new(),
findings,
structured_findings: Vec::new(),
next_steps: Vec::new(),
memory_skill_classification: MemorySkillClassification::default(),
raw_reviewer_output: String::new(),
resolution: None,
petition_attempts: 0,
petition_for: None,
created_at_unix: timestamp,
updated_at_unix: timestamp,
}
}
pub fn with_structured_review(
mut self,
summary: impl Into<String>,
structured_findings: Vec<StructuredFinding>,
next_steps: Vec<String>,
raw_reviewer_output: impl Into<String>,
) -> Self {
self.summary = summary.into();
self.structured_findings = structured_findings;
self.next_steps = next_steps;
self.raw_reviewer_output = raw_reviewer_output.into();
self
}
pub fn with_memory_skill_classification(
mut self,
classification: MemorySkillClassification,
) -> Self {
self.memory_skill_classification = classification;
self
}
pub fn is_unresolved_rejection(&self) -> bool {
self.verdict == Verdict::Reject && self.disposition == Disposition::Open
}
pub fn is_open_flag(&self) -> bool {
self.verdict == Verdict::Flag && self.disposition == Disposition::Open
}
pub fn is_needs_human(&self) -> bool {
self.disposition == Disposition::NeedsHuman
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Verdict {
Pass,
Reject,
Flag,
}
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"),
Self::Flag => formatter.write_str("FLAG"),
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FindingSeverity {
Critical,
High,
Medium,
Low,
}
impl fmt::Display for FindingSeverity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Critical => formatter.write_str("critical"),
Self::High => formatter.write_str("high"),
Self::Medium => formatter.write_str("medium"),
Self::Low => formatter.write_str("low"),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct StructuredFinding {
pub severity: FindingSeverity,
pub title: String,
pub body: String,
pub file: String,
pub line_start: u32,
pub line_end: u32,
#[serde(deserialize_with = "deserialize_confidence")]
pub confidence: u8,
pub recommendation: String,
}
impl StructuredFinding {
pub fn display_line(&self) -> String {
format!(
"{} [{}:{}-{}] {}: {} Recommendation: {} Confidence: {}%",
self.severity,
self.file,
self.line_start,
self.line_end,
self.title,
self.body,
self.recommendation,
self.confidence
)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct MemorySkillClassification {
pub kind: MemorySkillClassificationKind,
pub learning_source: String,
pub reasoning: String,
}
impl Default for MemorySkillClassification {
fn default() -> Self {
Self {
kind: MemorySkillClassificationKind::None,
learning_source: String::new(),
reasoning: String::new(),
}
}
}
impl MemorySkillClassification {
pub fn candidate_kind(&self) -> Option<MemorySkillCandidateKind> {
match self.kind {
MemorySkillClassificationKind::None => None,
MemorySkillClassificationKind::HowToSkill => Some(MemorySkillCandidateKind::HowToSkill),
MemorySkillClassificationKind::AntiPatternSkill => {
Some(MemorySkillCandidateKind::AntiPatternSkill)
}
MemorySkillClassificationKind::RemediationSkill => {
Some(MemorySkillCandidateKind::RemediationSkill)
}
}
}
pub fn validate_for_verdict(&self, verdict: Verdict) -> Result<(), String> {
if self.reasoning.trim().is_empty() {
return Err("memory_skill.reasoning must not be empty".to_owned());
}
if self.kind != MemorySkillClassificationKind::None
&& self.learning_source.trim().is_empty()
{
return Err(
"memory_skill.learning_source must not be empty when kind is not none".to_owned(),
);
}
match (verdict, self.kind) {
(Verdict::Pass, MemorySkillClassificationKind::AntiPatternSkill)
| (Verdict::Pass, MemorySkillClassificationKind::RemediationSkill) => Err(
"PASS verdict cannot propose anti_pattern_skill or remediation_skill".to_owned(),
),
(Verdict::Reject, MemorySkillClassificationKind::HowToSkill) => {
Err("REJECT verdict cannot propose how_to_skill".to_owned())
}
(Verdict::Flag, _) => Ok(()),
_ => Ok(()),
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MemorySkillClassificationKind {
None,
HowToSkill,
AntiPatternSkill,
RemediationSkill,
}
impl std::fmt::Display for MemorySkillClassificationKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
Self::None => "none",
Self::HowToSkill => "how_to_skill",
Self::AntiPatternSkill => "anti_pattern_skill",
Self::RemediationSkill => "remediation_skill",
};
formatter.write_str(value)
}
}
fn deserialize_confidence<'de, D>(deserializer: D) -> Result<u8, D::Error>
where
D: Deserializer<'de>,
{
struct ConfidenceVisitor;
impl<'de> de::Visitor<'de> for ConfidenceVisitor {
type Value = u8;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("an integer percent 0..=100 or a normalized float 0.0..=1.0")
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(clamp_percent(value))
}
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(u64::try_from(value).map_or(0, clamp_percent))
}
fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(confidence_from_f64(value))
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(value.trim().parse::<f64>().map_or(0, confidence_from_f64))
}
}
deserializer.deserialize_any(ConfidenceVisitor)
}
fn clamp_percent(value: u64) -> u8 {
value.min(100) as u8
}
fn confidence_from_f64(value: f64) -> u8 {
if value.is_nan() {
return 0;
}
let percent = if (0.0..=1.0).contains(&value) {
value * 100.0
} else {
value
};
if percent <= 0.0 {
0
} else if percent >= 100.0 {
100
} else {
percent.round() as u8
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Disposition {
Open,
Resolved,
Waived,
NeedsHuman,
}
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"),
Self::NeedsHuman => formatter.write_str("needs-human"),
}
}
}
#[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,
NeedsHuman,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct LedgerStats {
pub total: usize,
pub pass: usize,
pub reject: usize,
pub flag: usize,
pub unresolved: usize,
pub resolved: usize,
pub waived: usize,
pub needs_human: usize,
pub petition_attempts: u32,
}
#[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<String, LedgerEntry> = BTreeMap::new();
for entry in self.read_history()? {
match by_sha.get(&entry.commit_sha) {
Some(current) if current.petition_for.is_none() && entry.petition_for.is_some() => {
}
_ => {
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 blocking_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
Ok(self
.latest_entries()?
.into_iter()
.filter(|entry| entry.is_unresolved_rejection() || entry.is_needs_human())
.collect())
}
pub fn flagged_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
let mut flagged: Vec<LedgerEntry> = self
.latest_entries()?
.into_iter()
.filter(LedgerEntry::is_open_flag)
.collect();
let mut petition_flags: BTreeMap<(String, String), LedgerEntry> = BTreeMap::new();
for entry in self.read_history()? {
if let Some(original) = entry.petition_for.clone() {
petition_flags.insert((entry.commit_sha.clone(), original), entry);
}
}
let already: std::collections::BTreeSet<(String, Option<String>)> = flagged
.iter()
.map(|entry| (entry.commit_sha.clone(), entry.petition_for.clone()))
.collect();
for entry in petition_flags.into_values() {
if entry.is_open_flag()
&& !already.contains(&(entry.commit_sha.clone(), entry.petition_for.clone()))
{
flagged.push(entry);
}
}
Ok(flagged)
}
pub fn history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
self.read_history()
}
pub fn petition_attempts_for(&self, sha: &str) -> Result<u32, LedgerError> {
let mut max_attempts = 0u32;
for entry in self.read_history()? {
if entry.commit_sha != sha || entry.petition_for.is_some() {
continue;
}
max_attempts = max_attempts.max(entry.petition_attempts);
}
Ok(max_attempts)
}
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_waive(sha, reason)
}
pub fn escalate_to_needs_human(
&self,
sha: &str,
reason: &str,
) -> Result<LedgerEntry, LedgerError> {
let attempts = self.petition_attempts_for(sha)?;
self.append_petition_transition(
sha,
Disposition::NeedsHuman,
ResolutionKind::NeedsHuman,
reason,
attempts,
)
}
pub fn escalate_to_needs_human_with_attempts(
&self,
sha: &str,
reason: &str,
attempts: u32,
) -> Result<LedgerEntry, LedgerError> {
self.append_petition_transition(
sha,
Disposition::NeedsHuman,
ResolutionKind::NeedsHuman,
reason,
attempts,
)
}
pub fn append_petition_transition(
&self,
sha: &str,
disposition: Disposition,
kind: ResolutionKind,
reason: &str,
attempts: u32,
) -> Result<LedgerEntry, LedgerError> {
let mut entry = self.show(sha)?;
if !entry.is_unresolved_rejection() && !entry.is_needs_human() {
return Err(LedgerError::NoOpenRejection {
sha: sha.to_owned(),
});
}
let timestamp = unix_now();
entry.disposition = disposition;
entry.resolution = if disposition == Disposition::Open {
None
} else {
Some(Resolution {
kind,
reason: reason.trim().to_owned(),
resolved_at_unix: timestamp,
})
};
entry.petition_attempts = attempts;
entry.updated_at_unix = timestamp;
self.append_entry(&entry)?;
Ok(entry)
}
pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
let mut stats = LedgerStats::default();
let mut max_attempts_by_sha = BTreeMap::<String, u32>::new();
for entry in self.read_history()? {
if entry.petition_for.is_some() {
continue;
}
let slot = max_attempts_by_sha
.entry(entry.commit_sha.clone())
.or_insert(0);
if entry.petition_attempts > *slot {
*slot = entry.petition_attempts;
}
}
for entry in self.latest_entries()? {
stats.total += 1;
match entry.verdict {
Verdict::Pass => stats.pass += 1,
Verdict::Reject => stats.reject += 1,
Verdict::Flag => stats.flag += 1,
}
match entry.disposition {
Disposition::Open => {
if entry.verdict == Verdict::Reject {
stats.unresolved += 1;
}
}
Disposition::Resolved => stats.resolved += 1,
Disposition::Waived => stats.waived += 1,
Disposition::NeedsHuman => stats.needs_human += 1,
}
}
stats.petition_attempts = max_attempts_by_sha.values().copied().sum();
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)
}
fn transition_waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
let mut entry = self.show(sha)?;
if !entry.is_unresolved_rejection() && !entry.is_needs_human() {
return Err(LedgerError::NoOpenRejection {
sha: sha.to_owned(),
});
}
let timestamp = unix_now();
entry.disposition = Disposition::Waived;
entry.resolution = Some(Resolution {
kind: ResolutionKind::Waived,
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.blocking_rejections()?);
}
cli::LedgerCommand::Show { sha } => {
let entry = store.show(&sha)?;
print_entry(&entry);
}
cli::LedgerCommand::History { sha } => {
let entries = store
.history()?
.into_iter()
.filter(|entry| {
entry.commit_sha == sha || entry.petition_for.as_deref() == Some(sha.as_str())
})
.collect::<Vec<_>>();
print_history(&entries);
}
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!("- FLAG: {}\n", stats.flag));
output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
output.push_str(&format!("- Resolved: {}\n", stats.resolved));
output.push_str(&format!("- Waived: {}\n", stats.waived));
output.push_str(&format!(
"- Needs-human (escalated): {}\n",
stats.needs_human
));
output.push_str(&format!(
"- Petition attempts (cumulative): {}\n\n",
stats.petition_attempts
));
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.summary.trim().is_empty() {
output.push_str(&format!("- Summary: {}\n", entry.summary));
}
if entry.findings.is_empty() {
output.push_str("- Findings: none\n");
} else if !entry.structured_findings.is_empty() {
output.push_str("- Findings:\n");
for finding in &entry.structured_findings {
output.push_str(&format!(" - {}\n", finding.display_line()));
}
} else {
output.push_str("- Findings:\n");
for finding in &entry.findings {
output.push_str(&format!(" - {}\n", finding));
}
}
if !entry.next_steps.is_empty() {
output.push_str("- Next steps:\n");
for step in &entry.next_steps {
output.push_str(&format!(" - {}\n", step));
}
}
if !entry
.memory_skill_classification
.reasoning
.trim()
.is_empty()
{
let classification = &entry.memory_skill_classification;
output.push_str(&format!("- Memory skill: {}", classification.kind));
if !classification.learning_source.trim().is_empty() {
output.push_str(&format!(" - {}", classification.learning_source));
}
output.push_str(&format!(" - {}\n", classification.reasoning));
}
if !entry.raw_reviewer_output.trim().is_empty() {
output.push_str("- Raw reviewer output:\n\n```json\n");
output.push_str(entry.raw_reviewer_output.trim());
output.push_str("\n```\n");
}
if let Some(resolution) = &entry.resolution {
if entry.disposition == Disposition::Open {
output.push_str(&format!(
"- Petition event (rejection still open): {:?} - {}\n",
resolution.kind, resolution.reason
));
} else {
output.push_str(&format!(
"- Resolution: {:?} - {}\n",
resolution.kind, resolution.reason
));
}
}
if entry.petition_attempts > 0 || entry.petition_for.is_some() {
output.push_str(&format!(
"- Petition attempts: {}\n",
entry.petition_attempts
));
}
if let Some(original) = &entry.petition_for {
output.push_str(&format!("- Petition for: {original}\n"));
}
}
output
}
fn print_unresolved(entries: &[LedgerEntry]) {
if entries.is_empty() {
println!("No blocking commits (no open rejections, no needs-human escalations).");
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.summary.trim().is_empty() {
println!("summary: {}", entry.summary);
}
if entry.findings.is_empty() {
println!("findings: none");
} else if !entry.structured_findings.is_empty() {
println!("findings:");
for finding in &entry.structured_findings {
println!("- {}", finding.display_line());
}
} else {
println!("findings:");
for finding in &entry.findings {
println!("- {finding}");
}
}
if !entry.next_steps.is_empty() {
println!("next_steps:");
for step in &entry.next_steps {
println!("- {step}");
}
}
if !entry
.memory_skill_classification
.reasoning
.trim()
.is_empty()
{
let classification = &entry.memory_skill_classification;
println!("memory_skill_kind: {}", classification.kind);
if !classification.learning_source.trim().is_empty() {
println!(
"memory_skill_learning_source: {}",
classification.learning_source
);
}
println!("memory_skill_reasoning: {}", classification.reasoning);
}
if !entry.raw_reviewer_output.trim().is_empty() {
println!("raw_reviewer_output:");
println!("{}", entry.raw_reviewer_output.trim());
}
if entry.petition_attempts > 0 || entry.petition_for.is_some() {
println!("petition_attempts: {}", entry.petition_attempts);
}
if let Some(original) = &entry.petition_for {
println!("petition_for: {original}");
}
}
fn print_history(entries: &[LedgerEntry]) {
if entries.is_empty() {
println!("No ledger entries for that commit.");
return;
}
println!("audit trail ({} entries):", entries.len());
for entry in entries {
let resolution = entry
.resolution
.as_ref()
.map(|resolution| format!("{:?}", resolution.kind))
.unwrap_or_else(|| "-".to_owned());
println!(
" {} {} {} {} (attempts={}, petition_for={:?}, resolution={})",
entry.updated_at_unix,
entry.commit_sha,
entry.verdict,
entry.disposition,
entry.petition_attempts,
entry.petition_for,
resolution
);
}
}
fn print_stats(stats: &LedgerStats) {
println!("total={}", stats.total);
println!("pass={}", stats.pass);
println!("reject={}", stats.reject);
println!("flag={}", stats.flag);
println!("unresolved={}", stats.unresolved);
println!("resolved={}", stats.resolved);
println!("waived={}", stats.waived);
println!("needs_human={}", stats.needs_human);
println!("petition_attempts={}", stats.petition_attempts);
}
#[cfg(test)]
mod tests {
use std::fs;
use proptest::prelude::*;
use super::{
Disposition, FindingSeverity, LedgerEntry, LedgerError, LedgerStore,
MemorySkillClassification, MemorySkillClassificationKind, ResolutionKind, ReviewerConfig,
StructuredFinding, 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,
)
}
fn structured_finding() -> StructuredFinding {
StructuredFinding {
severity: FindingSeverity::High,
title: "missing proof".to_owned(),
body: "The evidence pointer does not prove the claim.".to_owned(),
file: "src/lib.rs".to_owned(),
line_start: 7,
line_end: 9,
confidence: 95,
recommendation: "Add executable evidence before claiming completion.".to_owned(),
}
}
#[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 markdown_mirror_renders_structured_review_provenance() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
let entry = rejected_entry("abc123")
.with_structured_review(
"The claim is not proven.",
vec![structured_finding()],
vec!["Run cargo test.".to_owned()],
r#"{"verdict":"REJECT"}"#,
)
.with_memory_skill_classification(MemorySkillClassification {
kind: MemorySkillClassificationKind::AntiPatternSkill,
learning_source: "missing proof".to_owned(),
reasoning: "The reviewer proposed an anti-pattern candidate.".to_owned(),
});
store.append_entry(&entry).unwrap();
let markdown = fs::read_to_string(store.markdown_path()).unwrap();
assert!(markdown.contains("The claim is not proven."));
assert!(markdown.contains("high [src/lib.rs:7-9] missing proof"));
assert!(markdown.contains("Run cargo test."));
assert!(markdown.contains("Memory skill: anti_pattern_skill - missing proof"));
assert!(markdown.contains(r#"{"verdict":"REJECT"}"#));
}
#[test]
fn old_ledger_entry_without_memory_skill_classification_reads_as_none() {
let entry: LedgerEntry = serde_json::from_value(serde_json::json!({
"commit_sha": "abc123",
"verdict": "REJECT",
"disposition": "open",
"claim": "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
"evidence": ["tests:cargo-test"],
"reviewer": {
"harness": "claude",
"model": "claude-opus-4-1",
"allow_same_model": false
},
"summary": "The claim is not proven.",
"findings": ["missing proof"],
"structured_findings": [],
"next_steps": [],
"raw_reviewer_output": "{}",
"resolution": null,
"created_at_unix": 100,
"updated_at_unix": 100
}))
.unwrap();
assert_eq!(
entry.memory_skill_classification.kind,
MemorySkillClassificationKind::None
);
}
#[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);
}
fn flagged_entry(sha: &str) -> LedgerEntry {
LedgerEntry::new_at(
sha,
Verdict::Flag,
"CLAIM: holds | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
vec!["evidence is thin".to_owned()],
100,
)
.with_structured_review(
"Claim substantiated, evidence is thin.",
vec![StructuredFinding {
severity: FindingSeverity::Medium,
title: "thin evidence".to_owned(),
body: "Claim holds but the only evidence is a passing test.".to_owned(),
file: "src/lib.rs".to_owned(),
line_start: 1,
line_end: 1,
confidence: 70,
recommendation: "Provide stronger evidence.".to_owned(),
}],
vec![],
r#"{"verdict":"FLAG"}"#,
)
}
#[test]
fn flag_is_recorded_as_open_flag_and_not_unresolved_rejection() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&flagged_entry("flag123")).unwrap();
let latest = store.show("flag123").unwrap();
assert_eq!(latest.verdict, Verdict::Flag);
assert_eq!(latest.disposition, Disposition::Open);
assert!(latest.is_open_flag());
assert!(!latest.is_unresolved_rejection());
assert!(store.unresolved_rejections().unwrap().is_empty());
assert!(store.blocking_rejections().unwrap().is_empty());
let flagged = store.flagged_entries().unwrap();
assert_eq!(flagged.len(), 1);
assert_eq!(flagged[0].commit_sha, "flag123");
}
#[test]
fn stats_separates_flag_from_unresolved() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("rej123")).unwrap();
store.append_entry(&flagged_entry("flag123")).unwrap();
let stats = store.stats().unwrap();
assert_eq!(stats.total, 2);
assert_eq!(stats.reject, 1);
assert_eq!(stats.flag, 1);
assert_eq!(stats.unresolved, 1);
}
#[test]
fn old_ledger_entry_without_petition_attempts_reads_as_zero() {
let entry: LedgerEntry = serde_json::from_value(serde_json::json!({
"commit_sha": "abc123",
"verdict": "REJECT",
"disposition": "open",
"claim": "CLAIM: legacy | verified: cargo test | evidence: tests:cargo-test",
"evidence": ["tests:cargo-test"],
"reviewer": {
"harness": "claude",
"model": "claude-opus-4-1",
"allow_same_model": false
},
"summary": "The claim is not proven.",
"findings": ["missing proof"],
"structured_findings": [],
"next_steps": [],
"raw_reviewer_output": "{}",
"resolution": null,
"created_at_unix": 100,
"updated_at_unix": 100
}))
.unwrap();
assert_eq!(entry.petition_attempts, 0);
assert!(entry.petition_for.is_none());
}
#[test]
fn petition_attempts_increment_via_append_petition_transition() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("abc123")).unwrap();
let first = store
.append_petition_transition(
"abc123",
Disposition::Open,
ResolutionKind::Resolved, "first petition attempt recorded",
1,
)
.unwrap();
assert_eq!(first.petition_attempts, 1);
assert_eq!(first.disposition, Disposition::Open);
assert_eq!(store.petition_attempts_for("abc123").unwrap(), 1);
let second = store
.append_petition_transition(
"abc123",
Disposition::Open,
ResolutionKind::Resolved,
"second petition attempt recorded",
2,
)
.unwrap();
assert_eq!(second.petition_attempts, 2);
assert_eq!(store.petition_attempts_for("abc123").unwrap(), 2);
}
#[test]
fn append_petition_transition_refuses_when_no_open_rejection() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("abc123")).unwrap();
store.resolve("abc123").unwrap();
let error = store
.append_petition_transition(
"abc123",
Disposition::Open,
ResolutionKind::Resolved,
"stale",
1,
)
.unwrap_err();
assert!(matches!(error, LedgerError::NoOpenRejection { .. }));
}
#[test]
fn blocking_rejections_includes_open_rejects_and_needs_human_only() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("rej1")).unwrap();
store.append_entry(&flagged_entry("flag1")).unwrap();
store.append_entry(&rejected_entry("rej2")).unwrap();
store.escalate_to_needs_human("rej2", "exhausted").unwrap();
store.resolve("rej1").unwrap();
let blocking = store.blocking_rejections().unwrap();
assert_eq!(blocking.len(), 1);
assert_eq!(blocking[0].commit_sha, "rej2");
assert_eq!(blocking[0].disposition, Disposition::NeedsHuman);
let flagged = store.flagged_entries().unwrap();
assert_eq!(flagged.len(), 1);
assert_eq!(flagged[0].commit_sha, "flag1");
}
#[test]
fn escalate_to_needs_human_uses_needs_human_disposition() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("abc123")).unwrap();
let escalated = store
.escalate_to_needs_human("abc123", "third petition attempt failed")
.unwrap();
assert_eq!(escalated.disposition, Disposition::NeedsHuman);
assert_eq!(
escalated.resolution.as_ref().unwrap().kind,
ResolutionKind::NeedsHuman
);
assert_eq!(
escalated.resolution.as_ref().unwrap().reason,
"third petition attempt failed"
);
assert!(escalated.is_needs_human());
}
#[test]
fn resolve_after_needs_human_is_refused() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("abc123")).unwrap();
store
.escalate_to_needs_human("abc123", "exhausted")
.unwrap();
let error = store.resolve("abc123").unwrap_err();
assert!(matches!(error, LedgerError::NoOpenRejection { .. }));
}
#[test]
fn ledger_round_trips_petition_for_marker() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
let mut entry = LedgerEntry::new_at(
"def456",
Verdict::Pass,
"CLAIM: resolves abc123 | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
Vec::new(),
100,
);
entry.petition_for = Some("abc123".to_owned());
entry.petition_attempts = 1;
store.append_entry(&entry).unwrap();
let history = store.history().unwrap();
let recovered = history
.iter()
.find(|item| item.commit_sha == "def456")
.expect("entry should be persisted");
assert_eq!(recovered.petition_for.as_deref(), Some("abc123"));
assert_eq!(recovered.petition_attempts, 1);
}
#[test]
fn ledger_round_trips_disposition_needs_human() {
let entry: LedgerEntry = serde_json::from_value(serde_json::json!({
"commit_sha": "abc123",
"verdict": "REJECT",
"disposition": "needs-human",
"claim": "CLAIM: x | verified: cargo test | evidence: tests:x",
"evidence": ["tests:x"],
"reviewer": {
"harness": "claude",
"model": "opus",
"allow_same_model": false
},
"summary": "x",
"findings": [],
"structured_findings": [],
"next_steps": [],
"raw_reviewer_output": "{}",
"resolution": {
"kind": "needs-human",
"reason": "third petition attempt failed",
"resolved_at_unix": 200
},
"petition_attempts": 2,
"created_at_unix": 100,
"updated_at_unix": 200
}))
.unwrap();
assert_eq!(entry.disposition, Disposition::NeedsHuman);
assert_eq!(
entry.resolution.as_ref().unwrap().kind,
ResolutionKind::NeedsHuman
);
assert_eq!(entry.petition_attempts, 2);
assert!(entry.is_needs_human());
}
#[test]
fn markdown_mirror_renders_flag_summary_and_petition_attempts() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&flagged_entry("flag1")).unwrap();
store.append_entry(&rejected_entry("rej1")).unwrap();
store
.append_petition_transition(
"rej1",
Disposition::Open,
ResolutionKind::Resolved,
"first petition attempt recorded",
1,
)
.unwrap();
let markdown = fs::read_to_string(store.markdown_path()).unwrap();
assert!(markdown.contains("FLAG: 1"));
assert!(markdown.contains("Petition attempts (cumulative): 1"));
assert!(markdown.contains("Petition attempts: 1"));
}
fn confidence_of(confidence: serde_json::Value) -> u8 {
let value = serde_json::json!({
"severity": "high",
"title": "missing proof",
"body": "The cited evidence does not prove the claim.",
"file": "src/lib.rs",
"line_start": 1,
"line_end": 1,
"confidence": confidence,
"recommendation": "Provide executable evidence.",
});
serde_json::from_value::<StructuredFinding>(value)
.expect("a finding must never fail to deserialize over confidence formatting")
.confidence
}
#[test]
fn confidence_accepts_codex_normalized_floats() {
assert_eq!(confidence_of(serde_json::json!(0.95)), 95);
assert_eq!(confidence_of(serde_json::json!(0.9)), 90);
assert_eq!(confidence_of(serde_json::json!(0.86)), 86);
}
#[test]
fn confidence_accepts_integer_percent() {
assert_eq!(confidence_of(serde_json::json!(86)), 86);
assert_eq!(confidence_of(serde_json::json!(0)), 0);
assert_eq!(confidence_of(serde_json::json!(100)), 100);
}
#[test]
fn confidence_normalizes_or_clamps_but_never_drops_the_verdict() {
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); }
proptest! {
#[test]
fn confidence_deserialization_is_total_over_all_finite_floats(value in -1000.0f64..1000.0) {
let confidence = confidence_of(serde_json::json!(value));
prop_assert!(confidence <= 100);
}
#[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());
}
}
#[test]
fn open_petition_transition_writes_no_resolution() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("abc123")).unwrap();
let entry = store
.append_petition_transition(
"abc123",
Disposition::Open,
ResolutionKind::Resolved,
"petition review enqueued: fix=def456, attempts=1/2",
1,
)
.unwrap();
assert_eq!(entry.disposition, Disposition::Open);
assert!(entry.resolution.is_none());
assert_eq!(entry.petition_attempts, 1);
assert!(entry.is_unresolved_rejection());
let resolved = store
.append_petition_transition(
"abc123",
Disposition::Resolved,
ResolutionKind::Resolved,
"petition accepted",
1,
)
.unwrap();
assert_eq!(
resolved.resolution.as_ref().unwrap().kind,
ResolutionKind::Resolved
);
}
#[test]
fn stats_petition_attempts_skip_petition_review_entries() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("orig123")).unwrap();
store
.append_petition_transition(
"orig123",
Disposition::Open,
ResolutionKind::Resolved,
"petition review enqueued: fix=fix456, attempts=1/2",
1,
)
.unwrap();
let mut review = LedgerEntry::new_at(
"fix456",
Verdict::Pass,
"CLAIM: fix | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
Vec::new(),
200,
);
review.petition_for = Some("orig123".to_owned());
review.petition_attempts = 1;
store.append_entry(&review).unwrap();
let stats = store.stats().unwrap();
assert_eq!(stats.petition_attempts, 1);
}
#[test]
fn is_open_flag_requires_open_disposition() {
let mut entry = LedgerEntry::new_at(
"flag1",
Verdict::Flag,
"CLAIM: holds | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
vec!["thin evidence".to_owned()],
100,
);
assert!(entry.is_open_flag());
entry.disposition = Disposition::Resolved;
assert!(!entry.is_open_flag());
entry.disposition = Disposition::Waived;
assert!(!entry.is_open_flag());
}
#[test]
fn flag_verdict_accepts_every_memory_skill_kind() {
for kind in [
MemorySkillClassificationKind::None,
MemorySkillClassificationKind::HowToSkill,
MemorySkillClassificationKind::AntiPatternSkill,
MemorySkillClassificationKind::RemediationSkill,
] {
let classification = MemorySkillClassification {
kind,
learning_source: "some reusable pattern".to_owned(),
reasoning: "flag carries debt context".to_owned(),
};
assert!(
classification.validate_for_verdict(Verdict::Flag).is_ok(),
"FLAG should accept {kind:?}"
);
}
}
#[test]
fn petition_review_entries_do_not_shadow_the_fix_commits_own_state() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("fix456")).unwrap();
let mut petition_review = LedgerEntry::new_at(
"fix456",
Verdict::Pass,
"CLAIM: fix | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
Vec::new(),
200,
);
petition_review.petition_for = Some("orig123".to_owned());
petition_review.petition_attempts = 1;
store.append_entry(&petition_review).unwrap();
let latest = store.show("fix456").unwrap();
assert_eq!(latest.verdict, Verdict::Reject);
assert!(latest.petition_for.is_none());
assert_eq!(store.blocking_rejections().unwrap().len(), 1);
}
#[test]
fn petition_flag_debt_survives_latest_entry_shadow_protection() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store
.append_entry(&LedgerEntry::new_at(
"fix456",
Verdict::Pass,
"CLAIM: fix | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
Vec::new(),
100,
))
.unwrap();
let mut petition_flag = LedgerEntry::new_at(
"fix456",
Verdict::Flag,
"CLAIM: fix | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
vec!["evidence could be tighter".to_owned()],
200,
);
petition_flag.petition_for = Some("orig123".to_owned());
petition_flag.petition_attempts = 1;
store.append_entry(&petition_flag).unwrap();
assert_eq!(store.show("fix456").unwrap().verdict, Verdict::Pass);
let flagged = store.flagged_entries().unwrap();
assert_eq!(flagged.len(), 1);
assert_eq!(flagged[0].petition_for.as_deref(), Some("orig123"));
}
#[test]
fn petition_attempts_ignore_petition_review_rows_under_the_same_sha() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&rejected_entry("fix456")).unwrap();
let mut petition_review = LedgerEntry::new_at(
"fix456",
Verdict::Reject,
"CLAIM: fix | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
reviewer(),
vec!["still unaddressed".to_owned()],
200,
);
petition_review.petition_for = Some("orig123".to_owned());
petition_review.petition_attempts = 2;
store.append_entry(&petition_review).unwrap();
assert_eq!(store.petition_attempts_for("fix456").unwrap(), 0);
}
}