Skip to main content

lean_ctx_protocol/
context_gateway.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Context Gateway v1 — the canonical, public decision contract.
3//!
4//! Every governed piece of context carries a [`ContextObjectPassportV1`]; the
5//! gateway records one [`ContextDecisionV1`] per object and summarises a
6//! delivery in one [`ContextDecisionReceiptV1`], which is the single source for
7//! audit, HUD and SDK surfaces. The contract never carries content, secret
8//! values or detector matches — only digests, counts and reason codes.
9//!
10//! Field-level invariants (principal, coverage, reason codes) are enforced at
11//! construction *and* deserialization; cross-field invariants by `validate()`,
12//! which `derive` and `canonical_digest` call. All of them fail closed:
13//! - an unknown principal is explicit and never carries an identity;
14//! - classification only ever rises through derivation ([`ClassificationV1::join`]);
15//! - destination constraints only accumulate;
16//! - a detector may not report more coverage than it inspected;
17//! - a receipt's security counts must equal its per-object decisions.
18
19use std::collections::BTreeSet;
20use std::fmt::Write as _;
21
22use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
23use sha2::{Digest, Sha256};
24
25use crate::common::{
26    TaskId, V1_SCHEMA_VERSION, ValidationError, deserialize_schema_version, validate_schema_version,
27};
28use crate::identity::{PolicyId, ProtocolReference, SemanticVersion, Sha256Digest, SourceId};
29
30/// Bounds keep untrusted receipts from growing without limit.
31pub const MAX_REASON_CODES: usize = 32;
32pub const MAX_SIGNALS: usize = 256;
33pub const MAX_DECISIONS: usize = 4_096;
34pub const MAX_LINEAGE: usize = 256;
35pub const MAX_POLICY_REFS: usize = 32;
36const MAX_REASON_CODE_LEN: usize = 64;
37
38// ─── Classification ─────────────────────────────────────────────────────────
39
40/// The one classification lattice: `Public < Internal < Confidential < Restricted`.
41///
42/// Secrets and credentials are `Restricted`. There is deliberately no
43/// `Default`: an unclassified object must be classified by the mode's rule
44/// ([`GatewayModeV1::unclassified`]), never by an implicit fallback.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum ClassificationV1 {
48    Public,
49    Internal,
50    Confidential,
51    Restricted,
52}
53
54impl ClassificationV1 {
55    /// Information-flow join: a derived object is at least as sensitive as the
56    /// most sensitive input.
57    #[must_use]
58    pub fn join(self, other: Self) -> Self {
59        self.max(other)
60    }
61}
62
63impl From<crate::team_context::TeamClassification> for ClassificationV1 {
64    fn from(value: crate::team_context::TeamClassification) -> Self {
65        use crate::team_context::TeamClassification as T;
66        match value {
67            T::Public => Self::Public,
68            T::Internal => Self::Internal,
69            T::Confidential => Self::Confidential,
70            T::Restricted => Self::Restricted,
71        }
72    }
73}
74
75impl From<crate::knowledge::ClassificationLevel> for ClassificationV1 {
76    fn from(value: crate::knowledge::ClassificationLevel) -> Self {
77        use crate::knowledge::ClassificationLevel as K;
78        match value {
79            K::Public => Self::Public,
80            K::Internal => Self::Internal,
81            K::Confidential => Self::Confidential,
82            K::Restricted => Self::Restricted,
83        }
84    }
85}
86
87impl From<crate::experiment::DataClassification> for ClassificationV1 {
88    fn from(value: crate::experiment::DataClassification) -> Self {
89        use crate::experiment::DataClassification as D;
90        match value {
91            D::Public => Self::Public,
92            D::Internal => Self::Internal,
93            D::Confidential => Self::Confidential,
94            D::Restricted => Self::Restricted,
95        }
96    }
97}
98
99/// Projection of the VIA classification onto the two gateway axes. VIA mixes
100/// sensitivity with placement (`LocalOnly`, `EnterprisePrivate`); the gateway
101/// keeps placement as a [`DestinationConstraintV1`] so locality never depends
102/// on a sensitivity estimate.
103#[must_use]
104pub fn project_via_classification(
105    value: crate::edge_via::ViaClassificationV1,
106) -> (ClassificationV1, Option<DestinationConstraintV1>) {
107    use crate::edge_via::ViaClassificationV1 as V;
108    match value {
109        V::Public => (ClassificationV1::Public, None),
110        V::Normal => (ClassificationV1::Internal, None),
111        V::Sensitive => (ClassificationV1::Confidential, None),
112        V::Secret => (ClassificationV1::Restricted, None),
113        V::LocalOnly => (
114            ClassificationV1::Confidential,
115            Some(DestinationConstraintV1::LocalOnly),
116        ),
117        V::EnterprisePrivate => (
118            ClassificationV1::Confidential,
119            Some(DestinationConstraintV1::OrganizationPrivate),
120        ),
121    }
122}
123
124/// Operating posture of the gateway.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum GatewayModeV1 {
128    /// Local-first, strong defaults, visible warnings.
129    Developer,
130    /// Organisation-controlled: identity and mandatory checks fail closed.
131    Governed,
132    /// Nothing leaves the trust boundary except to approved destinations.
133    Sovereign,
134}
135
136impl GatewayModeV1 {
137    /// Classification for an object no detector or label classified.
138    #[must_use]
139    pub fn unclassified(self) -> ClassificationV1 {
140        match self {
141            Self::Developer => ClassificationV1::Public,
142            Self::Governed | Self::Sovereign => ClassificationV1::Internal,
143        }
144    }
145}
146
147// ─── Principal and destination ──────────────────────────────────────────────
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum PrincipalKindV1 {
152    Person,
153    Team,
154    Organization,
155    Project,
156    Agent,
157    Session,
158    Workload,
159    /// No authenticated identity. Explicit, and never an authorization.
160    Unknown,
161}
162
163/// Who requested the context. `Unknown` never carries an id; every other
164/// kind must.
165#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
166pub struct PrincipalV1 {
167    pub kind: PrincipalKindV1,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub id: Option<ProtocolReference>,
170}
171
172impl PrincipalV1 {
173    pub fn new(kind: PrincipalKindV1, id: ProtocolReference) -> Result<Self, ValidationError> {
174        if kind == PrincipalKindV1::Unknown {
175            return Err(ValidationError::new(
176                "an unknown principal must not carry an identity",
177            ));
178        }
179        Ok(Self { kind, id: Some(id) })
180    }
181
182    #[must_use]
183    pub fn unknown() -> Self {
184        Self {
185            kind: PrincipalKindV1::Unknown,
186            id: None,
187        }
188    }
189
190    #[must_use]
191    pub fn is_known(&self) -> bool {
192        self.kind != PrincipalKindV1::Unknown
193    }
194}
195
196impl<'de> Deserialize<'de> for PrincipalV1 {
197    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
198        #[derive(Deserialize)]
199        #[serde(deny_unknown_fields)]
200        struct Wire {
201            kind: PrincipalKindV1,
202            #[serde(default)]
203            id: Option<ProtocolReference>,
204        }
205        let wire = Wire::deserialize(deserializer)?;
206        match (wire.kind, wire.id) {
207            (PrincipalKindV1::Unknown, None) => Ok(Self::unknown()),
208            (PrincipalKindV1::Unknown, Some(_)) => Err(DeError::custom(
209                "an unknown principal must not carry an identity",
210            )),
211            (_, None) => Err(DeError::custom("a known principal requires an id")),
212            (kind, Some(id)) => Ok(Self { kind, id: Some(id) }),
213        }
214    }
215}
216
217/// Placement constraints that only ever accumulate through derivation.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum DestinationConstraintV1 {
221    /// Must never leave the local machine.
222    LocalOnly,
223    /// Only organisation-managed destinations (or local ones).
224    OrganizationPrivate,
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
228#[serde(rename_all = "snake_case")]
229pub enum DestinationLocalityV1 {
230    Local,
231    Remote,
232    /// Not established. Never satisfies a locality constraint.
233    Unknown,
234}
235
236/// Where the compiled context is delivered. Models LeanCTX itself uses
237/// (detectors, summarisers) are destinations too.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct DestinationV1 {
241    pub provider: ProtocolReference,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub model: Option<ProtocolReference>,
244    pub locality: DestinationLocalityV1,
245    /// True only when the destination is attested as organisation-managed.
246    #[serde(default)]
247    pub organization_managed: bool,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub account_ref: Option<ProtocolReference>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub region: Option<ProtocolReference>,
252}
253
254impl DestinationV1 {
255    /// Whether this destination may receive an object with `constraint`.
256    #[must_use]
257    pub fn satisfies(&self, constraint: DestinationConstraintV1) -> bool {
258        match constraint {
259            DestinationConstraintV1::LocalOnly => self.locality == DestinationLocalityV1::Local,
260            DestinationConstraintV1::OrganizationPrivate => {
261                self.locality == DestinationLocalityV1::Local
262                    || (self.organization_managed && self.locality == DestinationLocalityV1::Remote)
263            }
264        }
265    }
266
267    /// Whether this destination satisfies every constraint in `constraints`.
268    #[must_use]
269    pub fn satisfies_all<'a>(
270        &self,
271        constraints: impl IntoIterator<Item = &'a DestinationConstraintV1>,
272    ) -> bool {
273        constraints
274            .into_iter()
275            .all(|constraint| self.satisfies(*constraint))
276    }
277}
278
279// ─── Reason codes and policy references ─────────────────────────────────────
280
281/// Stable, machine-readable reason: `[a-z][a-z0-9_.]{2,63}`.
282#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
283#[serde(transparent)]
284pub struct ReasonCodeV1(String);
285
286impl ReasonCodeV1 {
287    pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
288        let value = value.into();
289        let bytes = value.as_bytes();
290        let valid = (3..=MAX_REASON_CODE_LEN).contains(&bytes.len())
291            && bytes[0].is_ascii_lowercase()
292            && bytes
293                .iter()
294                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'_' || *b == b'.');
295        if !valid {
296            return Err(ValidationError::new(format!(
297                "reason code {value:?} must match [a-z][a-z0-9_.]{{2,63}}"
298            )));
299        }
300        Ok(Self(value))
301    }
302
303    #[must_use]
304    pub fn as_str(&self) -> &str {
305        &self.0
306    }
307}
308
309impl<'de> Deserialize<'de> for ReasonCodeV1 {
310    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
311        Self::new(String::deserialize(deserializer)?).map_err(|e| DeError::custom(e.0))
312    }
313}
314
315/// The exact policy that decided: identity, optional version, content digest.
316#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
317#[serde(deny_unknown_fields)]
318pub struct PolicyRefV1 {
319    pub id: PolicyId,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub version: Option<SemanticVersion>,
322    pub digest: Sha256Digest,
323}
324
325// ─── Detector signals ───────────────────────────────────────────────────────
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
328#[serde(rename_all = "snake_case")]
329pub enum DetectorCategoryV1 {
330    Secret,
331    Pii,
332    PromptInjection,
333    Classification,
334    Policy,
335    Custom,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum SeverityV1 {
341    Info,
342    Low,
343    Medium,
344    High,
345    Critical,
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
349#[serde(rename_all = "snake_case")]
350pub enum CoverageKindV1 {
351    /// Every byte of the object was inspected.
352    Complete,
353    /// Only part of the object was inspected (prefix, chunk budget, …).
354    Partial,
355    /// The detector cannot inspect this media type.
356    Unsupported,
357    /// The detector failed; nothing it reported can be relied on.
358    Failed,
359    /// Policy did not require this detector for this object.
360    NotRequired,
361}
362
363/// What a detector actually looked at. A result never implies more coverage
364/// than recorded here.
365#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
366pub struct DetectorCoverageV1 {
367    pub kind: CoverageKindV1,
368    pub bytes_total: u64,
369    pub bytes_inspected: u64,
370    pub chunks_total: u32,
371    pub chunks_inspected: u32,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub reason: Option<ReasonCodeV1>,
374}
375
376impl DetectorCoverageV1 {
377    pub fn validate(&self) -> Result<(), ValidationError> {
378        if self.bytes_inspected > self.bytes_total || self.chunks_inspected > self.chunks_total {
379            return Err(ValidationError::new(
380                "coverage must not inspect more than the object holds",
381            ));
382        }
383        let all_bytes = self.bytes_inspected == self.bytes_total;
384        match self.kind {
385            CoverageKindV1::Complete if !all_bytes => Err(ValidationError::new(
386                "complete coverage must inspect every byte",
387            )),
388            CoverageKindV1::Partial if all_bytes => Err(ValidationError::new(
389                "partial coverage must leave bytes uninspected",
390            )),
391            _ => Ok(()),
392        }
393    }
394
395    /// True only when the detector inspected the whole object.
396    #[must_use]
397    pub fn is_complete(&self) -> bool {
398        self.kind == CoverageKindV1::Complete
399    }
400}
401
402impl<'de> Deserialize<'de> for DetectorCoverageV1 {
403    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
404        #[derive(Deserialize)]
405        #[serde(deny_unknown_fields)]
406        struct Wire {
407            kind: CoverageKindV1,
408            bytes_total: u64,
409            bytes_inspected: u64,
410            chunks_total: u32,
411            chunks_inspected: u32,
412            #[serde(default)]
413            reason: Option<ReasonCodeV1>,
414        }
415        let w = Wire::deserialize(deserializer)?;
416        let coverage = Self {
417            kind: w.kind,
418            bytes_total: w.bytes_total,
419            bytes_inspected: w.bytes_inspected,
420            chunks_total: w.chunks_total,
421            chunks_inspected: w.chunks_inspected,
422            reason: w.reason,
423        };
424        coverage.validate().map_err(|e| DeError::custom(e.0))?;
425        Ok(coverage)
426    }
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
430#[serde(rename_all = "snake_case")]
431pub enum DetectorStatusV1 {
432    Completed,
433    Failed,
434    TimedOut,
435    Skipped,
436}
437
438#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
439#[serde(deny_unknown_fields)]
440pub struct DetectorRefV1 {
441    pub id: ProtocolReference,
442    pub version: SemanticVersion,
443}
444
445/// One detector's normalised result. Carries counts only — never the matched
446/// value — so telemetry and receipts cannot leak what was detected.
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(deny_unknown_fields)]
449pub struct DetectorSignalV1 {
450    pub detector: DetectorRefV1,
451    pub category: DetectorCategoryV1,
452    pub severity: SeverityV1,
453    /// Confidence in thousandths. Not a probability unless `calibrated`.
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub confidence_milli: Option<u16>,
456    #[serde(default)]
457    pub calibrated: bool,
458    pub evidence_count: u32,
459    pub coverage: DetectorCoverageV1,
460    pub status: DetectorStatusV1,
461    pub latency_us: u64,
462}
463
464impl DetectorSignalV1 {
465    pub fn validate(&self) -> Result<(), ValidationError> {
466        self.coverage.validate()?;
467        if self.confidence_milli.is_some_and(|c| c > 1_000) {
468            return Err(ValidationError::new(
469                "confidence_milli must be at most 1000",
470            ));
471        }
472        let failed = matches!(
473            self.status,
474            DetectorStatusV1::Failed | DetectorStatusV1::TimedOut
475        );
476        if failed && self.coverage.is_complete() {
477            return Err(ValidationError::new(
478                "a failed or timed-out detector cannot claim complete coverage",
479            ));
480        }
481        Ok(())
482    }
483
484    /// Whether this signal may satisfy a *required* detector: it ran to
485    /// completion over the whole object.
486    #[must_use]
487    pub fn satisfies_requirement(&self) -> bool {
488        self.status == DetectorStatusV1::Completed && self.coverage.is_complete()
489    }
490}
491
492// ─── Decisions and transformations ──────────────────────────────────────────
493
494/// Per-object outcome, ordered from least to most restrictive.
495#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
496#[serde(rename_all = "snake_case")]
497pub enum ContextDispositionV1 {
498    Allow,
499    AllowMinimized,
500    AllowRedacted,
501    AllowSummaryOnly,
502    AllowLocalModelOnly,
503    /// Held until an approver releases it; not delivered meanwhile.
504    AllowWithApproval,
505    Quarantine,
506    Deny,
507}
508
509impl ContextDispositionV1 {
510    /// Whether content (possibly transformed) reaches the destination now.
511    #[must_use]
512    pub fn delivers_content(self) -> bool {
513        self <= Self::AllowLocalModelOnly
514    }
515
516    /// Combining two rules keeps the stricter outcome.
517    #[must_use]
518    pub fn most_restrictive(self, other: Self) -> Self {
519        self.max(other)
520    }
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
524#[serde(rename_all = "snake_case")]
525pub enum TransformationKindV1 {
526    Redaction,
527    Classification,
528    Selection,
529    Deduplication,
530    StructuralExtraction,
531    Compression,
532    Summarization,
533    Recovery,
534    Reranking,
535}
536
537/// One step in an object's lineage: which transformation turned which input
538/// digest into which output digest, and what it cost or saved.
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540#[serde(deny_unknown_fields)]
541pub struct TransformationRecordV1 {
542    pub kind: TransformationKindV1,
543    pub input: Sha256Digest,
544    pub output: Sha256Digest,
545    pub tokens_before: u64,
546    pub tokens_after: u64,
547    #[serde(default, skip_serializing_if = "Vec::is_empty")]
548    pub reason_codes: Vec<ReasonCodeV1>,
549}
550
551/// The gateway's decision about one object. Anything but a plain `Allow`
552/// must say why.
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554#[serde(deny_unknown_fields)]
555pub struct ContextDecisionV1 {
556    pub object: Sha256Digest,
557    pub disposition: ContextDispositionV1,
558    #[serde(default, skip_serializing_if = "Vec::is_empty")]
559    pub reason_codes: Vec<ReasonCodeV1>,
560    #[serde(default, skip_serializing_if = "Vec::is_empty")]
561    pub signals: Vec<DetectorSignalV1>,
562    #[serde(default, skip_serializing_if = "Vec::is_empty")]
563    pub required_transformations: Vec<TransformationKindV1>,
564}
565
566impl ContextDecisionV1 {
567    pub fn validate(&self) -> Result<(), ValidationError> {
568        if self.disposition != ContextDispositionV1::Allow && self.reason_codes.is_empty() {
569            return Err(ValidationError::new(
570                "every non-allow decision requires at least one reason code",
571            ));
572        }
573        if self.reason_codes.len() > MAX_REASON_CODES || self.signals.len() > MAX_SIGNALS {
574            return Err(ValidationError::new("decision exceeds its bounds"));
575        }
576        for signal in &self.signals {
577            signal.validate()?;
578        }
579        Ok(())
580    }
581}
582
583// ─── Passport ───────────────────────────────────────────────────────────────
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
586#[serde(rename_all = "snake_case")]
587pub enum SourceKindV1 {
588    File,
589    Shell,
590    Search,
591    Provider,
592    Memory,
593    Knowledge,
594    Agent,
595    Web,
596    Other,
597}
598
599#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
600#[serde(deny_unknown_fields)]
601pub struct SourceRefV1 {
602    pub kind: SourceKindV1,
603    pub id: SourceId,
604}
605
606/// How far the source is trusted, least trusted first.
607#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
608#[serde(rename_all = "snake_case")]
609pub enum TrustLevelV1 {
610    Untrusted,
611    Unknown,
612    Internal,
613    Trusted,
614}
615
616impl TrustLevelV1 {
617    /// Untrusted or unknown: the one test for "handle as untrusted" (Rule U).
618    /// Callers use this instead of comparing against `Untrusted` alone.
619    pub fn is_external_or_unknown(self) -> bool {
620        matches!(self, Self::Untrusted | Self::Unknown)
621    }
622}
623
624/// Security and optimisation metadata that travels with one object. Content
625/// is referenced by digest, never embedded.
626#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
627#[serde(deny_unknown_fields)]
628pub struct ContextObjectPassportV1 {
629    #[serde(deserialize_with = "deserialize_schema_version")]
630    pub schema_version: u32,
631    pub object: Sha256Digest,
632    pub source: SourceRefV1,
633    pub principal: PrincipalV1,
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub task: Option<TaskId>,
636    pub classification: ClassificationV1,
637    pub trust: TrustLevelV1,
638    #[serde(default, skip_serializing_if = "Vec::is_empty")]
639    pub policy_refs: Vec<PolicyRefV1>,
640    #[serde(default, skip_serializing_if = "Vec::is_empty")]
641    pub risk_signals: Vec<DetectorSignalV1>,
642    #[serde(default, skip_serializing_if = "Vec::is_empty")]
643    pub destination_constraints: Vec<DestinationConstraintV1>,
644    pub tokens: u64,
645    pub bytes: u64,
646    #[serde(default, skip_serializing_if = "Vec::is_empty")]
647    pub lineage: Vec<TransformationRecordV1>,
648}
649
650impl ContextObjectPassportV1 {
651    pub fn validate(&self) -> Result<(), ValidationError> {
652        validate_schema_version(self.schema_version)?;
653        if self.policy_refs.len() > MAX_POLICY_REFS
654            || self.risk_signals.len() > MAX_SIGNALS
655            || self.lineage.len() > MAX_LINEAGE
656        {
657            return Err(ValidationError::new("passport exceeds its bounds"));
658        }
659        if !is_strictly_sorted(&self.destination_constraints)
660            || !is_strictly_sorted(&self.policy_refs)
661        {
662            return Err(ValidationError::new(
663                "destination constraints and policy refs must be sorted and unique",
664            ));
665        }
666        for signal in &self.risk_signals {
667            signal.validate()?;
668        }
669        Ok(())
670    }
671
672    /// Passport of an object derived from `inputs` by `step` (selection,
673    /// summary, dedup, …). Information-flow rule: the result is at least as
674    /// restricted as every input — classification is the join, constraints,
675    /// policies and risk signals accumulate, trust drops to the least trusted
676    /// input. Inputs must share one principal: context never silently crosses
677    /// principals.
678    pub fn derive(
679        inputs: &[&Self],
680        step: TransformationRecordV1,
681        source: SourceRefV1,
682        tokens: u64,
683        bytes: u64,
684    ) -> Result<Self, ValidationError> {
685        let Some(first) = inputs.first() else {
686            return Err(ValidationError::new(
687                "derivation requires at least one input",
688            ));
689        };
690        if inputs.iter().any(|p| p.principal != first.principal) {
691            return Err(ValidationError::new(
692                "derivation must not combine context of different principals",
693            ));
694        }
695        let task = first.task.clone();
696        let mut classification = first.classification;
697        let mut trust = first.trust;
698        let mut constraints = BTreeSet::new();
699        let mut policies = BTreeSet::new();
700        let mut signals = Vec::new();
701        let mut lineage = Vec::new();
702        for input in inputs {
703            classification = classification.join(input.classification);
704            trust = trust.min(input.trust);
705            constraints.extend(input.destination_constraints.iter().copied());
706            policies.extend(input.policy_refs.iter().cloned());
707            for signal in &input.risk_signals {
708                if !signals.contains(signal) {
709                    signals.push(signal.clone());
710                }
711            }
712            lineage.extend(input.lineage.iter().cloned());
713        }
714        let object = step.output.clone();
715        lineage.push(step);
716        let derived = Self {
717            schema_version: V1_SCHEMA_VERSION,
718            object,
719            source,
720            principal: first.principal.clone(),
721            task,
722            classification,
723            trust,
724            policy_refs: policies.into_iter().collect(),
725            risk_signals: signals,
726            destination_constraints: constraints.into_iter().collect(),
727            tokens,
728            bytes,
729            lineage,
730        };
731        derived.validate()?;
732        Ok(derived)
733    }
734}
735
736fn is_strictly_sorted<T: Ord>(values: &[T]) -> bool {
737    values.windows(2).all(|pair| pair[0] < pair[1])
738}
739
740// ─── Context quality section ────────────────────────────────────────────────
741
742/// How strong the quality evidence is, weakest first.
743#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
744#[serde(rename_all = "snake_case")]
745pub enum QualityEvidenceTierV1 {
746    Mechanism,
747    DeterministicQuality,
748    RecordedRegression,
749    LiveTaskEvaluation,
750    ProductionOutcome,
751}
752
753#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
754#[serde(rename_all = "snake_case")]
755pub enum QualityStateV1 {
756    Pass,
757    Fail,
758    Unmeasured,
759}
760
761/// Kinds of critical facts a retention probe checks — kinds only, never values.
762#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
763#[serde(rename_all = "snake_case")]
764pub enum QualityProbeKindV1 {
765    ErrorCode,
766    TestResult,
767    Status,
768    Location,
769    Path,
770    Hash,
771    Url,
772}
773
774#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
775#[serde(deny_unknown_fields)]
776pub struct RetentionCountsV1 {
777    pub retained: u64,
778    pub recoverable: u64,
779    pub lost: u64,
780}
781
782#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
783#[serde(deny_unknown_fields)]
784pub struct QualityRetentionV1 {
785    pub critical: RetentionCountsV1,
786    pub important: RetentionCountsV1,
787    /// Sorted and unique.
788    pub lost_critical_kinds: Vec<QualityProbeKindV1>,
789    /// Critical facts could not all be checked; the round can never pass.
790    pub critical_unchecked: bool,
791    pub truncated: bool,
792    /// Secret lines are withheld and never count as recoverable.
793    pub secret_lines_withheld: u64,
794}
795
796#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
797#[serde(deny_unknown_fields)]
798pub struct QualityRecoveryV1 {
799    pub handles_emitted: u64,
800    pub handles_verified: u64,
801    pub failures: u64,
802    pub critical_failures: u64,
803}
804
805/// Deterministic context-quality evidence for one round.
806#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
807#[serde(deny_unknown_fields)]
808pub struct ContextQualitySectionV1 {
809    pub evidence_tier: QualityEvidenceTierV1,
810    pub retention: QualityRetentionV1,
811    #[serde(default, skip_serializing_if = "Option::is_none")]
812    pub recovery: Option<QualityRecoveryV1>,
813    /// Task quality is never measured per round.
814    pub task_quality: QualityStateV1,
815    pub overall: QualityStateV1,
816}
817
818impl ContextQualitySectionV1 {
819    /// The overall state the measured dimensions imply.
820    #[must_use]
821    pub fn derived_overall(&self) -> QualityStateV1 {
822        let retention = &self.retention;
823        let retention_failed = retention.critical_unchecked
824            || retention.critical.lost > 0
825            || !retention.lost_critical_kinds.is_empty();
826        let recovery_failed = self
827            .recovery
828            .is_some_and(|recovery| recovery.critical_failures > 0 || recovery.failures > 0);
829        let measured = retention.critical.retained
830            + retention.critical.recoverable
831            + retention.critical.lost
832            + retention.important.retained
833            + retention.important.recoverable
834            + retention.important.lost
835            > 0
836            || retention.critical_unchecked
837            || self.recovery.is_some();
838        if retention_failed || recovery_failed {
839            QualityStateV1::Fail
840        } else if measured {
841            QualityStateV1::Pass
842        } else {
843            QualityStateV1::Unmeasured
844        }
845    }
846
847    pub fn validate(&self) -> Result<(), ValidationError> {
848        if self.task_quality != QualityStateV1::Unmeasured {
849            return Err(ValidationError::new(
850                "task quality is never measured per round",
851            ));
852        }
853        if !is_strictly_sorted(&self.retention.lost_critical_kinds) {
854            return Err(ValidationError::new(
855                "lost critical kinds must be sorted and unique",
856            ));
857        }
858        if let Some(recovery) = self.recovery
859            && (recovery.handles_verified.saturating_add(recovery.failures)
860                > recovery.handles_emitted
861                || recovery.critical_failures > recovery.failures)
862        {
863            return Err(ValidationError::new(
864                "recovery counts must satisfy verified + failures <= emitted and critical <= failures",
865            ));
866        }
867        if self.overall != self.derived_overall() {
868            return Err(ValidationError::new(
869                "overall quality must follow from the measured dimensions",
870            ));
871        }
872        Ok(())
873    }
874}
875
876// ─── Receipt ────────────────────────────────────────────────────────────────
877
878#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
879#[serde(rename_all = "snake_case")]
880pub enum DeliveryOutcomeV1 {
881    Delivered,
882    Withheld,
883    Failed,
884}
885
886#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
887#[serde(deny_unknown_fields)]
888pub struct SourceCountsV1 {
889    pub inspected: u32,
890    pub permitted: u32,
891    pub selected: u32,
892    pub blocked: u32,
893}
894
895#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
896#[serde(deny_unknown_fields)]
897pub struct SecurityCountsV1 {
898    pub redactions: u32,
899    pub blocked_objects: u32,
900    pub quarantined_objects: u32,
901    pub injection_signals: u32,
902    pub incomplete_coverage: u32,
903}
904
905#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
906#[serde(deny_unknown_fields)]
907pub struct TokenAccountV1 {
908    pub original: u64,
909    pub delivered: u64,
910}
911
912/// Receipt schema version 2 (AP10-C4, `context-gateway-v2.md`): every
913/// `decisions[].object` is keyed under the installation's receipt key and the
914/// receipt names that key in [`ContextDecisionReceiptV1::object_digest`].
915/// Version 1 receipts (plain SHA-256 objects) stay readable and valid.
916pub const RECEIPT_V2_SCHEMA_VERSION: u32 = 2;
917
918/// How the decision objects of a version 2 receipt are digested.
919#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
920pub enum ObjectDigestAlgorithmV2 {
921    /// `HMAC-SHA256(key, "leanctx.receipt.object.v2" || 0x00 || v1_object)`,
922    /// where `v1_object` is the `sha256:<hex>` a version 1 receipt would name.
923    #[serde(rename = "hmac-sha256")]
924    HmacSha256,
925}
926
927/// The key a version 2 receipt's decision objects are digested under: its
928/// algorithm and a public identifier, never the key.
929#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
930#[serde(deny_unknown_fields)]
931pub struct ReceiptObjectDigestV2 {
932    pub algorithm: ObjectDigestAlgorithmV2,
933    /// `rk-` and the first 32 hex digits of
934    /// `HMAC-SHA256(key, "leanctx.receipt.key-id.v2")`: tells keys apart,
935    /// reveals nothing of the key.
936    pub key_id: String,
937}
938
939impl ReceiptObjectDigestV2 {
940    pub fn validate(&self) -> Result<(), ValidationError> {
941        let valid = self.key_id.strip_prefix("rk-").is_some_and(|hex| {
942            hex.len() == 32 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
943        });
944        if valid {
945            Ok(())
946        } else {
947            Err(ValidationError::new(
948                "receipt key id must be rk- and 32 lowercase hex digits",
949            ))
950        }
951    }
952}
953
954fn deserialize_receipt_schema_version<'de, D>(deserializer: D) -> Result<u32, D::Error>
955where
956    D: Deserializer<'de>,
957{
958    let version = u32::deserialize(deserializer)?;
959    if version == V1_SCHEMA_VERSION || version == RECEIPT_V2_SCHEMA_VERSION {
960        Ok(version)
961    } else {
962        Err(DeError::custom(format!(
963            "unsupported receipt schema_version {version}; expected 1 or 2"
964        )))
965    }
966}
967
968/// One governed delivery: who asked, for what, what was considered, what was
969/// withheld and why, what reached which destination under which policy.
970///
971/// The type keeps its V1 name; it carries both receipt schema versions. In
972/// version 2 `decisions[].object` keeps its `sha256:<hex>` form but is keyed
973/// ([`ObjectDigestAlgorithmV2`]) under the key `object_digest` names.
974#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
975#[serde(deny_unknown_fields)]
976pub struct ContextDecisionReceiptV1 {
977    #[serde(deserialize_with = "deserialize_receipt_schema_version")]
978    pub schema_version: u32,
979    /// Present exactly on a version 2 receipt.
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub object_digest: Option<ReceiptObjectDigestV2>,
982    pub receipt_id: ProtocolReference,
983    pub mode: GatewayModeV1,
984    pub principal: PrincipalV1,
985    #[serde(default, skip_serializing_if = "Option::is_none")]
986    pub task: Option<TaskId>,
987    pub destination: DestinationV1,
988    #[serde(default, skip_serializing_if = "Option::is_none")]
989    pub policy: Option<PolicyRefV1>,
990    pub sources: SourceCountsV1,
991    #[serde(default, skip_serializing_if = "Vec::is_empty")]
992    pub decisions: Vec<ContextDecisionV1>,
993    pub security: SecurityCountsV1,
994    pub tokens: TokenAccountV1,
995    #[serde(default, skip_serializing_if = "Option::is_none")]
996    pub final_context: Option<Sha256Digest>,
997    pub outcome: DeliveryOutcomeV1,
998    pub duration_us: u64,
999    /// Context-quality evidence for this round (retention, recovery). Absent
1000    /// when nothing was measured; absent fields keep the receipt's digest.
1001    #[serde(default, skip_serializing_if = "Option::is_none")]
1002    pub quality: Option<ContextQualitySectionV1>,
1003}
1004
1005impl ContextDecisionReceiptV1 {
1006    /// Receipt consistency: counts must describe the recorded decisions, and
1007    /// only a delivered receipt names the delivered context.
1008    pub fn validate(&self) -> Result<(), ValidationError> {
1009        match (self.schema_version, &self.object_digest) {
1010            (V1_SCHEMA_VERSION, None) => {}
1011            (RECEIPT_V2_SCHEMA_VERSION, Some(object_digest)) => object_digest.validate()?,
1012            (V1_SCHEMA_VERSION | RECEIPT_V2_SCHEMA_VERSION, _) => {
1013                return Err(ValidationError::new(
1014                    "exactly a version 2 receipt names its object digest key",
1015                ));
1016            }
1017            (version, _) => {
1018                return Err(ValidationError::new(format!(
1019                    "unsupported receipt schema_version {version}; expected 1 or 2"
1020                )));
1021            }
1022        }
1023        if let Some(quality) = &self.quality {
1024            quality.validate()?;
1025        }
1026        if self.decisions.len() > MAX_DECISIONS {
1027            return Err(ValidationError::new("receipt exceeds its decision bound"));
1028        }
1029        for decision in &self.decisions {
1030            decision.validate()?;
1031        }
1032        let s = self.sources;
1033        if s.selected > s.permitted
1034            || u64::from(s.permitted) + u64::from(s.blocked) > u64::from(s.inspected)
1035        {
1036            return Err(ValidationError::new(
1037                "source counts must satisfy selected <= permitted and permitted + blocked <= inspected",
1038            ));
1039        }
1040        let count = |d: ContextDispositionV1| {
1041            self.decisions
1042                .iter()
1043                .filter(|decision| decision.disposition == d)
1044                .count()
1045        };
1046        if count(ContextDispositionV1::Deny) != self.security.blocked_objects as usize
1047            || count(ContextDispositionV1::Quarantine) != self.security.quarantined_objects as usize
1048        {
1049            return Err(ValidationError::new(
1050                "security counts must equal the recorded deny/quarantine decisions",
1051            ));
1052        }
1053        match (self.outcome, &self.final_context) {
1054            (DeliveryOutcomeV1::Delivered, None) => Err(ValidationError::new(
1055                "a delivered receipt must name the delivered context digest",
1056            )),
1057            (DeliveryOutcomeV1::Withheld | DeliveryOutcomeV1::Failed, Some(_)) => Err(
1058                ValidationError::new("only a delivered receipt may name a context digest"),
1059            ),
1060            _ => Ok(()),
1061        }
1062    }
1063
1064    /// Deterministic content identity of the receipt (#498): SHA-256 over the
1065    /// canonical JSON (sorted keys, no whitespace).
1066    pub fn canonical_digest(&self) -> Result<Sha256Digest, ValidationError> {
1067        self.validate()?;
1068        let value = serde_json::to_value(self)
1069            .map_err(|e| ValidationError::new(format!("receipt serialization: {e}")))?;
1070        let bytes = serde_json::to_vec(&value)
1071            .map_err(|e| ValidationError::new(format!("receipt serialization: {e}")))?;
1072        sha256_digest(&bytes)
1073    }
1074}
1075
1076/// The admission lifecycle for one exact request body. This does not change the
1077/// legacy ContextDecisionReceiptV1 wire format or its outcome semantics.
1078#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1079#[serde(rename_all = "snake_case")]
1080pub enum EgressAdmissionOutcomeV1 {
1081    /// The policy admitted these bytes and their digest is bound; no send is implied.
1082    Prepared,
1083    /// Policy withheld the request before dispatch.
1084    Withheld,
1085    /// Admission could not produce a dispatchable request.
1086    Failed,
1087}
1088
1089/// A versioned, content-addressable admission record for outbound request bytes.
1090#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1091#[serde(deny_unknown_fields)]
1092pub struct EgressAdmissionReceiptV1 {
1093    #[serde(deserialize_with = "deserialize_schema_version")]
1094    pub schema_version: u32,
1095    pub receipt_id: ProtocolReference,
1096    /// Digest of the associated ContextDecisionReceiptV1 when one was recorded.
1097    #[serde(default, skip_serializing_if = "Option::is_none")]
1098    pub decision_receipt_digest: Option<Sha256Digest>,
1099    pub outcome: EgressAdmissionOutcomeV1,
1100    /// SHA-256 of the exact prepared wire bytes; present only for Prepared.
1101    #[serde(default, skip_serializing_if = "Option::is_none")]
1102    pub prepared_bytes_digest: Option<Sha256Digest>,
1103}
1104
1105impl EgressAdmissionReceiptV1 {
1106    pub fn validate(&self) -> Result<(), ValidationError> {
1107        validate_schema_version(self.schema_version)?;
1108        match self.outcome {
1109            EgressAdmissionOutcomeV1::Prepared => {
1110                if self.prepared_bytes_digest.is_none() {
1111                    return Err(ValidationError::new(
1112                        "a prepared egress receipt must bind the prepared bytes digest",
1113                    ));
1114                }
1115                if self.decision_receipt_digest.is_none() {
1116                    return Err(ValidationError::new(
1117                        "a prepared egress receipt must link its decision receipt",
1118                    ));
1119                }
1120            }
1121            EgressAdmissionOutcomeV1::Withheld => {
1122                if self.prepared_bytes_digest.is_some() {
1123                    return Err(ValidationError::new(
1124                        "a withheld egress receipt cannot name prepared bytes",
1125                    ));
1126                }
1127                if self.decision_receipt_digest.is_none() {
1128                    return Err(ValidationError::new(
1129                        "a withheld egress receipt must link its decision receipt",
1130                    ));
1131                }
1132            }
1133            EgressAdmissionOutcomeV1::Failed => {
1134                if self.prepared_bytes_digest.is_some() {
1135                    return Err(ValidationError::new(
1136                        "a failed egress receipt cannot name prepared bytes",
1137                    ));
1138                }
1139            }
1140        }
1141        Ok(())
1142    }
1143
1144    /// Deterministic content identity over canonical JSON.
1145    pub fn canonical_digest(&self) -> Result<Sha256Digest, ValidationError> {
1146        self.validate()?;
1147        let value = serde_json::to_value(self)
1148            .map_err(|e| ValidationError::new(format!("egress admission serialization: {e}")))?;
1149        let bytes = serde_json::to_vec(&value)
1150            .map_err(|e| ValidationError::new(format!("egress admission serialization: {e}")))?;
1151        sha256_digest(&bytes)
1152    }
1153}
1154
1155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1156#[serde(rename_all = "snake_case")]
1157pub enum EgressTransportV1 {
1158    Http,
1159    WebSocket,
1160}
1161
1162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1163#[serde(rename_all = "snake_case")]
1164pub enum EgressTransportObserverV1 {
1165    /// The Engine transport itself observed this attempt.
1166    EngineObserved,
1167    /// A calling host reported what it observed; this is not independently attested.
1168    HostReported,
1169}
1170
1171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1172#[serde(rename_all = "snake_case")]
1173pub enum EgressTransportResultV1 {
1174    /// A response was observed. The status does not claim model consumption.
1175    ResponseObserved,
1176    /// No response was observed; request bytes may still have reached the peer.
1177    NoResponseObserved,
1178}
1179
1180/// Bounded error classes only; raw transport errors may contain sensitive data.
1181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1182#[serde(rename_all = "snake_case")]
1183pub enum EgressTransportFailureV1 {
1184    Connect,
1185    Timeout,
1186    Cancelled,
1187    Other,
1188}
1189
1190/// Append-only observation of one transport attempt, linked to its admission receipt.
1191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1192#[serde(deny_unknown_fields)]
1193pub struct EgressTransportObservationV1 {
1194    #[serde(deserialize_with = "deserialize_schema_version")]
1195    pub schema_version: u32,
1196    pub admission_receipt_digest: Sha256Digest,
1197    /// One-based attempt number for this admission receipt.
1198    pub attempt: u32,
1199    pub transport: EgressTransportV1,
1200    pub observer: EgressTransportObserverV1,
1201    pub result: EgressTransportResultV1,
1202    /// Required for an HTTP response; optional for a WebSocket handshake/message.
1203    #[serde(default, skip_serializing_if = "Option::is_none")]
1204    pub http_status: Option<u16>,
1205    #[serde(default, skip_serializing_if = "Option::is_none")]
1206    pub failure: Option<EgressTransportFailureV1>,
1207}
1208
1209impl EgressTransportObservationV1 {
1210    pub fn validate(&self) -> Result<(), ValidationError> {
1211        validate_schema_version(self.schema_version)?;
1212        if self.attempt == 0 {
1213            return Err(ValidationError::new(
1214                "transport attempt numbers are one-based",
1215            ));
1216        }
1217        if self
1218            .http_status
1219            .is_some_and(|status| !(100..=599).contains(&status))
1220        {
1221            return Err(ValidationError::new(
1222                "observed HTTP status must be between 100 and 599",
1223            ));
1224        }
1225        match self.result {
1226            EgressTransportResultV1::ResponseObserved => {
1227                if self.failure.is_some() {
1228                    return Err(ValidationError::new(
1229                        "a response observation cannot include a transport failure",
1230                    ));
1231                }
1232                if self.transport == EgressTransportV1::Http && self.http_status.is_none() {
1233                    return Err(ValidationError::new(
1234                        "an observed HTTP response must include its status",
1235                    ));
1236                }
1237            }
1238            EgressTransportResultV1::NoResponseObserved => {
1239                if self.http_status.is_some() || self.failure.is_none() {
1240                    return Err(ValidationError::new(
1241                        "a no-response observation needs one bounded failure and no status",
1242                    ));
1243                }
1244            }
1245        }
1246        Ok(())
1247    }
1248
1249    /// Check that this observation names the exact prepared admission record.
1250    pub fn validate_for(
1251        &self,
1252        admission: &EgressAdmissionReceiptV1,
1253    ) -> Result<(), ValidationError> {
1254        self.validate()?;
1255        admission.validate()?;
1256        if admission.outcome != EgressAdmissionOutcomeV1::Prepared {
1257            return Err(ValidationError::new(
1258                "transport observations require a prepared admission receipt",
1259            ));
1260        }
1261        if self.admission_receipt_digest != admission.canonical_digest()? {
1262            return Err(ValidationError::new(
1263                "transport observation does not match its admission receipt digest",
1264            ));
1265        }
1266        Ok(())
1267    }
1268
1269    /// Deterministic content identity over canonical JSON.
1270    pub fn canonical_digest(&self) -> Result<Sha256Digest, ValidationError> {
1271        self.validate()?;
1272        let value = serde_json::to_value(self).map_err(|e| {
1273            ValidationError::new(format!("transport observation serialization: {e}"))
1274        })?;
1275        let bytes = serde_json::to_vec(&value).map_err(|e| {
1276            ValidationError::new(format!("transport observation serialization: {e}"))
1277        })?;
1278        sha256_digest(&bytes)
1279    }
1280}
1281
1282fn sha256_digest(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
1283    let mut digest = String::from("sha256:");
1284    for byte in Sha256::digest(bytes) {
1285        write!(&mut digest, "{byte:02x}")
1286            .map_err(|_| ValidationError::new("gateway digest encoding failed"))?;
1287    }
1288    Sha256Digest::new(digest)
1289}
1290
1291#[cfg(test)]
1292#[path = "context_gateway_tests.rs"]
1293mod tests;