1use std::collections::{BTreeMap, BTreeSet};
10use std::fmt::{self, Write as _};
11
12use serde::de::{MapAccess, SeqAccess, Visitor};
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15use sha2::{Digest, Sha256};
16
17use crate::{
18 AcceptanceState, CapabilityId, OutcomeId, PlanId, ProtocolReference, SemanticVersion,
19 Sha256Digest, SignatureStatus, TaskId, UtcTimestamp, ValidationError,
20 deserialize_schema_version, validate_bounded_opaque_identifier, validate_schema_version,
21};
22
23pub const MAX_RECEIPT_ITEMS: usize = 64;
25
26pub const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
28
29const MAX_ITEMS: usize = MAX_RECEIPT_ITEMS;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ReceiptTerminalStatusV1 {
35 Succeeded,
36 Failed,
37 Rejected,
38 Cancelled,
39 TimedOut,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum ReceiptValueClassificationV1 {
46 Measured,
47 Estimated,
48 Calculated,
49 Reconciled,
50 Unavailable,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ReceiptEvidenceKindV1 {
57 Measurement,
58 Assumption,
59 Formula,
60 PriceTable,
61 Invoice,
62 Outcome,
63 Runtime,
64 Methodology,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct ReceiptEvidenceRefV1 {
71 pub kind: ReceiptEvidenceKindV1,
72 pub uri: ProtocolReference,
73 pub digest: Sha256Digest,
74 pub media_type: String,
75 pub signature_status: SignatureStatus,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ReceiptValueV1 {
82 pub name: String,
83 pub unit: String,
84 pub classification: ReceiptValueClassificationV1,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub value: Option<u64>,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub evidence_digests: Vec<Sha256Digest>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub formula_digest: Option<Sha256Digest>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub price_table_digest: Option<Sha256Digest>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub reconciliation_digest: Option<Sha256Digest>,
95}
96
97impl ReceiptValueV1 {
98 fn validate(
99 &self,
100 evidence: &BTreeMap<&str, ReceiptEvidenceKindV1>,
101 ) -> Result<(), ValidationError> {
102 validate_name(&self.name, "ReceiptValueV1 name")?;
103 validate_name(&self.unit, "ReceiptValueV1 unit")?;
104 validate_unique_digests(&self.evidence_digests, "ReceiptValueV1 evidence_digests")?;
105 if self
106 .evidence_digests
107 .iter()
108 .any(|digest| !evidence.contains_key(digest.as_str()))
109 {
110 return Err(ValidationError::new(
111 "ReceiptValueV1 references evidence absent from the receipt",
112 ));
113 }
114
115 let direct = self
116 .evidence_digests
117 .iter()
118 .map(|digest| digest.as_str())
119 .collect::<BTreeSet<_>>();
120 let mut derived = BTreeSet::new();
121 for (field, digest) in [
122 ("formula_digest", self.formula_digest.as_ref()),
123 ("price_table_digest", self.price_table_digest.as_ref()),
124 ("reconciliation_digest", self.reconciliation_digest.as_ref()),
125 ] {
126 if let Some(digest) = digest {
127 if direct.contains(digest.as_str()) || !derived.insert(digest.as_str()) {
128 return Err(ValidationError::new(format!(
129 "ReceiptValueV1 {field} must occur exactly once"
130 )));
131 }
132 if !evidence.contains_key(digest.as_str()) {
133 return Err(ValidationError::new(format!(
134 "ReceiptValueV1 {field} references evidence absent from the receipt"
135 )));
136 }
137 }
138 }
139 if self.value.is_some_and(|value| value > MAX_SAFE_INTEGER) {
140 return Err(ValidationError::new(
141 "ReceiptValueV1 value exceeds the cross-language safe integer ceiling",
142 ));
143 }
144
145 let has_value = self.value.is_some();
146 let has_sources = !self.evidence_digests.is_empty();
147 match self.classification {
148 ReceiptValueClassificationV1::Unavailable => {
149 if has_value
150 || has_sources
151 || self.formula_digest.is_some()
152 || self.price_table_digest.is_some()
153 || self.reconciliation_digest.is_some()
154 {
155 return Err(ValidationError::new(
156 "unavailable values must omit value and provenance",
157 ));
158 }
159 }
160 ReceiptValueClassificationV1::Measured | ReceiptValueClassificationV1::Estimated => {
161 if !has_value || !has_sources {
162 return Err(ValidationError::new(
163 "measured and estimated values require a value and evidence",
164 ));
165 }
166 let expected_kind = match self.classification {
167 ReceiptValueClassificationV1::Measured => ReceiptEvidenceKindV1::Measurement,
168 ReceiptValueClassificationV1::Estimated => ReceiptEvidenceKindV1::Assumption,
169 _ => unreachable!("classification branch is exhaustive"),
170 };
171 if !self
172 .evidence_digests
173 .iter()
174 .any(|digest| evidence.get(digest.as_str()) == Some(&expected_kind))
175 {
176 return Err(ValidationError::new(
177 "measured and estimated values require matching evidence kind",
178 ));
179 }
180 if self.formula_digest.is_some()
181 || self.price_table_digest.is_some()
182 || self.reconciliation_digest.is_some()
183 {
184 return Err(ValidationError::new(
185 "measured and estimated values cannot claim calculation or reconciliation",
186 ));
187 }
188 }
189 ReceiptValueClassificationV1::Calculated => {
190 if !has_value
191 || !has_sources
192 || self.formula_digest.is_none()
193 || self.price_table_digest.is_none()
194 || self.reconciliation_digest.is_some()
195 {
196 return Err(ValidationError::new(
197 "calculated values require value, evidence, formula and price-table digests",
198 ));
199 }
200 if !self.evidence_digests.iter().all(|digest| {
201 matches!(
202 evidence.get(digest.as_str()),
203 Some(
204 ReceiptEvidenceKindV1::Measurement | ReceiptEvidenceKindV1::Assumption
205 )
206 )
207 }) || !matches!(
208 self.formula_digest
209 .as_ref()
210 .and_then(|digest| evidence.get(digest.as_str())),
211 Some(ReceiptEvidenceKindV1::Formula)
212 ) || !matches!(
213 self.price_table_digest
214 .as_ref()
215 .and_then(|digest| evidence.get(digest.as_str())),
216 Some(ReceiptEvidenceKindV1::PriceTable)
217 ) {
218 return Err(ValidationError::new(
219 "calculated values require measurement/assumption, formula and price-table evidence",
220 ));
221 }
222 }
223 ReceiptValueClassificationV1::Reconciled => {
224 let Some(reconciliation) = self.reconciliation_digest.as_ref() else {
225 return Err(ValidationError::new(
226 "reconciled values require invoice evidence",
227 ));
228 };
229 if !has_value
230 || !has_sources
231 || self.formula_digest.is_none()
232 || self.price_table_digest.is_none()
233 {
234 return Err(ValidationError::new(
235 "reconciled values require calculated provenance and listed invoice evidence",
236 ));
237 }
238 if !self.evidence_digests.iter().all(|digest| {
239 matches!(
240 evidence.get(digest.as_str()),
241 Some(
242 ReceiptEvidenceKindV1::Measurement | ReceiptEvidenceKindV1::Assumption
243 )
244 )
245 }) || !matches!(
246 self.formula_digest
247 .as_ref()
248 .and_then(|digest| evidence.get(digest.as_str())),
249 Some(ReceiptEvidenceKindV1::Formula)
250 ) || !matches!(
251 self.price_table_digest
252 .as_ref()
253 .and_then(|digest| evidence.get(digest.as_str())),
254 Some(ReceiptEvidenceKindV1::PriceTable)
255 ) || evidence.get(reconciliation.as_str())
256 != Some(&ReceiptEvidenceKindV1::Invoice)
257 {
258 return Err(ValidationError::new(
259 "reconciled values require invoice, formula and price-table evidence",
260 ));
261 }
262 }
263 }
264 Ok(())
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct ReceiptCapabilityLinkV1 {
272 pub capability_id: CapabilityId,
273 pub capability_version: SemanticVersion,
274 pub invocation_ref: Sha256Digest,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(deny_unknown_fields)]
280pub struct ReceiptLineageV1 {
281 pub task_id: TaskId,
282 pub task_ref: Sha256Digest,
283 pub plan_id: PlanId,
284 pub plan_ref: Sha256Digest,
285 pub invocation_id: String,
286 pub invocation_ref: Sha256Digest,
287 pub identity_ref: Sha256Digest,
288 pub policy_refs: Vec<Sha256Digest>,
289 pub capabilities: Vec<ReceiptCapabilityLinkV1>,
290}
291
292impl ReceiptLineageV1 {
293 fn validate(&self) -> Result<(), ValidationError> {
294 validate_bounded_opaque_identifier(&self.invocation_id, "ReceiptLineageV1 invocation_id")?;
295 validate_unique_digests(&self.policy_refs, "ReceiptLineageV1 policy_refs")?;
296 if self.policy_refs.is_empty() {
297 return Err(ValidationError::new(
298 "ReceiptLineageV1 requires at least one policy reference",
299 ));
300 }
301 if self.capabilities.is_empty() || self.capabilities.len() > MAX_ITEMS {
302 return Err(ValidationError::new(
303 "ReceiptLineageV1 capabilities must contain 1..=64 entries",
304 ));
305 }
306 let mut unique = BTreeSet::new();
307 for capability in &self.capabilities {
308 if capability.invocation_ref != self.invocation_ref {
309 return Err(ValidationError::new(
310 "ReceiptLineageV1 capability invocation_ref must bind the invocation",
311 ));
312 }
313 if !unique.insert((
314 capability.capability_id.as_str(),
315 capability.capability_version.as_str(),
316 )) {
317 return Err(ValidationError::new(
318 "ReceiptLineageV1 capabilities must be unique",
319 ));
320 }
321 }
322 Ok(())
323 }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct ReceiptOutcomeLinkV1 {
330 pub state: AcceptanceState,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub outcome_id: Option<OutcomeId>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub outcome_ref: Option<Sha256Digest>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub acceptance_evidence_digest: Option<Sha256Digest>,
337}
338
339impl ReceiptOutcomeLinkV1 {
340 fn validate(
341 &self,
342 evidence: &BTreeMap<&str, ReceiptEvidenceKindV1>,
343 ) -> Result<(), ValidationError> {
344 match self.state {
345 AcceptanceState::Unknown => {
346 if self.outcome_id.is_some()
347 || self.outcome_ref.is_some()
348 || self.acceptance_evidence_digest.is_some()
349 {
350 return Err(ValidationError::new(
351 "unknown outcome must not claim an outcome or acceptance observation",
352 ));
353 }
354 }
355 AcceptanceState::Rejected => {
356 let Some(outcome_ref) = self.outcome_ref.as_ref() else {
357 return Err(ValidationError::new(
358 "rejected outcome requires outcome_id and outcome_ref",
359 ));
360 };
361 if self.outcome_id.is_none()
362 || evidence.get(outcome_ref.as_str()) != Some(&ReceiptEvidenceKindV1::Outcome)
363 || self.acceptance_evidence_digest.is_some()
364 {
365 return Err(ValidationError::new(
366 "rejected outcome requires listed outcome evidence and no acceptance claim",
367 ));
368 }
369 }
370 AcceptanceState::Accepted => {
371 let Some(outcome_ref) = self.outcome_ref.as_ref() else {
372 return Err(ValidationError::new(
373 "accepted outcome requires outcome_id and outcome_ref",
374 ));
375 };
376 let Some(digest) = self.acceptance_evidence_digest.as_ref() else {
377 return Err(ValidationError::new(
378 "accepted outcome requires identified acceptance evidence",
379 ));
380 };
381 if self.outcome_id.is_none()
382 || evidence.get(outcome_ref.as_str()) != Some(&ReceiptEvidenceKindV1::Outcome)
383 || !evidence.contains_key(digest.as_str())
384 || outcome_ref == digest
385 {
386 return Err(ValidationError::new(
387 "accepted outcome requires distinct listed outcome and acceptance evidence",
388 ));
389 }
390 }
391 }
392 Ok(())
393 }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(deny_unknown_fields)]
399pub struct ReceiptChainLinkV1 {
400 pub chain_id: String,
401 pub sequence_number: u64,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub previous_receipt_id: Option<Sha256Digest>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub previous_signature_digest: Option<Sha256Digest>,
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum ReceiptKeyAdmissionV1 {
412 ExternalTrustStore,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(deny_unknown_fields)]
418pub struct ReceiptSignerV1 {
419 pub algorithm: String,
420 pub key_id: String,
421 pub key_admission: ReceiptKeyAdmissionV1,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426#[serde(deny_unknown_fields)]
427pub struct ReceiptDocumentV1 {
428 #[serde(deserialize_with = "deserialize_schema_version")]
429 pub schema_version: u32,
430 pub receipt_id: Sha256Digest,
431 pub lineage: ReceiptLineageV1,
432 pub chain: ReceiptChainLinkV1,
433 pub status: ReceiptTerminalStatusV1,
434 pub values: Vec<ReceiptValueV1>,
435 pub outcome: ReceiptOutcomeLinkV1,
436 pub evidence_refs: Vec<ReceiptEvidenceRefV1>,
437 pub issued_at: UtcTimestamp,
438 pub signer: ReceiptSignerV1,
439 pub signature: String,
440}
441
442impl ReceiptDocumentV1 {
443 pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
445 canonical_without(self, &[])
446 }
447
448 pub fn canonical_json(&self) -> Result<String, ValidationError> {
450 String::from_utf8(self.canonical_bytes()?)
451 .map_err(|error| ValidationError::new(format!("canonical receipt UTF-8: {error}")))
452 }
453
454 pub fn identity_bytes(&self) -> Result<Vec<u8>, ValidationError> {
456 canonical_without(self, &["receipt_id", "signature"])
457 }
458
459 pub fn signing_bytes(&self) -> Result<Vec<u8>, ValidationError> {
461 canonical_without(self, &["signature"])
462 }
463
464 pub fn signature_bytes(&self) -> Result<Vec<u8>, ValidationError> {
466 self.signing_bytes()
467 }
468
469 pub fn derived_receipt_id(&self) -> Result<Sha256Digest, ValidationError> {
471 digest_bytes(&self.identity_bytes()?)
472 }
473
474 pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
476 let value = strict_json_value(bytes)?;
477 let document = serde_json::from_value::<Self>(value)
478 .map_err(|error| ValidationError::new(format!("decode receipt document: {error}")))?;
479 if document.canonical_bytes()? != bytes {
480 return Err(ValidationError::new(
481 "receipt document JSON is not canonical UTF-8, compactness or key order",
482 ));
483 }
484 document.validate()?;
485 Ok(document)
486 }
487
488 pub fn from_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
490 Self::from_canonical_bytes(bytes)
491 }
492
493 pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, ValidationError> {
495 Self::from_canonical_bytes(bytes)
496 }
497
498 pub fn validate(&self) -> Result<(), ValidationError> {
500 validate_schema_version(self.schema_version)?;
501 self.lineage.validate()?;
502 validate_bounded_opaque_identifier(&self.chain.chain_id, "ReceiptChainLinkV1 chain_id")?;
503 validate_key_id(&self.signer.key_id)?;
504 if self.signer.algorithm != "ed25519" {
505 return Err(ValidationError::new(
506 "ReceiptSignerV1 algorithm must be ed25519",
507 ));
508 }
509 if !valid_base64_signature(&self.signature) {
510 return Err(ValidationError::new(
511 "ReceiptDocumentV1 signature must be canonical base64 Ed25519 bytes",
512 ));
513 }
514 if self.chain.sequence_number == 0 || self.chain.sequence_number > MAX_SAFE_INTEGER {
515 return Err(ValidationError::new(
516 "ReceiptChainLinkV1 sequence_number must be 1..=9007199254740991",
517 ));
518 }
519 let predecessor_pair = (
520 self.chain.previous_receipt_id.is_some(),
521 self.chain.previous_signature_digest.is_some(),
522 );
523 if (self.chain.sequence_number == 1 && predecessor_pair != (false, false))
524 || (self.chain.sequence_number > 1 && predecessor_pair != (true, true))
525 {
526 return Err(ValidationError::new(
527 "ReceiptChainLinkV1 predecessor fields must be absent only at genesis",
528 ));
529 }
530 if self.chain.previous_receipt_id.as_ref() == Some(&self.receipt_id) {
531 return Err(ValidationError::new(
532 "ReceiptChainLinkV1 cannot reference the current receipt",
533 ));
534 }
535 if self.evidence_refs.len() > MAX_ITEMS || self.values.len() > MAX_ITEMS {
536 return Err(ValidationError::new(
537 "ReceiptDocumentV1 collection limit exceeded",
538 ));
539 }
540
541 let mut evidence = BTreeMap::new();
542 for reference in &self.evidence_refs {
543 validate_media_type(&reference.media_type)?;
544 validate_safe_evidence_uri(reference.uri.as_str())?;
545 if evidence
546 .insert(reference.digest.as_str(), reference.kind)
547 .is_some()
548 {
549 return Err(ValidationError::new(
550 "ReceiptDocumentV1 evidence digests must be unique",
551 ));
552 }
553 }
554 let mut value_names = BTreeSet::new();
555 for value in &self.values {
556 if !value_names.insert(value.name.as_str()) {
557 return Err(ValidationError::new(
558 "ReceiptDocumentV1 value names must be unique",
559 ));
560 }
561 value.validate(&evidence)?;
562 }
563 self.outcome.validate(&evidence)?;
564 match (self.status, self.outcome.state) {
565 (ReceiptTerminalStatusV1::Rejected, AcceptanceState::Rejected)
566 | (ReceiptTerminalStatusV1::Succeeded, AcceptanceState::Accepted) => {}
567 (ReceiptTerminalStatusV1::Rejected, _) => {
568 return Err(ValidationError::new(
569 "rejected terminal status requires a rejected outcome",
570 ));
571 }
572 (_, AcceptanceState::Unknown) => {}
573 (_, AcceptanceState::Rejected) => {
574 return Err(ValidationError::new(
575 "rejected outcome requires a rejected terminal status",
576 ));
577 }
578 (_, AcceptanceState::Accepted) => {
579 return Err(ValidationError::new(
580 "accepted outcome requires a succeeded terminal status",
581 ));
582 }
583 }
584 if self.derived_receipt_id()? != self.receipt_id {
585 return Err(ValidationError::new(
586 "ReceiptDocumentV1 receipt_id does not match canonical content",
587 ));
588 }
589 Ok(())
590 }
591}
592
593fn canonical_without<T: Serialize>(
594 value: &T,
595 omitted: &[&str],
596) -> Result<Vec<u8>, ValidationError> {
597 let mut value = serde_json::to_value(value)
598 .map_err(|error| ValidationError::new(format!("serialize receipt document: {error}")))?;
599 let object = value
600 .as_object_mut()
601 .ok_or_else(|| ValidationError::new("receipt document must serialize as an object"))?;
602 for field in omitted {
603 object.remove(*field);
604 }
605 serde_json::to_vec(&sort_json(value))
606 .map_err(|error| ValidationError::new(format!("canonicalize receipt document: {error}")))
607}
608
609pub(crate) fn sort_json(value: Value) -> Value {
610 match value {
611 Value::Object(object) => Value::Object(
612 object
613 .into_iter()
614 .map(|(key, value)| (key, sort_json(value)))
615 .collect::<BTreeMap<_, _>>()
616 .into_iter()
617 .collect(),
618 ),
619 Value::Array(values) => Value::Array(values.into_iter().map(sort_json).collect()),
620 scalar => scalar,
621 }
622}
623
624struct StrictJsonValue(Value);
626
627impl<'de> Deserialize<'de> for StrictJsonValue {
628 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
629 where
630 D: serde::Deserializer<'de>,
631 {
632 deserializer.deserialize_any(StrictJsonVisitor)
633 }
634}
635
636struct StrictJsonVisitor;
637
638impl<'de> Visitor<'de> for StrictJsonVisitor {
639 type Value = StrictJsonValue;
640
641 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
642 formatter.write_str("a JSON value with unique object keys")
643 }
644
645 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
646 where
647 E: serde::de::Error,
648 {
649 Ok(StrictJsonValue(Value::Bool(value)))
650 }
651
652 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
653 where
654 E: serde::de::Error,
655 {
656 Ok(StrictJsonValue(Value::Number(value.into())))
657 }
658
659 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
660 where
661 E: serde::de::Error,
662 {
663 Ok(StrictJsonValue(Value::Number(value.into())))
664 }
665
666 fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
667 where
668 E: serde::de::Error,
669 {
670 let _ = value;
671 Err(E::custom(
672 "floating-point JSON numbers are not canonical receipt values",
673 ))
674 }
675
676 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
677 where
678 E: serde::de::Error,
679 {
680 Ok(StrictJsonValue(Value::String(value.to_owned())))
681 }
682
683 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
684 where
685 E: serde::de::Error,
686 {
687 Ok(StrictJsonValue(Value::String(value)))
688 }
689
690 fn visit_none<E>(self) -> Result<Self::Value, E>
691 where
692 E: serde::de::Error,
693 {
694 Ok(StrictJsonValue(Value::Null))
695 }
696
697 fn visit_unit<E>(self) -> Result<Self::Value, E>
698 where
699 E: serde::de::Error,
700 {
701 Ok(StrictJsonValue(Value::Null))
702 }
703
704 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
705 where
706 A: SeqAccess<'de>,
707 {
708 let mut values = Vec::new();
709 while let Some(value) = seq.next_element::<StrictJsonValue>()? {
710 values.push(value.0);
711 }
712 Ok(StrictJsonValue(Value::Array(values)))
713 }
714
715 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
716 where
717 A: MapAccess<'de>,
718 {
719 let mut object = Map::new();
720 while let Some(key) = map.next_key::<String>()? {
721 if object.contains_key(&key) {
722 return Err(serde::de::Error::custom(format!(
723 "duplicate JSON object key {key:?}"
724 )));
725 }
726 let value = map.next_value::<StrictJsonValue>()?;
727 object.insert(key, value.0);
728 }
729 Ok(StrictJsonValue(Value::Object(object)))
730 }
731}
732
733fn strict_json_value(bytes: &[u8]) -> Result<Value, ValidationError> {
734 let mut deserializer = serde_json::Deserializer::from_slice(bytes);
735 let value = StrictJsonValue::deserialize(&mut deserializer)
736 .map_err(|error| ValidationError::new(format!("decode canonical JSON: {error}")))?
737 .0;
738 deserializer
739 .end()
740 .map_err(|error| ValidationError::new(format!("trailing canonical JSON: {error}")))?;
741 Ok(value)
742}
743
744pub(crate) fn digest_bytes(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
745 let mut value = String::with_capacity(71);
746 value.push_str("sha256:");
747 for byte in Sha256::digest(bytes) {
748 write!(&mut value, "{byte:02x}")
749 .map_err(|_| ValidationError::new("digest encoding failed"))?;
750 }
751 Sha256Digest::new(value)
752}
753
754fn validate_name(value: &str, field: &str) -> Result<(), ValidationError> {
755 if value.is_empty()
756 || value.len() > 64
757 || !value
758 .bytes()
759 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
760 {
761 return Err(ValidationError::new(format!(
762 "{field} must be 1..=64 lowercase ASCII letters, digits or underscores"
763 )));
764 }
765 Ok(())
766}
767
768fn validate_media_type(value: &str) -> Result<(), ValidationError> {
769 let mut parts = value.split('/');
770 let Some(major) = parts.next() else {
771 return Err(ValidationError::new("media type must contain one slash"));
772 };
773 let Some(subtype) = parts.next() else {
774 return Err(ValidationError::new("media type must contain one slash"));
775 };
776 if parts.next().is_some()
777 || value.is_empty()
778 || value.len() > 128
779 || major.is_empty()
780 || subtype.is_empty()
781 || !major.bytes().all(is_media_token)
782 || !subtype.bytes().all(is_media_token)
783 {
784 return Err(ValidationError::new(
785 "ReceiptEvidenceRefV1 media_type must be a bounded RFC token type/subtype",
786 ));
787 }
788 Ok(())
789}
790
791fn is_media_token(byte: u8) -> bool {
792 byte.is_ascii_alphanumeric() || b"!#$&^_.+-".contains(&byte)
793}
794
795fn validate_safe_evidence_uri(value: &str) -> Result<(), ValidationError> {
796 if value.is_empty()
797 || value.len() > 1_024
798 || !value.is_ascii()
799 || value
800 .bytes()
801 .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control())
802 || value.contains(['?', '#', '@'])
803 {
804 return Err(ValidationError::new(
805 "evidence URI must be an ASCII privacy-safe locator without query, fragment or userinfo",
806 ));
807 }
808 let allowed = [
809 "artifact://",
810 "evidence://",
811 "bundle://",
812 "source://",
813 "urn:",
814 ];
815 let Some(prefix) = allowed.iter().find(|prefix| value.starts_with(*prefix)) else {
816 return Err(ValidationError::new(
817 "evidence URI must use artifact, evidence, bundle, source or urn scheme",
818 ));
819 };
820 if value.len() == prefix.len() || value[prefix.len()..].starts_with('/') {
821 return Err(ValidationError::new(
822 "evidence URI must include a non-path locator",
823 ));
824 }
825 if value.contains("..") || value.starts_with("file:") {
826 return Err(ValidationError::new(
827 "evidence URI must not contain path traversal or local file paths",
828 ));
829 }
830 Ok(())
831}
832
833fn validate_key_id(value: &str) -> Result<(), ValidationError> {
834 if value.is_empty()
835 || value.len() > 128
836 || !value.is_ascii()
837 || !value.as_bytes()[0].is_ascii_alphanumeric()
838 || !value
839 .bytes()
840 .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte))
841 || value.starts_with("base64:")
842 || value.starts_with("hex:")
843 {
844 return Err(ValidationError::new(
845 "ReceiptSignerV1 key_id must be a bounded external key identifier, not key material",
846 ));
847 }
848 Ok(())
849}
850
851fn validate_unique_digests(values: &[Sha256Digest], field: &str) -> Result<(), ValidationError> {
852 if values.len() > MAX_ITEMS {
853 return Err(ValidationError::new(format!("{field} exceeds 64 entries")));
854 }
855 let mut unique = BTreeSet::new();
856 if values.iter().any(|value| !unique.insert(value.as_str())) {
857 return Err(ValidationError::new(format!("{field} must be unique")));
858 }
859 Ok(())
860}
861
862fn valid_base64_signature(value: &str) -> bool {
863 if value.len() != 88 || !value.ends_with("==") {
864 return false;
865 }
866 let bytes = value.as_bytes();
867 if !bytes[..86]
868 .iter()
869 .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'+' || *byte == b'/')
870 {
871 return false;
872 }
873 base64_value(bytes[85]).is_some_and(|last| last & 0x0f == 0)
877}
878
879fn base64_value(byte: u8) -> Option<u8> {
880 match byte {
881 b'A'..=b'Z' => Some(byte - b'A'),
882 b'a'..=b'z' => Some(byte - b'a' + 26),
883 b'0'..=b'9' => Some(byte - b'0' + 52),
884 b'+' => Some(62),
885 b'/' => Some(63),
886 _ => None,
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 const A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
895 const B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
896 const C: &str = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
897 const D: &str = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
898
899 fn digest(value: &str) -> Sha256Digest {
900 Sha256Digest::new(value).expect("valid digest")
901 }
902
903 fn evidence(
904 kind: ReceiptEvidenceKindV1,
905 digest_value: &str,
906 uri: &str,
907 ) -> ReceiptEvidenceRefV1 {
908 ReceiptEvidenceRefV1 {
909 kind,
910 uri: ProtocolReference::new(uri).unwrap(),
911 digest: digest(digest_value),
912 media_type: "application/json".to_owned(),
913 signature_status: SignatureStatus::Verified,
914 }
915 }
916
917 fn document() -> ReceiptDocumentV1 {
918 let mut document = ReceiptDocumentV1 {
919 schema_version: 1,
920 receipt_id: digest(B),
921 lineage: ReceiptLineageV1 {
922 task_id: TaskId::new("task-1").unwrap(),
923 task_ref: digest(A),
924 plan_id: PlanId::new("plan-1").unwrap(),
925 plan_ref: digest(B),
926 invocation_id: "invocation-1".to_owned(),
927 invocation_ref: digest(C),
928 identity_ref: digest(A),
929 policy_refs: vec![digest(B)],
930 capabilities: vec![ReceiptCapabilityLinkV1 {
931 capability_id: CapabilityId::new("capability://leanctx/context").unwrap(),
932 capability_version: SemanticVersion::new("1.0.0").unwrap(),
933 invocation_ref: digest(C),
934 }],
935 },
936 chain: ReceiptChainLinkV1 {
937 chain_id: "chain-1".to_owned(),
938 sequence_number: 1,
939 previous_receipt_id: None,
940 previous_signature_digest: None,
941 },
942 status: ReceiptTerminalStatusV1::Succeeded,
943 values: vec![ReceiptValueV1 {
944 name: "input_tokens".to_owned(),
945 unit: "token".to_owned(),
946 classification: ReceiptValueClassificationV1::Measured,
947 value: Some(42),
948 evidence_digests: vec![digest(A)],
949 formula_digest: None,
950 price_table_digest: None,
951 reconciliation_digest: None,
952 }],
953 outcome: ReceiptOutcomeLinkV1 {
954 state: AcceptanceState::Unknown,
955 outcome_id: None,
956 outcome_ref: None,
957 acceptance_evidence_digest: None,
958 },
959 evidence_refs: vec![evidence(
960 ReceiptEvidenceKindV1::Measurement,
961 A,
962 "artifact://usage",
963 )],
964 issued_at: UtcTimestamp::new("2026-08-23T12:00:00Z").unwrap(),
965 signer: ReceiptSignerV1 {
966 algorithm: "ed25519".to_owned(),
967 key_id: "test-key".to_owned(),
968 key_admission: ReceiptKeyAdmissionV1::ExternalTrustStore,
969 },
970 signature: "A".repeat(86) + "==",
971 };
972 document.receipt_id = document.derived_receipt_id().unwrap();
973 document
974 }
975
976 #[test]
977 fn canonical_identity_and_signature_bytes_are_deterministic() {
978 let document = document();
979 document.validate().unwrap();
980 assert_eq!(
981 document.identity_bytes().unwrap(),
982 document.identity_bytes().unwrap()
983 );
984 assert_ne!(
985 document.identity_bytes().unwrap(),
986 document.signing_bytes().unwrap()
987 );
988 assert_ne!(
989 document.signing_bytes().unwrap(),
990 document.canonical_bytes().unwrap()
991 );
992 }
993
994 #[test]
995 fn canonical_decoder_rejects_duplicates_order_whitespace_and_invalid_utf8() {
996 let document = document();
997 let canonical = document.canonical_bytes().unwrap();
998 let mut duplicate = canonical.clone();
999 duplicate.pop();
1000 duplicate.extend_from_slice(b",\"schema_version\":1}");
1001 assert!(ReceiptDocumentV1::from_canonical_bytes(&duplicate).is_err());
1002
1003 let mut whitespace = Vec::from(b" \n".as_slice());
1004 whitespace.extend_from_slice(&canonical);
1005 assert!(ReceiptDocumentV1::from_canonical_bytes(&whitespace).is_err());
1006 assert!(ReceiptDocumentV1::from_canonical_bytes(&[0xff]).is_err());
1007 assert!(strict_json_value(br#"{"value":1.0}"#).is_err());
1008 }
1009
1010 #[test]
1011 fn canonical_decoder_round_trips_golden_value() {
1012 let document = document();
1013 let bytes = document.canonical_bytes().unwrap();
1014 let decoded = ReceiptDocumentV1::from_canonical_bytes(&bytes).unwrap();
1015 assert_eq!(decoded, document);
1016 let golden =
1017 include_bytes!("../../../../docs/contracts/receipt-document/v1/valid-structure.json");
1018 let golden = golden.strip_suffix(b"\n").unwrap_or(golden);
1019 assert_eq!(
1020 ReceiptDocumentV1::from_canonical_bytes(golden).unwrap(),
1021 document
1022 );
1023 let identity = include_bytes!(
1024 "../../../../docs/contracts/receipt-document/v1/canonical-identity.json"
1025 );
1026 let identity = identity.strip_suffix(b"\n").unwrap_or(identity);
1027 assert_eq!(document.identity_bytes().unwrap(), identity);
1028 let signing =
1029 include_bytes!("../../../../docs/contracts/receipt-document/v1/canonical-signing.json");
1030 let signing = signing.strip_suffix(b"\n").unwrap_or(signing);
1031 assert_eq!(document.signing_bytes().unwrap(), signing);
1032 let invalid = include_bytes!(
1033 "../../../../docs/contracts/receipt-document/v1/invalid-unknown-field.json"
1034 );
1035 let invalid = invalid.strip_suffix(b"\n").unwrap_or(invalid);
1036 assert!(ReceiptDocumentV1::from_canonical_bytes(invalid).is_err());
1037 let unicode =
1038 include_bytes!("../../../../docs/contracts/receipt-document/v1/canonical-unicode.json");
1039 let unicode = unicode.strip_suffix(b"\n").unwrap_or(unicode);
1040 let unicode_document = ReceiptDocumentV1::from_canonical_bytes(unicode).unwrap();
1041 assert_eq!(unicode_document.lineage.task_id.as_str(), "tâsk-✓");
1042 assert_eq!(unicode_document.canonical_bytes().unwrap(), unicode);
1043 let escaped = include_bytes!(
1044 "../../../../docs/contracts/receipt-document/v1/invalid-unicode-escape.json"
1045 );
1046 let escaped = escaped.strip_suffix(b"\n").unwrap_or(escaped);
1047 assert!(ReceiptDocumentV1::from_canonical_bytes(escaped).is_err());
1048 }
1049
1050 #[test]
1051 fn integer_ceiling_and_timestamp_are_cross_language_strict() {
1052 let mut document = document();
1053 document.values[0].value = Some(MAX_SAFE_INTEGER + 1);
1054 document.receipt_id = document.derived_receipt_id().unwrap();
1055 assert!(document.validate().is_err());
1056 assert!(UtcTimestamp::new("2026-08-23T12:00:00.000Z").is_err());
1057 assert!(UtcTimestamp::new("2026-08-23T14:00:00+02:00").is_err());
1058 }
1059
1060 #[test]
1061 fn bounded_opaque_ids_enforce_utf8_byte_ceiling() {
1062 assert!(TaskId::new("é".repeat(128)).is_ok());
1063 assert!(TaskId::new("é".repeat(129)).is_err());
1064 }
1065
1066 #[test]
1067 fn base64_and_key_id_are_strict_without_embedded_trust() {
1068 assert!(valid_base64_signature(&("A".repeat(86) + "==")));
1069 assert!(!valid_base64_signature(&("A".repeat(85) + "B==")));
1070 let mut alias = document();
1071 alias.signature = "A".repeat(85) + "B==";
1072 assert!(alias.validate().is_err());
1073 assert!(validate_media_type("/json").is_err());
1074 assert!(validate_media_type("application/json").is_ok());
1075 assert!(validate_safe_evidence_uri("artifact://").is_err());
1076 assert!(validate_safe_evidence_uri("artifact:///tmp/private").is_err());
1077 assert!(validate_key_id("key://runtime/ed25519").is_ok());
1078 assert!(validate_key_id("base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").is_err());
1079 assert!(validate_key_id("bad key").is_err());
1080 }
1081
1082 #[test]
1083 fn calculated_and_reconciled_values_require_typed_evidence() {
1084 let mut calculated = document();
1085 calculated.evidence_refs.extend([
1086 evidence(ReceiptEvidenceKindV1::Formula, B, "artifact://formula"),
1087 evidence(ReceiptEvidenceKindV1::PriceTable, C, "artifact://price"),
1088 ]);
1089 calculated.values[0].classification = ReceiptValueClassificationV1::Calculated;
1090 calculated.values[0].formula_digest = Some(digest(B));
1091 calculated.values[0].price_table_digest = Some(digest(C));
1092 calculated.receipt_id = calculated.derived_receipt_id().unwrap();
1093 calculated.validate().unwrap();
1094 calculated.values[0].price_table_digest = Some(digest(B));
1095 calculated.receipt_id = calculated.derived_receipt_id().unwrap();
1096 assert!(calculated.validate().is_err());
1097 calculated.values[0].price_table_digest = Some(digest(C));
1098
1099 let mut reconciled = calculated;
1100 reconciled.evidence_refs.push(evidence(
1101 ReceiptEvidenceKindV1::Invoice,
1102 D,
1103 "artifact://invoice",
1104 ));
1105 reconciled.values[0].classification = ReceiptValueClassificationV1::Reconciled;
1106 reconciled.values[0].reconciliation_digest = Some(digest(D));
1107 reconciled.values[0].evidence_digests.push(digest(D));
1108 reconciled.receipt_id = reconciled.derived_receipt_id().unwrap();
1109 assert!(reconciled.validate().is_err());
1110 reconciled.values[0].evidence_digests.pop();
1111 reconciled.receipt_id = reconciled.derived_receipt_id().unwrap();
1112 reconciled.validate().unwrap();
1113 }
1114
1115 #[test]
1116 fn rejected_outcome_requires_outcome_evidence_and_terminal_status() {
1117 let mut document = document();
1118 document.status = ReceiptTerminalStatusV1::Rejected;
1119 document.receipt_id = document.derived_receipt_id().unwrap();
1120 assert!(document.validate().is_err());
1121
1122 document.evidence_refs.push(evidence(
1123 ReceiptEvidenceKindV1::Outcome,
1124 D,
1125 "artifact://outcome",
1126 ));
1127 document.outcome = ReceiptOutcomeLinkV1 {
1128 state: AcceptanceState::Rejected,
1129 outcome_id: Some(OutcomeId::new("outcome-1").unwrap()),
1130 outcome_ref: Some(digest(D)),
1131 acceptance_evidence_digest: None,
1132 };
1133 document.receipt_id = document.derived_receipt_id().unwrap();
1134 document.validate().unwrap();
1135 document.outcome.acceptance_evidence_digest = Some(digest(A));
1136 document.receipt_id = document.derived_receipt_id().unwrap();
1137 assert!(document.validate().is_err());
1138 }
1139
1140 #[test]
1141 fn duplicate_provenance_is_rejected() {
1142 let mut document = document();
1143 document.evidence_refs.push(evidence(
1144 ReceiptEvidenceKindV1::Runtime,
1145 A,
1146 "artifact://runtime",
1147 ));
1148 document.receipt_id = document.derived_receipt_id().unwrap();
1149 assert!(document.validate().is_err());
1150 }
1151
1152 #[test]
1153 fn strict_decoder_rejects_unknown_fields() {
1154 let mut value = serde_json::to_value(document()).unwrap();
1155 value["unknown"] = Value::Bool(true);
1156 assert!(serde_json::from_value::<ReceiptDocumentV1>(value).is_err());
1157 }
1158}