1use 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
30pub 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#[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 #[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum GatewayModeV1 {
128 Developer,
130 Governed,
132 Sovereign,
134}
135
136impl GatewayModeV1 {
137 #[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#[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 Unknown,
161}
162
163#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum DestinationConstraintV1 {
221 LocalOnly,
223 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 Unknown,
234}
235
236#[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 #[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 #[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 #[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#[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#[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#[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 Complete,
353 Partial,
355 Unsupported,
357 Failed,
359 NotRequired,
361}
362
363#[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 #[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#[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 #[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 #[must_use]
487 pub fn satisfies_requirement(&self) -> bool {
488 self.status == DetectorStatusV1::Completed && self.coverage.is_complete()
489 }
490}
491
492#[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 AllowWithApproval,
505 Quarantine,
506 Deny,
507}
508
509impl ContextDispositionV1 {
510 #[must_use]
512 pub fn delivers_content(self) -> bool {
513 self <= Self::AllowLocalModelOnly
514 }
515
516 #[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#[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#[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#[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#[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 pub fn is_external_or_unknown(self) -> bool {
620 matches!(self, Self::Untrusted | Self::Unknown)
621 }
622}
623
624#[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 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#[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#[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 pub lost_critical_kinds: Vec<QualityProbeKindV1>,
789 pub critical_unchecked: bool,
791 pub truncated: bool,
792 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#[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 pub task_quality: QualityStateV1,
815 pub overall: QualityStateV1,
816}
817
818impl ContextQualitySectionV1 {
819 #[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#[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
912pub const RECEIPT_V2_SCHEMA_VERSION: u32 = 2;
917
918#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
920pub enum ObjectDigestAlgorithmV2 {
921 #[serde(rename = "hmac-sha256")]
924 HmacSha256,
925}
926
927#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
930#[serde(deny_unknown_fields)]
931pub struct ReceiptObjectDigestV2 {
932 pub algorithm: ObjectDigestAlgorithmV2,
933 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#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub quality: Option<ContextQualitySectionV1>,
1003}
1004
1005impl ContextDecisionReceiptV1 {
1006 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1079#[serde(rename_all = "snake_case")]
1080pub enum EgressAdmissionOutcomeV1 {
1081 Prepared,
1083 Withheld,
1085 Failed,
1087}
1088
1089#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
1098 pub decision_receipt_digest: Option<Sha256Digest>,
1099 pub outcome: EgressAdmissionOutcomeV1,
1100 #[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 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 EngineObserved,
1167 HostReported,
1169}
1170
1171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1172#[serde(rename_all = "snake_case")]
1173pub enum EgressTransportResultV1 {
1174 ResponseObserved,
1176 NoResponseObserved,
1178}
1179
1180#[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#[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 pub attempt: u32,
1199 pub transport: EgressTransportV1,
1200 pub observer: EgressTransportObserverV1,
1201 pub result: EgressTransportResultV1,
1202 #[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 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 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;