Skip to main content

lean_ctx_protocol/
engine_context_source_execution.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Typed wire envelope for the operator-owned explicit-source execution flow.
4//!
5//! This module only validates joins among the existing task, execution-plan,
6//! source-plan, invocation, observation, and receipt projections.  It does not
7//! authorize a caller, verify a signer, or create a second receipt authority.
8
9use std::fmt::Write as _;
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14use crate::{
15    AcceptanceState, EngineContextSourceMaterializationRequestV1,
16    EngineContextSourcePlanResponseV1, EngineInvocationV1, EngineObservationStatusV1,
17    EngineObservationV1, EnginePolicyDecisionV1, ExecutionPlanV1, ProtocolReference, ReceiptId,
18    SemanticVersion, Sha256Digest, TaskEnvelopeV1, ValidationError, deserialize_schema_version,
19    validate_schema_version,
20};
21
22/// The host stdin request is bounded by the existing one-megabyte source
23/// transport limit before this typed envelope is decoded.
24pub const MAX_ENGINE_SOURCE_EXECUTION_REQUEST_BYTES: usize =
25    crate::MAX_ENGINE_SOURCE_PLAN_REQUEST_BYTES;
26
27/// Native Engine output uses the same one-megabyte bound as materialized input.
28pub const MAX_ENGINE_SOURCE_EXECUTION_VIEW_BYTES: usize =
29    crate::MAX_ENGINE_SOURCE_MATERIALIZED_CONTEXT_BYTES;
30
31const TRANSPORT_VERSION: u32 = 1;
32const ENGINE_INTERFACE_VERSION: &str = "1.0.0";
33const LOCAL_NATIVE_PROVIDER: &str = "local-native";
34const LOCAL_NATIVE_CAPABILITY: &str = "capability://leanctx/context-optimization";
35const LOCAL_NATIVE_CAPABILITY_VERSION: &str = "1.0.0";
36const INPUT_REF_PREFIX: &str = "input:source-materialization-sha256:";
37const SOURCE_PLAN_EVIDENCE_PREFIX: &str = "artifact://execution/evidence/";
38const TASK_REF_PREFIX: &str = "task:sha256:";
39const PLAN_REF_PREFIX: &str = "plan:sha256:";
40
41/// The exact operator request accepted by `context-sources-receipt`.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct EngineContextSourceExecutionRequestV1 {
45    #[serde(deserialize_with = "deserialize_schema_version")]
46    pub schema_version: u32,
47    pub transport_version: u32,
48    pub engine_interface_version: SemanticVersion,
49    pub task: TaskEnvelopeV1,
50    pub plan: ExecutionPlanV1,
51    pub materialization: EngineContextSourceMaterializationRequestV1,
52}
53
54impl EngineContextSourceExecutionRequestV1 {
55    /// Validate the request envelope and its cross-object identity joins.
56    pub fn validate(&self) -> Result<(), ValidationError> {
57        validate_header(
58            self.schema_version,
59            self.transport_version,
60            &self.engine_interface_version,
61        )?;
62        self.task.validate()?;
63        self.plan.validate()?;
64        self.materialization.validate_payload()?;
65        validate_header(
66            self.materialization.source_plan.planning.schema_version,
67            self.materialization.source_plan.planning.transport_version,
68            &self
69                .materialization
70                .source_plan
71                .planning
72                .engine_interface_version,
73        )?;
74        if self.task.task_id != self.plan.task_id
75            || self.materialization.source_plan.planning.task_id != self.task.task_id
76        {
77            return Err(ValidationError::new(
78                "source execution request task IDs do not agree",
79            ));
80        }
81        validate_local_native_plan(&self.plan)?;
82        if self.plan.context_plan_id.is_some()
83            || self.task.region_policy_ref.is_some()
84            || self.task.model_policy_ref.is_some()
85        {
86            return Err(ValidationError::new(
87                "source execution request is not a local-native declaration",
88            ));
89        }
90        bounded_wire_size(self, MAX_ENGINE_SOURCE_EXECUTION_REQUEST_BYTES)
91    }
92}
93
94/// The actual output view returned by the native Engine adapter.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct EngineContextSourceExecutionViewV1 {
98    pub text: String,
99    pub output_ref: Option<ProtocolReference>,
100    pub output_digest: Option<Sha256Digest>,
101}
102
103impl EngineContextSourceExecutionViewV1 {
104    fn validate_for(&self, observation: &EngineObservationV1) -> Result<(), ValidationError> {
105        if self.text.len() > MAX_ENGINE_SOURCE_EXECUTION_VIEW_BYTES
106            || self.output_ref != observation.output_ref
107            || self.output_digest != observation.output_digest
108        {
109            return Err(ValidationError::new(
110                "source execution view does not bind the Engine observation",
111            ));
112        }
113        let output_digest = self.output_digest.as_ref().ok_or_else(|| {
114            ValidationError::new("source execution view is missing its output digest")
115        })?;
116        let expected_output_ref = format!("output:{}", output_digest.hex());
117        if self.output_ref.as_ref().map(ProtocolReference::as_str)
118            != Some(expected_output_ref.as_str())
119        {
120            return Err(ValidationError::new(
121                "source execution output ref does not bind its digest",
122            ));
123        }
124        let digest = sha256_digest(self.text.as_bytes())?;
125        if self.output_digest.as_ref() != Some(&digest) {
126            return Err(ValidationError::new(
127                "source execution view digest does not bind its text",
128            ));
129        }
130        Ok(())
131    }
132}
133
134/// The canonical receipt identity projection returned by the host authority.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct EngineContextSourceCanonicalReceiptV1 {
138    pub receipt_id: ReceiptId,
139    pub receipt_ref: ProtocolReference,
140    pub receipt_digest: Sha256Digest,
141    pub outcome: AcceptanceState,
142}
143
144impl EngineContextSourceCanonicalReceiptV1 {
145    fn validate(&self) -> Result<(), ValidationError> {
146        if self.outcome != AcceptanceState::Unknown
147            || self.receipt_ref.as_str() != format!("id:{}", self.receipt_digest.as_str())
148        {
149            return Err(ValidationError::new(
150                "source execution receipt must be unknown and digest-bound",
151            ));
152        }
153        Ok(())
154    }
155}
156
157/// The actual v1 source execution response; fields intentionally match the
158/// existing JSON object emitted by the CLI host adapter.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct EngineContextSourceExecutionResponseV1 {
162    #[serde(deserialize_with = "deserialize_schema_version")]
163    pub schema_version: u32,
164    pub transport_version: u32,
165    pub engine_interface_version: SemanticVersion,
166    pub source_plan: EngineContextSourcePlanResponseV1,
167    pub execution_plan: ExecutionPlanV1,
168    pub view: EngineContextSourceExecutionViewV1,
169    pub invocation: EngineInvocationV1,
170    pub observation: EngineObservationV1,
171    pub canonical_receipt: EngineContextSourceCanonicalReceiptV1,
172}
173
174impl EngineContextSourceExecutionResponseV1 {
175    /// Validate response versions, canonical source evidence, and all joins
176    /// needed to consume this response as one bounded execution result.
177    pub fn validate(&self) -> Result<(), ValidationError> {
178        validate_header(
179            self.schema_version,
180            self.transport_version,
181            &self.engine_interface_version,
182        )?;
183        self.source_plan.validate_binding()?;
184        self.execution_plan.validate()?;
185        self.invocation.validate()?;
186        self.observation.validate_for(&self.invocation)?;
187        self.view.validate_for(&self.observation)?;
188        self.canonical_receipt.validate()?;
189
190        if self.execution_plan.task_id != self.source_plan.result.plan.task_id
191            || self.execution_plan.context_plan_id.as_ref()
192                != Some(&self.source_plan.result.plan.context_plan_id)
193            || self.execution_plan.context_token_limit()
194                != Some(self.source_plan.result.plan.budget_tokens)
195        {
196            return Err(ValidationError::new(
197                "source execution response plan does not bind its source plan",
198            ));
199        }
200        validate_local_native_plan(&self.execution_plan)?;
201        if self.invocation.operation.capability_id.as_str() != LOCAL_NATIVE_CAPABILITY
202            || self.invocation.operation.capability_version.as_str()
203                != LOCAL_NATIVE_CAPABILITY_VERSION
204        {
205            return Err(ValidationError::new(
206                "source execution response is not bound to the local-native capability",
207            ));
208        }
209        if self.invocation.policy_admission.decision != EnginePolicyDecisionV1::Admitted
210            || self.observation.status != EngineObservationStatusV1::Succeeded
211            || self.observation.source_lineage != self.invocation.source_refs
212        {
213            return Err(ValidationError::new(
214                "source execution response is not an admitted complete observation",
215            ));
216        }
217        let source_plan_digest = canonical_sha256(&self.source_plan)?;
218        let execution_plan_digest = canonical_sha256(&self.execution_plan)?;
219        validate_source_references(
220            &self.invocation,
221            &source_plan_digest,
222            &execution_plan_digest,
223        )?;
224        let receipt_link = self.observation.receipt_link.as_ref().ok_or_else(|| {
225            ValidationError::new("source execution response is missing the Engine receipt link")
226        })?;
227        if receipt_link.receipt_ref.as_str()
228            != format!("receipt:{}", receipt_link.receipt_digest.as_str())
229        {
230            return Err(ValidationError::new(
231                "source execution Engine receipt ref does not bind its digest",
232            ));
233        }
234        Ok(())
235    }
236
237    /// Validate this response against the exact request that admitted it.
238    /// The task evidence is not repeated in the response wire object, so this
239    /// join is explicit for consumers that retain the original request.
240    pub fn validate_against(
241        &self,
242        request: &EngineContextSourceExecutionRequestV1,
243    ) -> Result<(), ValidationError> {
244        request.validate()?;
245        self.validate()?;
246        if self.source_plan.binding_digest != request.materialization.expected_binding_digest
247            || self.execution_plan.task_id != request.task.task_id
248            || self.execution_plan.provider != request.plan.provider
249            || self.execution_plan.model != request.plan.model
250            || self.execution_plan.capability_ids != request.plan.capability_ids
251            || self.execution_plan.context_token_limit() != request.plan.context_token_limit()
252        {
253            return Err(ValidationError::new(
254                "source execution response does not bind its original request",
255            ));
256        }
257        let mut expected_plan = request.plan.clone();
258        expected_plan.context_plan_id = self.execution_plan.context_plan_id.clone();
259        let decision_ref = "context_autopilot_decision_ref";
260        if !expected_plan.extensions.contains_key(decision_ref) {
261            let value = self
262                .execution_plan
263                .extensions
264                .get(decision_ref)
265                .filter(|value| value.as_str().is_some_and(|id| !id.is_empty()))
266                .ok_or_else(|| {
267                    ValidationError::new("source execution decision reference is missing")
268                })?;
269            expected_plan
270                .extensions
271                .insert(decision_ref, value.clone())?;
272        }
273        if expected_plan != self.execution_plan
274            || self.source_plan.result.plan.budget_tokens
275                > u64::from(request.materialization.source_plan.planning.budget_tokens)
276            || self.source_plan.source_bindings.iter().any(|binding| {
277                !request
278                    .materialization
279                    .source_plan
280                    .sources
281                    .iter()
282                    .any(|source| source.descriptor == *binding)
283            })
284        {
285            return Err(ValidationError::new(
286                "source execution changed the declared plan or sources",
287            ));
288        }
289        if let Some(epoch) = &request.materialization.planning_evaluation_time {
290            let actual = self
291                .source_plan
292                .result
293                .plan
294                .extensions
295                .get("context_plan_evaluation_v1")
296                .and_then(|marker| marker.get("evaluation_time"))
297                .and_then(serde_json::Value::as_str);
298            if actual != Some(epoch.as_str()) {
299                return Err(ValidationError::new(
300                    "source execution changed the declared evaluation time",
301                ));
302            }
303        }
304        let task_digest = canonical_sha256(&request.task)?;
305        let expected_task_ref = format!("{TASK_REF_PREFIX}{}", task_digest.hex());
306        if !self
307            .invocation
308            .source_refs
309            .iter()
310            .any(|reference| reference.as_str() == expected_task_ref)
311        {
312            return Err(ValidationError::new(
313                "source execution task evidence does not bind the original task",
314            ));
315        }
316        Ok(())
317    }
318}
319
320fn validate_header(
321    schema_version: u32,
322    transport_version: u32,
323    engine_interface_version: &SemanticVersion,
324) -> Result<(), ValidationError> {
325    validate_schema_version(schema_version)?;
326    if transport_version != TRANSPORT_VERSION
327        || engine_interface_version.as_str() != ENGINE_INTERFACE_VERSION
328    {
329        return Err(ValidationError::new(
330            "unsupported source execution transport or Engine interface version",
331        ));
332    }
333    Ok(())
334}
335
336fn validate_local_native_plan(plan: &ExecutionPlanV1) -> Result<(), ValidationError> {
337    if plan.provider != LOCAL_NATIVE_PROVIDER
338        || plan.model != LOCAL_NATIVE_PROVIDER
339        || plan.capability_ids.len() != 1
340        || plan.capability_ids[0].as_str() != LOCAL_NATIVE_CAPABILITY
341        || plan.capability_bindings.len() != 1
342        || plan.capability_bindings[0].capability_id.as_str() != LOCAL_NATIVE_CAPABILITY
343        || plan.capability_bindings[0].version != LOCAL_NATIVE_CAPABILITY_VERSION
344    {
345        return Err(ValidationError::new(
346            "source execution plan is not bound to the local-native capability",
347        ));
348    }
349    Ok(())
350}
351
352fn validate_source_references(
353    invocation: &EngineInvocationV1,
354    source_plan_digest: &Sha256Digest,
355    execution_plan_digest: &Sha256Digest,
356) -> Result<(), ValidationError> {
357    if invocation.source_refs.len() != 4 {
358        return Err(ValidationError::new(
359            "source execution invocation must contain four bounded lineage references",
360        ));
361    }
362    let declared_input_digest = invocation
363        .input_ref
364        .as_str()
365        .strip_prefix(INPUT_REF_PREFIX)
366        .ok_or_else(|| {
367            ValidationError::new("source execution input_ref is not a materialization reference")
368        })
369        .and_then(digest_suffix)?;
370    if declared_input_digest != invocation.input_digest {
371        return Err(ValidationError::new(
372            "source execution input_ref does not bind input_digest",
373        ));
374    }
375    let mut input_digest = None;
376    let mut evidence_digest = None;
377    let mut task_count = 0_u8;
378    let mut plan_digest = None;
379    for reference in &invocation.source_refs {
380        let value = reference.as_str();
381        if let Some(suffix) = value.strip_prefix(INPUT_REF_PREFIX) {
382            if input_digest.replace(digest_suffix(suffix)?).is_some() {
383                return Err(ValidationError::new(
384                    "duplicate source execution input reference",
385                ));
386            }
387        } else if let Some(suffix) = value.strip_prefix(SOURCE_PLAN_EVIDENCE_PREFIX) {
388            if evidence_digest.replace(digest_suffix(suffix)?).is_some() {
389                return Err(ValidationError::new(
390                    "duplicate source execution evidence reference",
391                ));
392            }
393        } else if let Some(suffix) = value.strip_prefix(TASK_REF_PREFIX) {
394            digest_suffix(suffix)?;
395            task_count = task_count.saturating_add(1);
396        } else if let Some(suffix) = value.strip_prefix(PLAN_REF_PREFIX) {
397            if plan_digest.replace(digest_suffix(suffix)?).is_some() {
398                return Err(ValidationError::new(
399                    "duplicate source execution plan reference",
400                ));
401            }
402        } else {
403            return Err(ValidationError::new(
404                "source execution invocation contains an unknown lineage reference",
405            ));
406        }
407    }
408    if input_digest.as_ref() != Some(&invocation.input_digest)
409        || evidence_digest.as_ref() != Some(source_plan_digest)
410        || task_count != 1
411        || plan_digest.is_none()
412    {
413        return Err(ValidationError::new(
414            "source execution invocation lineage does not bind its input and source plan",
415        ));
416    }
417    if plan_digest.as_ref() != Some(execution_plan_digest) {
418        return Err(ValidationError::new(
419            "source execution plan evidence does not bind execution_plan",
420        ));
421    }
422    Ok(())
423}
424
425fn digest_suffix(suffix: &str) -> Result<Sha256Digest, ValidationError> {
426    Sha256Digest::new(format!("sha256:{suffix}"))
427}
428
429pub(super) fn canonical_sha256<T: Serialize>(value: &T) -> Result<Sha256Digest, ValidationError> {
430    let value = serde_json::to_value(value).map_err(|error| {
431        ValidationError::new(format!("serialize source execution value: {error}"))
432    })?;
433    let bytes = serde_json::to_vec(&crate::entitlement::sort_json(value)).map_err(|error| {
434        ValidationError::new(format!("canonicalize source execution value: {error}"))
435    })?;
436    sha256_digest(&bytes)
437}
438
439pub(super) fn sha256_digest(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
440    let mut value = String::with_capacity(71);
441    value.push_str("sha256:");
442    for byte in Sha256::digest(bytes) {
443        write!(&mut value, "{byte:02x}")
444            .map_err(|_| ValidationError::new("source execution digest encoding failed"))?;
445    }
446    Sha256Digest::new(value)
447}
448
449fn bounded_wire_size<T: Serialize>(value: &T, limit: usize) -> Result<(), ValidationError> {
450    let encoded = serde_json::to_vec(value).map_err(|error| {
451        ValidationError::new(format!("serialize source execution request: {error}"))
452    })?;
453    if encoded.len() > limit {
454        return Err(ValidationError::new(
455            "source execution request exceeds its byte bound",
456        ));
457    }
458    Ok(())
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::{
465        CapabilityId, EngineInvocationIdV1, EngineOperationV1, EnginePolicyAdmissionV1,
466        ResolvedLocalEngineIdentityV1,
467    };
468
469    fn digest(value: &str) -> Sha256Digest {
470        sha256_digest(value.as_bytes()).expect("digest")
471    }
472
473    #[test]
474    fn source_lineage_binds_input_and_descriptor_evidence() {
475        let input = digest("materialized");
476        let evidence = digest("source-plan");
477        let invocation = EngineInvocationV1 {
478            schema_version: 1,
479            invocation_id: EngineInvocationIdV1::new("invocation-1").unwrap(),
480            engine: ResolvedLocalEngineIdentityV1 {
481                engine_id: "engine-1".into(),
482                engine_version: SemanticVersion::new("1.0.0").unwrap(),
483            },
484            operation: EngineOperationV1 {
485                capability_id: CapabilityId::new("capability://leanctx/context-optimization")
486                    .unwrap(),
487                capability_version: SemanticVersion::new("1.0.0").unwrap(),
488            },
489            input_ref: ProtocolReference::new(format!("{INPUT_REF_PREFIX}{}", input.hex()))
490                .unwrap(),
491            input_digest: input.clone(),
492            source_refs: vec![
493                ProtocolReference::new(format!("{INPUT_REF_PREFIX}{}", input.hex())).unwrap(),
494                ProtocolReference::new(format!("{SOURCE_PLAN_EVIDENCE_PREFIX}{}", evidence.hex()))
495                    .unwrap(),
496                ProtocolReference::new(format!("{TASK_REF_PREFIX}{}", digest("task").hex()))
497                    .unwrap(),
498                ProtocolReference::new(format!("{PLAN_REF_PREFIX}{}", digest("plan").hex()))
499                    .unwrap(),
500            ],
501            policy_admission: EnginePolicyAdmissionV1 {
502                policy_ref: ProtocolReference::new("policy:engine-transport-v1:admitted").unwrap(),
503                decision: EnginePolicyDecisionV1::Admitted,
504            },
505        };
506        validate_source_references(&invocation, &evidence, &digest("plan")).unwrap();
507        let mut altered = invocation.clone();
508        altered.input_digest = digest("different");
509        assert!(validate_source_references(&altered, &evidence, &digest("plan")).is_err());
510        let mut altered_input_ref = invocation.clone();
511        altered_input_ref.input_ref = altered_input_ref.source_refs[2].clone();
512        assert!(
513            validate_source_references(&altered_input_ref, &evidence, &digest("plan")).is_err()
514        );
515        let mut altered_plan = invocation;
516        altered_plan.source_refs[3] =
517            ProtocolReference::new(format!("{PLAN_REF_PREFIX}{}", digest("other").hex())).unwrap();
518        assert!(validate_source_references(&altered_plan, &evidence, &digest("plan")).is_err());
519    }
520
521    #[test]
522    fn canonical_receipt_is_unknown_and_id_ref_digest_bound() {
523        let receipt_digest = digest("receipt");
524        let receipt = EngineContextSourceCanonicalReceiptV1 {
525            receipt_id: ReceiptId::new("receipt-id").unwrap(),
526            receipt_ref: ProtocolReference::new(format!("id:{}", receipt_digest.as_str())).unwrap(),
527            receipt_digest,
528            outcome: AcceptanceState::Unknown,
529        };
530        receipt.validate().unwrap();
531        let mut accepted = receipt;
532        accepted.outcome = AcceptanceState::Accepted;
533        assert!(accepted.validate().is_err());
534    }
535}