1use std::collections::BTreeSet;
6use std::fmt::Write as _;
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::common::{
12 ValidationError, deserialize_schema_version, validate_bounded_opaque_identifier,
13 validate_schema_version,
14};
15use crate::{MAX_SAFE_INTEGER, ProtocolReference, Sha256Digest};
16
17pub const VIA_SAVINGS_RECEIPT_SCHEMA_V1: u32 = 1;
18pub const MAX_VIA_RECEIPT_CONTEXTS: usize = 64;
19pub const MAX_VIA_RECEIPT_PROOFS: usize = 64;
20
21const RECEIPT_ID_DOMAIN: &[u8] = b"leanctx.via.savings-receipt.v1\0";
22const MAX_ID_BYTES: usize = 256;
23const MAX_VERSION_BYTES: usize = 128;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ViaReceiptModeV1 {
28 Bypass,
29 Shadow,
30 On,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ViaUsageStateV1 {
36 Measured,
37 Estimated,
38 Unavailable,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum ViaQualityStateV1 {
44 Passed,
45 Failed,
46 NotEvaluated,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum ViaFallbackReasonV1 {
52 BrainUnavailable,
53 BrainTimedOut,
54 PlanRejected,
55 ProviderUnavailable,
56 StreamIncomplete,
57 MeasurementUnavailable,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum ViaBaselineDefinitionV1 {
63 BypassActual,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct ViaVersionRefV1 {
69 pub version: String,
70 pub digest: Sha256Digest,
71}
72
73impl ViaVersionRefV1 {
74 fn validate(&self, field: &str) -> Result<(), ValidationError> {
75 validate_text(&self.version, field, MAX_VERSION_BYTES)
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ViaReceiptIdentityV1 {
82 pub invocation_id: ProtocolReference,
83 pub provider_id: String,
84 pub model_id: String,
85}
86
87impl ViaReceiptIdentityV1 {
88 fn validate(&self) -> Result<(), ValidationError> {
89 validate_logical_reference(&self.invocation_id, "Via receipt invocation_id")?;
90 validate_text(&self.provider_id, "Via receipt provider_id", MAX_ID_BYTES)?;
91 validate_text(&self.model_id, "Via receipt model_id", MAX_ID_BYTES)
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct ViaReceiptVersionsV1 {
98 pub policy: ViaVersionRefV1,
99 pub operator: ViaVersionRefV1,
100 pub methodology: ViaVersionRefV1,
101}
102
103impl ViaReceiptVersionsV1 {
104 fn validate(&self) -> Result<(), ValidationError> {
105 self.policy.validate("policy version")?;
106 self.operator.validate("operator version")?;
107 self.methodology.validate("methodology version")
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct ViaContextAttributionV1 {
114 pub context_id: ProtocolReference,
115 pub raw_observed_tokens: u64,
116 pub source_selected_tokens: u64,
117 pub representation_delivered_tokens: u64,
118 pub reuse_delivered_tokens: u64,
119 pub provider_context_final_tokens: u64,
120 pub raw_observed_bytes: u64,
121 pub remote_bytes_transferred: u64,
122}
123
124impl ViaContextAttributionV1 {
125 fn validate(&self) -> Result<(), ValidationError> {
126 validate_logical_reference(&self.context_id, "Via context_id")?;
127 for (field, value) in [
128 ("raw_observed_tokens", self.raw_observed_tokens),
129 ("source_selected_tokens", self.source_selected_tokens),
130 (
131 "representation_delivered_tokens",
132 self.representation_delivered_tokens,
133 ),
134 ("reuse_delivered_tokens", self.reuse_delivered_tokens),
135 (
136 "provider_context_final_tokens",
137 self.provider_context_final_tokens,
138 ),
139 ("raw_observed_bytes", self.raw_observed_bytes),
140 ("remote_bytes_transferred", self.remote_bytes_transferred),
141 ] {
142 validate_safe_integer(value, field)?;
143 }
144 if self.source_selected_tokens > self.raw_observed_tokens
145 || self.representation_delivered_tokens > self.source_selected_tokens
146 || self.reuse_delivered_tokens > self.representation_delivered_tokens
147 || self.provider_context_final_tokens > self.reuse_delivered_tokens
148 {
149 return Err(ValidationError::new(
150 "Via token checkpoints must be monotonically non-increasing",
151 ));
152 }
153 if self.remote_bytes_transferred > self.raw_observed_bytes {
154 return Err(ValidationError::new(
155 "remote_bytes_transferred exceeds raw_observed_bytes",
156 ));
157 }
158 Ok(())
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct ViaStageTotalsV1 {
165 pub source_avoidance_tokens: u64,
166 pub representation_reduction_tokens: u64,
167 pub upload_reuse_tokens: u64,
168 pub inference_reduction_tokens: u64,
169}
170
171impl ViaStageTotalsV1 {
172 pub fn from_contexts(contexts: &[ViaContextAttributionV1]) -> Result<Self, ValidationError> {
173 let mut totals = Self {
174 source_avoidance_tokens: 0,
175 representation_reduction_tokens: 0,
176 upload_reuse_tokens: 0,
177 inference_reduction_tokens: 0,
178 };
179 for context in contexts {
180 context.validate()?;
181 totals.source_avoidance_tokens = checked_add(
182 totals.source_avoidance_tokens,
183 checked_sub(
184 context.raw_observed_tokens,
185 context.source_selected_tokens,
186 "source avoidance",
187 )?,
188 "source avoidance total",
189 )?;
190 totals.representation_reduction_tokens = checked_add(
191 totals.representation_reduction_tokens,
192 checked_sub(
193 context.source_selected_tokens,
194 context.representation_delivered_tokens,
195 "representation reduction",
196 )?,
197 "representation reduction total",
198 )?;
199 totals.upload_reuse_tokens = checked_add(
200 totals.upload_reuse_tokens,
201 checked_sub(
202 context.representation_delivered_tokens,
203 context.reuse_delivered_tokens,
204 "upload reuse",
205 )?,
206 "upload reuse total",
207 )?;
208 totals.inference_reduction_tokens = checked_add(
209 totals.inference_reduction_tokens,
210 checked_sub(
211 context.reuse_delivered_tokens,
212 context.provider_context_final_tokens,
213 "inference reduction",
214 )?,
215 "inference reduction total",
216 )?;
217 }
218 Ok(totals)
219 }
220
221 pub fn total_avoided_tokens(&self) -> Result<u64, ValidationError> {
222 checked_sum(
223 [
224 self.source_avoidance_tokens,
225 self.representation_reduction_tokens,
226 self.upload_reuse_tokens,
227 self.inference_reduction_tokens,
228 ],
229 "stage total",
230 )
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(deny_unknown_fields)]
236pub struct ViaBandwidthV1 {
237 pub raw_observed_bytes: u64,
238 pub remote_bytes_transferred: u64,
239 pub avoided_bytes: u64,
240}
241
242impl ViaBandwidthV1 {
243 pub fn from_contexts(contexts: &[ViaContextAttributionV1]) -> Result<Self, ValidationError> {
244 let raw_observed_bytes = contexts.iter().try_fold(0_u64, |sum, context| {
245 checked_add(sum, context.raw_observed_bytes, "raw byte total")
246 })?;
247 let remote_bytes_transferred = contexts.iter().try_fold(0_u64, |sum, context| {
248 checked_add(sum, context.remote_bytes_transferred, "remote byte total")
249 })?;
250 let avoided_bytes = checked_sub(
251 raw_observed_bytes,
252 remote_bytes_transferred,
253 "avoided bytes",
254 )?;
255 Ok(Self {
256 raw_observed_bytes,
257 remote_bytes_transferred,
258 avoided_bytes,
259 })
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(deny_unknown_fields)]
265pub struct ViaProviderUsageV1 {
266 pub state: ViaUsageStateV1,
267 pub uncached_input_tokens: Option<u64>,
268 pub cache_write_input_tokens: Option<u64>,
269 pub cache_read_input_tokens: Option<u64>,
270 pub total_input_tokens: Option<u64>,
271 pub output_tokens: Option<u64>,
272}
273
274impl ViaProviderUsageV1 {
275 pub fn measured(
276 uncached_input_tokens: u64,
277 cache_write_input_tokens: u64,
278 cache_read_input_tokens: u64,
279 output_tokens: u64,
280 ) -> Result<Self, ValidationError> {
281 let total_input_tokens = checked_sum(
282 [
283 uncached_input_tokens,
284 cache_write_input_tokens,
285 cache_read_input_tokens,
286 ],
287 "provider input total",
288 )?;
289 Ok(Self {
290 state: ViaUsageStateV1::Measured,
291 uncached_input_tokens: Some(uncached_input_tokens),
292 cache_write_input_tokens: Some(cache_write_input_tokens),
293 cache_read_input_tokens: Some(cache_read_input_tokens),
294 total_input_tokens: Some(total_input_tokens),
295 output_tokens: Some(output_tokens),
296 })
297 }
298
299 pub fn unavailable() -> Self {
300 Self {
301 state: ViaUsageStateV1::Unavailable,
302 uncached_input_tokens: None,
303 cache_write_input_tokens: None,
304 cache_read_input_tokens: None,
305 total_input_tokens: None,
306 output_tokens: None,
307 }
308 }
309
310 pub(crate) fn validate(&self, field: &str) -> Result<(), ValidationError> {
311 let values = [
312 self.uncached_input_tokens,
313 self.cache_write_input_tokens,
314 self.cache_read_input_tokens,
315 self.total_input_tokens,
316 self.output_tokens,
317 ];
318 match self.state {
319 ViaUsageStateV1::Unavailable => {
320 if values.iter().any(Option::is_some) {
321 return Err(ValidationError::new(format!(
322 "{field} unavailable usage must omit every counter"
323 )));
324 }
325 }
326 ViaUsageStateV1::Measured | ViaUsageStateV1::Estimated => {
327 if values.iter().any(Option::is_none) {
328 return Err(ValidationError::new(format!(
329 "{field} measured/estimated usage requires every counter"
330 )));
331 }
332 for value in values.into_iter().flatten() {
333 validate_safe_integer(value, field)?;
334 }
335 let expected = checked_sum(
336 [
337 self.uncached_input_tokens.unwrap_or_default(),
338 self.cache_write_input_tokens.unwrap_or_default(),
339 self.cache_read_input_tokens.unwrap_or_default(),
340 ],
341 "provider input components",
342 )?;
343 if self.total_input_tokens != Some(expected) {
344 return Err(ValidationError::new(format!(
345 "{field} total_input_tokens does not match components"
346 )));
347 }
348 }
349 }
350 Ok(())
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(deny_unknown_fields)]
356pub struct ViaProviderUsageComparisonV1 {
357 pub baseline_actual: ViaProviderUsageV1,
358 pub treatment_actual: ViaProviderUsageV1,
359 pub counterfactual_final_input_tokens: Option<u64>,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(deny_unknown_fields)]
364pub struct ViaCacheArmV1 {
365 pub context_size_tokens: u64,
366 pub uncached_input_tokens: u64,
367 pub cache_write_input_tokens: u64,
368 pub cache_read_input_tokens: u64,
369 pub uncached_input_cost_micros: u64,
370 pub cache_write_cost_micros: u64,
371 pub cache_read_cost_micros: u64,
372 pub total_billed_input_cost_micros: u64,
373}
374
375impl ViaCacheArmV1 {
376 fn validate(&self, field: &str) -> Result<(), ValidationError> {
377 let tokens = checked_sum(
378 [
379 self.uncached_input_tokens,
380 self.cache_write_input_tokens,
381 self.cache_read_input_tokens,
382 ],
383 field,
384 )?;
385 if tokens != self.context_size_tokens {
386 return Err(ValidationError::new(format!(
387 "{field} context size does not match cache token components"
388 )));
389 }
390 let cost = checked_sum(
391 [
392 self.uncached_input_cost_micros,
393 self.cache_write_cost_micros,
394 self.cache_read_cost_micros,
395 ],
396 field,
397 )?;
398 if cost != self.total_billed_input_cost_micros {
399 return Err(ValidationError::new(format!(
400 "{field} billed cost does not match cache cost components"
401 )));
402 }
403 Ok(())
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct ViaCacheEconomicsV1 {
410 pub cost_basis: ViaVersionRefV1,
411 pub baseline: ViaCacheArmV1,
412 pub treatment: ViaCacheArmV1,
413 pub adverse_cost_justification: Option<Sha256Digest>,
414}
415
416impl ViaCacheEconomicsV1 {
417 fn validate(&self) -> Result<(), ValidationError> {
418 self.cost_basis.validate("cache cost basis")?;
419 self.baseline.validate("baseline cache arm")?;
420 self.treatment.validate("treatment cache arm")?;
421 if self.treatment.total_billed_input_cost_micros
422 > self.baseline.total_billed_input_cost_micros
423 && self.adverse_cost_justification.is_none()
424 {
425 return Err(ValidationError::new(
426 "adverse cache economics require a digest-bound justification",
427 ));
428 }
429 Ok(())
430 }
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(deny_unknown_fields)]
435pub struct ViaQualityResultV1 {
436 pub state: ViaQualityStateV1,
437 pub evidence_digest: Option<Sha256Digest>,
438}
439
440impl ViaQualityResultV1 {
441 fn validate(&self) -> Result<(), ValidationError> {
442 match self.state {
443 ViaQualityStateV1::Passed | ViaQualityStateV1::Failed
444 if self.evidence_digest.is_none() =>
445 {
446 Err(ValidationError::new(
447 "evaluated quality requires an evidence digest",
448 ))
449 }
450 ViaQualityStateV1::NotEvaluated if self.evidence_digest.is_some() => Err(
451 ValidationError::new("not-evaluated quality must omit evidence"),
452 ),
453 _ => Ok(()),
454 }
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459#[serde(deny_unknown_fields)]
460pub struct ViaFallbackV1 {
461 pub reason: ViaFallbackReasonV1,
462 pub evidence_digest: Sha256Digest,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466#[serde(deny_unknown_fields)]
467pub struct ViaLatencyV1 {
468 pub via_latency_micros: u64,
469 pub provider_latency_micros: Option<u64>,
470}
471
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473#[serde(deny_unknown_fields)]
474pub struct ViaSavingsReceiptPayloadV1 {
475 #[serde(deserialize_with = "deserialize_schema_version")]
476 pub schema_version: u32,
477 pub identity: ViaReceiptIdentityV1,
478 pub versions: ViaReceiptVersionsV1,
479 pub requested_mode: ViaReceiptModeV1,
480 pub effective_mode: ViaReceiptModeV1,
481 pub baseline_definition: ViaBaselineDefinitionV1,
482 pub input_body_digest: Sha256Digest,
483 pub forward_body_digest: Sha256Digest,
484 pub contexts: Vec<ViaContextAttributionV1>,
485 pub stage_totals: ViaStageTotalsV1,
486 pub provider_usage: ViaProviderUsageComparisonV1,
487 pub provider_cache_metrics: Option<ViaCacheEconomicsV1>,
488 pub via_bandwidth: ViaBandwidthV1,
489 pub latency: ViaLatencyV1,
490 pub quality_result: ViaQualityResultV1,
491 pub fallback: Option<ViaFallbackV1>,
492 pub counterfactual_avoided_tokens: Option<u64>,
493 pub actual_avoided_tokens: u64,
494 pub proof_refs: Vec<Sha256Digest>,
495}
496
497impl ViaSavingsReceiptPayloadV1 {
498 pub fn validate(&self) -> Result<(), ValidationError> {
499 validate_schema_version(self.schema_version)?;
500 if self.schema_version != VIA_SAVINGS_RECEIPT_SCHEMA_V1 {
501 return Err(ValidationError::new(
502 "unsupported Via savings receipt schema",
503 ));
504 }
505 self.identity.validate()?;
506 self.versions.validate()?;
507 if self.contexts.is_empty() || self.contexts.len() > MAX_VIA_RECEIPT_CONTEXTS {
508 return Err(ValidationError::new(
509 "Via receipt contexts must contain 1..=64 items",
510 ));
511 }
512 let mut context_ids = BTreeSet::new();
513 for context in &self.contexts {
514 context.validate()?;
515 if !context_ids.insert(context.context_id.as_str()) {
516 return Err(ValidationError::new("duplicate Via context attribution"));
517 }
518 }
519 if self
520 .contexts
521 .windows(2)
522 .any(|pair| pair[0].context_id.as_str() >= pair[1].context_id.as_str())
523 {
524 return Err(ValidationError::new(
525 "Via receipt contexts must use stable context_id order",
526 ));
527 }
528 let expected_stages = ViaStageTotalsV1::from_contexts(&self.contexts)?;
529 if self.stage_totals != expected_stages {
530 return Err(ValidationError::new(
531 "stage totals do not match adjacent context checkpoints",
532 ));
533 }
534 let expected_bandwidth = ViaBandwidthV1::from_contexts(&self.contexts)?;
535 if self.via_bandwidth != expected_bandwidth {
536 return Err(ValidationError::new(
537 "Via bandwidth does not match context byte checkpoints",
538 ));
539 }
540 validate_safe_integer(self.latency.via_latency_micros, "Via latency")?;
541 if let Some(value) = self.latency.provider_latency_micros {
542 validate_safe_integer(value, "provider latency")?;
543 }
544 self.quality_result.validate()?;
545 validate_proofs(&self.proof_refs)?;
546 self.provider_usage
547 .baseline_actual
548 .validate("baseline provider usage")?;
549 self.provider_usage
550 .treatment_actual
551 .validate("treatment provider usage")?;
552 if let Some(value) = self.provider_usage.counterfactual_final_input_tokens {
553 validate_safe_integer(value, "counterfactual provider input")?;
554 }
555 if let Some(cache) = &self.provider_cache_metrics {
556 cache.validate()?;
557 validate_cache_usage_match(cache, &self.provider_usage)?;
558 }
559 validate_evidence_bindings(self)?;
560 self.validate_mode_reconciliation()
561 }
562
563 fn validate_mode_reconciliation(&self) -> Result<(), ValidationError> {
564 let raw = sum_context_tokens(&self.contexts, |value| value.raw_observed_tokens)?;
565 let final_context =
566 sum_context_tokens(&self.contexts, |value| value.provider_context_final_tokens)?;
567 let avoided = self.stage_totals.total_avoided_tokens()?;
568 if avoided != checked_sub(raw, final_context, "total avoided tokens")? {
569 return Err(ValidationError::new(
570 "stage totals do not telescope to raw minus provider final",
571 ));
572 }
573 let baseline = self.provider_usage.baseline_actual.total_input_tokens;
574 let treatment = self.provider_usage.treatment_actual.total_input_tokens;
575 if self.provider_usage.baseline_actual.state == ViaUsageStateV1::Measured
576 && baseline != Some(raw)
577 {
578 return Err(ValidationError::new(
579 "measured BYPASS usage does not match raw observed tokens",
580 ));
581 }
582 let valid_transition = matches!(
583 (self.requested_mode, self.effective_mode),
584 (ViaReceiptModeV1::Bypass, ViaReceiptModeV1::Bypass)
585 | (ViaReceiptModeV1::Shadow, ViaReceiptModeV1::Shadow)
586 | (ViaReceiptModeV1::Shadow, ViaReceiptModeV1::Bypass)
587 | (ViaReceiptModeV1::On, ViaReceiptModeV1::On)
588 | (ViaReceiptModeV1::On, ViaReceiptModeV1::Bypass)
589 );
590 if !valid_transition {
591 return Err(ValidationError::new(
592 "invalid requested/effective Via mode transition",
593 ));
594 }
595 if (self.requested_mode != self.effective_mode) != self.fallback.is_some() {
596 return Err(ValidationError::new(
597 "changed effective mode requires exactly one fallback",
598 ));
599 }
600 match self.effective_mode {
601 ViaReceiptModeV1::Bypass => {
602 if avoided != 0
603 || self.input_body_digest != self.forward_body_digest
604 || self.provider_usage.treatment_actual != self.provider_usage.baseline_actual
605 || self.counterfactual_avoided_tokens.is_some()
606 || self.actual_avoided_tokens != 0
607 {
608 return Err(ValidationError::new(
609 "BYPASS/fallback receipt cannot claim or apply savings",
610 ));
611 }
612 }
613 ViaReceiptModeV1::Shadow => {
614 if self.provider_usage.treatment_actual != self.provider_usage.baseline_actual
615 || self.input_body_digest != self.forward_body_digest
616 || self.provider_usage.counterfactual_final_input_tokens != Some(final_context)
617 || self.counterfactual_avoided_tokens != Some(avoided)
618 || self.actual_avoided_tokens != 0
619 {
620 return Err(ValidationError::new(
621 "SHADOW must preserve actual usage and separate its counterfactual",
622 ));
623 }
624 }
625 ViaReceiptModeV1::On => {
626 if self
627 .provider_usage
628 .counterfactual_final_input_tokens
629 .is_some()
630 {
631 return Err(ValidationError::new(
632 "ON receipt must reconcile the applied provider context directly",
633 ));
634 }
635 if self.provider_usage.treatment_actual.state == ViaUsageStateV1::Measured
636 && treatment != Some(final_context)
637 {
638 return Err(ValidationError::new(
639 "measured ON usage does not match provider final tokens",
640 ));
641 }
642 let claim_allowed = self.quality_result.state == ViaQualityStateV1::Passed
643 && self.fallback.is_none()
644 && self.provider_usage.baseline_actual.state == ViaUsageStateV1::Measured
645 && self.provider_usage.treatment_actual.state == ViaUsageStateV1::Measured
646 && self.provider_cache_metrics.is_some();
647 let expected_actual = if claim_allowed { avoided } else { 0 };
648 if self.actual_avoided_tokens != expected_actual
649 || self.counterfactual_avoided_tokens.is_some()
650 {
651 return Err(ValidationError::new(
652 "ON actual savings do not match quality/usage/cache gates",
653 ));
654 }
655 }
656 }
657 Ok(())
658 }
659}
660
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
662#[serde(deny_unknown_fields)]
663pub struct ViaSavingsReceiptV1 {
664 pub receipt_id: Sha256Digest,
665 pub payload: ViaSavingsReceiptPayloadV1,
666}
667
668impl ViaSavingsReceiptV1 {
669 pub fn new(payload: ViaSavingsReceiptPayloadV1) -> Result<Self, ValidationError> {
670 payload.validate()?;
671 let receipt_id = derive_receipt_id(&payload)?;
672 Ok(Self {
673 receipt_id,
674 payload,
675 })
676 }
677
678 pub fn validate(&self) -> Result<(), ValidationError> {
679 self.payload.validate()?;
680 if self.receipt_id != derive_receipt_id(&self.payload)? {
681 return Err(ValidationError::new(
682 "Via savings receipt_id does not match canonical payload",
683 ));
684 }
685 Ok(())
686 }
687
688 pub fn canonical_json(&self) -> Result<Vec<u8>, ValidationError> {
689 self.validate()?;
690 serde_json::to_vec(self)
691 .map_err(|error| ValidationError::new(format!("encode Via receipt: {error}")))
692 }
693
694 pub fn from_json(bytes: &[u8]) -> Result<Self, ValidationError> {
695 let receipt: Self = serde_json::from_slice(bytes)
696 .map_err(|error| ValidationError::new(format!("decode Via receipt: {error}")))?;
697 receipt.validate()?;
698 Ok(receipt)
699 }
700}
701
702fn derive_receipt_id(
703 payload: &ViaSavingsReceiptPayloadV1,
704) -> Result<Sha256Digest, ValidationError> {
705 let bytes = serde_json::to_vec(payload)
706 .map_err(|error| ValidationError::new(format!("encode Via receipt payload: {error}")))?;
707 let mut hash = Sha256::new();
708 hash.update(RECEIPT_ID_DOMAIN);
709 hash.update(bytes);
710 let mut value = String::from("sha256:");
711 for byte in hash.finalize() {
712 write!(&mut value, "{byte:02x}").expect("writing to String cannot fail");
713 }
714 Sha256Digest::new(value)
715}
716
717fn validate_cache_usage_match(
718 cache: &ViaCacheEconomicsV1,
719 usage: &ViaProviderUsageComparisonV1,
720) -> Result<(), ValidationError> {
721 for (field, arm, observed) in [
722 ("baseline", &cache.baseline, &usage.baseline_actual),
723 ("treatment", &cache.treatment, &usage.treatment_actual),
724 ] {
725 if observed.state != ViaUsageStateV1::Measured
726 || observed.uncached_input_tokens != Some(arm.uncached_input_tokens)
727 || observed.cache_write_input_tokens != Some(arm.cache_write_input_tokens)
728 || observed.cache_read_input_tokens != Some(arm.cache_read_input_tokens)
729 || observed.total_input_tokens != Some(arm.context_size_tokens)
730 {
731 return Err(ValidationError::new(format!(
732 "{field} cache metrics do not match measured provider usage"
733 )));
734 }
735 }
736 Ok(())
737}
738
739fn validate_proofs(proofs: &[Sha256Digest]) -> Result<(), ValidationError> {
740 if proofs.is_empty() || proofs.len() > MAX_VIA_RECEIPT_PROOFS {
741 return Err(ValidationError::new(
742 "Via receipt proof_refs must contain 1..=64 digests",
743 ));
744 }
745 let mut unique = BTreeSet::new();
746 for proof in proofs {
747 if !unique.insert(proof.as_str()) {
748 return Err(ValidationError::new("duplicate Via receipt proof digest"));
749 }
750 }
751 if proofs
752 .windows(2)
753 .any(|pair| pair[0].as_str() >= pair[1].as_str())
754 {
755 return Err(ValidationError::new(
756 "Via receipt proof_refs must use stable digest order",
757 ));
758 }
759 Ok(())
760}
761
762fn validate_evidence_bindings(payload: &ViaSavingsReceiptPayloadV1) -> Result<(), ValidationError> {
763 let proofs = payload
764 .proof_refs
765 .iter()
766 .map(Sha256Digest::as_str)
767 .collect::<BTreeSet<_>>();
768 let mut required = Vec::new();
769 if let Some(digest) = payload.quality_result.evidence_digest.as_ref() {
770 required.push(("quality evidence", digest));
771 }
772 if let Some(fallback) = payload.fallback.as_ref() {
773 required.push(("fallback evidence", &fallback.evidence_digest));
774 }
775 if let Some(cache) = payload.provider_cache_metrics.as_ref() {
776 required.push(("cache cost basis", &cache.cost_basis.digest));
777 if let Some(digest) = cache.adverse_cost_justification.as_ref() {
778 required.push(("cache justification", digest));
779 }
780 }
781 for (field, digest) in required {
782 if !proofs.contains(digest.as_str()) {
783 return Err(ValidationError::new(format!(
784 "{field} digest is absent from proof_refs"
785 )));
786 }
787 }
788 Ok(())
789}
790
791fn validate_logical_reference(
792 value: &ProtocolReference,
793 field: &str,
794) -> Result<(), ValidationError> {
795 validate_bounded_opaque_identifier(value.as_str(), field)?;
796 validate_text(value.as_str(), field, MAX_ID_BYTES)
797}
798
799fn validate_text(value: &str, field: &str, maximum: usize) -> Result<(), ValidationError> {
800 if value.is_empty() || value.len() > maximum || value.chars().any(char::is_control) {
801 return Err(ValidationError::new(format!(
802 "{field} must be non-empty, printable and at most {maximum} bytes"
803 )));
804 }
805 if value.starts_with('/')
806 || value.starts_with("~/")
807 || value.contains("../")
808 || value.contains('\\')
809 {
810 return Err(ValidationError::new(format!(
811 "{field} must not contain a filesystem path"
812 )));
813 }
814 Ok(())
815}
816
817fn validate_safe_integer(value: u64, field: &str) -> Result<(), ValidationError> {
818 if value > MAX_SAFE_INTEGER {
819 return Err(ValidationError::new(format!(
820 "{field} exceeds the cross-language safe integer ceiling"
821 )));
822 }
823 Ok(())
824}
825
826fn checked_add(left: u64, right: u64, field: &str) -> Result<u64, ValidationError> {
827 let value = left
828 .checked_add(right)
829 .ok_or_else(|| ValidationError::new(format!("{field} overflowed")))?;
830 validate_safe_integer(value, field)?;
831 Ok(value)
832}
833
834fn checked_sub(left: u64, right: u64, field: &str) -> Result<u64, ValidationError> {
835 left.checked_sub(right)
836 .ok_or_else(|| ValidationError::new(format!("{field} underflowed")))
837}
838
839fn checked_sum<const N: usize>(values: [u64; N], field: &str) -> Result<u64, ValidationError> {
840 values
841 .into_iter()
842 .try_fold(0_u64, |sum, value| checked_add(sum, value, field))
843}
844
845fn sum_context_tokens(
846 contexts: &[ViaContextAttributionV1],
847 select: impl Fn(&ViaContextAttributionV1) -> u64,
848) -> Result<u64, ValidationError> {
849 contexts.iter().try_fold(0_u64, |sum, context| {
850 checked_add(sum, select(context), "context token total")
851 })
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857
858 fn digest(seed: u8) -> Sha256Digest {
859 Sha256Digest::new(format!("sha256:{}", format!("{seed:02x}").repeat(32))).unwrap()
860 }
861
862 fn context() -> ViaContextAttributionV1 {
863 ViaContextAttributionV1 {
864 context_id: ProtocolReference::new("context-1").unwrap(),
865 raw_observed_tokens: 40_000,
866 source_selected_tokens: 8_000,
867 representation_delivered_tokens: 6_500,
868 reuse_delivered_tokens: 4_500,
869 provider_context_final_tokens: 3_900,
870 raw_observed_bytes: 160_000,
871 remote_bytes_transferred: 15_600,
872 }
873 }
874
875 fn usage(total: u64) -> ViaProviderUsageV1 {
876 ViaProviderUsageV1::measured(total, 0, 0, 8).unwrap()
877 }
878
879 fn cache(total: u64, cost: u64) -> ViaCacheArmV1 {
880 ViaCacheArmV1 {
881 context_size_tokens: total,
882 uncached_input_tokens: total,
883 cache_write_input_tokens: 0,
884 cache_read_input_tokens: 0,
885 uncached_input_cost_micros: cost,
886 cache_write_cost_micros: 0,
887 cache_read_cost_micros: 0,
888 total_billed_input_cost_micros: cost,
889 }
890 }
891
892 fn payload(mode: ViaReceiptModeV1) -> ViaSavingsReceiptPayloadV1 {
893 let contexts = if mode == ViaReceiptModeV1::Bypass {
894 vec![ViaContextAttributionV1 {
895 source_selected_tokens: 40_000,
896 representation_delivered_tokens: 40_000,
897 reuse_delivered_tokens: 40_000,
898 provider_context_final_tokens: 40_000,
899 remote_bytes_transferred: 160_000,
900 ..context()
901 }]
902 } else {
903 vec![context()]
904 };
905 let stage_totals = ViaStageTotalsV1::from_contexts(&contexts).unwrap();
906 let via_bandwidth = ViaBandwidthV1::from_contexts(&contexts).unwrap();
907 let final_tokens = contexts[0].provider_context_final_tokens;
908 let treatment = if mode == ViaReceiptModeV1::Shadow {
909 usage(40_000)
910 } else {
911 usage(final_tokens)
912 };
913 ViaSavingsReceiptPayloadV1 {
914 schema_version: 1,
915 identity: ViaReceiptIdentityV1 {
916 invocation_id: ProtocolReference::new("invocation-1").unwrap(),
917 provider_id: "anthropic".to_owned(),
918 model_id: "claude-haiku".to_owned(),
919 },
920 versions: ViaReceiptVersionsV1 {
921 policy: ViaVersionRefV1 {
922 version: "1.0.0".to_owned(),
923 digest: digest(1),
924 },
925 operator: ViaVersionRefV1 {
926 version: "1.0.0".to_owned(),
927 digest: digest(2),
928 },
929 methodology: ViaVersionRefV1 {
930 version: "1.0.0".to_owned(),
931 digest: digest(3),
932 },
933 },
934 requested_mode: mode,
935 effective_mode: mode,
936 baseline_definition: ViaBaselineDefinitionV1::BypassActual,
937 input_body_digest: digest(4),
938 forward_body_digest: if mode == ViaReceiptModeV1::On {
939 digest(5)
940 } else {
941 digest(4)
942 },
943 contexts,
944 stage_totals,
945 provider_usage: ViaProviderUsageComparisonV1 {
946 baseline_actual: usage(40_000),
947 treatment_actual: treatment,
948 counterfactual_final_input_tokens: (mode == ViaReceiptModeV1::Shadow)
949 .then_some(final_tokens),
950 },
951 provider_cache_metrics: (mode == ViaReceiptModeV1::On).then(|| ViaCacheEconomicsV1 {
952 cost_basis: ViaVersionRefV1 {
953 version: "2026-08-28".to_owned(),
954 digest: digest(10),
955 },
956 baseline: cache(40_000, 4_000),
957 treatment: cache(final_tokens, 390),
958 adverse_cost_justification: None,
959 }),
960 via_bandwidth,
961 latency: ViaLatencyV1 {
962 via_latency_micros: 200,
963 provider_latency_micros: Some(2_000),
964 },
965 quality_result: ViaQualityResultV1 {
966 state: if mode == ViaReceiptModeV1::On {
967 ViaQualityStateV1::Passed
968 } else {
969 ViaQualityStateV1::NotEvaluated
970 },
971 evidence_digest: (mode == ViaReceiptModeV1::On).then(|| digest(6)),
972 },
973 fallback: None,
974 counterfactual_avoided_tokens: (mode == ViaReceiptModeV1::Shadow).then_some(36_100),
975 actual_avoided_tokens: if mode == ViaReceiptModeV1::On {
976 36_100
977 } else {
978 0
979 },
980 proof_refs: if mode == ViaReceiptModeV1::On {
981 vec![digest(6), digest(7), digest(8), digest(10)]
982 } else {
983 vec![digest(7), digest(8)]
984 },
985 }
986 }
987
988 #[test]
989 fn authority_example_attributes_each_token_once() {
990 let totals = ViaStageTotalsV1::from_contexts(&[context()]).unwrap();
991 assert_eq!(totals.source_avoidance_tokens, 32_000);
992 assert_eq!(totals.representation_reduction_tokens, 1_500);
993 assert_eq!(totals.upload_reuse_tokens, 2_000);
994 assert_eq!(totals.inference_reduction_tokens, 600);
995 assert_eq!(totals.total_avoided_tokens().unwrap(), 36_100);
996 ViaSavingsReceiptV1::new(payload(ViaReceiptModeV1::On)).unwrap();
997 }
998
999 #[test]
1000 fn bypass_and_shadow_never_claim_actual_savings() {
1001 for mode in [ViaReceiptModeV1::Bypass, ViaReceiptModeV1::Shadow] {
1002 let receipt = ViaSavingsReceiptV1::new(payload(mode)).unwrap();
1003 assert_eq!(receipt.payload.actual_avoided_tokens, 0);
1004 }
1005 }
1006
1007 #[test]
1008 fn unavailable_usage_is_distinct_from_measured_zero() {
1009 ViaProviderUsageV1::unavailable()
1010 .validate("unavailable")
1011 .unwrap();
1012 let measured_zero = ViaProviderUsageV1::measured(0, 0, 0, 0).unwrap();
1013 assert_ne!(ViaProviderUsageV1::unavailable(), measured_zero);
1014 let mut invalid = ViaProviderUsageV1::unavailable();
1015 invalid.total_input_tokens = Some(0);
1016 assert!(invalid.validate("invalid").is_err());
1017 }
1018
1019 #[test]
1020 fn wp019_measured_reduction_reconciles() {
1021 let mut value = payload(ViaReceiptModeV1::On);
1022 value.contexts[0] = ViaContextAttributionV1 {
1023 context_id: ProtocolReference::new("wp019-context").unwrap(),
1024 raw_observed_tokens: 165,
1025 source_selected_tokens: 165,
1026 representation_delivered_tokens: 165,
1027 reuse_delivered_tokens: 165,
1028 provider_context_final_tokens: 138,
1029 raw_observed_bytes: 660,
1030 remote_bytes_transferred: 552,
1031 };
1032 value.stage_totals = ViaStageTotalsV1::from_contexts(&value.contexts).unwrap();
1033 value.via_bandwidth = ViaBandwidthV1::from_contexts(&value.contexts).unwrap();
1034 value.provider_usage.baseline_actual = usage(165);
1035 value.provider_usage.treatment_actual = usage(138);
1036 value.provider_cache_metrics = Some(ViaCacheEconomicsV1 {
1037 cost_basis: ViaVersionRefV1 {
1038 version: "2026-08-28".to_owned(),
1039 digest: digest(10),
1040 },
1041 baseline: cache(165, 165),
1042 treatment: cache(138, 138),
1043 adverse_cost_justification: None,
1044 });
1045 value.actual_avoided_tokens = 27;
1046 ViaSavingsReceiptV1::new(value).unwrap();
1047 }
1048
1049 #[test]
1050 fn rejects_non_monotone_duplicate_and_adverse_unjustified_data() {
1051 let mut non_monotone = context();
1052 non_monotone.source_selected_tokens = non_monotone.raw_observed_tokens + 1;
1053 assert!(non_monotone.validate().is_err());
1054
1055 let mut duplicate = payload(ViaReceiptModeV1::On);
1056 duplicate.contexts.push(duplicate.contexts[0].clone());
1057 duplicate.stage_totals = ViaStageTotalsV1::from_contexts(&duplicate.contexts).unwrap();
1058 duplicate.via_bandwidth = ViaBandwidthV1::from_contexts(&duplicate.contexts).unwrap();
1059 assert!(duplicate.validate().is_err());
1060
1061 let mut adverse = payload(ViaReceiptModeV1::On);
1062 adverse.provider_cache_metrics.as_mut().unwrap().treatment = cache(3_900, 5_000);
1063 assert!(adverse.validate().is_err());
1064 adverse
1065 .provider_cache_metrics
1066 .as_mut()
1067 .unwrap()
1068 .adverse_cost_justification = Some(digest(9));
1069 adverse.proof_refs.insert(3, digest(9));
1070 ViaSavingsReceiptV1::new(adverse).unwrap();
1071 }
1072
1073 #[test]
1074 fn quality_fallback_and_identity_gate_actual_claims() {
1075 let mut failed = payload(ViaReceiptModeV1::On);
1076 failed.quality_result.state = ViaQualityStateV1::Failed;
1077 assert!(failed.validate().is_err());
1078 failed.actual_avoided_tokens = 0;
1079 ViaSavingsReceiptV1::new(failed).unwrap();
1080
1081 let mut fallback = payload(ViaReceiptModeV1::Bypass);
1082 fallback.requested_mode = ViaReceiptModeV1::On;
1083 fallback.fallback = Some(ViaFallbackV1 {
1084 reason: ViaFallbackReasonV1::BrainTimedOut,
1085 evidence_digest: digest(9),
1086 });
1087 fallback.proof_refs.push(digest(9));
1088 ViaSavingsReceiptV1::new(fallback).unwrap();
1089
1090 let receipt = ViaSavingsReceiptV1::new(payload(ViaReceiptModeV1::On)).unwrap();
1091 let same = ViaSavingsReceiptV1::new(receipt.payload.clone()).unwrap();
1092 assert_eq!(receipt.receipt_id, same.receipt_id);
1093 let bytes = receipt.canonical_json().unwrap();
1094 assert_eq!(ViaSavingsReceiptV1::from_json(&bytes).unwrap(), receipt);
1095 let mut tampered = receipt;
1096 tampered.payload.latency.via_latency_micros += 1;
1097 assert!(tampered.validate().is_err());
1098 }
1099
1100 #[test]
1101 fn strict_wire_and_privacy_canaries() {
1102 let receipt = ViaSavingsReceiptV1::new(payload(ViaReceiptModeV1::On)).unwrap();
1103 let bytes = receipt.canonical_json().unwrap();
1104 let mut value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1105 value
1106 .as_object_mut()
1107 .unwrap()
1108 .insert("unknown".to_owned(), serde_json::Value::Bool(true));
1109 assert!(ViaSavingsReceiptV1::from_json(&serde_json::to_vec(&value).unwrap()).is_err());
1110 let wire = String::from_utf8(bytes).unwrap();
1111 for canary in [
1112 "sk-secret-canary",
1113 "prompt-secret-canary",
1114 "/private/secret/path",
1115 "authorization",
1116 "cookie-secret-canary",
1117 "request-body-secret-canary",
1118 ] {
1119 assert!(!wire.contains(canary));
1120 }
1121
1122 let mut path = payload(ViaReceiptModeV1::On);
1123 path.identity.invocation_id = ProtocolReference::new("/private/secret/path").unwrap();
1124 assert!(path.validate().is_err());
1125
1126 let mut oversized = context();
1127 oversized.raw_observed_tokens = MAX_SAFE_INTEGER + 1;
1128 assert!(oversized.validate().is_err());
1129 }
1130}