Skip to main content

lean_ctx_protocol/
invocation_context_binding.rs

1//! Strict, canonical signed admission context for one Engine invocation.
2//!
3//! `InvocationContextBindingV1` is a sidecar to the invocation and evidence
4//! manifest.  It binds the session, task, plan, invocation, admission policy,
5//! complete source lineage, and selected capabilities without embedding any
6//! of their payload bytes.  Trust-store lookup, revocation, one-time
7//! consumption, and wall-clock admission checks belong to the runtime gate.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt;
11
12use serde::de::{MapAccess, SeqAccess, Visitor};
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15use sha2::{Digest, Sha256};
16
17use crate::{
18    DecisionId, EngineInvocationIdV1, EnginePolicyDecisionV1, InvocationCapabilityBindingV1,
19    InvocationSourceBindingV1, InvocationSourceRoleV1, PlanId, ProtocolReference, Sha256Digest,
20    TaskId, UtcTimestamp, ValidationError, deserialize_schema_version,
21    validate_bounded_opaque_identifier, validate_schema_version,
22};
23
24/// Domain prefix covered by an InvocationContextBindingV1 signature.
25pub const INVOCATION_CONTEXT_BINDING_SIGNATURE_DOMAIN: &[u8] =
26    b"leanctx/invocation-context-binding/v1\0";
27
28/// Maximum source and capability entries in one binding.
29pub const MAX_INVOCATION_CONTEXT_BINDING_ITEMS: usize = 64;
30
31/// Metadata identifying the key used to sign an invocation context binding.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct InvocationContextBindingSignerV1 {
35    pub algorithm: String,
36    pub key_id: String,
37    pub public_key_digest: Sha256Digest,
38}
39
40/// Canonical signed admission context for one Engine invocation.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct InvocationContextBindingV1 {
44    #[serde(deserialize_with = "deserialize_schema_version")]
45    pub schema_version: u32,
46    pub admission_id: DecisionId,
47    pub session_identity_ref: Sha256Digest,
48    pub task_id: TaskId,
49    pub task_ref: Sha256Digest,
50    pub plan_id: PlanId,
51    pub plan_ref: Sha256Digest,
52    pub invocation_id: EngineInvocationIdV1,
53    pub invocation_ref: Sha256Digest,
54    pub policy_ref: ProtocolReference,
55    pub policy_digest: Sha256Digest,
56    pub decision: EnginePolicyDecisionV1,
57    pub source_bindings: Vec<InvocationSourceBindingV1>,
58    pub capability_bindings: Vec<InvocationCapabilityBindingV1>,
59    pub issued_at: UtcTimestamp,
60    pub not_before: UtcTimestamp,
61    pub expires_at: UtcTimestamp,
62    pub signer: InvocationContextBindingSignerV1,
63    pub signature: String,
64}
65
66impl InvocationContextBindingV1 {
67    /// Schema version represented by this type.
68    pub const SCHEMA_VERSION: u32 = 1;
69
70    /// Return canonical compact JSON bytes including the signature.
71    pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
72        self.validate()?;
73        canonical_value_without_signature(self, false)
74    }
75
76    /// Return canonical JSON as UTF-8 text including the signature.
77    pub fn canonical_json(&self) -> Result<String, ValidationError> {
78        String::from_utf8(self.canonical_bytes()?).map_err(|error| {
79            ValidationError::new(format!(
80                "invocation context binding canonical UTF-8: {error}"
81            ))
82        })
83    }
84
85    /// Return canonical JSON bytes covered by the signature, without signature.
86    pub fn unsigned_canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
87        self.validate_unsigned()?;
88        canonical_value_without_signature(self, true)
89    }
90
91    /// Return the signature payload, including the versioned domain prefix.
92    pub fn signing_bytes(&self) -> Result<Vec<u8>, ValidationError> {
93        let unsigned = self.unsigned_canonical_bytes()?;
94        let mut bytes =
95            Vec::with_capacity(INVOCATION_CONTEXT_BINDING_SIGNATURE_DOMAIN.len() + unsigned.len());
96        bytes.extend_from_slice(INVOCATION_CONTEXT_BINDING_SIGNATURE_DOMAIN);
97        bytes.extend_from_slice(&unsigned);
98        Ok(bytes)
99    }
100
101    /// Alias for callers that name signature coverage bytes explicitly.
102    pub fn signature_bytes(&self) -> Result<Vec<u8>, ValidationError> {
103        self.signing_bytes()
104    }
105
106    /// Digest the complete canonical binding bytes, including its signature.
107    pub fn digest(&self) -> Result<Sha256Digest, ValidationError> {
108        let bytes = self.canonical_bytes()?;
109        digest_bytes(&bytes)
110    }
111
112    /// Compatibility alias for callers naming the content identity explicitly.
113    pub fn derived_binding_digest(&self) -> Result<Sha256Digest, ValidationError> {
114        self.digest()
115    }
116
117    /// Decode exactly canonical JSON and validate every binding invariant.
118    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
119        let value = strict_json_value(bytes)?;
120        let binding = serde_json::from_value::<Self>(value).map_err(|error| {
121            ValidationError::new(format!("decode invocation context binding: {error}"))
122        })?;
123        if binding.canonical_bytes()? != bytes {
124            return Err(ValidationError::new(
125                "invocation context binding JSON is not canonical UTF-8, compactness or key order",
126            ));
127        }
128        Ok(binding)
129    }
130
131    /// Compatibility alias for callers naming the wire input `from_bytes`.
132    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
133        Self::from_canonical_bytes(bytes)
134    }
135
136    /// Validate all semantic and canonical-signature metadata invariants.
137    pub fn validate(&self) -> Result<(), ValidationError> {
138        self.validate_unsigned()?;
139        validate_base64_signature(&self.signature)?;
140        Ok(())
141    }
142
143    fn validate_unsigned(&self) -> Result<(), ValidationError> {
144        validate_schema_version(self.schema_version)?;
145        validate_bounded_binding_identifier(self.admission_id.as_str(), "admission_id")?;
146        validate_bounded_binding_identifier(self.task_id.as_str(), "task_id")?;
147        validate_bounded_binding_identifier(self.plan_id.as_str(), "plan_id")?;
148        validate_bounded_binding_identifier(self.invocation_id.as_str(), "invocation_id")?;
149        validate_schema_digest(&self.session_identity_ref, "session_identity_ref")?;
150        validate_schema_digest(&self.task_ref, "task_ref")?;
151        validate_schema_digest(&self.plan_ref, "plan_ref")?;
152        validate_schema_digest(&self.invocation_ref, "invocation_ref")?;
153        validate_manifest_reference(&self.policy_ref, "policy_ref")?;
154        validate_schema_digest(&self.policy_digest, "policy_digest")?;
155        if self.decision != EnginePolicyDecisionV1::Admitted {
156            return Err(ValidationError::new(
157                "InvocationContextBindingV1 decision must be admitted",
158            ));
159        }
160        validate_source_bindings(&self.source_bindings)?;
161        validate_capability_bindings(&self.capability_bindings)?;
162        if self.not_before > self.issued_at {
163            return Err(ValidationError::new(
164                "InvocationContextBindingV1 not_before must be at or before issued_at",
165            ));
166        }
167        if self.issued_at >= self.expires_at {
168            return Err(ValidationError::new(
169                "InvocationContextBindingV1 issued_at must be before expires_at",
170            ));
171        }
172        validate_signer(&self.signer)?;
173        Ok(())
174    }
175}
176
177fn validate_signer(signer: &InvocationContextBindingSignerV1) -> Result<(), ValidationError> {
178    if signer.algorithm != "ed25519" {
179        return Err(ValidationError::new(
180            "InvocationContextBindingSignerV1 algorithm must be ed25519",
181        ));
182    }
183    reject_binding_feff(&signer.key_id, "key_id")?;
184    validate_key_id(&signer.key_id)?;
185    // Sha256Digest is exactly 32 bytes represented by 64 lowercase hex bytes;
186    // retaining it as a typed field prevents embedding a public key or a
187    // variable-length key digest in this metadata-only contract.
188    validate_schema_digest(&signer.public_key_digest, "public_key_digest")
189}
190
191fn validate_bounded_binding_identifier(value: &str, field: &str) -> Result<(), ValidationError> {
192    reject_binding_feff(value, field)?;
193    validate_bounded_opaque_identifier(value, field)
194}
195
196fn reject_binding_feff(value: &str, field: &str) -> Result<(), ValidationError> {
197    if value.contains('\u{feff}') {
198        return Err(ValidationError::new(format!(
199            "{field} must not contain U+FEFF"
200        )));
201    }
202    Ok(())
203}
204
205fn validate_key_id(value: &str) -> Result<(), ValidationError> {
206    if value.is_empty()
207        || value.len() > 128
208        || !value.is_ascii()
209        || !value.as_bytes()[0].is_ascii_alphanumeric()
210        || !value
211            .bytes()
212            .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte))
213        || value.starts_with("base64:")
214        || value.starts_with("hex:")
215    {
216        return Err(ValidationError::new(
217            "InvocationContextBindingSignerV1 key_id must be a bounded identifier, not key material",
218        ));
219    }
220    Ok(())
221}
222
223fn validate_schema_digest(value: &Sha256Digest, field: &str) -> Result<(), ValidationError> {
224    if value.as_str().len() != "sha256:".len() + 64 {
225        return Err(ValidationError::new(format!(
226            "{field} must be a SHA-256 digest"
227        )));
228    }
229    Ok(())
230}
231
232fn validate_manifest_reference(
233    reference: &ProtocolReference,
234    field: &str,
235) -> Result<(), ValidationError> {
236    if reference.as_str().contains('\u{feff}') {
237        return Err(ValidationError::new(format!(
238            "{field} must not contain U+FEFF"
239        )));
240    }
241    if reference.as_str().trim().is_empty() {
242        return Err(ValidationError::new(format!("{field} must not be empty")));
243    }
244    Ok(())
245}
246
247fn validate_source_bindings(bindings: &[InvocationSourceBindingV1]) -> Result<(), ValidationError> {
248    if bindings.is_empty() || bindings.len() > MAX_INVOCATION_CONTEXT_BINDING_ITEMS {
249        return Err(ValidationError::new(format!(
250            "source_bindings must contain 1..={MAX_INVOCATION_CONTEXT_BINDING_ITEMS} entries"
251        )));
252    }
253    let mut refs = BTreeSet::new();
254    let mut digests = BTreeSet::new();
255    let mut input_count = 0usize;
256    let mut previous_ref = None;
257    for binding in bindings {
258        validate_manifest_reference(&binding.source_ref, "source_ref")?;
259        if !refs.insert(binding.source_ref.as_str()) {
260            return Err(ValidationError::new(
261                "source_bindings source_ref values must be unique",
262            ));
263        }
264        if !digests.insert(binding.digest.as_str()) {
265            return Err(ValidationError::new(
266                "source_bindings digest values must be unique",
267            ));
268        }
269        if let Some(previous) = previous_ref {
270            if previous >= binding.source_ref.as_str() {
271                return Err(ValidationError::new(
272                    "source_bindings must be sorted by source_ref",
273                ));
274            }
275        }
276        previous_ref = Some(binding.source_ref.as_str());
277        if binding.role == InvocationSourceRoleV1::Input {
278            input_count += 1;
279        }
280    }
281    if input_count != 1 {
282        return Err(ValidationError::new(
283            "source_bindings must contain exactly one input",
284        ));
285    }
286    Ok(())
287}
288
289fn validate_capability_bindings(
290    bindings: &[InvocationCapabilityBindingV1],
291) -> Result<(), ValidationError> {
292    if bindings.len() != 1 {
293        return Err(ValidationError::new(format!(
294            "capability_bindings must contain exactly one entry in V1 (maximum {MAX_INVOCATION_CONTEXT_BINDING_ITEMS})"
295        )));
296    }
297    let mut keys = BTreeSet::new();
298    let mut digests = BTreeSet::new();
299    let mut previous_key = None;
300    for binding in bindings {
301        validate_bounded_binding_identifier(binding.capability_id.as_str(), "capability_id")?;
302        reject_binding_feff(binding.capability_version.as_str(), "capability_version")?;
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        let ordering_key = (
318            binding.capability_id.as_str(),
319            binding.capability_version.as_str(),
320        );
321        if let Some(previous) = previous_key {
322            if previous >= ordering_key {
323                return Err(ValidationError::new(
324                    "capability_bindings must be sorted by capability_id and capability_version",
325                ));
326            }
327        }
328        previous_key = Some(ordering_key);
329    }
330    Ok(())
331}
332
333fn validate_base64_signature(value: &str) -> Result<(), ValidationError> {
334    if value.len() != 88 || !value.ends_with("==") {
335        return Err(ValidationError::new(
336            "InvocationContextBindingV1 signature must be canonical base64 Ed25519 bytes",
337        ));
338    }
339    let bytes = value.as_bytes();
340    if !bytes[..86]
341        .iter()
342        .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'+' || *byte == b'/')
343    {
344        return Err(ValidationError::new(
345            "InvocationContextBindingV1 signature contains invalid base64 characters",
346        ));
347    }
348    // 64 bytes encode as 86 meaningful characters plus `==`; low four bits of
349    // the final sextet are padding and must be zero for one canonical spelling.
350    if base64_value(bytes[85]).is_none_or(|last| last & 0x0f != 0) {
351        return Err(ValidationError::new(
352            "InvocationContextBindingV1 signature has non-zero base64 pad bits",
353        ));
354    }
355    Ok(())
356}
357
358fn base64_value(byte: u8) -> Option<u8> {
359    match byte {
360        b'A'..=b'Z' => Some(byte - b'A'),
361        b'a'..=b'z' => Some(byte - b'a' + 26),
362        b'0'..=b'9' => Some(byte - b'0' + 52),
363        b'+' => Some(62),
364        b'/' => Some(63),
365        _ => None,
366    }
367}
368
369fn canonical_value_without_signature<T: Serialize>(
370    value: &T,
371    omit_signature: bool,
372) -> Result<Vec<u8>, ValidationError> {
373    let mut value = serde_json::to_value(value).map_err(|error| {
374        ValidationError::new(format!("serialize invocation context binding: {error}"))
375    })?;
376    if omit_signature {
377        let Value::Object(object) = &mut value else {
378            return Err(ValidationError::new(
379                "invocation context binding must serialize as a JSON object",
380            ));
381        };
382        object.remove("signature");
383    }
384    serde_json::to_vec(&sort_json(value)).map_err(|error| {
385        ValidationError::new(format!("canonicalize invocation context binding: {error}"))
386    })
387}
388
389fn sort_json(value: Value) -> Value {
390    match value {
391        Value::Object(object) => Value::Object(
392            object
393                .into_iter()
394                .map(|(key, value)| (key, sort_json(value)))
395                .collect::<BTreeMap<_, _>>()
396                .into_iter()
397                .collect(),
398        ),
399        Value::Array(values) => Value::Array(values.into_iter().map(sort_json).collect()),
400        scalar => scalar,
401    }
402}
403
404/// Serde visitor rejecting duplicate keys and floating-point numbers.
405struct StrictJsonValue(Value);
406
407impl<'de> Deserialize<'de> for StrictJsonValue {
408    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
409    where
410        D: serde::Deserializer<'de>,
411    {
412        deserializer.deserialize_any(StrictJsonVisitor)
413    }
414}
415
416struct StrictJsonVisitor;
417
418impl<'de> Visitor<'de> for StrictJsonVisitor {
419    type Value = StrictJsonValue;
420
421    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
422        formatter.write_str("a canonical JSON value with unique object keys")
423    }
424
425    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
426    where
427        E: serde::de::Error,
428    {
429        Ok(StrictJsonValue(Value::Bool(value)))
430    }
431
432    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
433    where
434        E: serde::de::Error,
435    {
436        Ok(StrictJsonValue(Value::Number(value.into())))
437    }
438
439    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
440    where
441        E: serde::de::Error,
442    {
443        Ok(StrictJsonValue(Value::Number(value.into())))
444    }
445
446    fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E>
447    where
448        E: serde::de::Error,
449    {
450        Err(E::custom(
451            "floating-point JSON numbers are not canonical binding values",
452        ))
453    }
454
455    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
456    where
457        E: serde::de::Error,
458    {
459        Ok(StrictJsonValue(Value::String(value.to_owned())))
460    }
461
462    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
463    where
464        E: serde::de::Error,
465    {
466        Ok(StrictJsonValue(Value::String(value)))
467    }
468
469    fn visit_none<E>(self) -> Result<Self::Value, E>
470    where
471        E: serde::de::Error,
472    {
473        Ok(StrictJsonValue(Value::Null))
474    }
475
476    fn visit_unit<E>(self) -> Result<Self::Value, E>
477    where
478        E: serde::de::Error,
479    {
480        Ok(StrictJsonValue(Value::Null))
481    }
482
483    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
484    where
485        A: SeqAccess<'de>,
486    {
487        let mut values = Vec::new();
488        while let Some(value) = seq.next_element::<StrictJsonValue>()? {
489            values.push(value.0);
490        }
491        Ok(StrictJsonValue(Value::Array(values)))
492    }
493
494    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
495    where
496        A: MapAccess<'de>,
497    {
498        let mut object = Map::new();
499        while let Some(key) = map.next_key::<String>()? {
500            if object.contains_key(&key) {
501                return Err(serde::de::Error::custom(format!(
502                    "duplicate JSON object key {key:?}"
503                )));
504            }
505            let value = map.next_value::<StrictJsonValue>()?;
506            object.insert(key, value.0);
507        }
508        Ok(StrictJsonValue(Value::Object(object)))
509    }
510}
511
512fn strict_json_value(bytes: &[u8]) -> Result<Value, ValidationError> {
513    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
514    let value = StrictJsonValue::deserialize(&mut deserializer)
515        .map_err(|error| ValidationError::new(format!("decode canonical binding JSON: {error}")))?
516        .0;
517    deserializer.end().map_err(|error| {
518        ValidationError::new(format!("trailing canonical binding JSON: {error}"))
519    })?;
520    Ok(value)
521}
522
523fn digest_bytes(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
524    let digest = Sha256::digest(bytes);
525    let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2);
526    encoded.push_str("sha256:");
527    for byte in digest {
528        use std::fmt::Write as _;
529        write!(encoded, "{byte:02x}")
530            .map_err(|error| ValidationError::new(format!("encode binding digest: {error}")))?;
531    }
532    Sha256Digest::new(encoded)
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    fn digest(ch: char) -> Sha256Digest {
540        Sha256Digest::new(format!("sha256:{}", ch.to_string().repeat(64))).unwrap()
541    }
542
543    fn source_ref(value: &str) -> ProtocolReference {
544        ProtocolReference::new(value).unwrap()
545    }
546
547    fn valid_binding() -> InvocationContextBindingV1 {
548        InvocationContextBindingV1 {
549            schema_version: 1,
550            admission_id: DecisionId::new("admission-1").unwrap(),
551            session_identity_ref: digest('a'),
552            task_id: TaskId::new("task-1").unwrap(),
553            task_ref: digest('b'),
554            plan_id: PlanId::new("plan-1").unwrap(),
555            plan_ref: digest('c'),
556            invocation_id: EngineInvocationIdV1::new("invocation-1").unwrap(),
557            invocation_ref: digest('d'),
558            policy_ref: source_ref("policy:invocation-admission"),
559            policy_digest: digest('e'),
560            decision: EnginePolicyDecisionV1::Admitted,
561            source_bindings: vec![InvocationSourceBindingV1 {
562                source_ref: source_ref("source:input"),
563                digest: digest('f'),
564                role: InvocationSourceRoleV1::Input,
565            }],
566            capability_bindings: vec![InvocationCapabilityBindingV1 {
567                capability_id: crate::CapabilityId::new("capability:engine").unwrap(),
568                capability_version: crate::SemanticVersion::new("1.0.0").unwrap(),
569                manifest_digest: digest('1'),
570            }],
571            issued_at: UtcTimestamp::new("2026-08-23T12:00:00Z").unwrap(),
572            not_before: UtcTimestamp::new("2026-08-23T11:59:00Z").unwrap(),
573            expires_at: UtcTimestamp::new("2026-08-23T12:05:00Z").unwrap(),
574            signer: InvocationContextBindingSignerV1 {
575                algorithm: "ed25519".to_owned(),
576                key_id: "test-key".to_owned(),
577                public_key_digest: digest('2'),
578            },
579            signature: "A".repeat(86) + "==",
580        }
581    }
582
583    #[test]
584    fn canonical_round_trip_and_signature_domain_are_stable() {
585        let binding = valid_binding();
586        binding.validate().unwrap();
587        let bytes = binding.canonical_bytes().unwrap();
588        let decoded = InvocationContextBindingV1::from_canonical_bytes(&bytes).unwrap();
589        assert_eq!(decoded, binding);
590        let golden =
591            include_bytes!("../../../../docs/contracts/invocation-context-binding/v1/valid.json");
592        let golden = golden.strip_suffix(b"\n").unwrap_or(golden);
593        assert_eq!(
594            InvocationContextBindingV1::from_canonical_bytes(golden).unwrap(),
595            binding
596        );
597        let signing = include_bytes!(
598            "../../../../docs/contracts/invocation-context-binding/v1/canonical-signing.json"
599        );
600        let signing = signing.strip_suffix(b"\n").unwrap_or(signing);
601        assert_eq!(binding.unsigned_canonical_bytes().unwrap(), signing);
602        let signing_bytes = binding.signing_bytes().unwrap();
603        assert!(signing_bytes.starts_with(INVOCATION_CONTEXT_BINDING_SIGNATURE_DOMAIN));
604        assert_eq!(
605            signing_bytes[INVOCATION_CONTEXT_BINDING_SIGNATURE_DOMAIN.len() - 1],
606            0
607        );
608        let expected_hex = include_bytes!(
609            "../../../../docs/contracts/invocation-context-binding/v1/signing-bytes.hex"
610        );
611        let expected_hex = expected_hex.strip_suffix(b"\n").unwrap_or(expected_hex);
612        let expected_signing_bytes = decode_hex(expected_hex);
613        assert_eq!(signing_bytes, expected_signing_bytes);
614        let expected_hash = include_bytes!(
615            "../../../../docs/contracts/invocation-context-binding/v1/signing-bytes.sha256"
616        );
617        let expected_hash = expected_hash.strip_suffix(b"\n").unwrap_or(expected_hash);
618        let actual_hash = Sha256::digest(&signing_bytes);
619        let actual_hash = actual_hash.iter().fold(String::new(), |mut value, byte| {
620            use std::fmt::Write as _;
621            write!(value, "{byte:02x}").unwrap();
622            value
623        });
624        assert_eq!(actual_hash.as_bytes(), expected_hash);
625        assert_ne!(
626            binding.canonical_bytes().unwrap(),
627            binding.unsigned_canonical_bytes().unwrap()
628        );
629        assert_eq!(binding.digest().unwrap(), decoded.digest().unwrap());
630    }
631
632    #[test]
633    fn strict_decoder_rejects_duplicate_unknown_float_whitespace_and_mutations() {
634        let binding = valid_binding();
635        let canonical = binding.canonical_bytes().unwrap();
636        let mut duplicate = canonical.clone();
637        duplicate.pop();
638        duplicate.extend_from_slice(b",\"schema_version\":1}");
639        assert!(InvocationContextBindingV1::from_canonical_bytes(&duplicate).is_err());
640
641        let mut whitespace = b" \n".to_vec();
642        whitespace.extend_from_slice(&canonical);
643        assert!(InvocationContextBindingV1::from_canonical_bytes(&whitespace).is_err());
644        assert!(strict_json_value(br#"{"schema_version":1.0}"#).is_err());
645
646        let mut unknown = canonical.clone();
647        unknown.pop();
648        unknown.extend_from_slice(b",\"unknown\":true}");
649        assert!(InvocationContextBindingV1::from_canonical_bytes(&unknown).is_err());
650    }
651
652    #[test]
653    fn unicode_fixture_uses_utf8_without_optional_escapes() {
654        let bytes = include_bytes!(
655            "../../../../docs/contracts/invocation-context-binding/v1/valid-unicode.json"
656        );
657        let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes);
658        let binding = InvocationContextBindingV1::from_canonical_bytes(bytes).unwrap();
659        let canonical = binding.canonical_bytes().unwrap();
660        assert!(
661            canonical
662                .windows("source:é".len())
663                .any(|w| w == "source:é".as_bytes())
664        );
665        assert!(!canonical.windows(br"\u00e9".len()).any(|w| w == br"\u00e9"));
666    }
667
668    #[test]
669    fn signature_metadata_and_admission_invariants_are_strict() {
670        let mut binding = valid_binding();
671        binding.signature = "A".repeat(85) + "B==";
672        assert!(binding.validate().is_err());
673        binding = valid_binding();
674        binding.signer.algorithm = "ed25519ph".to_owned();
675        assert!(binding.validate().is_err());
676        binding = valid_binding();
677        binding.decision = EnginePolicyDecisionV1::Rejected;
678        assert!(binding.validate().is_err());
679        binding = valid_binding();
680        binding.expires_at = binding.issued_at.clone();
681        assert!(binding.validate().is_err());
682        binding = valid_binding();
683        binding.source_bindings.push(InvocationSourceBindingV1 {
684            source_ref: source_ref("source:other"),
685            digest: digest('3'),
686            role: InvocationSourceRoleV1::Context,
687        });
688        binding.source_bindings.reverse();
689        assert!(binding.validate().is_err());
690    }
691
692    #[test]
693    fn bounded_ids_reject_feff_on_every_binding_identity_path() {
694        let mut binding = valid_binding();
695        binding.admission_id = DecisionId::new("admission:\u{feff}").unwrap();
696        assert!(binding.validate().is_err());
697        let mut binding = valid_binding();
698        binding.task_id = TaskId::new("task:\u{feff}").unwrap();
699        assert!(binding.validate().is_err());
700        let mut binding = valid_binding();
701        binding.plan_id = PlanId::new("plan:\u{feff}").unwrap();
702        assert!(binding.validate().is_err());
703        let mut binding = valid_binding();
704        binding.invocation_id = EngineInvocationIdV1::new("invocation:\u{feff}").unwrap();
705        assert!(binding.validate().is_err());
706        let mut binding = valid_binding();
707        binding.signer.key_id = "key:\u{feff}".to_owned();
708        assert!(binding.validate().is_err());
709        let mut binding = valid_binding();
710        binding.policy_ref = source_ref("policy:\u{feff}ref");
711        assert!(binding.validate().is_err());
712        let mut binding = valid_binding();
713        binding.source_bindings[0].source_ref = source_ref("source:\u{feff}input");
714        assert!(binding.validate().is_err());
715        let mut binding = valid_binding();
716        binding.capability_bindings[0].capability_id =
717            crate::CapabilityId::new("capability:\u{feff}engine").unwrap();
718        assert!(binding.validate().is_err());
719        assert!(crate::SemanticVersion::new("1.0.0\u{feff}").is_err());
720    }
721
722    #[test]
723    fn wrong_public_key_metadata_changes_coverage_without_trust_claim() {
724        let binding = valid_binding();
725        let expected = binding.signing_bytes().unwrap();
726        let mut wrong_key = binding.clone();
727        wrong_key.signer.public_key_digest = digest('3');
728        wrong_key.validate().unwrap();
729        assert_ne!(wrong_key.signing_bytes().unwrap(), expected);
730        // Key lookup and cryptographic verification intentionally remain a
731        // caller/runtime gate; protocol validation only checks metadata shape.
732    }
733
734    #[test]
735    fn signature_mutation_changes_full_digest_not_unsigned_coverage() {
736        let binding = valid_binding();
737        let unsigned = binding.unsigned_canonical_bytes().unwrap();
738        let original_digest = binding.digest().unwrap();
739        let mut mutated = binding.clone();
740        mutated.signature.replace_range(0..1, "B");
741        assert_ne!(mutated.digest().unwrap(), original_digest);
742        assert_eq!(mutated.unsigned_canonical_bytes().unwrap(), unsigned);
743    }
744
745    #[test]
746    fn capability_cardinality_is_exactly_one_in_v1() {
747        let mut binding = valid_binding();
748        assert!(binding.validate().is_ok());
749        binding
750            .capability_bindings
751            .push(InvocationCapabilityBindingV1 {
752                capability_id: crate::CapabilityId::new("capability:other").unwrap(),
753                capability_version: crate::SemanticVersion::new("1.0.0").unwrap(),
754                manifest_digest: digest('3'),
755            });
756        assert_eq!(binding.capability_bindings.len(), 2);
757        assert!(binding.validate().is_err());
758    }
759
760    #[test]
761    fn adversarial_fixture_set_is_rejected() {
762        for name in [
763            "invalid-duplicate-key.json",
764            "invalid-schema-version-float.json",
765            "invalid-unknown-field.json",
766            "invalid-signature-pad-bits.json",
767            "invalid-time-order.json",
768            "invalid-duplicate-source.json",
769            "invalid-feff-task-id.json",
770        ] {
771            let bytes: &[u8] = match name {
772                "invalid-duplicate-key.json" => include_bytes!(
773                    "../../../../docs/contracts/invocation-context-binding/v1/invalid-duplicate-key.json"
774                ),
775                "invalid-schema-version-float.json" => include_bytes!(
776                    "../../../../docs/contracts/invocation-context-binding/v1/invalid-schema-version-float.json"
777                ),
778                "invalid-unknown-field.json" => include_bytes!(
779                    "../../../../docs/contracts/invocation-context-binding/v1/invalid-unknown-field.json"
780                ),
781                "invalid-signature-pad-bits.json" => include_bytes!(
782                    "../../../../docs/contracts/invocation-context-binding/v1/invalid-signature-pad-bits.json"
783                ),
784                "invalid-time-order.json" => include_bytes!(
785                    "../../../../docs/contracts/invocation-context-binding/v1/invalid-time-order.json"
786                ),
787                "invalid-duplicate-source.json" => include_bytes!(
788                    "../../../../docs/contracts/invocation-context-binding/v1/invalid-duplicate-source.json"
789                ),
790                "invalid-feff-task-id.json" => include_bytes!(
791                    "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-task-id.json"
792                ),
793                _ => unreachable!(),
794            };
795            assert!(
796                InvocationContextBindingV1::from_canonical_bytes(bytes).is_err(),
797                "fixture {name} must be rejected"
798            );
799        }
800    }
801
802    #[test]
803    fn additional_adversarial_fixtures_are_rejected() {
804        let fixtures: &[&[u8]] = &[
805            include_bytes!(
806                "../../../../docs/contracts/invocation-context-binding/v1/invalid-algorithm.json"
807            ) as &[u8],
808            include_bytes!(
809                "../../../../docs/contracts/invocation-context-binding/v1/invalid-signature-length.json"
810            ),
811            include_bytes!(
812                "../../../../docs/contracts/invocation-context-binding/v1/invalid-zero-input.json"
813            ),
814            include_bytes!(
815                "../../../../docs/contracts/invocation-context-binding/v1/invalid-multiple-inputs.json"
816            ),
817            include_bytes!(
818                "../../../../docs/contracts/invocation-context-binding/v1/invalid-extra-capability.json"
819            ),
820            include_bytes!(
821                "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-admission-id.json"
822            ),
823            include_bytes!(
824                "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-plan-id.json"
825            ),
826            include_bytes!(
827                "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-invocation-id.json"
828            ),
829            include_bytes!(
830                "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-policy-reference.json"
831            ),
832            include_bytes!(
833                "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-source-reference.json"
834            ),
835            include_bytes!(
836                "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-capability-id.json"
837            ),
838            include_bytes!(
839                "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-capability-version.json"
840            ),
841            include_bytes!(
842                "../../../../docs/contracts/invocation-context-binding/v1/invalid-feff-key-id.json"
843            ),
844            include_bytes!(
845                "../../../../docs/contracts/invocation-context-binding/v1/invalid-key-id-material.json"
846            ) as &[u8],
847        ];
848        for bytes in fixtures {
849            assert!(InvocationContextBindingV1::from_canonical_bytes(bytes).is_err());
850        }
851        let error = InvocationContextBindingV1::from_canonical_bytes(include_bytes!(
852            "../../../../docs/contracts/invocation-context-binding/v1/invalid-signature-pad-bits.json"
853        ))
854        .unwrap_err();
855        assert!(error.to_string().contains("pad bits"));
856    }
857
858    fn decode_hex(bytes: &[u8]) -> Vec<u8> {
859        fn nibble(byte: u8) -> u8 {
860            match byte {
861                b'0'..=b'9' => byte - b'0',
862                b'a'..=b'f' => byte - b'a' + 10,
863                _ => panic!("invalid hex fixture"),
864            }
865        }
866        bytes
867            .chunks_exact(2)
868            .map(|pair| (nibble(pair[0]) << 4) | nibble(pair[1]))
869            .collect()
870    }
871
872    #[test]
873    fn schema_declares_semantic_and_runtime_conformance_boundaries() {
874        let schema: Value = serde_json::from_slice(include_bytes!(
875            "../../../../docs/contracts/invocation-context-binding-v1.schema.json"
876        ))
877        .unwrap();
878        assert_eq!(schema["x-conformance"]["mode"], "three_stage");
879        assert_eq!(
880            schema["x-conformance"]["semantic_validation_required"],
881            true
882        );
883        assert_eq!(
884            schema["x-conformance"]["cross_artifact_join_required"],
885            true
886        );
887        assert_eq!(schema["x-conformance"]["signature"]["decoded_bytes"], 64);
888        assert_eq!(
889            schema["x-conformance"]["signature_domain_terminator_hex"],
890            "00"
891        );
892        assert_eq!(
893            schema["x-conformance"]["canonical_json"]["encoding"],
894            "UTF-8"
895        );
896        assert_eq!(schema["properties"]["capability_bindings"]["minItems"], 1);
897        assert_eq!(schema["properties"]["capability_bindings"]["maxItems"], 1);
898        let requirements = schema["x-conformance"]["stages"][2]["requirements"]
899            .as_array()
900            .unwrap();
901        assert!(requirements.iter().any(|requirement| {
902            requirement
903                .as_str()
904                .is_some_and(|value| value.contains("policy_digest"))
905        }));
906        assert!(
907            schema["x-conformance"]["runtime_gates_not_in_decoder"]
908                .as_array()
909                .unwrap()
910                .iter()
911                .any(|value| value == "one_time_consumption")
912        );
913    }
914}