Skip to main content

lean_ctx_protocol/
context_lineage.rs

1// SPDX-License-Identifier: Apache-2.0
2//! One context decision lineage (strategy package 2026-10-14, doc 19).
3//!
4//! The gateway already decides every context object once, in one place: the
5//! admission that emits [`ContextDecisionReceiptV1`]. This module adds what
6//! that record does not say, without changing it: the request the decision
7//! answered (who, through which route, from which sources, toward which
8//! destination), the state of each source candidate, a single outcome
9//! vocabulary, and the honest delivery state of the receipt. A
10//! [`ContextDecisionLineageV1`] references the existing records by digest; it
11//! replaces none of them, so every V1 reader and digest stays as it is.
12//!
13//! Three rules are enforced by validation, not by convention:
14//!
15//! - only a [`SourceValidityV1::Valid`] candidate may be materialized: a
16//!   stale, revoked, deleted, unavailable or unsupported source never becomes
17//!   an allow, and nothing falls back from revoked to stale;
18//! - [`ContextReceiptStateV1::ObservedModelEgress`] is claimed only for a route
19//!   that observes model egress AND with at least one transport observation; a
20//!   context-only route reports `prepared` or `handed_off`, never what the host
21//!   may have sent afterwards;
22//! - every outcome other than `allow` names a reason.
23use 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/// State of a source candidate at decision time. Exactly one of these, from
39/// the source's own re-check; only `valid` may be materialized.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum SourceValidityV1 {
43    Valid,
44    /// The authority or policy changed since it was last checked; ask again.
45    RevalidateRequired,
46    /// Readable, but not the revision the stored copy was derived from.
47    Stale,
48    /// The principal or team lost the right to read it.
49    Revoked,
50    Deleted,
51    /// The source could not answer (timeout, rate limit, outage).
52    Unavailable,
53    /// The source cannot re-check this item (no ACL semantics or no binding).
54    Unsupported,
55}
56
57impl SourceValidityV1 {
58    #[must_use]
59    pub fn permits_materialization(self) -> bool {
60        self == Self::Valid
61    }
62
63    /// The stable reason code of a non-valid state.
64    #[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    /// The state the existing reason-code families already express (adapters
78    /// for stored copies, recovery/presenter lineage and team artifacts). An
79    /// unknown code is not a state.
80    #[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/// The five outcomes of doc 19. Projection of [`ContextDispositionV1`]; a
120/// source that could not be decided at all is `unavailable`.
121#[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            // Local-model-only delivers unchanged content under a destination
135            // constraint, which the receipt's destination carries.
136            ContextDispositionV1::Allow | ContextDispositionV1::AllowLocalModelOnly => Self::Allow,
137            ContextDispositionV1::AllowRedacted => Self::Redact,
138            ContextDispositionV1::AllowMinimized | ContextDispositionV1::AllowSummaryOnly => {
139                Self::Transform
140            }
141            // Pending approval delivers nothing now.
142            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/// The consumer path a request came through, and what that path can observe.
157#[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    /// Only a path that carries the model request itself can see what reached
172    /// the model; every other path hands context to a host that may add more.
173    #[must_use]
174    pub fn observes_model_egress(self) -> bool {
175        matches!(self, Self::Proxy | Self::HostEgress)
176    }
177}
178
179/// How far a delivery is known to have gone.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum ContextReceiptStateV1 {
183    /// Admitted and bound; nothing after that was observed.
184    Prepared,
185    /// Returned to the caller's transport (CLI output written, MCP reply sent).
186    HandedOff,
187    /// The exact bytes were seen leaving toward the model on an instrumented path.
188    ObservedModelEgress,
189    /// Nothing was delivered.
190    Rejected,
191    /// Some objects were delivered, some withheld or incompletely inspected.
192    Partial,
193}
194
195impl ContextReceiptStateV1 {
196    /// The strongest state the evidence supports, never more.
197    #[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/// What was asked: identity, purpose, sources in scope, route, destination.
220/// A caller-supplied tenant or user string is not an identity; the principal
221/// is the one the gateway authenticated or `unknown`.
222#[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/// How the candidate's right to be read was established.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum AclCheckV1 {
239    /// Re-read now with the principal's own credential.
240    Rechecked,
241    /// Read now from a local, PathJail-bounded source the process may open.
242    LocalRead,
243    /// Team authority answered for this use (membership, ACL version).
244    TeamAuthority,
245    /// No check possible; the candidate cannot be valid for governed use.
246    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/// The decision about one object, in the doc 19 vocabulary.
260#[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/// Request → candidates → decisions → receipt, as one record over the
270/// existing ones (referenced by digest, never copied).
271#[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    /// Canonical digest of the [`ContextDecisionReceiptV1`] this lineage explains.
279    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    /// The signed execution receipt of the same call, where one exists.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub execution_receipt: Option<ProtocolReference>,
289}
290
291impl ContextDecisionLineageV1 {
292    /// Build the lineage of one decision receipt. The outcomes are the
293    /// receipt's own decisions projected, never re-decided here.
294    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            // A withheld delivery may still list objects that were admissible on
388            // their own; nothing of it reached the destination either way.
389            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;