1use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11pub const MECHANISM_COMPRESSION: &str = "compression";
14pub const MECHANISM_ROUTING: &str = "routing";
17pub const MECHANISM_CACHING: &str = "caching";
20
21fn default_mechanism() -> String {
22 MECHANISM_COMPRESSION.to_string()
23}
24
25fn default_version() -> String {
30 String::new()
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum MeasurementMethod {
39 DirectCount,
41 Holdout,
43 BaselineEstimate,
45 ProviderReconciled,
47 Unknown,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "snake_case")]
54pub enum EvidenceClass {
55 Measured,
57 Approximated,
59 Statistical,
61 Declared,
63 Unclassified,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub enum CustomerApproval {
74 Pending,
76 Approved,
78 Disputed,
80 Superseded,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86#[serde(rename_all = "snake_case")]
87pub enum SettlementStatus {
88 Ineligible,
90 Eligible,
92 Settled,
94 Reversed,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99pub struct SavingsEvent {
100 pub ts: String,
101 pub tool: String,
104 #[serde(default = "default_mechanism")]
108 pub mechanism: String,
109 pub model_id: String,
111 pub tokenizer: String,
115 pub baseline_tokens: u64,
117 pub actual_tokens: u64,
119 pub saved_tokens: u64,
121 pub bounce_adjustment: u64,
124 pub unit_price_per_m_usd: f64,
126 pub saved_usd: f64,
129 pub repo_hash: String,
132 pub agent_id: String,
133 pub prev_hash: String,
134 pub entry_hash: String,
135 #[serde(default = "default_version")]
141 pub version: String,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub intent_tag: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub outcome: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub model_original: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub model_routed: Option<String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub routing_savings: Option<u64>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub response_original_tokens: Option<u64>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub response_delivered_tokens: Option<u64>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub agent_chain_id: Option<String>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub chain_depth: Option<u8>,
171
172 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub measurement_method: Option<MeasurementMethod>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub evidence_class: Option<EvidenceClass>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub confidence: Option<f64>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub request_id: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub session_id: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub trace_id: Option<String>,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub solution_decision: Option<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub loc_added: Option<u64>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub loc_removed: Option<u64>,
203
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub path: Option<String>,
207
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub lines_added: Option<u64>,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub lines_removed: Option<u64>,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub net: Option<i64>,
219
220 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub quality_signal: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub attribution_group: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub attribution_id: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub baseline_ref: Option<String>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub price_version: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub customer_approval: Option<CustomerApproval>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub settlement_status: Option<SettlementStatus>,
243
244 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub is_first_inject: Option<bool>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub cache_read_per_m_usd: Option<f64>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub cache_write_per_m_usd: Option<f64>,
255
256 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub governor: Option<GovernorReceipt>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
267pub struct GovernorReceipt {
268 pub rule_id: String,
270 pub rule_version: u32,
272 pub decision: String,
274 pub host: String,
276 pub session_hash: String,
278 pub would_save_tokens: u64,
280}
281
282impl SavingsEvent {
283 pub fn canonical_content(&self) -> String {
290 format!(
291 "v7|{}|{}",
292 self.canonical_content_v6()
293 .strip_prefix("v6|")
294 .unwrap_or_default(),
295 option_str(self.session_id.as_ref()),
296 )
297 }
298
299 pub fn canonical_content_v6(&self) -> String {
301 format!(
302 "v6|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
303 self.ts,
304 self.tool,
305 self.mechanism,
306 self.model_id,
307 self.tokenizer,
308 self.baseline_tokens,
309 self.actual_tokens,
310 self.saved_tokens,
311 self.bounce_adjustment,
312 micro_usd(self.unit_price_per_m_usd),
313 micro_usd(self.saved_usd),
314 self.repo_hash,
315 self.agent_id,
316 self.version,
317 option_str(self.attribution_id.as_ref()),
318 option_str(self.intent_tag.as_ref()),
319 option_str(self.model_routed.as_ref()),
320 self.measurement_method.as_ref().map_or("_", |m| match m {
321 MeasurementMethod::DirectCount => "direct_count",
322 MeasurementMethod::Holdout => "holdout",
323 MeasurementMethod::BaselineEstimate => "baseline_estimate",
324 MeasurementMethod::ProviderReconciled => "provider_reconciled",
325 MeasurementMethod::Unknown => "unknown",
326 }),
327 self.evidence_class.as_ref().map_or("_", |e| match e {
328 EvidenceClass::Measured => "measured",
329 EvidenceClass::Approximated => "approximated",
330 EvidenceClass::Statistical => "statistical",
331 EvidenceClass::Declared => "declared",
332 EvidenceClass::Unclassified => "unclassified",
333 }),
334 option_str(self.path.as_ref()),
335 self.lines_added
336 .map_or_else(|| "_".to_string(), |value| value.to_string()),
337 self.lines_removed
338 .map_or_else(|| "_".to_string(), |value| value.to_string()),
339 self.net
340 .map_or_else(|| "_".to_string(), |value| value.to_string()),
341 )
342 }
343
344 pub fn canonical_content_v5(&self) -> String {
346 format!(
347 "v5|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
348 self.ts,
349 self.tool,
350 self.mechanism,
351 self.model_id,
352 self.tokenizer,
353 self.baseline_tokens,
354 self.actual_tokens,
355 self.saved_tokens,
356 self.bounce_adjustment,
357 micro_usd(self.unit_price_per_m_usd),
358 micro_usd(self.saved_usd),
359 self.repo_hash,
360 self.agent_id,
361 self.version,
362 option_str(self.attribution_id.as_ref()),
363 option_str(self.intent_tag.as_ref()),
364 option_str(self.model_routed.as_ref()),
365 self.measurement_method.as_ref().map_or("_", |m| match m {
366 MeasurementMethod::DirectCount => "direct_count",
367 MeasurementMethod::Holdout => "holdout",
368 MeasurementMethod::BaselineEstimate => "baseline_estimate",
369 MeasurementMethod::ProviderReconciled => "provider_reconciled",
370 MeasurementMethod::Unknown => "unknown",
371 }),
372 self.evidence_class.as_ref().map_or("_", |e| match e {
373 EvidenceClass::Measured => "measured",
374 EvidenceClass::Approximated => "approximated",
375 EvidenceClass::Statistical => "statistical",
376 EvidenceClass::Declared => "declared",
377 EvidenceClass::Unclassified => "unclassified",
378 }),
379 )
380 }
381
382 pub fn canonical_content_v4(&self) -> String {
384 format!(
385 "v4|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
386 self.ts,
387 self.tool,
388 self.mechanism,
389 self.model_id,
390 self.tokenizer,
391 self.baseline_tokens,
392 self.actual_tokens,
393 self.saved_tokens,
394 self.bounce_adjustment,
395 micro_usd(self.unit_price_per_m_usd),
396 micro_usd(self.saved_usd),
397 self.repo_hash,
398 self.agent_id,
399 self.version,
400 )
401 }
402
403 pub fn canonical_content_v3(&self) -> String {
407 format!(
408 "v3|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
409 self.ts,
410 self.tool,
411 self.mechanism,
412 self.model_id,
413 self.tokenizer,
414 self.baseline_tokens,
415 self.actual_tokens,
416 self.saved_tokens,
417 self.bounce_adjustment,
418 micro_usd(self.unit_price_per_m_usd),
419 micro_usd(self.saved_usd),
420 self.repo_hash,
421 self.agent_id,
422 )
423 }
424
425 pub fn canonical_content_v2(&self) -> String {
428 format!(
429 "v2|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
430 self.ts,
431 self.tool,
432 self.model_id,
433 self.tokenizer,
434 self.baseline_tokens,
435 self.actual_tokens,
436 self.saved_tokens,
437 self.bounce_adjustment,
438 micro_usd(self.unit_price_per_m_usd),
439 micro_usd(self.saved_usd),
440 self.repo_hash,
441 self.agent_id,
442 )
443 }
444
445 pub fn canonical_content_legacy(&self) -> String {
449 format!(
450 "{}|{}|{}|{}|{}|{}|{}|{}|{:.6}|{:.6}|{}|{}",
451 self.ts,
452 self.tool,
453 self.model_id,
454 self.tokenizer,
455 self.baseline_tokens,
456 self.actual_tokens,
457 self.saved_tokens,
458 self.bounce_adjustment,
459 self.unit_price_per_m_usd,
460 self.saved_usd,
461 self.repo_hash,
462 self.agent_id,
463 )
464 }
465
466 pub fn hash_matches(&self, prev_hash: &str) -> bool {
471 self.entry_hash == compute_hash(prev_hash, &self.canonical_content())
472 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v6())
473 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v5())
474 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v4())
475 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v3())
476 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_v2())
477 || self.entry_hash == compute_hash(prev_hash, &self.canonical_content_legacy())
478 }
479}
480
481fn micro_usd(usd: f64) -> i64 {
492 const TIE_EPSILON_MICRO: f64 = 1e-6;
493 let scaled = usd * 1_000_000.0;
494 (scaled + TIE_EPSILON_MICRO.copysign(scaled)).round() as i64
495}
496
497fn option_str(opt: Option<&String>) -> &str {
499 opt.map_or("_", String::as_str)
500}
501
502pub fn compute_hash(prev_hash: &str, content: &str) -> String {
504 let mut hasher = Sha256::new();
505 hasher.update(prev_hash.as_bytes());
506 hasher.update(content.as_bytes());
507 crate::core::agent_identity::hex_encode(&hasher.finalize())
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 fn ev() -> SavingsEvent {
515 SavingsEvent {
516 ts: "2026-06-01T00:00:00+00:00".into(),
517 tool: "ctx_read".into(),
518 mechanism: MECHANISM_COMPRESSION.into(),
519 model_id: "claude-3.5-sonnet".into(),
520 tokenizer: "o200k_base".into(),
521 baseline_tokens: 1000,
522 actual_tokens: 300,
523 saved_tokens: 700,
524 bounce_adjustment: 0,
525 unit_price_per_m_usd: 3.0,
526 saved_usd: 0.0021,
527 repo_hash: "abc123".into(),
528 agent_id: "local".into(),
529 prev_hash: String::new(),
530 entry_hash: String::new(),
531 version: "3.9.0".into(),
532 intent_tag: None,
533 outcome: None,
534 model_original: None,
535 model_routed: None,
536 routing_savings: None,
537 response_original_tokens: None,
538 response_delivered_tokens: None,
539 agent_chain_id: None,
540 chain_depth: None,
541 measurement_method: None,
542 evidence_class: None,
543 confidence: None,
544 request_id: None,
545 session_id: None,
546 trace_id: None,
547 solution_decision: None,
548 loc_added: None,
549 loc_removed: None,
550 path: None,
551 lines_added: None,
552 lines_removed: None,
553 net: None,
554 quality_signal: None,
555 attribution_group: None,
556 attribution_id: None,
557 baseline_ref: None,
558 price_version: None,
559 customer_approval: None,
560 settlement_status: None,
561 is_first_inject: None,
562 cache_read_per_m_usd: None,
563 cache_write_per_m_usd: None,
564 governor: None,
565 }
566 }
567
568 #[test]
569 fn hash_is_deterministic() {
570 let e = ev();
571 let a = compute_hash("genesis", &e.canonical_content());
572 let b = compute_hash("genesis", &e.canonical_content());
573 assert_eq!(a, b);
574 assert_eq!(a.len(), 64, "sha-256 hex is 64 chars");
575 }
576
577 #[test]
578 fn hash_changes_when_content_changes() {
579 let mut e = ev();
580 let a = compute_hash("genesis", &e.canonical_content());
581 e.saved_tokens = 701;
582 let b = compute_hash("genesis", &e.canonical_content());
583 assert_ne!(a, b, "tampering with a content field must change the hash");
584 }
585
586 #[test]
587 fn hash_depends_on_prev() {
588 let e = ev();
589 let a = compute_hash("genesis", &e.canonical_content());
590 let b = compute_hash("other", &e.canonical_content());
591 assert_ne!(a, b, "chain link must depend on prev_hash");
592 }
593
594 #[test]
598 fn v2_hash_is_roundtrip_stable_on_decimal_tie() {
599 let mut e = ev();
600 e.saved_tokens = 9423;
601 e.unit_price_per_m_usd = 2.5;
602 e.saved_usd = 9423.0 * 2.5 / 1_000_000.0; e.prev_hash = "genesis".into();
604 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v4());
605
606 let json = serde_json::to_string(&e).unwrap();
607 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
608
609 assert!(
610 parsed.hash_matches(&parsed.prev_hash),
611 "v2 chain must survive a JSON round-trip on a decimal-tie value"
612 );
613 }
614
615 #[test]
620 fn v2_hash_is_roundtrip_stable_on_production_order_tie() {
621 let mut e = ev();
622 e.saved_tokens = 7831;
623 e.unit_price_per_m_usd = 2.5;
624 e.saved_usd = e.saved_tokens as f64 / 1_000_000.0 * e.unit_price_per_m_usd;
626 e.prev_hash = "genesis".into();
627 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v5());
628
629 let json = serde_json::to_string(&e).unwrap();
630 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
631 assert!(
632 parsed.hash_matches(&parsed.prev_hash),
633 "v2 chain must survive a JSON round-trip on a production-order half-micro tie"
634 );
635 }
636
637 #[test]
638 fn micro_usd_resolves_half_micro_ties_consistently() {
639 let tie = 19_577.5_f64 / 1_000_000.0;
642 let below = f64::from_bits(tie.to_bits() - 1);
643 assert_eq!(micro_usd(tie), micro_usd(below));
644 }
645
646 #[test]
647 fn legacy_v1_hash_still_verifies() {
648 let mut e = ev();
651 e.prev_hash = "genesis".into();
652 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_legacy());
653 assert!(e.hash_matches(&e.prev_hash), "legacy v1 hash must verify");
654 }
655
656 #[test]
657 fn v2_hash_still_verifies_and_v3_commits_mechanism() {
658 let mut e = ev();
661 e.prev_hash = "genesis".into();
662 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v2());
663 assert!(e.hash_matches(&e.prev_hash), "v2 hash must verify");
664
665 let json = serde_json::to_string(&e).unwrap();
666 let stripped = json.replace(r#""mechanism":"compression","#, "");
667 let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
668 assert_eq!(parsed.mechanism, MECHANISM_COMPRESSION, "serde default");
669 assert!(parsed.hash_matches(&parsed.prev_hash), "v2 after roundtrip");
670
671 let mut v3 = ev();
673 v3.mechanism = MECHANISM_ROUTING.into();
674 v3.prev_hash = "genesis".into();
675 v3.entry_hash = compute_hash(&v3.prev_hash, &v3.canonical_content());
676 assert!(v3.hash_matches(&v3.prev_hash));
677 let mut forged = v3.clone();
678 forged.mechanism = MECHANISM_COMPRESSION.into();
679 assert!(
680 !forged.hash_matches(&forged.prev_hash),
681 "reattributing a routing saving to compression must be tamper-evident"
682 );
683 }
684
685 #[test]
686 fn v3_hash_still_verifies_and_v4_commits_version() {
687 let mut e = ev();
690 e.prev_hash = "genesis".into();
691 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v3());
692 assert!(e.hash_matches(&e.prev_hash), "v3 hash must verify");
693
694 let json = serde_json::to_string(&e).unwrap();
695 let stripped = json.replace(r#","version":"3.9.0""#, "");
698 let parsed: SavingsEvent = serde_json::from_str(&stripped).unwrap();
699 assert_eq!(parsed.version, "", "serde default for a pre-v4 entry");
700 assert!(parsed.hash_matches(&parsed.prev_hash), "v3 after roundtrip");
701
702 let mut v4 = ev();
704 v4.version = "3.8.18".into();
705 v4.prev_hash = "genesis".into();
706 v4.entry_hash = compute_hash(&v4.prev_hash, &v4.canonical_content_v4());
707 assert!(v4.hash_matches(&v4.prev_hash));
708 let mut forged = v4.clone();
709 forged.version = "3.9.0".into();
710 assert!(
711 !forged.hash_matches(&forged.prev_hash),
712 "rewriting which version recorded a saving must be tamper-evident"
713 );
714 }
715
716 #[test]
717 fn micro_usd_quantizes_to_millionths() {
718 assert_eq!(micro_usd(2.5), 2_500_000);
719 assert_eq!(micro_usd(0.0), 0);
720 assert_eq!(micro_usd(0.000_001), 1);
721 let tie = 9423.0 * 2.5 / 1_000_000.0;
724 assert_eq!(micro_usd(tie), micro_usd(tie));
725 }
726 #[test]
727 fn v4_hash_still_verifies_after_v5_upgrade() {
728 let mut e = ev();
729 e.prev_hash = "genesis".into();
730 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v4());
731 assert!(
732 e.hash_matches(&e.prev_hash),
733 "v4 hash must verify via hash_matches"
734 );
735 }
736
737 #[test]
738 fn v5_commits_p5_fields() {
739 let mut e = ev();
740 e.attribution_id = Some("attr_001".into());
741 e.measurement_method = Some(MeasurementMethod::DirectCount);
742 e.evidence_class = Some(EvidenceClass::Measured);
743 e.prev_hash = "genesis".into();
744 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content_v5());
745 assert!(e.hash_matches(&e.prev_hash));
746
747 let mut forged = e.clone();
748 forged.attribution_id = Some("attr_002".into());
749 assert!(
750 !forged.hash_matches(&forged.prev_hash),
751 "rewriting attribution_id must be tamper-evident"
752 );
753 }
754
755 #[test]
756 fn v6_commits_edit_fields() {
757 let mut e = ev();
758 e.path = Some("rust/src/core/edit_metering.rs".into());
759 e.lines_added = Some(2);
760 e.lines_removed = Some(5);
761 e.net = Some(3);
762 e.prev_hash = "genesis".into();
763 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
764 assert!(e.hash_matches(&e.prev_hash));
765
766 let mut forged = e.clone();
767 forged.net = Some(2);
768 assert!(
769 !forged.hash_matches(&forged.prev_hash),
770 "rewriting edit LOC must be tamper-evident"
771 );
772 }
773
774 #[test]
775 fn v7_commits_session_id() {
776 let mut e = ev();
777 e.session_id = Some("sess-a".into());
778 e.prev_hash = "genesis".into();
779 e.entry_hash = compute_hash(&e.prev_hash, &e.canonical_content());
780 assert!(e.canonical_content().starts_with("v7|"));
781 assert!(e.hash_matches(&e.prev_hash));
782
783 let mut moved = e.clone();
784 moved.session_id = Some("sess-b".into());
785 assert!(
786 !moved.hash_matches(&moved.prev_hash),
787 "re-attributing an event to another session must be tamper-evident"
788 );
789
790 let mut v6 = ev();
791 v6.prev_hash = "genesis".into();
792 v6.entry_hash = compute_hash(&v6.prev_hash, &v6.canonical_content_v6());
793 assert!(v6.hash_matches(&v6.prev_hash), "v6 ledgers keep verifying");
794 }
795
796 #[test]
797 fn p5_fields_default_to_none_on_deserialize() {
798 let e = ev();
799 let json = serde_json::to_string(&e).unwrap();
800 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
801 assert_eq!(parsed.attribution_id, None);
802 assert_eq!(parsed.measurement_method, None);
803 assert_eq!(parsed.evidence_class, None);
804 assert_eq!(parsed.customer_approval, None);
805 assert_eq!(parsed.settlement_status, None);
806 }
807
808 #[test]
809 fn p5_enums_serialize_roundtrip() {
810 let mut e = ev();
811 e.measurement_method = Some(MeasurementMethod::Holdout);
812 e.evidence_class = Some(EvidenceClass::Statistical);
813 e.customer_approval = Some(CustomerApproval::Approved);
814 e.settlement_status = Some(SettlementStatus::Eligible);
815 e.confidence = Some(0.95);
816 e.attribution_id = Some("blake3_abc".into());
817
818 let json = serde_json::to_string(&e).unwrap();
819 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
820 assert_eq!(parsed.measurement_method, Some(MeasurementMethod::Holdout));
821 assert_eq!(parsed.evidence_class, Some(EvidenceClass::Statistical));
822 assert_eq!(parsed.customer_approval, Some(CustomerApproval::Approved));
823 assert_eq!(parsed.settlement_status, Some(SettlementStatus::Eligible));
824 assert_eq!(parsed.confidence, Some(0.95));
825 assert_eq!(parsed.attribution_id, Some("blake3_abc".into()));
826 }
827
828 #[test]
829 fn governor_receipt_is_optional_and_outside_the_hash_chain() {
830 let plain = ev();
831 let json = serde_json::to_string(&plain).unwrap();
832 assert!(
833 !json.contains("governor"),
834 "absent receipt is not serialised"
835 );
836 let parsed: SavingsEvent = serde_json::from_str(&json).unwrap();
837 assert_eq!(parsed.governor, None, "pre-governor events still parse");
838
839 let mut with = plain.clone();
840 with.governor = Some(GovernorReceipt {
841 rule_id: "R1.bash".into(),
842 rule_version: 1,
843 decision: "shadow".into(),
844 host: "claude-code".into(),
845 session_hash: "0123456789abcdef".into(),
846 would_save_tokens: 900,
847 });
848 let back: SavingsEvent =
849 serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
850 assert_eq!(back.governor, with.governor);
851 assert_eq!(
852 with.canonical_content(),
853 plain.canonical_content(),
854 "the v7 chain contract is unchanged"
855 );
856 }
857
858 #[test]
859 fn option_str_maps_none_to_underscore() {
860 assert_eq!(option_str(None), "_");
861 assert_eq!(option_str(Some(&"val".to_string())), "val");
862 }
863}