Skip to main content

lean_ctx_protocol/
receipt_document.rs

1//! Canonical signed local evidence receipt.
2//!
3//! `ReceiptDocumentV1` is the immutable, digest-addressed record for one
4//! Task -> Plan -> Invocation -> Receipt -> Outcome lineage.  It deliberately
5//! contains metadata and references only: prompts, completions, source files,
6//! credentials, headers, and other private payloads belong in separately
7//! persisted evidence artifacts.
8
9use 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
23/// Maximum array length in the receipt wire contract.
24pub const MAX_RECEIPT_ITEMS: usize = 64;
25
26/// Maximum integer that round-trips exactly through every v1 JSON consumer.
27pub const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
28
29const MAX_ITEMS: usize = MAX_RECEIPT_ITEMS;
30
31/// Terminal state of the recorded execution.
32#[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/// Provenance class of one integer observation.
43#[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/// Kind of payload referenced by a receipt.
54#[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/// Strict digest-only evidence reference; payload bytes are never embedded.
68#[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/// One named integer fact with enough provenance to interpret its claim class.
79#[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/// Version-pinned capability that participated in the invocation.
269#[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/// Immutable identity map for the execution lineage.
278#[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/// Link to the separately persisted outcome observation.
327#[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/// Chain predecessor covered by this receipt signature.
397#[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/// Admission mode for signer keys.  No key bytes are embedded in a receipt.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum ReceiptKeyAdmissionV1 {
412    ExternalTrustStore,
413}
414
415/// Explicit signer identity without an embedded trust root.
416#[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/// Canonical signed receipt document.
425#[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    /// Canonical bytes for the complete document, including signature.
444    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
445        canonical_without(self, &[])
446    }
447
448    /// Return canonical JSON as UTF-8 text without changing its bytes.
449    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    /// Canonical bytes used to derive the receipt ID.
455    pub fn identity_bytes(&self) -> Result<Vec<u8>, ValidationError> {
456        canonical_without(self, &["receipt_id", "signature"])
457    }
458
459    /// Canonical bytes covered by the Ed25519 signature.
460    pub fn signing_bytes(&self) -> Result<Vec<u8>, ValidationError> {
461        canonical_without(self, &["signature"])
462    }
463
464    /// Alias used by verifiers when requesting signature coverage bytes.
465    pub fn signature_bytes(&self) -> Result<Vec<u8>, ValidationError> {
466        self.signing_bytes()
467    }
468
469    /// Derive the canonical SHA-256 receipt identity.
470    pub fn derived_receipt_id(&self) -> Result<Sha256Digest, ValidationError> {
471        digest_bytes(&self.identity_bytes()?)
472    }
473
474    /// Decode exactly canonical UTF-8 JSON and validate all protocol joins.
475    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    /// Compatibility alias for callers that name the wire input `from_bytes`.
489    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
490        Self::from_canonical_bytes(bytes)
491    }
492
493    /// Compatibility alias for callers that name the wire input JSON.
494    pub fn from_canonical_json(bytes: &[u8]) -> Result<Self, ValidationError> {
495        Self::from_canonical_bytes(bytes)
496    }
497
498    /// Validate the full signed lineage and its content identity.
499    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
609fn 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
624/// A serde JSON visitor that rejects duplicate object keys at every depth.
625struct 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
744fn 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}").expect("writing to String cannot fail");
749    }
750    Sha256Digest::new(value)
751}
752
753fn validate_name(value: &str, field: &str) -> Result<(), ValidationError> {
754    if value.is_empty()
755        || value.len() > 64
756        || !value
757            .bytes()
758            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
759    {
760        return Err(ValidationError::new(format!(
761            "{field} must be 1..=64 lowercase ASCII letters, digits or underscores"
762        )));
763    }
764    Ok(())
765}
766
767fn validate_media_type(value: &str) -> Result<(), ValidationError> {
768    let mut parts = value.split('/');
769    let Some(major) = parts.next() else {
770        return Err(ValidationError::new("media type must contain one slash"));
771    };
772    let Some(subtype) = parts.next() else {
773        return Err(ValidationError::new("media type must contain one slash"));
774    };
775    if parts.next().is_some()
776        || value.is_empty()
777        || value.len() > 128
778        || major.is_empty()
779        || subtype.is_empty()
780        || !major.bytes().all(is_media_token)
781        || !subtype.bytes().all(is_media_token)
782    {
783        return Err(ValidationError::new(
784            "ReceiptEvidenceRefV1 media_type must be a bounded RFC token type/subtype",
785        ));
786    }
787    Ok(())
788}
789
790fn is_media_token(byte: u8) -> bool {
791    byte.is_ascii_alphanumeric() || b"!#$&^_.+-".contains(&byte)
792}
793
794fn validate_safe_evidence_uri(value: &str) -> Result<(), ValidationError> {
795    if value.is_empty()
796        || value.len() > 1_024
797        || !value.is_ascii()
798        || value
799            .bytes()
800            .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control())
801        || value.contains(['?', '#', '@'])
802    {
803        return Err(ValidationError::new(
804            "evidence URI must be an ASCII privacy-safe locator without query, fragment or userinfo",
805        ));
806    }
807    let allowed = [
808        "artifact://",
809        "evidence://",
810        "bundle://",
811        "source://",
812        "urn:",
813    ];
814    let Some(prefix) = allowed.iter().find(|prefix| value.starts_with(*prefix)) else {
815        return Err(ValidationError::new(
816            "evidence URI must use artifact, evidence, bundle, source or urn scheme",
817        ));
818    };
819    if value.len() == prefix.len() || value[prefix.len()..].starts_with('/') {
820        return Err(ValidationError::new(
821            "evidence URI must include a non-path locator",
822        ));
823    }
824    if value.contains("..") || value.starts_with("file:") {
825        return Err(ValidationError::new(
826            "evidence URI must not contain path traversal or local file paths",
827        ));
828    }
829    Ok(())
830}
831
832fn validate_key_id(value: &str) -> Result<(), ValidationError> {
833    if value.is_empty()
834        || value.len() > 128
835        || !value.is_ascii()
836        || !value.as_bytes()[0].is_ascii_alphanumeric()
837        || !value
838            .bytes()
839            .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte))
840        || value.starts_with("base64:")
841        || value.starts_with("hex:")
842    {
843        return Err(ValidationError::new(
844            "ReceiptSignerV1 key_id must be a bounded external key identifier, not key material",
845        ));
846    }
847    Ok(())
848}
849
850fn validate_unique_digests(values: &[Sha256Digest], field: &str) -> Result<(), ValidationError> {
851    if values.len() > MAX_ITEMS {
852        return Err(ValidationError::new(format!("{field} exceeds 64 entries")));
853    }
854    let mut unique = BTreeSet::new();
855    if values.iter().any(|value| !unique.insert(value.as_str())) {
856        return Err(ValidationError::new(format!("{field} must be unique")));
857    }
858    Ok(())
859}
860
861fn valid_base64_signature(value: &str) -> bool {
862    if value.len() != 88 || !value.ends_with("==") {
863        return false;
864    }
865    let bytes = value.as_bytes();
866    if !bytes[..86]
867        .iter()
868        .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'+' || *byte == b'/')
869    {
870        return false;
871    }
872    // 64 bytes encode as 86 meaningful base64 characters plus `==`.  The
873    // low four bits of the final sextet are padding and must be zero; without
874    // this check multiple encodings would verify the same signature bytes.
875    base64_value(bytes[85]).is_some_and(|last| last & 0x0f == 0)
876}
877
878fn base64_value(byte: u8) -> Option<u8> {
879    match byte {
880        b'A'..=b'Z' => Some(byte - b'A'),
881        b'a'..=b'z' => Some(byte - b'a' + 26),
882        b'0'..=b'9' => Some(byte - b'0' + 52),
883        b'+' => Some(62),
884        b'/' => Some(63),
885        _ => None,
886    }
887}
888
889#[cfg(test)]
890mod tests {
891    use super::*;
892
893    const A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
894    const B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
895    const C: &str = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
896    const D: &str = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
897
898    fn digest(value: &str) -> Sha256Digest {
899        Sha256Digest::new(value).expect("valid digest")
900    }
901
902    fn evidence(
903        kind: ReceiptEvidenceKindV1,
904        digest_value: &str,
905        uri: &str,
906    ) -> ReceiptEvidenceRefV1 {
907        ReceiptEvidenceRefV1 {
908            kind,
909            uri: ProtocolReference::new(uri).unwrap(),
910            digest: digest(digest_value),
911            media_type: "application/json".to_owned(),
912            signature_status: SignatureStatus::Verified,
913        }
914    }
915
916    fn document() -> ReceiptDocumentV1 {
917        let mut document = ReceiptDocumentV1 {
918            schema_version: 1,
919            receipt_id: digest(B),
920            lineage: ReceiptLineageV1 {
921                task_id: TaskId::new("task-1").unwrap(),
922                task_ref: digest(A),
923                plan_id: PlanId::new("plan-1").unwrap(),
924                plan_ref: digest(B),
925                invocation_id: "invocation-1".to_owned(),
926                invocation_ref: digest(C),
927                identity_ref: digest(A),
928                policy_refs: vec![digest(B)],
929                capabilities: vec![ReceiptCapabilityLinkV1 {
930                    capability_id: CapabilityId::new("capability://leanctx/context").unwrap(),
931                    capability_version: SemanticVersion::new("1.0.0").unwrap(),
932                    invocation_ref: digest(C),
933                }],
934            },
935            chain: ReceiptChainLinkV1 {
936                chain_id: "chain-1".to_owned(),
937                sequence_number: 1,
938                previous_receipt_id: None,
939                previous_signature_digest: None,
940            },
941            status: ReceiptTerminalStatusV1::Succeeded,
942            values: vec![ReceiptValueV1 {
943                name: "input_tokens".to_owned(),
944                unit: "token".to_owned(),
945                classification: ReceiptValueClassificationV1::Measured,
946                value: Some(42),
947                evidence_digests: vec![digest(A)],
948                formula_digest: None,
949                price_table_digest: None,
950                reconciliation_digest: None,
951            }],
952            outcome: ReceiptOutcomeLinkV1 {
953                state: AcceptanceState::Unknown,
954                outcome_id: None,
955                outcome_ref: None,
956                acceptance_evidence_digest: None,
957            },
958            evidence_refs: vec![evidence(
959                ReceiptEvidenceKindV1::Measurement,
960                A,
961                "artifact://usage",
962            )],
963            issued_at: UtcTimestamp::new("2026-08-23T12:00:00Z").unwrap(),
964            signer: ReceiptSignerV1 {
965                algorithm: "ed25519".to_owned(),
966                key_id: "test-key".to_owned(),
967                key_admission: ReceiptKeyAdmissionV1::ExternalTrustStore,
968            },
969            signature: "A".repeat(86) + "==",
970        };
971        document.receipt_id = document.derived_receipt_id().unwrap();
972        document
973    }
974
975    #[test]
976    fn canonical_identity_and_signature_bytes_are_deterministic() {
977        let document = document();
978        document.validate().unwrap();
979        assert_eq!(
980            document.identity_bytes().unwrap(),
981            document.identity_bytes().unwrap()
982        );
983        assert_ne!(
984            document.identity_bytes().unwrap(),
985            document.signing_bytes().unwrap()
986        );
987        assert_ne!(
988            document.signing_bytes().unwrap(),
989            document.canonical_bytes().unwrap()
990        );
991    }
992
993    #[test]
994    fn canonical_decoder_rejects_duplicates_order_whitespace_and_invalid_utf8() {
995        let document = document();
996        let canonical = document.canonical_bytes().unwrap();
997        let mut duplicate = canonical.clone();
998        duplicate.pop();
999        duplicate.extend_from_slice(b",\"schema_version\":1}");
1000        assert!(ReceiptDocumentV1::from_canonical_bytes(&duplicate).is_err());
1001
1002        let mut whitespace = Vec::from(b" \n".as_slice());
1003        whitespace.extend_from_slice(&canonical);
1004        assert!(ReceiptDocumentV1::from_canonical_bytes(&whitespace).is_err());
1005        assert!(ReceiptDocumentV1::from_canonical_bytes(&[0xff]).is_err());
1006        assert!(strict_json_value(br#"{"value":1.0}"#).is_err());
1007    }
1008
1009    #[test]
1010    fn canonical_decoder_round_trips_golden_value() {
1011        let document = document();
1012        let bytes = document.canonical_bytes().unwrap();
1013        let decoded = ReceiptDocumentV1::from_canonical_bytes(&bytes).unwrap();
1014        assert_eq!(decoded, document);
1015        let golden =
1016            include_bytes!("../../../../docs/contracts/receipt-document/v1/valid-structure.json");
1017        let golden = golden.strip_suffix(b"\n").unwrap_or(golden);
1018        assert_eq!(
1019            ReceiptDocumentV1::from_canonical_bytes(golden).unwrap(),
1020            document
1021        );
1022        let identity = include_bytes!(
1023            "../../../../docs/contracts/receipt-document/v1/canonical-identity.json"
1024        );
1025        let identity = identity.strip_suffix(b"\n").unwrap_or(identity);
1026        assert_eq!(document.identity_bytes().unwrap(), identity);
1027        let signing =
1028            include_bytes!("../../../../docs/contracts/receipt-document/v1/canonical-signing.json");
1029        let signing = signing.strip_suffix(b"\n").unwrap_or(signing);
1030        assert_eq!(document.signing_bytes().unwrap(), signing);
1031        let invalid = include_bytes!(
1032            "../../../../docs/contracts/receipt-document/v1/invalid-unknown-field.json"
1033        );
1034        let invalid = invalid.strip_suffix(b"\n").unwrap_or(invalid);
1035        assert!(ReceiptDocumentV1::from_canonical_bytes(invalid).is_err());
1036        let unicode =
1037            include_bytes!("../../../../docs/contracts/receipt-document/v1/canonical-unicode.json");
1038        let unicode = unicode.strip_suffix(b"\n").unwrap_or(unicode);
1039        let unicode_document = ReceiptDocumentV1::from_canonical_bytes(unicode).unwrap();
1040        assert_eq!(unicode_document.lineage.task_id.as_str(), "tâsk-✓");
1041        assert_eq!(unicode_document.canonical_bytes().unwrap(), unicode);
1042        let escaped = include_bytes!(
1043            "../../../../docs/contracts/receipt-document/v1/invalid-unicode-escape.json"
1044        );
1045        let escaped = escaped.strip_suffix(b"\n").unwrap_or(escaped);
1046        assert!(ReceiptDocumentV1::from_canonical_bytes(escaped).is_err());
1047    }
1048
1049    #[test]
1050    fn integer_ceiling_and_timestamp_are_cross_language_strict() {
1051        let mut document = document();
1052        document.values[0].value = Some(MAX_SAFE_INTEGER + 1);
1053        document.receipt_id = document.derived_receipt_id().unwrap();
1054        assert!(document.validate().is_err());
1055        assert!(UtcTimestamp::new("2026-08-23T12:00:00.000Z").is_err());
1056        assert!(UtcTimestamp::new("2026-08-23T14:00:00+02:00").is_err());
1057    }
1058
1059    #[test]
1060    fn bounded_opaque_ids_enforce_utf8_byte_ceiling() {
1061        assert!(TaskId::new("é".repeat(128)).is_ok());
1062        assert!(TaskId::new("é".repeat(129)).is_err());
1063    }
1064
1065    #[test]
1066    fn base64_and_key_id_are_strict_without_embedded_trust() {
1067        assert!(valid_base64_signature(&("A".repeat(86) + "==")));
1068        assert!(!valid_base64_signature(&("A".repeat(85) + "B==")));
1069        let mut alias = document();
1070        alias.signature = "A".repeat(85) + "B==";
1071        assert!(alias.validate().is_err());
1072        assert!(validate_media_type("/json").is_err());
1073        assert!(validate_media_type("application/json").is_ok());
1074        assert!(validate_safe_evidence_uri("artifact://").is_err());
1075        assert!(validate_safe_evidence_uri("artifact:///tmp/private").is_err());
1076        assert!(validate_key_id("key://runtime/ed25519").is_ok());
1077        assert!(validate_key_id("base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").is_err());
1078        assert!(validate_key_id("bad key").is_err());
1079    }
1080
1081    #[test]
1082    fn calculated_and_reconciled_values_require_typed_evidence() {
1083        let mut calculated = document();
1084        calculated.evidence_refs.extend([
1085            evidence(ReceiptEvidenceKindV1::Formula, B, "artifact://formula"),
1086            evidence(ReceiptEvidenceKindV1::PriceTable, C, "artifact://price"),
1087        ]);
1088        calculated.values[0].classification = ReceiptValueClassificationV1::Calculated;
1089        calculated.values[0].formula_digest = Some(digest(B));
1090        calculated.values[0].price_table_digest = Some(digest(C));
1091        calculated.receipt_id = calculated.derived_receipt_id().unwrap();
1092        calculated.validate().unwrap();
1093        calculated.values[0].price_table_digest = Some(digest(B));
1094        calculated.receipt_id = calculated.derived_receipt_id().unwrap();
1095        assert!(calculated.validate().is_err());
1096        calculated.values[0].price_table_digest = Some(digest(C));
1097
1098        let mut reconciled = calculated;
1099        reconciled.evidence_refs.push(evidence(
1100            ReceiptEvidenceKindV1::Invoice,
1101            D,
1102            "artifact://invoice",
1103        ));
1104        reconciled.values[0].classification = ReceiptValueClassificationV1::Reconciled;
1105        reconciled.values[0].reconciliation_digest = Some(digest(D));
1106        reconciled.values[0].evidence_digests.push(digest(D));
1107        reconciled.receipt_id = reconciled.derived_receipt_id().unwrap();
1108        assert!(reconciled.validate().is_err());
1109        reconciled.values[0].evidence_digests.pop();
1110        reconciled.receipt_id = reconciled.derived_receipt_id().unwrap();
1111        reconciled.validate().unwrap();
1112    }
1113
1114    #[test]
1115    fn rejected_outcome_requires_outcome_evidence_and_terminal_status() {
1116        let mut document = document();
1117        document.status = ReceiptTerminalStatusV1::Rejected;
1118        document.receipt_id = document.derived_receipt_id().unwrap();
1119        assert!(document.validate().is_err());
1120
1121        document.evidence_refs.push(evidence(
1122            ReceiptEvidenceKindV1::Outcome,
1123            D,
1124            "artifact://outcome",
1125        ));
1126        document.outcome = ReceiptOutcomeLinkV1 {
1127            state: AcceptanceState::Rejected,
1128            outcome_id: Some(OutcomeId::new("outcome-1").unwrap()),
1129            outcome_ref: Some(digest(D)),
1130            acceptance_evidence_digest: None,
1131        };
1132        document.receipt_id = document.derived_receipt_id().unwrap();
1133        document.validate().unwrap();
1134        document.outcome.acceptance_evidence_digest = Some(digest(A));
1135        document.receipt_id = document.derived_receipt_id().unwrap();
1136        assert!(document.validate().is_err());
1137    }
1138
1139    #[test]
1140    fn duplicate_provenance_is_rejected() {
1141        let mut document = document();
1142        document.evidence_refs.push(evidence(
1143            ReceiptEvidenceKindV1::Runtime,
1144            A,
1145            "artifact://runtime",
1146        ));
1147        document.receipt_id = document.derived_receipt_id().unwrap();
1148        assert!(document.validate().is_err());
1149    }
1150
1151    #[test]
1152    fn strict_decoder_rejects_unknown_fields() {
1153        let mut value = serde_json::to_value(document()).unwrap();
1154        value["unknown"] = Value::Bool(true);
1155        assert!(serde_json::from_value::<ReceiptDocumentV1>(value).is_err());
1156    }
1157}