Skip to main content

lean_ctx_protocol/
via_receipt.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Via-specific, content-free savings receipt with non-overlapping attribution.
4
5use 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}