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
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
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
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    // 64 bytes encode as 86 meaningful base64 characters plus `==`.  The
874    // low four bits of the final sextet are padding and must be zero; without
875    // this check multiple encodings would verify the same signature bytes.
876    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}