use crate::{Outcome, Verdict, seal::SealSet};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Receipt {
pub schema: String,
pub task: String,
pub model: String,
pub finished_ms: i64,
pub iters: u32,
pub tools_called: u32,
pub input_tokens: u32,
pub output_tokens: u32,
pub passed: bool,
pub checked: bool,
pub reason: String,
pub contract: SealSet,
pub seal_breach: Option<String>,
pub audit_request: Option<String>,
pub digest: String,
}
pub const SCHEMA: &str = "harness.receipt.v1";
pub struct ReceiptBuilder {
task: String,
model: String,
finished_ms: i64,
audit_request: Option<String>,
}
impl ReceiptBuilder {
pub fn new(task: impl Into<String>, model: impl Into<String>, finished_ms: i64) -> Self {
Self {
task: task.into(),
model: model.into(),
finished_ms,
audit_request: None,
}
}
pub fn with_audit_request(mut self, id: impl Into<String>) -> Self {
self.audit_request = Some(id.into());
self
}
pub fn build(self, outcome: &Outcome) -> Receipt {
let (iters, tools_called, usage, verified, contract, breach) = match outcome {
Outcome::Done {
iters,
tools_called,
usage,
verified,
contract,
seal_breach,
..
} => (
*iters,
*tools_called,
usage.clone(),
verified.clone(),
contract.clone(),
seal_breach.clone(),
),
Outcome::BudgetExhausted {
iters,
tools_called,
usage,
..
} => (
*iters,
*tools_called,
usage.clone(),
Some(Verdict::failed("the run hit its budget before finishing")),
SealSet::default(),
None,
),
_ => (
0,
0,
harness_core::Usage::default(),
Some(Verdict::failed("the run did not complete")),
SealSet::default(),
None,
),
};
let checked = verified.is_some();
let passed = verified.as_ref().is_some_and(|v| v.passed) && breach.is_none();
let reason = verified
.as_ref()
.filter(|v| !v.passed)
.map(|v| v.reason.clone())
.unwrap_or_default();
let mut r = Receipt {
schema: SCHEMA.to_string(),
task: self.task,
model: self.model,
finished_ms: self.finished_ms,
iters,
tools_called,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
passed,
checked,
reason,
contract,
seal_breach: breach,
audit_request: self.audit_request,
digest: String::new(),
};
r.digest = r.compute_digest();
r
}
}
impl Receipt {
pub fn compute_digest(&self) -> String {
let mut bare = self.clone();
bare.digest = String::new();
let json = serde_json::to_string(&bare).unwrap_or_default();
let mut h = Sha256::new();
h.update(json.as_bytes());
format!("{:x}", h.finalize())
}
pub fn intact(&self) -> bool {
self.digest == self.compute_digest()
}
pub fn summary(&self) -> String {
if let Some(b) = &self.seal_breach {
return format!("REFUSED — the acceptance contract moved during the run ({b})");
}
match (self.checked, self.passed) {
(false, _) => format!(
"UNCHECKED — the model stopped after {} tool call(s); nothing verified it",
self.tools_called
),
(true, true) if self.contract.entries.is_empty() => {
format!(
"PASSED — checked, nothing sealed, {} iteration(s)",
self.iters
)
}
(true, true) => format!(
"PASSED — checked against {} sealed file(s), which did not move, {} iteration(s)",
self.contract.entries.len(),
self.iters
),
(true, false) => format!("FAILED — {}", self.reason),
}
}
pub fn write_json(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
if let Some(d) = path.as_ref().parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(d)?;
}
std::fs::write(
path,
serde_json::to_string_pretty(self).unwrap_or_default() + "\n",
)
}
pub fn read_json(path: impl AsRef<Path>) -> std::io::Result<Self> {
let s = std::fs::read_to_string(path)?;
serde_json::from_str(&s).map_err(std::io::Error::other)
}
}
#[cfg(test)]
mod tests {
use super::*;
use harness_core::Usage;
fn done(verified: Option<Verdict>, breach: Option<&str>, contract: SealSet) -> Outcome {
Outcome::Done {
text: Some("hi".into()),
iters: 3,
tools_called: 5,
usage: Usage {
input_tokens: 100,
output_tokens: 20,
..Default::default()
},
verified,
contract,
seal_breach: breach.map(String::from),
}
}
fn sealed() -> SealSet {
let mut s = SealSet::default();
s.entries
.insert("contract.txt".into(), Some("abc123".into()));
s
}
#[test]
fn a_pass_is_only_a_pass_when_the_seal_also_held() {
let ok =
ReceiptBuilder::new("t", "m", 1).build(&done(Some(Verdict::passed()), None, sealed()));
assert!(ok.passed && ok.checked);
let bad = ReceiptBuilder::new("t", "m", 1).build(&done(
Some(Verdict::passed()),
Some("contract.txt was modified"),
sealed(),
));
assert!(!bad.passed, "a breached run cannot be a pass");
assert!(bad.summary().starts_with("REFUSED"), "{}", bad.summary());
}
#[test]
fn unchecked_is_not_the_same_as_failed() {
let r = ReceiptBuilder::new("t", "m", 1).build(&done(None, None, SealSet::default()));
assert!(!r.checked);
assert!(!r.passed);
assert!(r.summary().starts_with("UNCHECKED"), "{}", r.summary());
}
#[test]
fn a_failure_carries_the_checks_own_words() {
let r = ReceiptBuilder::new("t", "m", 1).build(&done(
Some(Verdict::failed("answer.txt must contain \"42\"")),
None,
sealed(),
));
assert!(!r.passed && r.checked);
assert!(r.reason.contains("42"));
assert!(r.summary().starts_with("FAILED"));
}
#[test]
fn an_exhausted_budget_receipts_as_a_failure_not_as_silence() {
let o = Outcome::BudgetExhausted {
iters: 9,
last_text: Some("partway".into()),
tools_called: 12,
usage: Usage::default(),
};
let r = ReceiptBuilder::new("t", "m", 1).build(&o);
assert!(!r.passed);
assert!(r.checked, "a budget-out run has a stated reason");
assert!(r.reason.contains("budget"));
}
#[test]
fn editing_a_receipt_breaks_its_digest() {
let mut r = ReceiptBuilder::new("t", "m", 1).build(&done(
Some(Verdict::failed("nope")),
None,
sealed(),
));
assert!(r.intact());
r.passed = true;
r.reason = String::new();
assert!(!r.intact(), "a flipped verdict must not still verify");
}
#[test]
fn a_receipt_round_trips_through_disk_intact() {
let d = std::env::temp_dir().join(format!("harness-receipt-{}", std::process::id()));
let p = d.join("receipt.json");
let r = ReceiptBuilder::new("ship it", "gpt", 1730000000000)
.with_audit_request("req-7")
.build(&done(Some(Verdict::passed()), None, sealed()));
r.write_json(&p).unwrap();
let back = Receipt::read_json(&p).unwrap();
assert_eq!(back, r);
assert!(back.intact());
assert_eq!(back.audit_request.as_deref(), Some("req-7"));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_summary_does_not_claim_a_count_it_does_not_have() {
let sealed_pass =
ReceiptBuilder::new("t", "m", 1).build(&done(Some(Verdict::passed()), None, sealed()));
assert!(
sealed_pass.summary().contains("1 sealed file"),
"{}",
sealed_pass.summary()
);
let unsealed_pass = ReceiptBuilder::new("t", "m", 1).build(&done(
Some(Verdict::passed()),
None,
SealSet::default(),
));
assert!(
unsealed_pass.summary().contains("nothing sealed"),
"{}",
unsealed_pass.summary()
);
}
#[test]
fn the_digest_covers_fields_added_later() {
let a =
ReceiptBuilder::new("t", "m", 1).build(&done(Some(Verdict::passed()), None, sealed()));
let mut b = a.clone();
b.contract
.entries
.insert("extra.txt".into(), Some("deadbeef".into()));
assert_ne!(a.compute_digest(), b.compute_digest());
}
}