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
167#[async_trait]
168pub trait ReceiptSink {
169    type Error;
170
171    async fn persist_receipt(&self, receipt: &Receipt) -> Result<(), Self::Error>;
172}
173
174#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum ReceiptStatus {
177    Accepted,
178    #[default]
179    Running,
180    Success,
181    Noop,
182    Failure,
183    Denied,
184    Duplicate,
185    Cancelled,
186}
187
188#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub enum RedactionClass {
191    Public,
192    #[default]
193    Internal,
194    ReceiptOnly,
195    Secret,
196}
197
198#[derive(Clone, Debug, PartialEq)]
199pub enum ReceiptValidationError {
200    InvalidSchema(String),
201    MissingField(&'static str),
202    InvalidCost(f64),
203}
204
205impl std::fmt::Display for ReceiptValidationError {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            ReceiptValidationError::InvalidSchema(schema) => {
209                write!(f, "unsupported receipt schema `{schema}`")
210            }
211            ReceiptValidationError::MissingField(field) => {
212                write!(f, "receipt is missing required field `{field}`")
213            }
214            ReceiptValidationError::InvalidCost(cost) => {
215                write!(
216                    f,
217                    "receipt cost_usd must be finite and non-negative, got {cost}"
218                )
219            }
220        }
221    }
222}
223
224impl std::error::Error for ReceiptValidationError {}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use serde_json::json;
230
231    fn fixture_receipt() -> Receipt {
232        Receipt::new(
233            "receipt_01JZCANONICAL",
234            "merge_captain",
235            "trace_01JZCANONICAL",
236            OffsetDateTime::from_unix_timestamp(1_777_000_000).unwrap(),
237        )
238        .completed(
239            OffsetDateTime::from_unix_timestamp(1_777_000_030).unwrap(),
240            ReceiptStatus::Success,
241        )
242    }
243
244    #[test]
245    fn receipt_new_serializes_with_canonical_defaults() {
246        let value = serde_json::to_value(Receipt::new(
247            "receipt_1",
248            "persona",
249            "trace_1",
250            OffsetDateTime::from_unix_timestamp(1_777_000_000).unwrap(),
251        ))
252        .unwrap();
253
254        assert_eq!(value["schema"], RECEIPT_SCHEMA_VERSION);
255        assert_eq!(value["status"], "running");
256        assert_eq!(value["redaction_class"], "internal");
257        assert!(value["model_calls"].as_array().unwrap().is_empty());
258        assert!(value["tool_calls"].as_array().unwrap().is_empty());
259        assert!(value["approvals"].as_array().unwrap().is_empty());
260        assert!(value["handoffs"].as_array().unwrap().is_empty());
261        assert!(value["side_effects"].as_array().unwrap().is_empty());
262    }
263
264    #[test]
265    fn receipt_to_value_matches_published_schema_surface() {
266        let receipt_value = serde_json::to_value(fixture_receipt()).unwrap();
267        let receipt_object = receipt_value.as_object().unwrap();
268        let schema = Receipt::schema_json().unwrap();
269        let schema_object = schema.as_object().unwrap();
270        let properties = schema_object["properties"].as_object().unwrap();
271
272        for required in schema_object["required"].as_array().unwrap() {
273            let key = required.as_str().unwrap();
274            assert!(
275                receipt_object.contains_key(key),
276                "serialized receipt is missing schema-required key `{key}`"
277            );
278        }
279
280        for key in receipt_object.keys() {
281            assert!(
282                properties.contains_key(key),
283                "serialized receipt has key `{key}` not published in docs/schemas/receipt.v1.json"
284            );
285        }
286
287        assert_eq!(schema_object["$id"], RECEIPT_SCHEMA_ID);
288        assert_eq!(properties["schema"]["const"], json!(RECEIPT_SCHEMA_VERSION));
289        assert!(properties["status"]["enum"]
290            .as_array()
291            .unwrap()
292            .contains(&json!("success")));
293        assert!(properties["redaction_class"]["enum"]
294            .as_array()
295            .unwrap()
296            .contains(&json!("receipt_only")));
297    }
298
299    #[test]
300    fn embedded_receipt_schema_matches_workspace_docs_when_available() {
301        let docs_schema = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
302            .join("../../docs/schemas/receipt.v1.json");
303        if !docs_schema.exists() {
304            return;
305        }
306        let source = std::fs::read_to_string(&docs_schema)
307            .unwrap_or_else(|e| panic!("failed to read {}: {e}", docs_schema.display()));
308        assert_eq!(
309            RECEIPT_SCHEMA_JSON, source,
310            "embedded receipt schema drifted from docs/schemas/receipt.v1.json"
311        );
312    }
313
314    #[test]
315    fn receipt_attaches_per_step_breakdown_with_aggregated_cost() {
316        let summary = crate::step_runtime::CompletedStep {
317            name: "classify".to_string(),
318            function: "classify_step".to_string(),
319            model: Some("claude-haiku-4-5".to_string()),
320            input_tokens: 5,
321            output_tokens: 5,
322            cost_usd: 0.000_05,
323            llm_calls: 1,
324            status: "completed".to_string(),
325            error: None,
326        };
327        let mut receipt = fixture_receipt();
328        let starting_cost = receipt.cost_usd;
329        receipt.push_step_breakdown(&summary);
330        assert_eq!(receipt.model_calls.len(), 1);
331        let entry = &receipt.model_calls[0];
332        assert_eq!(entry["step"], json!("classify"));
333        assert_eq!(entry["function"], json!("classify_step"));
334        assert_eq!(entry["model"], json!("claude-haiku-4-5"));
335        assert_eq!(entry["input_tokens"], json!(5));
336        assert_eq!(entry["output_tokens"], json!(5));
337        assert_eq!(entry["llm_calls"], json!(1));
338        assert!((receipt.cost_usd - starting_cost - 0.000_05).abs() < 1e-9);
339    }
340
341    #[test]
342    fn receipt_shape_validation_rejects_bad_core_fields() {
343        let mut receipt = fixture_receipt();
344        assert!(receipt.validate_required_shape().is_ok());
345
346        receipt.trace_id.clear();
347        assert_eq!(
348            receipt.validate_required_shape(),
349            Err(ReceiptValidationError::MissingField("trace_id"))
350        );
351
352        receipt.trace_id = "trace_ok".to_string();
353        receipt.cost_usd = -0.01;
354        assert_eq!(
355            receipt.validate_required_shape(),
356            Err(ReceiptValidationError::InvalidCost(-0.01))
357        );
358    }
359}