Skip to main content

lean_ctx_protocol/
engine_context_sources.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Explicit source-batch planning for an operator-owned Engine process.
4//! Producer permission labels can restrict input, never authorize a remote caller.
5
6use std::collections::BTreeSet;
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::{
12    ContextDispositionV1, DataClassification, EngineContextPlanRequestV1,
13    EngineContextPlanResponseV1, ProtocolReference, Sha256Digest, SourceId, UtcTimestamp,
14    ValidationError,
15};
16
17pub const MAX_ENGINE_SOURCE_PLAN_REQUEST_BYTES: usize = 1024 * 1024;
18pub const MAX_ENGINE_SOURCE_PLAN_SOURCES: usize = 64;
19pub const MAX_ENGINE_SOURCE_CONTENT_BYTES: usize = 64 * 1024;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum EngineContextSourceTypeV1 {
24    Filesystem,
25    IssueTracker,
26    RelationalDatabase,
27    Other,
28}
29
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum EngineContextSourcePermissionV1 {
33    Permitted,
34    Denied,
35    #[default]
36    Unknown,
37}
38
39/// Factual source binding. Missing metadata is unknown, not verified/current.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct EngineContextSourceDescriptorV1 {
43    pub object_ref: ProtocolReference,
44    pub source_id: SourceId,
45    pub source_type: EngineContextSourceTypeV1,
46    pub content_digest: Sha256Digest,
47    pub revision: Option<ProtocolReference>,
48    pub owner: Option<ProtocolReference>,
49    pub observed_at: Option<UtcTimestamp>,
50    pub valid_until: Option<UtcTimestamp>,
51    pub classification: Option<DataClassification>,
52    #[serde(default)]
53    pub permission: EngineContextSourcePermissionV1,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct EngineContextSourceV1 {
59    pub descriptor: EngineContextSourceDescriptorV1,
60    pub content: String,
61}
62
63/// A separate operation keeps the existing strict local-store request unchanged.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct EngineContextSourcePlanRequestV1 {
67    pub planning: EngineContextPlanRequestV1,
68    pub sources: Vec<EngineContextSourceV1>,
69}
70
71impl EngineContextSourcePlanRequestV1 {
72    pub fn validate_payload(&self) -> Result<(), ValidationError> {
73        self.planning.validate_payload()?;
74        if self.sources.len() > MAX_ENGINE_SOURCE_PLAN_SOURCES {
75            return Err(ValidationError::new("too many Engine source candidates"));
76        }
77        let mut references = BTreeSet::new();
78        for source in &self.sources {
79            if source.content.len() > MAX_ENGINE_SOURCE_CONTENT_BYTES
80                || source.content.trim().is_empty()
81                || !references.insert(&source.descriptor.object_ref)
82                || source.descriptor.content_digest != sha256_digest(source.content.as_bytes())?
83                || matches!((&source.descriptor.observed_at, &source.descriptor.valid_until),
84                    (Some(observed), Some(until)) if until <= observed)
85            {
86                return Err(ValidationError::new("invalid Engine source binding"));
87            }
88        }
89        Ok(())
90    }
91}
92
93/// Metadata for selected candidates only. No source body or execution receipt.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct EngineContextSourcePlanResponseV1 {
97    pub result: EngineContextPlanResponseV1,
98    pub source_bindings: Vec<EngineContextSourceDescriptorV1>,
99    /// Integrity of the result and ordered bindings, not an authentication signature.
100    pub binding_digest: Sha256Digest,
101}
102
103impl EngineContextSourcePlanResponseV1 {
104    pub fn new(
105        result: EngineContextPlanResponseV1,
106        mut source_bindings: Vec<EngineContextSourceDescriptorV1>,
107    ) -> Result<Self, ValidationError> {
108        source_bindings.sort_by(|left, right| left.object_ref.cmp(&right.object_ref));
109        let binding_digest = binding_digest(&result, &source_bindings)?;
110        let response = Self {
111            result,
112            source_bindings,
113            binding_digest,
114        };
115        response.validate_binding()?;
116        Ok(response)
117    }
118
119    pub fn validate_binding(&self) -> Result<(), ValidationError> {
120        self.result.plan.validate()?;
121        if self.result.schema_version != 1
122            || self.result.transport_version != 1
123            || self.result.engine_interface_version.as_str() != "1.0.0"
124            || self.result.plan.projection_digest.is_none()
125            || self
126                .source_bindings
127                .windows(2)
128                .any(|pair| pair[0].object_ref >= pair[1].object_ref)
129        {
130            return Err(ValidationError::new(
131                "invalid Engine source response envelope",
132            ));
133        }
134        let selected = self
135            .result
136            .plan
137            .selections
138            .iter()
139            .filter(|selection| selection.disposition == ContextDispositionV1::Selected)
140            .collect::<Vec<_>>();
141        if selected.len() != self.source_bindings.len()
142            || self.source_bindings.iter().any(|binding| {
143                !selected.iter().any(|selection| {
144                    selection.source_ref == binding.object_ref.as_str()
145                        && selection.provider == binding.source_id.as_str()
146                        && selection.sha256_digest.as_deref()
147                            == Some(binding.content_digest.as_str())
148                })
149            })
150        {
151            return Err(ValidationError::new(
152                "Engine source selection binding mismatch",
153            ));
154        }
155        if binding_digest(&self.result, &self.source_bindings)? != self.binding_digest {
156            return Err(ValidationError::new(
157                "Engine source binding digest mismatch",
158            ));
159        }
160        Ok(())
161    }
162}
163
164fn binding_digest(
165    result: &EngineContextPlanResponseV1,
166    sources: &[EngineContextSourceDescriptorV1],
167) -> Result<Sha256Digest, ValidationError> {
168    let value = serde_json::to_value((result, sources))
169        .map_err(|_| ValidationError::new("invalid Engine source binding encoding"))?;
170    let bytes = serde_json::to_vec(&crate::entitlement::sort_json(value))
171        .map_err(|_| ValidationError::new("invalid Engine source binding encoding"))?;
172    sha256_digest(&bytes)
173}
174
175fn sha256_digest(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
176    let hex = Sha256::digest(bytes)
177        .iter()
178        .map(|byte| format!("{byte:02x}"))
179        .collect::<String>();
180    Sha256Digest::new(format!("sha256:{hex}"))
181}