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
616#[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 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#[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#[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 pub lost_critical_kinds: Vec<QualityProbeKindV1>,
781 pub critical_unchecked: bool,
783 pub truncated: bool,
784 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#[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 pub task_quality: QualityStateV1,
807 pub overall: QualityStateV1,
808}
809
810impl ContextQualitySectionV1 {
811 #[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#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
931 pub quality: Option<ContextQualitySectionV1>,
932}
933
934impl ContextDecisionReceiptV1 {
935 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 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;