Skip to main content

lean_ctx_protocol/
invocation_evidence.rs

1//! Canonical digest manifest for one admitted Engine invocation.
2//!
3//! The manifest is a small, strict join document.  It records no payloads:
4//! Engine receipts, sources, policies, and capability manifests remain
5//! separately persisted and are resolved by their digest bindings.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9
10use serde::de::{MapAccess, SeqAccess, Visitor};
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13
14use crate::{
15    CapabilityId, ProtocolReference, SemanticVersion, Sha256Digest, ValidationError,
16    deserialize_schema_version, validate_bounded_opaque_identifier, validate_schema_version,
17};
18
19/// Maximum number of bindings of each manifest collection.
20pub const MAX_INVOCATION_EVIDENCE_ITEMS: usize = 64;
21
22/// Source role in the invocation's complete source lineage.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum InvocationSourceRoleV1 {
26    /// The one exact input consumed by the Engine.
27    Input,
28    /// A further source made available to the Engine invocation.
29    Context,
30}
31
32/// One source locator bound to the digest of its exact persisted bytes.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct InvocationSourceBindingV1 {
36    pub source_ref: ProtocolReference,
37    pub digest: Sha256Digest,
38    pub role: InvocationSourceRoleV1,
39}
40
41/// Required policy role in the invocation evidence join.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum InvocationPolicyRoleV1 {
45    TaskRegion,
46    TaskModel,
47    PlanDecision,
48    InvocationAdmission,
49}
50
51/// One policy locator bound to the digest of the exact policy decision bytes.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct InvocationPolicyBindingV1 {
55    pub policy_ref: ProtocolReference,
56    pub digest: Sha256Digest,
57    pub role: InvocationPolicyRoleV1,
58}
59
60/// One selected capability bound to its canonical CapabilityManifest bytes.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct InvocationCapabilityBindingV1 {
64    pub capability_id: CapabilityId,
65    pub capability_version: SemanticVersion,
66    pub manifest_digest: Sha256Digest,
67}
68
69/// Exact Engine receipt locator and content digest.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct InvocationEngineReceiptBindingV1 {
73    /// Must be exactly `receipt:sha256:<64 lowercase hex characters>`.
74    pub receipt_ref: ProtocolReference,
75    /// Digest of the exact Engine receipt bytes.
76    pub receipt_digest: Sha256Digest,
77}
78
79impl InvocationEngineReceiptBindingV1 {
80    fn validate(&self) -> Result<(), ValidationError> {
81        validate_manifest_reference(&self.receipt_ref, "receipt_ref")?;
82        let expected = format!("receipt:{}", self.receipt_digest.as_str());
83        if self.receipt_ref.as_str() != expected {
84            return Err(ValidationError::new(
85                "InvocationEngineReceiptBindingV1 receipt_ref must equal receipt:<receipt_digest>",
86            ));
87        }
88        Ok(())
89    }
90}
91
92fn validate_manifest_reference(
93    reference: &ProtocolReference,
94    field: &str,
95) -> Result<(), ValidationError> {
96    reject_manifest_feff(reference.as_str(), field)
97}
98
99fn reject_manifest_feff(value: &str, field: &str) -> Result<(), ValidationError> {
100    if value.contains('\u{feff}') {
101        return Err(ValidationError::new(format!(
102            "{field} must not contain U+FEFF"
103        )));
104    }
105    Ok(())
106}
107
108/// Strict, canonical evidence join for one Engine invocation.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct InvocationEvidenceManifestV1 {
112    #[serde(deserialize_with = "deserialize_schema_version")]
113    pub schema_version: u32,
114    /// Canonical digest of the Engine invocation record.
115    pub invocation_ref: Sha256Digest,
116    pub engine_receipt: InvocationEngineReceiptBindingV1,
117    /// Complete source lineage; exactly one binding has role `input`.
118    pub source_bindings: Vec<InvocationSourceBindingV1>,
119    /// One or more policy bindings; `invocation_admission` is always present.
120    pub policy_bindings: Vec<InvocationPolicyBindingV1>,
121    /// Selected capability ID/version to canonical manifest digest bindings.
122    pub capability_bindings: Vec<InvocationCapabilityBindingV1>,
123}
124
125impl InvocationEvidenceManifestV1 {
126    /// Schema version represented by this type.
127    pub const SCHEMA_VERSION: u32 = 1;
128
129    /// Return compact canonical JSON bytes for this manifest.
130    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
131        self.validate()?;
132        canonical_value(self)
133    }
134
135    /// Return canonical JSON as UTF-8 text.
136    pub fn canonical_json(&self) -> Result<String, ValidationError> {
137        String::from_utf8(self.canonical_bytes()?)
138            .map_err(|error| ValidationError::new(format!("manifest canonical UTF-8: {error}")))
139    }
140
141    /// Decode exactly canonical JSON and validate every semantic binding.
142    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
143        let value = strict_json_value(bytes)?;
144        let manifest = serde_json::from_value::<Self>(value).map_err(|error| {
145            ValidationError::new(format!("decode invocation evidence manifest: {error}"))
146        })?;
147        if manifest.canonical_bytes()? != bytes {
148            return Err(ValidationError::new(
149                "invocation evidence manifest JSON is not canonical UTF-8, compactness or key order",
150            ));
151        }
152        Ok(manifest)
153    }
154
155    /// Compatibility alias for callers naming the input `from_bytes`.
156    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
157        Self::from_canonical_bytes(bytes)
158    }
159
160    /// Validate schema, bounds, uniqueness, and cross-binding invariants.
161    pub fn validate(&self) -> Result<(), ValidationError> {
162        validate_schema_version(self.schema_version)?;
163        self.engine_receipt.validate()?;
164        validate_source_bindings(&self.source_bindings)?;
165        validate_policy_bindings(&self.policy_bindings)?;
166        validate_capability_bindings(&self.capability_bindings)?;
167        Ok(())
168    }
169}
170
171fn validate_source_bindings(bindings: &[InvocationSourceBindingV1]) -> Result<(), ValidationError> {
172    if bindings.is_empty() || bindings.len() > MAX_INVOCATION_EVIDENCE_ITEMS {
173        return Err(ValidationError::new(format!(
174            "source_bindings must contain 1..={MAX_INVOCATION_EVIDENCE_ITEMS} entries"
175        )));
176    }
177    let mut refs = BTreeSet::new();
178    let mut digests = BTreeSet::new();
179    let mut input_count = 0usize;
180    for binding in bindings {
181        validate_manifest_reference(&binding.source_ref, "source_ref")?;
182        if binding.source_ref.as_str().trim().is_empty() {
183            return Err(ValidationError::new(
184                "InvocationSourceBindingV1 source_ref must not be empty",
185            ));
186        }
187        if !refs.insert(binding.source_ref.as_str()) {
188            return Err(ValidationError::new(
189                "source_bindings source_ref values must be unique",
190            ));
191        }
192        if !digests.insert(binding.digest.as_str()) {
193            return Err(ValidationError::new(
194                "source_bindings digest values must be unique",
195            ));
196        }
197        if binding.role == InvocationSourceRoleV1::Input {
198            input_count += 1;
199        }
200    }
201    if input_count != 1 {
202        return Err(ValidationError::new(
203            "source_bindings must contain exactly one input",
204        ));
205    }
206    Ok(())
207}
208
209fn validate_policy_bindings(bindings: &[InvocationPolicyBindingV1]) -> Result<(), ValidationError> {
210    if bindings.is_empty() || bindings.len() > 4 {
211        return Err(ValidationError::new(
212            "policy_bindings must contain 1..=4 entries",
213        ));
214    }
215    let mut refs = BTreeMap::new();
216    let mut digests = BTreeMap::new();
217    let mut binding_keys = BTreeSet::new();
218    let mut roles = BTreeSet::new();
219    let mut admission_ref = None;
220    let mut admission_digest = None;
221    for binding in bindings {
222        validate_manifest_reference(&binding.policy_ref, "policy_ref")?;
223        if binding.policy_ref.as_str().trim().is_empty() {
224            return Err(ValidationError::new(
225                "InvocationPolicyBindingV1 policy_ref must not be empty",
226            ));
227        }
228        let binding_key = (
229            binding.policy_ref.as_str(),
230            binding.digest.as_str(),
231            binding.role,
232        );
233        if !binding_keys.insert(binding_key) {
234            return Err(ValidationError::new(
235                "policy_bindings contains a duplicate identical binding",
236            ));
237        }
238        if !roles.insert(binding.role) {
239            return Err(ValidationError::new(
240                "policy_bindings role values must be unique",
241            ));
242        }
243        if binding.role == InvocationPolicyRoleV1::InvocationAdmission {
244            if refs.contains_key(binding.policy_ref.as_str())
245                || digests.contains_key(binding.digest.as_str())
246            {
247                return Err(ValidationError::new(
248                    "invocation_admission must not alias another policy role",
249                ));
250            }
251            admission_ref = Some(binding.policy_ref.as_str());
252            admission_digest = Some(binding.digest.as_str());
253        } else if admission_ref == Some(binding.policy_ref.as_str())
254            || admission_digest == Some(binding.digest.as_str())
255        {
256            return Err(ValidationError::new(
257                "optional policy roles must not alias invocation_admission",
258            ));
259        }
260        if refs
261            .get(binding.policy_ref.as_str())
262            .is_some_and(|digest| *digest != binding.digest.as_str())
263        {
264            return Err(ValidationError::new(
265                "policy_bindings policy_ref values must keep one digest",
266            ));
267        }
268        refs.insert(binding.policy_ref.as_str(), binding.digest.as_str());
269        if digests
270            .get(binding.digest.as_str())
271            .is_some_and(|policy_ref| *policy_ref != binding.policy_ref.as_str())
272        {
273            return Err(ValidationError::new(
274                "policy_bindings digest values must keep one policy_ref",
275            ));
276        }
277        digests.insert(binding.digest.as_str(), binding.policy_ref.as_str());
278    }
279    if !roles.contains(&InvocationPolicyRoleV1::InvocationAdmission) {
280        return Err(ValidationError::new(
281            "policy_bindings must include exactly one invocation_admission role",
282        ));
283    }
284    Ok(())
285}
286
287fn validate_capability_bindings(
288    bindings: &[InvocationCapabilityBindingV1],
289) -> Result<(), ValidationError> {
290    if bindings.is_empty() || bindings.len() > MAX_INVOCATION_EVIDENCE_ITEMS {
291        return Err(ValidationError::new(format!(
292            "capability_bindings must contain 1..={MAX_INVOCATION_EVIDENCE_ITEMS} entries"
293        )));
294    }
295    let mut keys = BTreeSet::new();
296    let mut digests = BTreeSet::new();
297    for binding in bindings {
298        reject_manifest_feff(binding.capability_id.as_str(), "capability_id")?;
299        validate_bounded_opaque_identifier(
300            binding.capability_id.as_str(),
301            "InvocationCapabilityBindingV1 capability_id",
302        )?;
303        let key = (
304            binding.capability_id.as_str(),
305            binding.capability_version.as_str(),
306        );
307        if !keys.insert(key) {
308            return Err(ValidationError::new(
309                "capability_bindings capability_id/version pairs must be unique",
310            ));
311        }
312        if !digests.insert(binding.manifest_digest.as_str()) {
313            return Err(ValidationError::new(
314                "capability_bindings manifest_digest values must be unique",
315            ));
316        }
317    }
318    Ok(())
319}
320
321fn canonical_value<T: Serialize>(value: &T) -> Result<Vec<u8>, ValidationError> {
322    let value = serde_json::to_value(value).map_err(|error| {
323        ValidationError::new(format!("serialize invocation evidence manifest: {error}"))
324    })?;
325    serde_json::to_vec(&sort_json(value)).map_err(|error| {
326        ValidationError::new(format!(
327            "canonicalize invocation evidence manifest: {error}"
328        ))
329    })
330}
331
332fn sort_json(value: Value) -> Value {
333    match value {
334        Value::Object(object) => Value::Object(
335            object
336                .into_iter()
337                .map(|(key, value)| (key, sort_json(value)))
338                .collect::<BTreeMap<_, _>>()
339                .into_iter()
340                .collect(),
341        ),
342        Value::Array(values) => Value::Array(values.into_iter().map(sort_json).collect()),
343        scalar => scalar,
344    }
345}
346
347/// Serde visitor rejecting duplicate keys at every JSON object depth.
348struct StrictJsonValue(Value);
349
350impl<'de> Deserialize<'de> for StrictJsonValue {
351    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
352    where
353        D: serde::Deserializer<'de>,
354    {
355        deserializer.deserialize_any(StrictJsonVisitor)
356    }
357}
358
359struct StrictJsonVisitor;
360
361impl<'de> Visitor<'de> for StrictJsonVisitor {
362    type Value = StrictJsonValue;
363
364    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
365        formatter.write_str("a canonical JSON value with unique object keys")
366    }
367
368    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
369    where
370        E: serde::de::Error,
371    {
372        Ok(StrictJsonValue(Value::Bool(value)))
373    }
374
375    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
376    where
377        E: serde::de::Error,
378    {
379        Ok(StrictJsonValue(Value::Number(value.into())))
380    }
381
382    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
383    where
384        E: serde::de::Error,
385    {
386        Ok(StrictJsonValue(Value::Number(value.into())))
387    }
388
389    fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E>
390    where
391        E: serde::de::Error,
392    {
393        Err(E::custom(
394            "floating-point JSON numbers are not canonical manifest values",
395        ))
396    }
397
398    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
399    where
400        E: serde::de::Error,
401    {
402        Ok(StrictJsonValue(Value::String(value.to_owned())))
403    }
404
405    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
406    where
407        E: serde::de::Error,
408    {
409        Ok(StrictJsonValue(Value::String(value)))
410    }
411
412    fn visit_none<E>(self) -> Result<Self::Value, E>
413    where
414        E: serde::de::Error,
415    {
416        Ok(StrictJsonValue(Value::Null))
417    }
418
419    fn visit_unit<E>(self) -> Result<Self::Value, E>
420    where
421        E: serde::de::Error,
422    {
423        Ok(StrictJsonValue(Value::Null))
424    }
425
426    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
427    where
428        A: SeqAccess<'de>,
429    {
430        let mut values = Vec::new();
431        while let Some(value) = seq.next_element::<StrictJsonValue>()? {
432            values.push(value.0);
433        }
434        Ok(StrictJsonValue(Value::Array(values)))
435    }
436
437    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
438    where
439        A: MapAccess<'de>,
440    {
441        let mut object = Map::new();
442        while let Some(key) = map.next_key::<String>()? {
443            if object.contains_key(&key) {
444                return Err(serde::de::Error::custom(format!(
445                    "duplicate JSON object key {key:?}"
446                )));
447            }
448            let value = map.next_value::<StrictJsonValue>()?;
449            object.insert(key, value.0);
450        }
451        Ok(StrictJsonValue(Value::Object(object)))
452    }
453}
454
455fn strict_json_value(bytes: &[u8]) -> Result<Value, ValidationError> {
456    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
457    let value = StrictJsonValue::deserialize(&mut deserializer)
458        .map_err(|error| ValidationError::new(format!("decode canonical manifest JSON: {error}")))?
459        .0;
460    deserializer.end().map_err(|error| {
461        ValidationError::new(format!("trailing canonical manifest JSON: {error}"))
462    })?;
463    Ok(value)
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    fn digest(hex: char) -> Sha256Digest {
471        Sha256Digest::new(format!("sha256:{}", hex.to_string().repeat(64)))
472            .expect("fixture digest should be valid")
473    }
474
475    fn reference(value: &str) -> ProtocolReference {
476        ProtocolReference::new(value).expect("fixture reference should be valid")
477    }
478
479    fn capability(value: &str) -> CapabilityId {
480        CapabilityId::new(value).expect("fixture capability should be valid")
481    }
482
483    fn version(value: &str) -> SemanticVersion {
484        SemanticVersion::new(value).expect("fixture version should be valid")
485    }
486
487    fn valid_manifest() -> InvocationEvidenceManifestV1 {
488        InvocationEvidenceManifestV1 {
489            schema_version: 1,
490            invocation_ref: digest('a'),
491            engine_receipt: InvocationEngineReceiptBindingV1 {
492                receipt_ref: reference(&format!("receipt:sha256:{}", "b".repeat(64))),
493                receipt_digest: digest('b'),
494            },
495            source_bindings: vec![
496                InvocationSourceBindingV1 {
497                    source_ref: reference("source:input"),
498                    digest: digest('c'),
499                    role: InvocationSourceRoleV1::Input,
500                },
501                InvocationSourceBindingV1 {
502                    source_ref: reference("source:context"),
503                    digest: digest('d'),
504                    role: InvocationSourceRoleV1::Context,
505                },
506            ],
507            policy_bindings: vec![
508                InvocationPolicyBindingV1 {
509                    policy_ref: reference("policy:task-region"),
510                    digest: digest('e'),
511                    role: InvocationPolicyRoleV1::TaskRegion,
512                },
513                InvocationPolicyBindingV1 {
514                    policy_ref: reference("policy:task-model"),
515                    digest: digest('f'),
516                    role: InvocationPolicyRoleV1::TaskModel,
517                },
518                InvocationPolicyBindingV1 {
519                    policy_ref: reference("policy:plan-decision"),
520                    digest: digest('1'),
521                    role: InvocationPolicyRoleV1::PlanDecision,
522                },
523                InvocationPolicyBindingV1 {
524                    policy_ref: reference("policy:invocation-admission"),
525                    digest: digest('2'),
526                    role: InvocationPolicyRoleV1::InvocationAdmission,
527                },
528            ],
529            capability_bindings: vec![InvocationCapabilityBindingV1 {
530                capability_id: capability("capability:engine"),
531                capability_version: version("1.0.0"),
532                manifest_digest: digest('3'),
533            }],
534        }
535    }
536
537    #[test]
538    fn valid_manifest_round_trips_and_is_canonical() {
539        let manifest = valid_manifest();
540        let bytes = manifest.canonical_bytes().expect("manifest canonicalizes");
541        let decoded = InvocationEvidenceManifestV1::from_canonical_bytes(&bytes)
542            .expect("canonical bytes decode");
543        assert_eq!(manifest, decoded);
544        assert_eq!(
545            serde_json::to_vec(&serde_json::from_slice::<Value>(&bytes).unwrap())
546                .unwrap()
547                .len(),
548            bytes.len()
549        );
550    }
551
552    #[test]
553    fn multibyte_reference_within_byte_bound_is_valid() {
554        let mut manifest = valid_manifest();
555        manifest.source_bindings[0].source_ref = reference("source:é");
556        manifest
557            .validate()
558            .expect("short multibyte reference is valid");
559        assert!(CapabilityId::new("é".repeat(128)).is_ok());
560        assert!(CapabilityId::new("é".repeat(129)).is_err());
561    }
562
563    #[test]
564    fn optional_policy_roles_are_valid_but_admission_is_mandatory() {
565        let mut manifest = valid_manifest();
566        manifest
567            .policy_bindings
568            .retain(|binding| binding.role == InvocationPolicyRoleV1::InvocationAdmission);
569        manifest
570            .validate()
571            .expect("admission-only policy set is valid");
572        manifest.policy_bindings.clear();
573        assert!(manifest.validate().is_err());
574        manifest.policy_bindings.push(InvocationPolicyBindingV1 {
575            policy_ref: reference("policy:task-region"),
576            digest: digest('e'),
577            role: InvocationPolicyRoleV1::TaskRegion,
578        });
579        assert!(manifest.validate().is_err());
580    }
581
582    #[test]
583    fn shared_policy_alias_is_valid_across_optional_roles_only() {
584        let mut manifest = valid_manifest();
585        let shared_ref = reference("policy:shared");
586        let shared_digest = digest('a');
587        manifest.policy_bindings = vec![
588            InvocationPolicyBindingV1 {
589                policy_ref: shared_ref.clone(),
590                digest: shared_digest.clone(),
591                role: InvocationPolicyRoleV1::PlanDecision,
592            },
593            InvocationPolicyBindingV1 {
594                policy_ref: reference("policy:task-model"),
595                digest: digest('b'),
596                role: InvocationPolicyRoleV1::TaskModel,
597            },
598            InvocationPolicyBindingV1 {
599                policy_ref: reference("policy:invocation-admission"),
600                digest: digest('c'),
601                role: InvocationPolicyRoleV1::InvocationAdmission,
602            },
603        ];
604        manifest
605            .validate()
606            .expect("one policy may serve two optional roles");
607
608        manifest.policy_bindings[2].policy_ref = shared_ref;
609        manifest.policy_bindings[2].digest = shared_digest;
610        assert!(manifest.validate().is_err());
611    }
612
613    #[test]
614    fn policy_alias_rejects_conflicting_digest_and_duplicate_role() {
615        let mut manifest = valid_manifest();
616        let shared_ref = reference("policy:shared");
617        manifest.policy_bindings = vec![
618            InvocationPolicyBindingV1 {
619                policy_ref: shared_ref.clone(),
620                digest: digest('a'),
621                role: InvocationPolicyRoleV1::PlanDecision,
622            },
623            InvocationPolicyBindingV1 {
624                policy_ref: shared_ref,
625                digest: digest('b'),
626                role: InvocationPolicyRoleV1::InvocationAdmission,
627            },
628        ];
629        assert!(manifest.validate().is_err());
630
631        let mut manifest = valid_manifest();
632        manifest.policy_bindings[1].role = manifest.policy_bindings[0].role;
633        assert!(manifest.validate().is_err());
634    }
635
636    #[test]
637    fn shared_references_reject_whitespace_only_and_c1_controls() {
638        assert!(ProtocolReference::new(" \t\n").is_err());
639        assert!(ProtocolReference::new("\u{0085}").is_err());
640        assert!(CapabilityId::new(" \t\n").is_err());
641        assert!(CapabilityId::new("\u{0085}").is_err());
642    }
643
644    #[test]
645    fn manifest_protocol_references_reject_feff_on_all_paths() {
646        let mut manifest = valid_manifest();
647        manifest.source_bindings[0].source_ref = reference("source:\u{feff}");
648        assert!(manifest.validate().is_err());
649
650        let mut manifest = valid_manifest();
651        manifest.policy_bindings[0].policy_ref = reference("policy:\u{feff}");
652        assert!(manifest.validate().is_err());
653
654        let mut manifest = valid_manifest();
655        manifest.engine_receipt.receipt_ref = reference(&format!(
656            "receipt:\u{feff}{}",
657            manifest.engine_receipt.receipt_digest.as_str()
658        ));
659        assert!(manifest.validate().is_err());
660    }
661
662    #[test]
663    fn manifest_capability_ids_reject_feff() {
664        let mut manifest = valid_manifest();
665        manifest.capability_bindings[0].capability_id = capability("capability:\u{feff}");
666        assert!(manifest.validate().is_err());
667    }
668
669    #[test]
670    fn canonical_decoder_rejects_whitespace_key_order_and_duplicates() {
671        let canonical = valid_manifest().canonical_bytes().expect("canonicalizes");
672        let pretty =
673            serde_json::to_vec_pretty(&serde_json::from_slice::<Value>(&canonical).unwrap())
674                .expect("pretty JSON");
675        assert!(InvocationEvidenceManifestV1::from_canonical_bytes(&pretty).is_err());
676
677        let mut reordered = canonical.clone();
678        reordered.reverse();
679        assert!(InvocationEvidenceManifestV1::from_canonical_bytes(&reordered).is_err());
680
681        let duplicate = br#"{"schema_version":1,"schema_version":1}"#;
682        assert!(InvocationEvidenceManifestV1::from_canonical_bytes(duplicate).is_err());
683    }
684
685    #[test]
686    fn validation_rejects_receipt_ref_mismatch() {
687        let mut manifest = valid_manifest();
688        manifest.engine_receipt.receipt_ref = reference(
689            "receipt:sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
690        );
691        assert!(manifest.validate().is_err());
692    }
693
694    #[test]
695    fn validation_rejects_missing_or_duplicate_input() {
696        let mut manifest = valid_manifest();
697        manifest.source_bindings[0].role = InvocationSourceRoleV1::Context;
698        assert!(manifest.validate().is_err());
699        manifest.source_bindings[0].role = InvocationSourceRoleV1::Input;
700        manifest.source_bindings[1].role = InvocationSourceRoleV1::Input;
701        assert!(manifest.validate().is_err());
702    }
703
704    #[test]
705    fn validation_rejects_policy_role_gaps_and_duplicates() {
706        let mut manifest = valid_manifest();
707        manifest.policy_bindings[0].role = InvocationPolicyRoleV1::TaskModel;
708        assert!(manifest.validate().is_err());
709        manifest = valid_manifest();
710        manifest.policy_bindings[0].policy_ref = manifest.policy_bindings[1].policy_ref.clone();
711        assert!(manifest.validate().is_err());
712    }
713
714    #[test]
715    fn validation_rejects_duplicate_capability_key() {
716        let mut manifest = valid_manifest();
717        manifest
718            .capability_bindings
719            .push(manifest.capability_bindings[0].clone());
720        assert!(manifest.validate().is_err());
721    }
722
723    #[test]
724    fn unknown_fields_are_denied_at_every_binding_level() {
725        let mut value = serde_json::to_value(valid_manifest()).expect("serialize fixture");
726        value["engine_receipt"]["future"] = Value::Bool(true);
727        assert!(serde_json::from_value::<InvocationEvidenceManifestV1>(value).is_err());
728    }
729
730    #[test]
731    fn schema_declares_mandatory_three_stage_conformance() {
732        let schema: Value = serde_json::from_slice(include_bytes!(
733            "../../../../docs/contracts/invocation-evidence-manifest-v1.schema.json"
734        ))
735        .expect("manifest schema should be valid JSON");
736        assert_eq!(schema["x-conformance"]["mode"], "three_stage");
737        assert_eq!(schema["x-conformance"]["schema_validation_required"], true);
738        assert_eq!(
739            schema["x-conformance"]["semantic_validation_required"],
740            true
741        );
742        assert_eq!(
743            schema["x-conformance"]["utf8_bounds"]["keyword"],
744            "x-maxUtf8Bytes"
745        );
746        assert_eq!(
747            schema["x-conformance"]["utf8_bounds"]["schema_enforcement"],
748            "annotation_only"
749        );
750        assert_eq!(
751            schema["x-conformance"]["cross_artifact_join_required"],
752            true
753        );
754        assert_eq!(
755            schema["x-conformance"]["stages"][2]["name"],
756            "cross_artifact_join"
757        );
758        assert_eq!(schema["x-conformance"]["stages"][2]["required"], true);
759        assert_eq!(
760            schema["x-conformance"]["stages"][2]["protocol_decoder_sufficient"],
761            false
762        );
763        let requirements = schema["x-conformance"]["stages"][2]["requirements"]
764            .as_array()
765            .expect("cross-artifact requirements should be an array");
766        assert!(
767            requirements
768                .iter()
769                .any(|requirement| { requirement == "resolve_and_verify_policy_artifact_bytes" })
770        );
771        assert!(requirements.iter().any(|requirement| {
772            requirement == "policy_aliases_repeat_only_exact_ref_and_digest_across_optional_roles"
773        }));
774        assert!(requirements.iter().any(|requirement| {
775            requirement == "policy_conflicting_ref_digest_and_duplicate_role_bindings_rejected"
776        }));
777        assert!(
778            requirements
779                .iter()
780                .any(|requirement| requirement == "policy_duplicate_identical_bindings_rejected")
781        );
782    }
783
784    #[test]
785    fn canonical_golden_and_negative_fixtures_are_exhaustive() {
786        let valid =
787            include_bytes!("../../../../docs/contracts/invocation-evidence-manifest/v1/valid.json");
788        let valid = valid.strip_suffix(b"\n").unwrap_or(valid);
789        let canonical = include_bytes!(
790            "../../../../docs/contracts/invocation-evidence-manifest/v1/canonical.json"
791        );
792        let canonical = canonical.strip_suffix(b"\n").unwrap_or(canonical);
793        let decoded = InvocationEvidenceManifestV1::from_canonical_bytes(valid)
794            .expect("valid fixture should decode canonically");
795        assert_eq!(
796            decoded.canonical_bytes().expect("canonical bytes"),
797            canonical
798        );
799        let optional = include_bytes!(
800            "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-optional-policy.json"
801        );
802        let optional = optional.strip_suffix(b"\n").unwrap_or(optional);
803        let optional_manifest = InvocationEvidenceManifestV1::from_canonical_bytes(optional)
804            .expect("admission-only policy fixture should decode");
805        assert_eq!(optional_manifest.policy_bindings.len(), 1);
806        let shared = include_bytes!(
807            "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-shared-policy-alias.json"
808        );
809        let shared = shared.strip_suffix(b"\n").unwrap_or(shared);
810        let shared_manifest = InvocationEvidenceManifestV1::from_canonical_bytes(shared)
811            .expect("shared policy alias fixture should decode");
812        assert_eq!(shared_manifest.policy_bindings.len(), 3);
813        let multibyte = include_bytes!(
814            "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-multibyte.json"
815        );
816        let multibyte = multibyte.strip_suffix(b"\n").unwrap_or(multibyte);
817        InvocationEvidenceManifestV1::from_canonical_bytes(multibyte)
818            .expect("short multibyte reference fixture should decode");
819
820        for (name, expected) in [
821            ("valid-u2028-reference.json", "source:\u{2028}value"),
822            ("valid-u2029-reference.json", "policy:\u{2029}decision"),
823        ] {
824            let bytes: &[u8] = match name {
825                "valid-u2028-reference.json" => include_bytes!(
826                    "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-u2028-reference.json"
827                ),
828                "valid-u2029-reference.json" => include_bytes!(
829                    "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-u2029-reference.json"
830                ),
831                _ => unreachable!(),
832            };
833            let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes);
834            let manifest = InvocationEvidenceManifestV1::from_canonical_bytes(bytes)
835                .expect("U+2028/U+2029 reference fixture should decode");
836            assert!(
837                manifest
838                    .source_bindings
839                    .iter()
840                    .any(|binding| binding.source_ref.as_str() == expected)
841                    || manifest
842                        .policy_bindings
843                        .iter()
844                        .any(|binding| binding.policy_ref.as_str() == expected)
845            );
846        }
847
848        let exact_reference = include_bytes!(
849            "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-reference-1024.json"
850        );
851        let exact_reference = exact_reference
852            .strip_suffix(b"\n")
853            .unwrap_or(exact_reference);
854        let exact_manifest = InvocationEvidenceManifestV1::from_canonical_bytes(exact_reference)
855            .expect("exact 1024-byte ProtocolReference fixture should decode");
856        assert_eq!(
857            exact_manifest.source_bindings[0].source_ref.as_str().len(),
858            1024
859        );
860
861        let overlong = include_bytes!(
862            "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-utf8-byte-bound.json"
863        );
864        let overlong = overlong.strip_suffix(b"\n").unwrap_or(overlong);
865        assert!(InvocationEvidenceManifestV1::from_canonical_bytes(overlong).is_err());
866
867        let overlong_reference = include_bytes!(
868            "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-reference-1025.json"
869        );
870        let overlong_reference = overlong_reference
871            .strip_suffix(b"\n")
872            .unwrap_or(overlong_reference);
873        assert!(InvocationEvidenceManifestV1::from_canonical_bytes(overlong_reference).is_err());
874
875        for name in [
876            "invalid-unknown-field.json",
877            "invalid-receipt-ref-mismatch.json",
878            "invalid-source-input-count.json",
879            "invalid-policy-role.json",
880            "invalid-capability-key.json",
881            "invalid-duplicate-source-ref.json",
882            "invalid-duplicate-source-digest.json",
883            "invalid-duplicate-policy-digest.json",
884            "invalid-duplicate-policy-role.json",
885            "invalid-duplicate-policy-ref.json",
886            "invalid-duplicate-capability-manifest-digest.json",
887            "invalid-missing-invocation-admission.json",
888            "invalid-whitespace-reference.json",
889            "invalid-c1-control-reference.json",
890            "invalid-feff-source-reference.json",
891            "invalid-feff-policy-reference.json",
892            "invalid-feff-receipt-reference.json",
893            "invalid-feff-capability-id.json",
894            "invalid-schema-version-float.json",
895            "invalid-policy-alias-conflicting-digest.json",
896            "invalid-policy-alias-duplicate-role.json",
897            "invalid-policy-alias-admission.json",
898        ] {
899            let bytes: &[u8] = match name {
900                "invalid-unknown-field.json" => include_bytes!(
901                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-unknown-field.json"
902                ),
903                "invalid-receipt-ref-mismatch.json" => include_bytes!(
904                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-receipt-ref-mismatch.json"
905                ),
906                "invalid-source-input-count.json" => include_bytes!(
907                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-source-input-count.json"
908                ),
909                "invalid-policy-role.json" => include_bytes!(
910                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-policy-role.json"
911                ),
912                "invalid-capability-key.json" => include_bytes!(
913                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-capability-key.json"
914                ),
915                "invalid-duplicate-source-ref.json" => include_bytes!(
916                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-source-ref.json"
917                ),
918                "invalid-duplicate-source-digest.json" => include_bytes!(
919                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-source-digest.json"
920                ),
921                "invalid-duplicate-policy-digest.json" => include_bytes!(
922                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-policy-digest.json"
923                ),
924                "invalid-duplicate-policy-role.json" => include_bytes!(
925                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-policy-role.json"
926                ),
927                "invalid-duplicate-policy-ref.json" => include_bytes!(
928                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-policy-ref.json"
929                ),
930                "invalid-duplicate-capability-manifest-digest.json" => include_bytes!(
931                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-capability-manifest-digest.json"
932                ),
933                "invalid-missing-invocation-admission.json" => include_bytes!(
934                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-missing-invocation-admission.json"
935                ),
936                "invalid-whitespace-reference.json" => include_bytes!(
937                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-whitespace-reference.json"
938                ),
939                "invalid-c1-control-reference.json" => include_bytes!(
940                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-c1-control-reference.json"
941                ),
942                "invalid-feff-source-reference.json" => include_bytes!(
943                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-feff-source-reference.json"
944                ),
945                "invalid-feff-policy-reference.json" => include_bytes!(
946                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-feff-policy-reference.json"
947                ),
948                "invalid-feff-receipt-reference.json" => include_bytes!(
949                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-feff-receipt-reference.json"
950                ),
951                "invalid-feff-capability-id.json" => include_bytes!(
952                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-feff-capability-id.json"
953                ),
954                "invalid-schema-version-float.json" => include_bytes!(
955                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-schema-version-float.json"
956                ),
957                "invalid-policy-alias-conflicting-digest.json" => include_bytes!(
958                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-policy-alias-conflicting-digest.json"
959                ),
960                "invalid-policy-alias-duplicate-role.json" => include_bytes!(
961                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-policy-alias-duplicate-role.json"
962                ),
963                "invalid-policy-alias-admission.json" => include_bytes!(
964                    "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-policy-alias-admission.json"
965                ),
966                _ => unreachable!(),
967            };
968            let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes);
969            assert!(
970                InvocationEvidenceManifestV1::from_canonical_bytes(bytes).is_err(),
971                "{name} must fail closed"
972            );
973        }
974    }
975}