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
616/// Security and optimisation metadata that travels with one object. Content
617/// is referenced by digest, never embedded.
618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619#[serde(deny_unknown_fields)]
620pub struct ContextObjectPassportV1 {
621    #[serde(deserialize_with = "deserialize_schema_version")]
622    pub schema_version: u32,
623    pub object: Sha256Digest,
624    pub source: SourceRefV1,
625    pub principal: PrincipalV1,
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub task: Option<TaskId>,
628    pub classification: ClassificationV1,
629    pub trust: TrustLevelV1,
630    #[serde(default, skip_serializing_if = "Vec::is_empty")]
631    pub policy_refs: Vec<PolicyRefV1>,
632    #[serde(default, skip_serializing_if = "Vec::is_empty")]
633    pub risk_signals: Vec<DetectorSignalV1>,
634    #[serde(default, skip_serializing_if = "Vec::is_empty")]
635    pub destination_constraints: Vec<DestinationConstraintV1>,
636    pub tokens: u64,
637    pub bytes: u64,
638    #[serde(default, skip_serializing_if = "Vec::is_empty")]
639    pub lineage: Vec<TransformationRecordV1>,
640}
641
642impl ContextObjectPassportV1 {
643    pub fn validate(&self) -> Result<(), ValidationError> {
644        validate_schema_version(self.schema_version)?;
645        if self.policy_refs.len() > MAX_POLICY_REFS
646            || self.risk_signals.len() > MAX_SIGNALS
647            || self.lineage.len() > MAX_LINEAGE
648        {
649            return Err(ValidationError::new("passport exceeds its bounds"));
650        }
651        if !is_strictly_sorted(&self.destination_constraints)
652            || !is_strictly_sorted(&self.policy_refs)
653        {
654            return Err(ValidationError::new(
655                "destination constraints and policy refs must be sorted and unique",
656            ));
657        }
658        for signal in &self.risk_signals {
659            signal.validate()?;
660        }
661        Ok(())
662    }
663
664    /// Passport of an object derived from `inputs` by `step` (selection,
665    /// summary, dedup, …). Information-flow rule: the result is at least as
666    /// restricted as every input — classification is the join, constraints,
667    /// policies and risk signals accumulate, trust drops to the least trusted
668    /// input. Inputs must share one principal: context never silently crosses
669    /// principals.
670    pub fn derive(
671        inputs: &[&Self],
672        step: TransformationRecordV1,
673        source: SourceRefV1,
674        tokens: u64,
675        bytes: u64,
676    ) -> Result<Self, ValidationError> {
677        let Some(first) = inputs.first() else {
678            return Err(ValidationError::new(
679                "derivation requires at least one input",
680            ));
681        };
682        if inputs.iter().any(|p| p.principal != first.principal) {
683            return Err(ValidationError::new(
684                "derivation must not combine context of different principals",
685            ));
686        }
687        let task = first.task.clone();
688        let mut classification = first.classification;
689        let mut trust = first.trust;
690        let mut constraints = BTreeSet::new();
691        let mut policies = BTreeSet::new();
692        let mut signals = Vec::new();
693        let mut lineage = Vec::new();
694        for input in inputs {
695            classification = classification.join(input.classification);
696            trust = trust.min(input.trust);
697            constraints.extend(input.destination_constraints.iter().copied());
698            policies.extend(input.policy_refs.iter().cloned());
699            for signal in &input.risk_signals {
700                if !signals.contains(signal) {
701                    signals.push(signal.clone());
702                }
703            }
704            lineage.extend(input.lineage.iter().cloned());
705        }
706        let object = step.output.clone();
707        lineage.push(step);
708        let derived = Self {
709            schema_version: V1_SCHEMA_VERSION,
710            object,
711            source,
712            principal: first.principal.clone(),
713            task,
714            classification,
715            trust,
716            policy_refs: policies.into_iter().collect(),
717            risk_signals: signals,
718            destination_constraints: constraints.into_iter().collect(),
719            tokens,
720            bytes,
721            lineage,
722        };
723        derived.validate()?;
724        Ok(derived)
725    }
726}
727
728fn is_strictly_sorted<T: Ord>(values: &[T]) -> bool {
729    values.windows(2).all(|pair| pair[0] < pair[1])
730}
731
732// ─── Context quality section ────────────────────────────────────────────────
733
734/// How strong the quality evidence is, weakest first.
735#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
736#[serde(rename_all = "snake_case")]
737pub enum QualityEvidenceTierV1 {
738    Mechanism,
739    DeterministicQuality,
740    RecordedRegression,
741    LiveTaskEvaluation,
742    ProductionOutcome,
743}
744
745#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
746#[serde(rename_all = "snake_case")]
747pub enum QualityStateV1 {
748    Pass,
749    Fail,
750    Unmeasured,
751}
752
753/// Kinds of critical facts a retention probe checks — kinds only, never values.
754#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
755#[serde(rename_all = "snake_case")]
756pub enum QualityProbeKindV1 {
757    ErrorCode,
758    TestResult,
759    Status,
760    Location,
761    Path,
762    Hash,
763    Url,
764}
765
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
767#[serde(deny_unknown_fields)]
768pub struct RetentionCountsV1 {
769    pub retained: u64,
770    pub recoverable: u64,
771    pub lost: u64,
772}
773
774#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
775#[serde(deny_unknown_fields)]
776pub struct QualityRetentionV1 {
777    pub critical: RetentionCountsV1,
778    pub important: RetentionCountsV1,
779    /// Sorted and unique.
780    pub lost_critical_kinds: Vec<QualityProbeKindV1>,
781    /// Critical facts could not all be checked; the round can never pass.
782    pub critical_unchecked: bool,
783    pub truncated: bool,
784    /// Secret lines are withheld and never count as recoverable.
785    pub secret_lines_withheld: u64,
786}
787
788#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
789#[serde(deny_unknown_fields)]
790pub struct QualityRecoveryV1 {
791    pub handles_emitted: u64,
792    pub handles_verified: u64,
793    pub failures: u64,
794    pub critical_failures: u64,
795}
796
797/// Deterministic context-quality evidence for one round.
798#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
799#[serde(deny_unknown_fields)]
800pub struct ContextQualitySectionV1 {
801    pub evidence_tier: QualityEvidenceTierV1,
802    pub retention: QualityRetentionV1,
803    #[serde(default, skip_serializing_if = "Option::is_none")]
804    pub recovery: Option<QualityRecoveryV1>,
805    /// Task quality is never measured per round.
806    pub task_quality: QualityStateV1,
807    pub overall: QualityStateV1,
808}
809
810impl ContextQualitySectionV1 {
811    /// The overall state the measured dimensions imply.
812    #[must_use]
813    pub fn derived_overall(&self) -> QualityStateV1 {
814        let retention = &self.retention;
815        let retention_failed = retention.critical_unchecked
816            || retention.critical.lost > 0
817            || !retention.lost_critical_kinds.is_empty();
818        let recovery_failed = self
819            .recovery
820            .is_some_and(|recovery| recovery.critical_failures > 0 || recovery.failures > 0);
821        let measured = retention.critical.retained
822            + retention.critical.recoverable
823            + retention.critical.lost
824            + retention.important.retained
825            + retention.important.recoverable
826            + retention.important.lost
827            > 0
828            || retention.critical_unchecked
829            || self.recovery.is_some();
830        if retention_failed || recovery_failed {
831            QualityStateV1::Fail
832        } else if measured {
833            QualityStateV1::Pass
834        } else {
835            QualityStateV1::Unmeasured
836        }
837    }
838
839    pub fn validate(&self) -> Result<(), ValidationError> {
840        if self.task_quality != QualityStateV1::Unmeasured {
841            return Err(ValidationError::new(
842                "task quality is never measured per round",
843            ));
844        }
845        if !is_strictly_sorted(&self.retention.lost_critical_kinds) {
846            return Err(ValidationError::new(
847                "lost critical kinds must be sorted and unique",
848            ));
849        }
850        if let Some(recovery) = self.recovery
851            && (recovery.handles_verified.saturating_add(recovery.failures)
852                > recovery.handles_emitted
853                || recovery.critical_failures > recovery.failures)
854        {
855            return Err(ValidationError::new(
856                "recovery counts must satisfy verified + failures <= emitted and critical <= failures",
857            ));
858        }
859        if self.overall != self.derived_overall() {
860            return Err(ValidationError::new(
861                "overall quality must follow from the measured dimensions",
862            ));
863        }
864        Ok(())
865    }
866}
867
868// ─── Receipt ────────────────────────────────────────────────────────────────
869
870#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
871#[serde(rename_all = "snake_case")]
872pub enum DeliveryOutcomeV1 {
873    Delivered,
874    Withheld,
875    Failed,
876}
877
878#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
879#[serde(deny_unknown_fields)]
880pub struct SourceCountsV1 {
881    pub inspected: u32,
882    pub permitted: u32,
883    pub selected: u32,
884    pub blocked: u32,
885}
886
887#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct SecurityCountsV1 {
890    pub redactions: u32,
891    pub blocked_objects: u32,
892    pub quarantined_objects: u32,
893    pub injection_signals: u32,
894    pub incomplete_coverage: u32,
895}
896
897#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
898#[serde(deny_unknown_fields)]
899pub struct TokenAccountV1 {
900    pub original: u64,
901    pub delivered: u64,
902}
903
904/// Receipt schema version 2 (AP10-C4, `context-gateway-v2.md`): every
905/// `decisions[].object` is keyed under the installation's receipt key and the
906/// receipt names that key in [`ContextDecisionReceiptV1::object_digest`].
907/// Version 1 receipts (plain SHA-256 objects) stay readable and valid.
908pub const RECEIPT_V2_SCHEMA_VERSION: u32 = 2;
909
910/// How the decision objects of a version 2 receipt are digested.
911#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
912pub enum ObjectDigestAlgorithmV2 {
913    /// `HMAC-SHA256(key, "leanctx.receipt.object.v2" || 0x00 || v1_object)`,
914    /// where `v1_object` is the `sha256:<hex>` a version 1 receipt would name.
915    #[serde(rename = "hmac-sha256")]
916    HmacSha256,
917}
918
919/// The key a version 2 receipt's decision objects are digested under: its
920/// algorithm and a public identifier, never the key.
921#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
922#[serde(deny_unknown_fields)]
923pub struct ReceiptObjectDigestV2 {
924    pub algorithm: ObjectDigestAlgorithmV2,
925    /// `rk-` and the first 32 hex digits of
926    /// `HMAC-SHA256(key, "leanctx.receipt.key-id.v2")`: tells keys apart,
927    /// reveals nothing of the key.
928    pub key_id: String,
929}
930
931impl ReceiptObjectDigestV2 {
932    pub fn validate(&self) -> Result<(), ValidationError> {
933        let valid = self.key_id.strip_prefix("rk-").is_some_and(|hex| {
934            hex.len() == 32 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
935        });
936        if valid {
937            Ok(())
938        } else {
939            Err(ValidationError::new(
940                "receipt key id must be rk- and 32 lowercase hex digits",
941            ))
942        }
943    }
944}
945
946fn deserialize_receipt_schema_version<'de, D>(deserializer: D) -> Result<u32, D::Error>
947where
948    D: Deserializer<'de>,
949{
950    let version = u32::deserialize(deserializer)?;
951    if version == V1_SCHEMA_VERSION || version == RECEIPT_V2_SCHEMA_VERSION {
952        Ok(version)
953    } else {
954        Err(DeError::custom(format!(
955            "unsupported receipt schema_version {version}; expected 1 or 2"
956        )))
957    }
958}
959
960/// One governed delivery: who asked, for what, what was considered, what was
961/// withheld and why, what reached which destination under which policy.
962///
963/// The type keeps its V1 name; it carries both receipt schema versions. In
964/// version 2 `decisions[].object` keeps its `sha256:<hex>` form but is keyed
965/// ([`ObjectDigestAlgorithmV2`]) under the key `object_digest` names.
966#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
967#[serde(deny_unknown_fields)]
968pub struct ContextDecisionReceiptV1 {
969    #[serde(deserialize_with = "deserialize_receipt_schema_version")]
970    pub schema_version: u32,
971    /// Present exactly on a version 2 receipt.
972    #[serde(default, skip_serializing_if = "Option::is_none")]
973    pub object_digest: Option<ReceiptObjectDigestV2>,
974    pub receipt_id: ProtocolReference,
975    pub mode: GatewayModeV1,
976    pub principal: PrincipalV1,
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub task: Option<TaskId>,
979    pub destination: DestinationV1,
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub policy: Option<PolicyRefV1>,
982    pub sources: SourceCountsV1,
983    #[serde(default, skip_serializing_if = "Vec::is_empty")]
984    pub decisions: Vec<ContextDecisionV1>,
985    pub security: SecurityCountsV1,
986    pub tokens: TokenAccountV1,
987    #[serde(default, skip_serializing_if = "Option::is_none")]
988    pub final_context: Option<Sha256Digest>,
989    pub outcome: DeliveryOutcomeV1,
990    pub duration_us: u64,
991    /// Context-quality evidence for this round (retention, recovery). Absent
992    /// when nothing was measured; absent fields keep the receipt's digest.
993    #[serde(default, skip_serializing_if = "Option::is_none")]
994    pub quality: Option<ContextQualitySectionV1>,
995}
996
997impl ContextDecisionReceiptV1 {
998    /// Receipt consistency: counts must describe the recorded decisions, and
999    /// only a delivered receipt names the delivered context.
1000    pub fn validate(&self) -> Result<(), ValidationError> {
1001        match (self.schema_version, &self.object_digest) {
1002            (V1_SCHEMA_VERSION, None) => {}
1003            (RECEIPT_V2_SCHEMA_VERSION, Some(object_digest)) => object_digest.validate()?,
1004            (V1_SCHEMA_VERSION | RECEIPT_V2_SCHEMA_VERSION, _) => {
1005                return Err(ValidationError::new(
1006                    "exactly a version 2 receipt names its object digest key",
1007                ));
1008            }
1009            (version, _) => {
1010                return Err(ValidationError::new(format!(
1011                    "unsupported receipt schema_version {version}; expected 1 or 2"
1012                )));
1013            }
1014        }
1015        if let Some(quality) = &self.quality {
1016            quality.validate()?;
1017        }
1018        if self.decisions.len() > MAX_DECISIONS {
1019            return Err(ValidationError::new("receipt exceeds its decision bound"));
1020        }
1021        for decision in &self.decisions {
1022            decision.validate()?;
1023        }
1024        let s = self.sources;
1025        if s.selected > s.permitted
1026            || u64::from(s.permitted) + u64::from(s.blocked) > u64::from(s.inspected)
1027        {
1028            return Err(ValidationError::new(
1029                "source counts must satisfy selected <= permitted and permitted + blocked <= inspected",
1030            ));
1031        }
1032        let count = |d: ContextDispositionV1| {
1033            self.decisions
1034                .iter()
1035                .filter(|decision| decision.disposition == d)
1036                .count()
1037        };
1038        if count(ContextDispositionV1::Deny) != self.security.blocked_objects as usize
1039            || count(ContextDispositionV1::Quarantine) != self.security.quarantined_objects as usize
1040        {
1041            return Err(ValidationError::new(
1042                "security counts must equal the recorded deny/quarantine decisions",
1043            ));
1044        }
1045        match (self.outcome, &self.final_context) {
1046            (DeliveryOutcomeV1::Delivered, None) => Err(ValidationError::new(
1047                "a delivered receipt must name the delivered context digest",
1048            )),
1049            (DeliveryOutcomeV1::Withheld | DeliveryOutcomeV1::Failed, Some(_)) => Err(
1050                ValidationError::new("only a delivered receipt may name a context digest"),
1051            ),
1052            _ => Ok(()),
1053        }
1054    }
1055
1056    /// Deterministic content identity of the receipt (#498): SHA-256 over the
1057    /// canonical JSON (sorted keys, no whitespace).
1058    pub fn canonical_digest(&self) -> Result<Sha256Digest, ValidationError> {
1059        self.validate()?;
1060        let value = serde_json::to_value(self)
1061            .map_err(|e| ValidationError::new(format!("receipt serialization: {e}")))?;
1062        let bytes = serde_json::to_vec(&value)
1063            .map_err(|e| ValidationError::new(format!("receipt serialization: {e}")))?;
1064        sha256_digest(&bytes)
1065    }
1066}
1067
1068/// The admission lifecycle for one exact request body. This does not change the
1069/// legacy ContextDecisionReceiptV1 wire format or its outcome semantics.
1070#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1071#[serde(rename_all = "snake_case")]
1072pub enum EgressAdmissionOutcomeV1 {
1073    /// The policy admitted these bytes and their digest is bound; no send is implied.
1074    Prepared,
1075    /// Policy withheld the request before dispatch.
1076    Withheld,
1077    /// Admission could not produce a dispatchable request.
1078    Failed,
1079}
1080
1081/// A versioned, content-addressable admission record for outbound request bytes.
1082#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1083#[serde(deny_unknown_fields)]
1084pub struct EgressAdmissionReceiptV1 {
1085    #[serde(deserialize_with = "deserialize_schema_version")]
1086    pub schema_version: u32,
1087    pub receipt_id: ProtocolReference,
1088    /// Digest of the associated ContextDecisionReceiptV1 when one was recorded.
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub decision_receipt_digest: Option<Sha256Digest>,
1091    pub outcome: EgressAdmissionOutcomeV1,
1092    /// SHA-256 of the exact prepared wire bytes; present only for Prepared.
1093    #[serde(default, skip_serializing_if = "Option::is_none")]
1094    pub prepared_bytes_digest: Option<Sha256Digest>,
1095}
1096
1097impl EgressAdmissionReceiptV1 {
1098    pub fn validate(&self) -> Result<(), ValidationError> {
1099        validate_schema_version(self.schema_version)?;
1100        match self.outcome {
1101            EgressAdmissionOutcomeV1::Prepared => {
1102                if self.prepared_bytes_digest.is_none() {
1103                    return Err(ValidationError::new(
1104                        "a prepared egress receipt must bind the prepared bytes digest",
1105                    ));
1106                }
1107                if self.decision_receipt_digest.is_none() {
1108                    return Err(ValidationError::new(
1109                        "a prepared egress receipt must link its decision receipt",
1110                    ));
1111                }
1112            }
1113            EgressAdmissionOutcomeV1::Withheld => {
1114                if self.prepared_bytes_digest.is_some() {
1115                    return Err(ValidationError::new(
1116                        "a withheld egress receipt cannot name prepared bytes",
1117                    ));
1118                }
1119                if self.decision_receipt_digest.is_none() {
1120                    return Err(ValidationError::new(
1121                        "a withheld egress receipt must link its decision receipt",
1122                    ));
1123                }
1124            }
1125            EgressAdmissionOutcomeV1::Failed => {
1126                if self.prepared_bytes_digest.is_some() {
1127                    return Err(ValidationError::new(
1128                        "a failed egress receipt cannot name prepared bytes",
1129                    ));
1130                }
1131            }
1132        }
1133        Ok(())
1134    }
1135
1136    /// Deterministic content identity over canonical JSON.
1137    pub fn canonical_digest(&self) -> Result<Sha256Digest, ValidationError> {
1138        self.validate()?;
1139        let value = serde_json::to_value(self)
1140            .map_err(|e| ValidationError::new(format!("egress admission serialization: {e}")))?;
1141        let bytes = serde_json::to_vec(&value)
1142            .map_err(|e| ValidationError::new(format!("egress admission serialization: {e}")))?;
1143        sha256_digest(&bytes)
1144    }
1145}
1146
1147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1148#[serde(rename_all = "snake_case")]
1149pub enum EgressTransportV1 {
1150    Http,
1151    WebSocket,
1152}
1153
1154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1155#[serde(rename_all = "snake_case")]
1156pub enum EgressTransportObserverV1 {
1157    /// The Engine transport itself observed this attempt.
1158    EngineObserved,
1159    /// A calling host reported what it observed; this is not independently attested.
1160    HostReported,
1161}
1162
1163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1164#[serde(rename_all = "snake_case")]
1165pub enum EgressTransportResultV1 {
1166    /// A response was observed. The status does not claim model consumption.
1167    ResponseObserved,
1168    /// No response was observed; request bytes may still have reached the peer.
1169    NoResponseObserved,
1170}
1171
1172/// Bounded error classes only; raw transport errors may contain sensitive data.
1173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1174#[serde(rename_all = "snake_case")]
1175pub enum EgressTransportFailureV1 {
1176    Connect,
1177    Timeout,
1178    Cancelled,
1179    Other,
1180}
1181
1182/// Append-only observation of one transport attempt, linked to its admission receipt.
1183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1184#[serde(deny_unknown_fields)]
1185pub struct EgressTransportObservationV1 {
1186    #[serde(deserialize_with = "deserialize_schema_version")]
1187    pub schema_version: u32,
1188    pub admission_receipt_digest: Sha256Digest,
1189    /// One-based attempt number for this admission receipt.
1190    pub attempt: u32,
1191    pub transport: EgressTransportV1,
1192    pub observer: EgressTransportObserverV1,
1193    pub result: EgressTransportResultV1,
1194    /// Required for an HTTP response; optional for a WebSocket handshake/message.
1195    #[serde(default, skip_serializing_if = "Option::is_none")]
1196    pub http_status: Option<u16>,
1197    #[serde(default, skip_serializing_if = "Option::is_none")]
1198    pub failure: Option<EgressTransportFailureV1>,
1199}
1200
1201impl EgressTransportObservationV1 {
1202    pub fn validate(&self) -> Result<(), ValidationError> {
1203        validate_schema_version(self.schema_version)?;
1204        if self.attempt == 0 {
1205            return Err(ValidationError::new(
1206                "transport attempt numbers are one-based",
1207            ));
1208        }
1209        if self
1210            .http_status
1211            .is_some_and(|status| !(100..=599).contains(&status))
1212        {
1213            return Err(ValidationError::new(
1214                "observed HTTP status must be between 100 and 599",
1215            ));
1216        }
1217        match self.result {
1218            EgressTransportResultV1::ResponseObserved => {
1219                if self.failure.is_some() {
1220                    return Err(ValidationError::new(
1221                        "a response observation cannot include a transport failure",
1222                    ));
1223                }
1224                if self.transport == EgressTransportV1::Http && self.http_status.is_none() {
1225                    return Err(ValidationError::new(
1226                        "an observed HTTP response must include its status",
1227                    ));
1228                }
1229            }
1230            EgressTransportResultV1::NoResponseObserved => {
1231                if self.http_status.is_some() || self.failure.is_none() {
1232                    return Err(ValidationError::new(
1233                        "a no-response observation needs one bounded failure and no status",
1234                    ));
1235                }
1236            }
1237        }
1238        Ok(())
1239    }
1240
1241    /// Check that this observation names the exact prepared admission record.
1242    pub fn validate_for(
1243        &self,
1244        admission: &EgressAdmissionReceiptV1,
1245    ) -> Result<(), ValidationError> {
1246        self.validate()?;
1247        admission.validate()?;
1248        if admission.outcome != EgressAdmissionOutcomeV1::Prepared {
1249            return Err(ValidationError::new(
1250                "transport observations require a prepared admission receipt",
1251            ));
1252        }
1253        if self.admission_receipt_digest != admission.canonical_digest()? {
1254            return Err(ValidationError::new(
1255                "transport observation does not match its admission receipt digest",
1256            ));
1257        }
1258        Ok(())
1259    }
1260
1261    /// Deterministic content identity over canonical JSON.
1262    pub fn canonical_digest(&self) -> Result<Sha256Digest, ValidationError> {
1263        self.validate()?;
1264        let value = serde_json::to_value(self).map_err(|e| {
1265            ValidationError::new(format!("transport observation serialization: {e}"))
1266        })?;
1267        let bytes = serde_json::to_vec(&value).map_err(|e| {
1268            ValidationError::new(format!("transport observation serialization: {e}"))
1269        })?;
1270        sha256_digest(&bytes)
1271    }
1272}
1273
1274fn sha256_digest(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
1275    let mut digest = String::from("sha256:");
1276    for byte in Sha256::digest(bytes) {
1277        write!(&mut digest, "{byte:02x}")
1278            .map_err(|_| ValidationError::new("gateway digest encoding failed"))?;
1279    }
1280    Sha256Digest::new(digest)
1281}
1282
1283#[cfg(test)]
1284#[path = "context_gateway_tests.rs"]
1285mod tests;