Skip to main content

harn_vm/receipts/
mod.rs

1use std::collections::BTreeMap;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::Value as JsonValue;
6use time::OffsetDateTime;
7
8pub const RECEIPT_SCHEMA_ID: &str = "https://harnlang.com/schemas/receipt.v1.json";
9pub const RECEIPT_SCHEMA_VERSION: &str = "harn.receipt.v1";
10pub const RECEIPT_SCHEMA_JSON: &str = include_str!("receipt.v1.json");
11
12#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
13#[serde(default)]
14pub struct Receipt {
15    pub schema: String,
16    pub id: String,
17    pub parent_run_id: Option<String>,
18    pub persona: String,
19    pub step: Option<String>,
20    pub trace_id: String,
21    #[serde(with = "time::serde::rfc3339")]
22    pub started_at: OffsetDateTime,
23    #[serde(with = "time::serde::rfc3339::option")]
24    pub completed_at: Option<OffsetDateTime>,
25    pub status: ReceiptStatus,
26    pub inputs_digest: Option<String>,
27    pub outputs_digest: Option<String>,
28    pub model_calls: Vec<BTreeMap<String, JsonValue>>,
29    pub tool_calls: Vec<BTreeMap<String, JsonValue>>,
30    pub cost_usd: f64,
31    pub approvals: Vec<BTreeMap<String, JsonValue>>,
32    pub handoffs: Vec<BTreeMap<String, JsonValue>>,
33    pub side_effects: Vec<BTreeMap<String, JsonValue>>,
34    pub error: Option<BTreeMap<String, JsonValue>>,
35    pub redaction_class: RedactionClass,
36    pub metadata: BTreeMap<String, JsonValue>,
37}
38
39impl Default for Receipt {
40    fn default() -> Self {
41        Self {
42            schema: RECEIPT_SCHEMA_VERSION.to_string(),
43            id: String::new(),
44            parent_run_id: None,
45            persona: String::new(),
46            step: None,
47            trace_id: String::new(),
48            started_at: OffsetDateTime::from_unix_timestamp(0).unwrap(),
49            completed_at: None,
50            status: ReceiptStatus::default(),
51            inputs_digest: None,
52            outputs_digest: None,
53            model_calls: Vec::new(),
54            tool_calls: Vec::new(),
55            cost_usd: 0.0,
56            approvals: Vec::new(),
57            handoffs: Vec::new(),
58            side_effects: Vec::new(),
59            error: None,
60            redaction_class: RedactionClass::default(),
61            metadata: BTreeMap::new(),
62        }
63    }
64}
65
66impl Receipt {
67    pub fn new(
68        id: impl Into<String>,
69        persona: impl Into<String>,
70        trace_id: impl Into<String>,
71        started_at: OffsetDateTime,
72    ) -> Self {
73        Self {
74            schema: RECEIPT_SCHEMA_VERSION.to_string(),
75            id: id.into(),
76            persona: persona.into(),
77            trace_id: trace_id.into(),
78            started_at,
79            status: ReceiptStatus::Running,
80            redaction_class: RedactionClass::Internal,
81            ..Self::default()
82        }
83    }
84
85    pub fn completed(mut self, completed_at: OffsetDateTime, status: ReceiptStatus) -> Self {
86        self.completed_at = Some(completed_at);
87        self.status = status;
88        self
89    }
90
91    pub fn validate_required_shape(&self) -> Result<(), ReceiptValidationError> {
92        if self.schema != RECEIPT_SCHEMA_VERSION {
93            return Err(ReceiptValidationError::InvalidSchema(self.schema.clone()));
94        }
95        if self.id.trim().is_empty() {
96            return Err(ReceiptValidationError::MissingField("id"));
97        }
98        if self.persona.trim().is_empty() {
99            return Err(ReceiptValidationError::MissingField("persona"));
100        }
101        if self.trace_id.trim().is_empty() {
102            return Err(ReceiptValidationError::MissingField("trace_id"));
103        }
104        if !self.cost_usd.is_finite() || self.cost_usd < 0.0 {
105            return Err(ReceiptValidationError::InvalidCost(self.cost_usd));
106        }
107        Ok(())
108    }
109
110    pub fn schema_json() -> Result<JsonValue, serde_json::Error> {
111        serde_json::from_str(RECEIPT_SCHEMA_JSON)
112    }
113
114    /// Append a `model_calls[]` entry that records the per-step model
115    /// + token + cost breakdown produced by `crates/harn-vm/src/step_runtime.rs`.
116    ///   Used by run-receipt builders (harn-cloud-store, burin-code) so a
117    ///   single canonical envelope carries per-step economics without
118    ///   each consumer reinventing the field layout.
119    pub fn push_step_breakdown(&mut self, summary: &crate::step_runtime::CompletedStep) {
120        let mut entry: BTreeMap<String, JsonValue> = BTreeMap::new();
121        entry.insert("step".to_string(), JsonValue::String(summary.name.clone()));
122        entry.insert(
123            "function".to_string(),
124            JsonValue::String(summary.function.clone()),
125        );
126        if let Some(model) = summary.model.as_deref() {
127            entry.insert("model".to_string(), JsonValue::String(model.to_string()));
128        }
129        entry.insert(
130            "input_tokens".to_string(),
131            JsonValue::Number(summary.input_tokens.into()),
132        );
133        entry.insert(
134            "output_tokens".to_string(),
135            JsonValue::Number(summary.output_tokens.into()),
136        );
137        entry.insert(
138            "llm_calls".to_string(),
139            JsonValue::Number(summary.llm_calls.into()),
140        );
141        entry.insert(
142            "status".to_string(),
143            JsonValue::String(summary.status.clone()),
144        );
145        if let Some(error) = summary.error.as_deref() {
146            entry.insert("error".to_string(), JsonValue::String(error.to_string()));
147        }
148        if summary.cost_usd.is_finite() {
149            if let Some(num) = serde_json::Number::from_f64(summary.cost_usd) {
150                entry.insert("cost_usd".to_string(), JsonValue::Number(num));
151            }
152            self.cost_usd += summary.cost_usd;
153        }
154        self.model_calls.push(entry);
155    }
156
157    /// Drain the per-thread step log into this receipt's `model_calls[]`
158    /// in declaration order. Idempotent: a second call after the
159    /// thread-local has been drained appends nothing.
160    pub fn attach_completed_steps(&mut self) {
161        for summary in crate::step_runtime::drain_completed_steps() {
162            self.push_step_breakdown(&summary);
163        }
164    }
165
166    /// Apply the unified [`crate::redact::RedactionPolicy`] to every
167    /// caller-supplied JSON field on the receipt. Fixed envelope fields
168    /// (id, persona, trace_id, status, schema, digests, cost) are not
169    /// touched — the persistence layer needs them stable for query and
170    /// replay.
171    pub fn redact_in_place(&mut self, policy: &crate::redact::RedactionPolicy) {
172        fn redact_entries(
173            entries: &mut [BTreeMap<String, JsonValue>],
174            policy: &crate::redact::RedactionPolicy,
175        ) {
176            for entry in entries {
177                for (key, value) in entry.iter_mut() {
178                    if policy.field_is_sensitive(key) {
179                        *value = JsonValue::String(crate::redact::REDACTED_PLACEHOLDER.to_string());
180                    } else {
181                        policy.redact_json_in_place(value);
182                    }
183                }
184            }
185        }
186
187        redact_entries(&mut self.model_calls, policy);
188        redact_entries(&mut self.tool_calls, policy);
189        redact_entries(&mut self.approvals, policy);
190        redact_entries(&mut self.handoffs, policy);
191        redact_entries(&mut self.side_effects, policy);
192        if let Some(error) = self.error.as_mut() {
193            for (key, value) in error.iter_mut() {
194                if policy.field_is_sensitive(key) {
195                    *value = JsonValue::String(crate::redact::REDACTED_PLACEHOLDER.to_string());
196                } else {
197                    policy.redact_json_in_place(value);
198                }
199            }
200        }
201        for (key, value) in self.metadata.iter_mut() {
202            if policy.field_is_sensitive(key) {
203                *value = JsonValue::String(crate::redact::REDACTED_PLACEHOLDER.to_string());
204            } else {
205                policy.redact_json_in_place(value);
206            }
207        }
208    }
209}
210
211#[async_trait]
212pub trait ReceiptSink {
213    type Error;
214
215    async fn persist_receipt(&self, receipt: &Receipt) -> Result<(), Self::Error>;
216}
217
218/// `ReceiptSink` decorator that runs each receipt through
219/// [`Receipt::redact_in_place`] before delegating to the inner sink.
220/// Hosts can wrap their existing sinks to opt every persistence path
221/// into the unified redaction policy without touching call sites that
222/// build the receipts.
223pub struct RedactingReceiptSink<Inner> {
224    inner: Inner,
225    policy: crate::redact::RedactionPolicy,
226}
227
228impl<Inner> RedactingReceiptSink<Inner> {
229    pub fn new(inner: Inner, policy: crate::redact::RedactionPolicy) -> Self {
230        Self { inner, policy }
231    }
232
233    /// Convenience constructor that wraps `inner` with the currently
234    /// installed thread-local policy.
235    pub fn with_current_policy(inner: Inner) -> Self {
236        Self::new(inner, crate::redact::current_policy())
237    }
238}
239
240#[async_trait]
241impl<Inner> ReceiptSink for RedactingReceiptSink<Inner>
242where
243    Inner: ReceiptSink + Send + Sync,
244{
245    type Error = Inner::Error;
246
247    async fn persist_receipt(&self, receipt: &Receipt) -> Result<(), Self::Error> {
248        let mut redacted = receipt.clone();
249        redacted.redact_in_place(&self.policy);
250        self.inner.persist_receipt(&redacted).await
251    }
252}
253
254#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum ReceiptStatus {
257    Accepted,
258    #[default]
259    Running,
260    Success,
261    Noop,
262    Failure,
263    Denied,
264    Duplicate,
265    Cancelled,
266}
267
268#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270pub enum RedactionClass {
271    Public,
272    #[default]
273    Internal,
274    ReceiptOnly,
275    Secret,
276}
277
278#[derive(Clone, Debug, PartialEq)]
279pub enum ReceiptValidationError {
280    InvalidSchema(String),
281    MissingField(&'static str),
282    InvalidCost(f64),
283}
284
285impl std::fmt::Display for ReceiptValidationError {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        match self {
288            ReceiptValidationError::InvalidSchema(schema) => {
289                write!(f, "unsupported receipt schema `{schema}`")
290            }
291            ReceiptValidationError::MissingField(field) => {
292                write!(f, "receipt is missing required field `{field}`")
293            }
294            ReceiptValidationError::InvalidCost(cost) => {
295                write!(
296                    f,
297                    "receipt cost_usd must be finite and non-negative, got {cost}"
298                )
299            }
300        }
301    }
302}
303
304impl std::error::Error for ReceiptValidationError {}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use serde_json::json;
310
311    fn fixture_receipt() -> Receipt {
312        Receipt::new(
313            "receipt_01JZCANONICAL",
314            "merge_captain",
315            "trace_01JZCANONICAL",
316            OffsetDateTime::from_unix_timestamp(1_777_000_000).unwrap(),
317        )
318        .completed(
319            OffsetDateTime::from_unix_timestamp(1_777_000_030).unwrap(),
320            ReceiptStatus::Success,
321        )
322    }
323
324    #[test]
325    fn receipt_new_serializes_with_canonical_defaults() {
326        let value = serde_json::to_value(Receipt::new(
327            "receipt_1",
328            "persona",
329            "trace_1",
330            OffsetDateTime::from_unix_timestamp(1_777_000_000).unwrap(),
331        ))
332        .unwrap();
333
334        assert_eq!(value["schema"], RECEIPT_SCHEMA_VERSION);
335        assert_eq!(value["status"], "running");
336        assert_eq!(value["redaction_class"], "internal");
337        assert!(value["model_calls"].as_array().unwrap().is_empty());
338        assert!(value["tool_calls"].as_array().unwrap().is_empty());
339        assert!(value["approvals"].as_array().unwrap().is_empty());
340        assert!(value["handoffs"].as_array().unwrap().is_empty());
341        assert!(value["side_effects"].as_array().unwrap().is_empty());
342    }
343
344    #[test]
345    fn receipt_to_value_matches_published_schema_surface() {
346        let receipt_value = serde_json::to_value(fixture_receipt()).unwrap();
347        let receipt_object = receipt_value.as_object().unwrap();
348        let schema = Receipt::schema_json().unwrap();
349        let schema_object = schema.as_object().unwrap();
350        let properties = schema_object["properties"].as_object().unwrap();
351
352        for required in schema_object["required"].as_array().unwrap() {
353            let key = required.as_str().unwrap();
354            assert!(
355                receipt_object.contains_key(key),
356                "serialized receipt is missing schema-required key `{key}`"
357            );
358        }
359
360        for key in receipt_object.keys() {
361            assert!(
362                properties.contains_key(key),
363                "serialized receipt has key `{key}` not published in docs/schemas/receipt.v1.json"
364            );
365        }
366
367        assert_eq!(schema_object["$id"], RECEIPT_SCHEMA_ID);
368        assert_eq!(properties["schema"]["const"], json!(RECEIPT_SCHEMA_VERSION));
369        assert!(properties["status"]["enum"]
370            .as_array()
371            .unwrap()
372            .contains(&json!("success")));
373        assert!(properties["redaction_class"]["enum"]
374            .as_array()
375            .unwrap()
376            .contains(&json!("receipt_only")));
377    }
378
379    #[test]
380    fn embedded_receipt_schema_matches_workspace_docs_when_available() {
381        let docs_schema = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
382            .join("../../docs/schemas/receipt.v1.json");
383        if !docs_schema.exists() {
384            return;
385        }
386        let source = std::fs::read_to_string(&docs_schema)
387            .unwrap_or_else(|e| panic!("failed to read {}: {e}", docs_schema.display()));
388        assert_eq!(
389            RECEIPT_SCHEMA_JSON, source,
390            "embedded receipt schema drifted from docs/schemas/receipt.v1.json"
391        );
392    }
393
394    #[test]
395    fn receipt_attaches_per_step_breakdown_with_aggregated_cost() {
396        let summary = crate::step_runtime::CompletedStep {
397            name: "classify".to_string(),
398            function: "classify_step".to_string(),
399            model: Some("claude-haiku-4-5".to_string()),
400            input_tokens: 5,
401            output_tokens: 5,
402            cost_usd: 0.000_05,
403            llm_calls: 1,
404            status: "completed".to_string(),
405            error: None,
406        };
407        let mut receipt = fixture_receipt();
408        let starting_cost = receipt.cost_usd;
409        receipt.push_step_breakdown(&summary);
410        assert_eq!(receipt.model_calls.len(), 1);
411        let entry = &receipt.model_calls[0];
412        assert_eq!(entry["step"], json!("classify"));
413        assert_eq!(entry["function"], json!("classify_step"));
414        assert_eq!(entry["model"], json!("claude-haiku-4-5"));
415        assert_eq!(entry["input_tokens"], json!(5));
416        assert_eq!(entry["output_tokens"], json!(5));
417        assert_eq!(entry["llm_calls"], json!(1));
418        assert!((receipt.cost_usd - starting_cost - 0.000_05).abs() < 1e-9);
419    }
420
421    #[test]
422    fn receipt_shape_validation_rejects_bad_core_fields() {
423        let mut receipt = fixture_receipt();
424        assert!(receipt.validate_required_shape().is_ok());
425
426        receipt.trace_id.clear();
427        assert_eq!(
428            receipt.validate_required_shape(),
429            Err(ReceiptValidationError::MissingField("trace_id"))
430        );
431
432        receipt.trace_id = "trace_ok".to_string();
433        receipt.cost_usd = -0.01;
434        assert_eq!(
435            receipt.validate_required_shape(),
436            Err(ReceiptValidationError::InvalidCost(-0.01))
437        );
438    }
439}