Skip to main content

harness_loop/
receipt.rs

1//! What the run can show for itself.
2//!
3//! A finished run leaves a diff and a transcript. Neither says *this was
4//! checked, against this contract, and it held* — so the reviewer of a large
5//! change is back to reading everything, which is the job delegating the work
6//! was supposed to remove. The transcript is not a substitute: it is long, it
7//! is the agent's own account, and nothing in it distinguishes a check that
8//! passed from a check that was never run.
9//!
10//! A [`Receipt`] is the short answer. One JSON object per run: what was asked,
11//! which model answered, what the acceptance contract was, what the verdict
12//! was, and whether the contract survived the run. Small enough to attach to a
13//! pull request, and structured enough to fail a build on.
14//!
15//! **What the digest is for.** [`Receipt::digest`] is a hash of the receipt's
16//! own content. It tells you two receipts are identical, and it catches a file
17//! that was edited by hand or truncated in transit. It is *not* a signature:
18//! anyone who can rewrite the receipt can recompute it. If you need the trail
19//! itself to be tamper-evident, chain it —
20//! `harness_hooks::audit::HashChainSink` already does that, and
21//! [`Receipt::audit_request`] is where you put the id that points at it.
22
23use crate::{Outcome, Verdict, seal::SealSet};
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26use std::path::Path;
27
28/// The one-page account of a run.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Receipt {
31    /// Schema marker, so a reader can refuse a shape it does not know rather
32    /// than silently misread one.
33    pub schema: String,
34    /// What was asked.
35    pub task: String,
36    /// Model handle that answered, as the provider names it.
37    pub model: String,
38    /// Wall-clock, milliseconds since the epoch. Supplied by the caller — this
39    /// crate does not read the clock, so a receipt is reproducible in tests.
40    pub finished_ms: i64,
41    pub iters: u32,
42    pub tools_called: u32,
43    pub input_tokens: u32,
44    pub output_tokens: u32,
45    /// `true` only when a check was asked *and* agreed. `false` covers both
46    /// "checked and refused" and "nobody looked" — [`Self::checked`]
47    /// distinguishes them, and the distinction matters more than the flag.
48    pub passed: bool,
49    /// Whether any acceptance check ran at all.
50    pub checked: bool,
51    /// Why it failed, verbatim from the check. Empty on a pass.
52    pub reason: String,
53    /// The sealed contract, path → digest, as it stood before the first turn.
54    pub contract: SealSet,
55    /// Set when a sealed file moved during the run. A receipt carrying this is
56    /// evidence of tampering, not of work.
57    pub seal_breach: Option<String>,
58    /// The `audit.request` id, when the host runs an audit trail. Follow it to
59    /// the full record; the receipt is the summary, not the evidence itself.
60    pub audit_request: Option<String>,
61    /// Hash over every field above. See the module docs for what it proves.
62    pub digest: String,
63}
64
65/// Current [`Receipt::schema`].
66pub const SCHEMA: &str = "harness.receipt.v1";
67
68/// Assembles a [`Receipt`] from a finished run plus the things the loop does
69/// not know: the clock, the model's name, and the audit id.
70pub 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    /// Build from the outcome.
93    ///
94    /// Outcomes other than `Done` are receipted too, and as failures: a run
95    /// that exhausted its budget produced no verified result, and a receipt
96    /// that quietly omitted it would let "no receipt" and "a bad receipt" look
97    /// the same to whatever is reading them.
98    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    /// Hash of every field but `digest` itself.
171    ///
172    /// Over the serialised form with the field cleared, rather than over a
173    /// hand-written concatenation: a concatenation drifts the moment a field is
174    /// added and starts silently covering less than it claims to.
175    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    /// Whether the receipt still matches its own digest.
185    pub fn intact(&self) -> bool {
186        self.digest == self.compute_digest()
187    }
188
189    /// One line, for a build log or a PR comment.
190    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        // Same verdict, breached contract. The check agreed; the receipt must
265        // not, because by then it was measuring something else.
266        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        // The distinction the whole artifact exists for: "nobody looked" must
278        // never render as a failure OR as a pass.
279        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        // The edit someone would actually make.
320        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        // It said "1 check(s) held" whatever had run. A receipt that rounds
343        // its own evidence is the thing this module exists to replace.
344        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        // Guards the reason `compute_digest` serialises rather than
367        // concatenating: a new field must change the hash without anyone
368        // remembering to add it to a list.
369        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}