1use serde::{Deserialize, Serialize};
24
25use crate::common::{TaskId, ValidationError};
26use crate::context_gateway::{
27 ContextDecisionReceiptV1, ContextDispositionV1, DeliveryOutcomeV1, DestinationV1, PrincipalV1,
28 ReasonCodeV1, SourceRefV1,
29};
30use crate::identity::{ProtocolReference, Sha256Digest};
31
32pub const CONTEXT_LINEAGE_SCHEMA_VERSION: u32 = 1;
33pub const MAX_LINEAGE_CANDIDATES: usize = 256;
34pub const MAX_LINEAGE_OUTCOMES: usize = 256;
35pub const MAX_LINEAGE_OBSERVATIONS: usize = 16;
36const MAX_REVISION_LEN: usize = 256;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum SourceValidityV1 {
43 Valid,
44 RevalidateRequired,
46 Stale,
48 Revoked,
50 Deleted,
51 Unavailable,
53 Unsupported,
55}
56
57impl SourceValidityV1 {
58 #[must_use]
59 pub fn permits_materialization(self) -> bool {
60 self == Self::Valid
61 }
62
63 #[must_use]
65 pub fn reason_code(self) -> &'static str {
66 match self {
67 Self::Valid => "source.valid",
68 Self::RevalidateRequired => "source.revalidate_required",
69 Self::Stale => "source.stale",
70 Self::Revoked => "source.revoked",
71 Self::Deleted => "source.deleted",
72 Self::Unavailable => "source.unavailable",
73 Self::Unsupported => "source.unsupported",
74 }
75 }
76
77 #[must_use]
81 pub fn from_reason_code(code: &str) -> Option<Self> {
82 Some(match code {
83 "store.source_changed" | "recovery.source_changed" | "lineage.source_changed" => {
84 Self::Stale
85 }
86 "store.source_unavailable" | "recovery.missing" | "recovery.source_missing" => {
87 Self::Deleted
88 }
89 "store.project_mismatch"
90 | "store.binding_mismatch"
91 | "lineage.source_no_longer_admitted"
92 | "recovery.source_not_presented"
93 | "presenter.revoked" => Self::Revoked,
94 "store.no_source_authority"
95 | "store.lineage_unbounded"
96 | "recovery.unknown_lineage"
97 | "coverage.unsupported" => Self::Unsupported,
98 "egress.policy_changed" | "policy.changed" | "presenter.expired" => {
99 Self::RevalidateRequired
100 }
101 "source.timeout" | "source.rate_limited" | "semantic.unavailable" => Self::Unavailable,
102 other => {
103 return [
104 Self::Valid,
105 Self::RevalidateRequired,
106 Self::Stale,
107 Self::Revoked,
108 Self::Deleted,
109 Self::Unavailable,
110 Self::Unsupported,
111 ]
112 .into_iter()
113 .find(|state| state.reason_code() == other);
114 }
115 })
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum ContextDecisionOutcomeV1 {
124 Allow,
125 Deny,
126 Redact,
127 Transform,
128 Unavailable,
129}
130
131impl From<ContextDispositionV1> for ContextDecisionOutcomeV1 {
132 fn from(value: ContextDispositionV1) -> Self {
133 match value {
134 ContextDispositionV1::Allow | ContextDispositionV1::AllowLocalModelOnly => Self::Allow,
137 ContextDispositionV1::AllowRedacted => Self::Redact,
138 ContextDispositionV1::AllowMinimized | ContextDispositionV1::AllowSummaryOnly => {
139 Self::Transform
140 }
141 ContextDispositionV1::AllowWithApproval
143 | ContextDispositionV1::Quarantine
144 | ContextDispositionV1::Deny => Self::Deny,
145 }
146 }
147}
148
149impl ContextDecisionOutcomeV1 {
150 #[must_use]
151 pub fn delivers_content(self) -> bool {
152 matches!(self, Self::Allow | Self::Redact | Self::Transform)
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum IntegrationRouteV1 {
160 Mcp,
161 Cli,
162 Hook,
163 Proxy,
164 ToolSession,
165 EngineInterface,
166 HostEgress,
167 Sdk,
168}
169
170impl IntegrationRouteV1 {
171 #[must_use]
174 pub fn observes_model_egress(self) -> bool {
175 matches!(self, Self::Proxy | Self::HostEgress)
176 }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum ContextReceiptStateV1 {
183 Prepared,
185 HandedOff,
187 ObservedModelEgress,
189 Rejected,
191 Partial,
193}
194
195impl ContextReceiptStateV1 {
196 #[must_use]
198 pub fn derive(
199 outcome: DeliveryOutcomeV1,
200 route: IntegrationRouteV1,
201 handed_off: bool,
202 egress_observations: usize,
203 withheld_or_incomplete: bool,
204 ) -> Self {
205 match outcome {
206 DeliveryOutcomeV1::Withheld | DeliveryOutcomeV1::Failed => Self::Rejected,
207 DeliveryOutcomeV1::Delivered if withheld_or_incomplete => Self::Partial,
208 DeliveryOutcomeV1::Delivered
209 if route.observes_model_egress() && egress_observations > 0 =>
210 {
211 Self::ObservedModelEgress
212 }
213 DeliveryOutcomeV1::Delivered if handed_off => Self::HandedOff,
214 DeliveryOutcomeV1::Delivered => Self::Prepared,
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct ContextRequestV1 {
225 pub request_id: ProtocolReference,
226 pub principal: PrincipalV1,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub purpose: Option<TaskId>,
229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
230 pub source_scope: Vec<SourceRefV1>,
231 pub route: IntegrationRouteV1,
232 pub destination: DestinationV1,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum AclCheckV1 {
239 Rechecked,
241 LocalRead,
243 TeamAuthority,
245 Unsupported,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct SourceCandidateV1 {
252 pub source: SourceRefV1,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub revision: Option<String>,
255 pub acl_check: AclCheckV1,
256 pub validity: SourceValidityV1,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct ObjectOutcomeV1 {
263 pub object: Sha256Digest,
264 pub outcome: ContextDecisionOutcomeV1,
265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
266 pub reason_codes: Vec<ReasonCodeV1>,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct ContextDecisionLineageV1 {
274 pub schema_version: u32,
275 pub request: ContextRequestV1,
276 #[serde(default, skip_serializing_if = "Vec::is_empty")]
277 pub candidates: Vec<SourceCandidateV1>,
278 pub decision_receipt: Sha256Digest,
280 pub outcomes: Vec<ObjectOutcomeV1>,
281 pub receipt_state: ContextReceiptStateV1,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub egress_admission: Option<Sha256Digest>,
284 #[serde(default, skip_serializing_if = "Vec::is_empty")]
285 pub egress_observations: Vec<Sha256Digest>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub execution_receipt: Option<ProtocolReference>,
289}
290
291impl ContextDecisionLineageV1 {
292 pub fn for_receipt(
295 request: ContextRequestV1,
296 candidates: Vec<SourceCandidateV1>,
297 receipt: &ContextDecisionReceiptV1,
298 handed_off: bool,
299 egress_admission: Option<Sha256Digest>,
300 egress_observations: Vec<Sha256Digest>,
301 ) -> Result<Self, ValidationError> {
302 let outcomes: Vec<ObjectOutcomeV1> = receipt
303 .decisions
304 .iter()
305 .map(|decision| ObjectOutcomeV1 {
306 object: decision.object.clone(),
307 outcome: decision.disposition.into(),
308 reason_codes: decision.reason_codes.clone(),
309 })
310 .collect();
311 let withheld_or_incomplete = outcomes.iter().any(|o| !o.outcome.delivers_content())
312 || receipt.security.incomplete_coverage > 0
313 || candidates
314 .iter()
315 .any(|c| !c.validity.permits_materialization());
316 let receipt_state = ContextReceiptStateV1::derive(
317 receipt.outcome,
318 request.route,
319 handed_off,
320 egress_observations.len(),
321 withheld_or_incomplete,
322 );
323 let lineage = Self {
324 schema_version: CONTEXT_LINEAGE_SCHEMA_VERSION,
325 request,
326 candidates,
327 decision_receipt: receipt.canonical_digest()?,
328 outcomes,
329 receipt_state,
330 egress_admission,
331 egress_observations,
332 execution_receipt: None,
333 };
334 lineage.validate()?;
335 Ok(lineage)
336 }
337
338 pub fn validate(&self) -> Result<(), ValidationError> {
339 if self.schema_version != CONTEXT_LINEAGE_SCHEMA_VERSION {
340 return Err(ValidationError::new(
341 "unsupported context lineage schema version",
342 ));
343 }
344 if self.candidates.len() > MAX_LINEAGE_CANDIDATES
345 || self.outcomes.len() > MAX_LINEAGE_OUTCOMES
346 || self.egress_observations.len() > MAX_LINEAGE_OBSERVATIONS
347 {
348 return Err(ValidationError::new("context lineage exceeds its bounds"));
349 }
350 for candidate in &self.candidates {
351 if candidate
352 .revision
353 .as_ref()
354 .is_some_and(|r| r.is_empty() || r.len() > MAX_REVISION_LEN)
355 {
356 return Err(ValidationError::new(
357 "candidate revision is empty or too long",
358 ));
359 }
360 if candidate.acl_check == AclCheckV1::Unsupported
361 && candidate.validity.permits_materialization()
362 {
363 return Err(ValidationError::new(
364 "a candidate without an ACL check cannot be valid for governed use",
365 ));
366 }
367 }
368 for outcome in &self.outcomes {
369 if outcome.outcome != ContextDecisionOutcomeV1::Allow && outcome.reason_codes.is_empty()
370 {
371 return Err(ValidationError::new(
372 "every outcome other than allow names a reason",
373 ));
374 }
375 }
376 let delivered = self.outcomes.iter().any(|o| o.outcome.delivers_content());
377 match self.receipt_state {
378 ContextReceiptStateV1::ObservedModelEgress => {
379 if !self.request.route.observes_model_egress()
380 || self.egress_observations.is_empty()
381 {
382 return Err(ValidationError::new(
383 "model egress is claimed only for an instrumented route with an observation",
384 ));
385 }
386 }
387 ContextReceiptStateV1::Rejected => {}
390 ContextReceiptStateV1::Prepared
391 | ContextReceiptStateV1::HandedOff
392 | ContextReceiptStateV1::Partial => {
393 if !self.outcomes.is_empty() && !delivered {
394 return Err(ValidationError::new(
395 "a delivery state needs a delivered object",
396 ));
397 }
398 }
399 }
400 if !self.egress_observations.is_empty() && self.egress_admission.is_none() {
401 return Err(ValidationError::new(
402 "an egress observation needs its admission",
403 ));
404 }
405 Ok(())
406 }
407}
408
409#[cfg(test)]
410#[path = "context_lineage_tests.rs"]
411mod tests;