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 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 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}