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