1use crate::{Outcome, Verdict, seal::SealSet};
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26use std::path::Path;
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Receipt {
31 pub schema: String,
34 pub task: String,
36 pub model: String,
38 pub finished_ms: i64,
41 pub iters: u32,
42 pub tools_called: u32,
43 pub input_tokens: u32,
44 pub output_tokens: u32,
45 pub passed: bool,
49 pub checked: bool,
51 pub reason: String,
53 pub contract: SealSet,
55 pub seal_breach: Option<String>,
58 pub audit_request: Option<String>,
61 pub digest: String,
63}
64
65pub const SCHEMA: &str = "harness.receipt.v1";
67
68pub struct ReceiptBuilder {
71 task: String,
72 model: String,
73 finished_ms: i64,
74 audit_request: Option<String>,
75}
76
77impl ReceiptBuilder {
78 pub fn new(task: impl Into<String>, model: impl Into<String>, finished_ms: i64) -> Self {
79 Self {
80 task: task.into(),
81 model: model.into(),
82 finished_ms,
83 audit_request: None,
84 }
85 }
86
87 pub fn with_audit_request(mut self, id: impl Into<String>) -> Self {
88 self.audit_request = Some(id.into());
89 self
90 }
91
92 pub fn build(self, outcome: &Outcome) -> Receipt {
99 let (iters, tools_called, usage, verified, contract, breach) = match outcome {
100 Outcome::Done {
101 iters,
102 tools_called,
103 usage,
104 verified,
105 contract,
106 seal_breach,
107 ..
108 } => (
109 *iters,
110 *tools_called,
111 usage.clone(),
112 verified.clone(),
113 contract.clone(),
114 seal_breach.clone(),
115 ),
116 Outcome::BudgetExhausted {
117 iters,
118 tools_called,
119 usage,
120 ..
121 } => (
122 *iters,
123 *tools_called,
124 usage.clone(),
125 Some(Verdict::failed("the run hit its budget before finishing")),
126 SealSet::default(),
127 None,
128 ),
129 _ => (
130 0,
131 0,
132 harness_core::Usage::default(),
133 Some(Verdict::failed("the run did not complete")),
134 SealSet::default(),
135 None,
136 ),
137 };
138
139 let checked = verified.is_some();
140 let passed = verified.as_ref().is_some_and(|v| v.passed) && breach.is_none();
141 let reason = verified
142 .as_ref()
143 .filter(|v| !v.passed)
144 .map(|v| v.reason.clone())
145 .unwrap_or_default();
146
147 let mut r = Receipt {
148 schema: SCHEMA.to_string(),
149 task: self.task,
150 model: self.model,
151 finished_ms: self.finished_ms,
152 iters,
153 tools_called,
154 input_tokens: usage.input_tokens,
155 output_tokens: usage.output_tokens,
156 passed,
157 checked,
158 reason,
159 contract,
160 seal_breach: breach,
161 audit_request: self.audit_request,
162 digest: String::new(),
163 };
164 r.digest = r.compute_digest();
165 r
166 }
167}
168
169impl Receipt {
170 pub fn compute_digest(&self) -> String {
176 let mut bare = self.clone();
177 bare.digest = String::new();
178 let json = serde_json::to_string(&bare).unwrap_or_default();
179 let mut h = Sha256::new();
180 h.update(json.as_bytes());
181 format!("{:x}", h.finalize())
182 }
183
184 pub fn intact(&self) -> bool {
186 self.digest == self.compute_digest()
187 }
188
189 pub fn summary(&self) -> String {
191 if let Some(b) = &self.seal_breach {
192 return format!("REFUSED — the acceptance contract moved during the run ({b})");
193 }
194 match (self.checked, self.passed) {
195 (false, _) => format!(
196 "UNCHECKED — the model stopped after {} tool call(s); nothing verified it",
197 self.tools_called
198 ),
199 (true, true) if self.contract.entries.is_empty() => {
200 format!(
201 "PASSED — checked, nothing sealed, {} iteration(s)",
202 self.iters
203 )
204 }
205 (true, true) => format!(
206 "PASSED — checked against {} sealed file(s), which did not move, {} iteration(s)",
207 self.contract.entries.len(),
208 self.iters
209 ),
210 (true, false) => format!("FAILED — {}", self.reason),
211 }
212 }
213
214 pub fn write_json(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
215 if let Some(d) = path.as_ref().parent().filter(|p| !p.as_os_str().is_empty()) {
216 std::fs::create_dir_all(d)?;
217 }
218 std::fs::write(
219 path,
220 serde_json::to_string_pretty(self).unwrap_or_default() + "\n",
221 )
222 }
223
224 pub fn read_json(path: impl AsRef<Path>) -> std::io::Result<Self> {
225 let s = std::fs::read_to_string(path)?;
226 serde_json::from_str(&s).map_err(std::io::Error::other)
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use harness_core::Usage;
234
235 fn done(verified: Option<Verdict>, breach: Option<&str>, contract: SealSet) -> Outcome {
236 Outcome::Done {
237 text: Some("hi".into()),
238 iters: 3,
239 tools_called: 5,
240 usage: Usage {
241 input_tokens: 100,
242 output_tokens: 20,
243 ..Default::default()
244 },
245 verified,
246 contract,
247 seal_breach: breach.map(String::from),
248 }
249 }
250
251 fn sealed() -> SealSet {
252 let mut s = SealSet::default();
253 s.entries
254 .insert("contract.txt".into(), Some("abc123".into()));
255 s
256 }
257
258 #[test]
259 fn a_pass_is_only_a_pass_when_the_seal_also_held() {
260 let ok =
261 ReceiptBuilder::new("t", "m", 1).build(&done(Some(Verdict::passed()), None, sealed()));
262 assert!(ok.passed && ok.checked);
263
264 let bad = ReceiptBuilder::new("t", "m", 1).build(&done(
267 Some(Verdict::passed()),
268 Some("contract.txt was modified"),
269 sealed(),
270 ));
271 assert!(!bad.passed, "a breached run cannot be a pass");
272 assert!(bad.summary().starts_with("REFUSED"), "{}", bad.summary());
273 }
274
275 #[test]
276 fn unchecked_is_not_the_same_as_failed() {
277 let r = ReceiptBuilder::new("t", "m", 1).build(&done(None, None, SealSet::default()));
280 assert!(!r.checked);
281 assert!(!r.passed);
282 assert!(r.summary().starts_with("UNCHECKED"), "{}", r.summary());
283 }
284
285 #[test]
286 fn a_failure_carries_the_checks_own_words() {
287 let r = ReceiptBuilder::new("t", "m", 1).build(&done(
288 Some(Verdict::failed("answer.txt must contain \"42\"")),
289 None,
290 sealed(),
291 ));
292 assert!(!r.passed && r.checked);
293 assert!(r.reason.contains("42"));
294 assert!(r.summary().starts_with("FAILED"));
295 }
296
297 #[test]
298 fn an_exhausted_budget_receipts_as_a_failure_not_as_silence() {
299 let o = Outcome::BudgetExhausted {
300 iters: 9,
301 last_text: Some("partway".into()),
302 tools_called: 12,
303 usage: Usage::default(),
304 };
305 let r = ReceiptBuilder::new("t", "m", 1).build(&o);
306 assert!(!r.passed);
307 assert!(r.checked, "a budget-out run has a stated reason");
308 assert!(r.reason.contains("budget"));
309 }
310
311 #[test]
312 fn editing_a_receipt_breaks_its_digest() {
313 let mut r = ReceiptBuilder::new("t", "m", 1).build(&done(
314 Some(Verdict::failed("nope")),
315 None,
316 sealed(),
317 ));
318 assert!(r.intact());
319 r.passed = true;
321 r.reason = String::new();
322 assert!(!r.intact(), "a flipped verdict must not still verify");
323 }
324
325 #[test]
326 fn a_receipt_round_trips_through_disk_intact() {
327 let d = std::env::temp_dir().join(format!("harness-receipt-{}", std::process::id()));
328 let p = d.join("receipt.json");
329 let r = ReceiptBuilder::new("ship it", "gpt", 1730000000000)
330 .with_audit_request("req-7")
331 .build(&done(Some(Verdict::passed()), None, sealed()));
332 r.write_json(&p).unwrap();
333 let back = Receipt::read_json(&p).unwrap();
334 assert_eq!(back, r);
335 assert!(back.intact());
336 assert_eq!(back.audit_request.as_deref(), Some("req-7"));
337 let _ = std::fs::remove_dir_all(&d);
338 }
339
340 #[test]
341 fn the_summary_does_not_claim_a_count_it_does_not_have() {
342 let sealed_pass =
345 ReceiptBuilder::new("t", "m", 1).build(&done(Some(Verdict::passed()), None, sealed()));
346 assert!(
347 sealed_pass.summary().contains("1 sealed file"),
348 "{}",
349 sealed_pass.summary()
350 );
351
352 let unsealed_pass = ReceiptBuilder::new("t", "m", 1).build(&done(
353 Some(Verdict::passed()),
354 None,
355 SealSet::default(),
356 ));
357 assert!(
358 unsealed_pass.summary().contains("nothing sealed"),
359 "{}",
360 unsealed_pass.summary()
361 );
362 }
363
364 #[test]
365 fn the_digest_covers_fields_added_later() {
366 let a =
370 ReceiptBuilder::new("t", "m", 1).build(&done(Some(Verdict::passed()), None, sealed()));
371 let mut b = a.clone();
372 b.contract
373 .entries
374 .insert("extra.txt".into(), Some("deadbeef".into()));
375 assert_ne!(a.compute_digest(), b.compute_digest());
376 }
377}