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/// One governed delivery: who asked, for what, what was considered, what was
905/// withheld and why, what reached which destination under which policy.
906#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
907#[serde(deny_unknown_fields)]
908pub struct ContextDecisionReceiptV1 {
909    #[serde(deserialize_with = "deserialize_schema_version")]
910    pub schema_version: u32,
911    pub receipt_id: ProtocolReference,
912    pub mode: GatewayModeV1,
913    pub principal: PrincipalV1,
914    #[serde(default, skip_serializing_if = "Option::is_none")]
915    pub task: Option<TaskId>,
916    pub destination: DestinationV1,
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    pub policy: Option<PolicyRefV1>,
919    pub sources: SourceCountsV1,
920    #[serde(default, skip_serializing_if = "Vec::is_empty")]
921    pub decisions: Vec<ContextDecisionV1>,
922    pub security: SecurityCountsV1,
923    pub tokens: TokenAccountV1,
924    #[serde(default, skip_serializing_if = "Option::is_none")]
925    pub final_context: Option<Sha256Digest>,
926    pub outcome: DeliveryOutcomeV1,
927    pub duration_us: u64,
928    /// Context-quality evidence for this round (retention, recovery). Absent
929    /// when nothing was measured; absent fields keep the receipt's digest.
930    #[serde(default, skip_serializing_if = "Option::is_none")]
931    pub quality: Option<ContextQualitySectionV1>,
932}
933
934impl ContextDecisionReceiptV1 {
935    /// Receipt consistency: counts must describe the recorded decisions, and
936    /// only a delivered receipt names the delivered context.
937    pub fn validate(&self) -> Result<(), ValidationError> {
938        validate_schema_version(self.schema_version)?;
939        if let Some(quality) = &self.quality {
940            quality.validate()?;
941        }
942        if self.decisions.len() > MAX_DECISIONS {
943            return Err(ValidationError::new("receipt exceeds its decision bound"));
944        }
945        for decision in &self.decisions {
946            decision.validate()?;
947        }
948        let s = self.sources;
949        if s.selected > s.permitted
950            || u64::from(s.permitted) + u64::from(s.blocked) > u64::from(s.inspected)
951        {
952            return Err(ValidationError::new(
953                "source counts must satisfy selected <= permitted and permitted + blocked <= inspected",
954            ));
955        }
956        let count = |d: ContextDispositionV1| {
957            self.decisions
958                .iter()
959                .filter(|decision| decision.disposition == d)
960                .count()
961        };
962        if count(ContextDispositionV1::Deny) != self.security.blocked_objects as usize
963            || count(ContextDispositionV1::Quarantine) != self.security.quarantined_objects as usize
964        {
965            return Err(ValidationError::new(
966                "security counts must equal the recorded deny/quarantine decisions",
967            ));
968        }
969        match (self.outcome, &self.final_context) {
970            (DeliveryOutcomeV1::Delivered, None) => Err(ValidationError::new(
971                "a delivered receipt must name the delivered context digest",
972            )),
973            (DeliveryOutcomeV1::Withheld | DeliveryOutcomeV1::Failed, Some(_)) => Err(
974                ValidationError::new("only a delivered receipt may name a context digest"),
975            ),
976            _ => Ok(()),
977        }
978    }
979
980    /// Deterministic content identity of the receipt (#498): SHA-256 over the
981    /// canonical JSON (sorted keys, no whitespace).
982    pub fn canonical_digest(&self) -> Result<Sha256Digest, ValidationError> {
983        self.validate()?;
984        let value = serde_json::to_value(self)
985            .map_err(|e| ValidationError::new(format!("receipt serialization: {e}")))?;
986        let bytes = serde_json::to_vec(&value)
987            .map_err(|e| ValidationError::new(format!("receipt serialization: {e}")))?;
988        sha256_digest(&bytes)
989    }
990}
991
992fn sha256_digest(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
993    let mut digest = String::from("sha256:");
994    for byte in Sha256::digest(bytes) {
995        write!(&mut digest, "{byte:02x}")
996            .map_err(|_| ValidationError::new("gateway digest encoding failed"))?;
997    }
998    Sha256Digest::new(digest)
999}
1000
1001#[cfg(test)]
1002#[path = "context_gateway_tests.rs"]
1003mod tests;