1use 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
22pub const MAX_ENGINE_SOURCE_EXECUTION_REQUEST_BYTES: usize =
25 crate::MAX_ENGINE_SOURCE_PLAN_REQUEST_BYTES;
26
27pub 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#[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 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#[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#[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#[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 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 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}