1use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::{
13 AcceptanceState, AttemptId, EngineContextSourceMaterializationResponseV1, EngineFailureV1,
14 ExecutionPlanV1, MAX_SAFE_INTEGER, PlanId, SemanticVersion, Sha256Digest, TaskEnvelopeV1,
15 TaskId, ValidationError, ViaProviderUsageV1, deserialize_schema_version,
16 validate_bounded_string,
17};
18
19pub const ENGINE_PROVIDER_EXECUTION_REQUEST_DIGEST_DOMAIN: &[u8] =
21 b"leanctx.engine.provider-execution.request.v1\0";
22
23const INTENT_DIGEST_DOMAIN: &[u8] = b"leanctx.engine.provider-execution.intent.v1\0";
24
25pub const MAX_ENGINE_PROVIDER_EXECUTION_REQUEST_BYTES: usize =
27 crate::MAX_ENGINE_SOURCE_PLAN_REQUEST_BYTES + 64 * 1024;
28
29pub const MAX_ENGINE_PROVIDER_EXECUTION_RESPONSE_BYTES: usize = 1024 * 1024;
31
32pub const MAX_ENGINE_PROVIDER_OUTPUT_BYTES: usize = 256 * 1024;
34
35pub const MAX_ENGINE_PROVIDER_OUTPUT_TOKENS: u32 = 65_536;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum EngineProviderExecutionStatusV1 {
41 Succeeded,
42 Failed,
43 Rejected,
44 TimedOut,
45 DispatchUncertain,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct EngineProviderOutputV1 {
51 pub content: String,
52 pub sha256_digest: Sha256Digest,
53}
54
55impl EngineProviderOutputV1 {
56 fn validate(&self) -> Result<(), ValidationError> {
57 if self.content.len() > MAX_ENGINE_PROVIDER_OUTPUT_BYTES
58 || sha256_digest(self.content.as_bytes())? != self.sha256_digest
59 {
60 return Err(ValidationError::new(
61 "invalid Engine provider output payload",
62 ));
63 }
64 Ok(())
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(tag = "basis", rename_all = "snake_case", deny_unknown_fields)]
72pub enum EngineProviderCostV1 {
73 Unavailable {},
74 UsagePricedEstimate { micros: u64 },
75 ObservedCharge { micros: u64 },
76}
77
78impl EngineProviderCostV1 {
79 fn validate(&self) -> Result<(), ValidationError> {
80 let micros = match self {
81 Self::Unavailable {} => return Ok(()),
82 Self::UsagePricedEstimate { micros } | Self::ObservedCharge { micros } => micros,
83 };
84 if *micros > MAX_SAFE_INTEGER {
85 return Err(ValidationError::new(
86 "Engine provider cost exceeds the safe integer ceiling",
87 ));
88 }
89 Ok(())
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct EngineProviderExecutionRequestV1 {
98 #[serde(deserialize_with = "deserialize_schema_version")]
99 pub schema_version: u32,
100 pub transport_version: u32,
101 pub engine_interface_version: SemanticVersion,
102 pub attempt_id: AttemptId,
103 pub task: TaskEnvelopeV1,
104 pub plan: ExecutionPlanV1,
105 pub materialization: EngineContextSourceMaterializationResponseV1,
106 pub query: String,
107 pub max_output_tokens: u32,
108}
109
110impl EngineProviderExecutionRequestV1 {
111 pub fn validate(&self) -> Result<(), ValidationError> {
112 if self.schema_version != 1
113 || self.transport_version != 1
114 || self.engine_interface_version.as_str() != "1.0.0"
115 || self.query.trim().is_empty()
116 || self.query.len() > crate::MAX_ENGINE_CONTEXT_PLAN_QUERY_BYTES
117 || self.query.contains('\0')
118 || !(1..=MAX_ENGINE_PROVIDER_OUTPUT_TOKENS).contains(&self.max_output_tokens)
119 {
120 return Err(ValidationError::new(
121 "invalid Engine provider execution request envelope",
122 ));
123 }
124 self.task.validate()?;
125 self.plan.validate()?;
126 self.materialization.validate()?;
127 validate_provider_plan(&self.plan)?;
128 let source_plan = &self.materialization.plan.result.plan;
129 if self.plan.task_id != self.task.task_id
130 || source_plan.task_id != self.task.task_id
131 || self.plan.context_plan_id.as_ref() != Some(&source_plan.context_plan_id)
132 || self.plan.context_token_limit() != Some(source_plan.budget_tokens)
133 {
134 return Err(ValidationError::new(
135 "Engine provider request task, context plan, or budget does not bind",
136 ));
137 }
138 bounded_wire_size(self, MAX_ENGINE_PROVIDER_EXECUTION_REQUEST_BYTES)
139 }
140
141 pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
143 self.validate()?;
144 let value = serde_json::to_value(self)
145 .map_err(|_| ValidationError::new("encode Engine provider request"))?;
146 serde_json::to_vec(&crate::entitlement::sort_json(value))
147 .map_err(|_| ValidationError::new("canonicalize Engine provider request"))
148 }
149
150 pub fn canonical_digest(&self) -> Result<Sha256Digest, ValidationError> {
152 digest_with_domain(
153 ENGINE_PROVIDER_EXECUTION_REQUEST_DIGEST_DOMAIN,
154 &self.canonical_bytes()?,
155 )
156 }
157
158 pub fn canonical_intent_digest(&self) -> Result<Sha256Digest, ValidationError> {
161 self.validate()?;
162 let mut value = serde_json::to_value(self)
163 .map_err(|_| ValidationError::new("encode Engine provider intent"))?;
164 value
165 .as_object_mut()
166 .ok_or_else(|| ValidationError::new("invalid Engine provider intent"))?
167 .remove("attempt_id");
168 let bytes = serde_json::to_vec(&crate::entitlement::sort_json(value))
169 .map_err(|_| ValidationError::new("canonicalize Engine provider intent"))?;
170 digest_with_domain(INTENT_DIGEST_DOMAIN, &bytes)
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct EngineProviderExecutionResponseV1 {
179 #[serde(deserialize_with = "deserialize_schema_version")]
180 pub schema_version: u32,
181 pub transport_version: u32,
182 pub engine_interface_version: SemanticVersion,
183 pub attempt_id: AttemptId,
184 pub task_id: TaskId,
185 pub plan_id: PlanId,
186 pub context_digest: Sha256Digest,
187 pub request_digest: Sha256Digest,
189 pub provider: String,
190 pub model: String,
191 pub status: EngineProviderExecutionStatusV1,
192 pub acceptance: AcceptanceState,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub output: Option<EngineProviderOutputV1>,
195 pub usage: ViaProviderUsageV1,
196 pub cost: EngineProviderCostV1,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub failure: Option<EngineFailureV1>,
199}
200
201impl EngineProviderExecutionResponseV1 {
202 pub fn validate(&self) -> Result<(), ValidationError> {
203 if self.schema_version != 1
204 || self.transport_version != 1
205 || self.engine_interface_version.as_str() != "1.0.0"
206 || self.acceptance != AcceptanceState::Unknown
207 {
208 return Err(ValidationError::new(
209 "invalid Engine provider execution response envelope",
210 ));
211 }
212 validate_bounded_string(&self.provider, "provider")?;
213 validate_bounded_string(&self.model, "model")?;
214 self.usage.validate("provider usage")?;
215 self.cost.validate()?;
216 if matches!(self.cost, EngineProviderCostV1::UsagePricedEstimate { .. })
217 && self.usage.state == crate::ViaUsageStateV1::Unavailable
218 {
219 return Err(ValidationError::new(
220 "cannot price unavailable provider usage",
221 ));
222 }
223 if let Some(output) = &self.output {
224 output.validate()?;
225 }
226 if let Some(failure) = &self.failure {
227 failure.validate()?;
228 if failure.retryable_by_host {
229 return Err(ValidationError::new(
230 "provider failures must not request automatic host retry",
231 ));
232 }
233 }
234 match (self.status, self.output.is_some(), self.failure.is_some()) {
235 (EngineProviderExecutionStatusV1::Succeeded, true, false) => Ok(()),
236 (
237 EngineProviderExecutionStatusV1::Failed
238 | EngineProviderExecutionStatusV1::Rejected
239 | EngineProviderExecutionStatusV1::TimedOut
240 | EngineProviderExecutionStatusV1::DispatchUncertain,
241 false,
242 true,
243 ) => Ok(()),
244 _ => Err(ValidationError::new(
245 "Engine provider response status, output, and failure disagree",
246 )),
247 }?;
248 bounded_wire_size(self, MAX_ENGINE_PROVIDER_EXECUTION_RESPONSE_BYTES)
249 }
250
251 pub fn validate_for(
252 &self,
253 request: &EngineProviderExecutionRequestV1,
254 ) -> Result<(), ValidationError> {
255 request.validate()?;
256 self.validate()?;
257 if self.attempt_id != request.attempt_id
258 || self.task_id != request.task.task_id
259 || self.plan_id != request.plan.plan_id
260 || self.context_digest != request.materialization.materialized_digest
261 || self.request_digest != request.canonical_digest()?
262 || self.provider != request.plan.provider
263 || self.model != request.plan.model
264 {
265 return Err(ValidationError::new(
266 "Engine provider response does not bind its request",
267 ));
268 }
269 Ok(())
270 }
271}
272
273fn validate_provider_plan(plan: &ExecutionPlanV1) -> Result<(), ValidationError> {
274 if plan.provider == "local-native"
275 || plan.model == "local-native"
276 || plan.provider == "auto"
277 || plan.model == "auto"
278 || plan.max_retries != 0
279 || !plan.fallback_refs.is_empty()
280 {
281 return Err(ValidationError::new(
282 "Engine provider request requires a concrete non-local provider plan",
283 ));
284 }
285 Ok(())
286}
287
288fn bounded_wire_size<T: Serialize>(value: &T, limit: usize) -> Result<(), ValidationError> {
289 let encoded = serde_json::to_vec(value)
290 .map_err(|_| ValidationError::new("encode Engine provider wire value"))?;
291 if encoded.len() > limit {
292 return Err(ValidationError::new(
293 "Engine provider wire value exceeds its byte bound",
294 ));
295 }
296 Ok(())
297}
298
299fn digest_with_domain(domain: &[u8], bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
300 let mut hash = Sha256::new();
301 hash.update(domain);
302 hash.update(bytes);
303 let hex = hash
304 .finalize()
305 .iter()
306 .map(|byte| format!("{byte:02x}"))
307 .collect::<String>();
308 Sha256Digest::new(format!("sha256:{hex}"))
309}
310
311fn sha256_digest(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
312 digest_with_domain(&[], bytes)
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::{
319 CapabilityId, ContextBudgetPolicyV1, ContextPlanId, ContextPlanProjectionV1,
320 ContextStrategy, EngineContextPlanResponseV1, EngineContextSourcePlanResponseV1,
321 ExecutionPlanV1, StopCondition, TaskComplexity,
322 };
323
324 fn id<T>(value: &str) -> T
325 where
326 T: TryFrom<String>,
327 <T as TryFrom<String>>::Error: std::fmt::Debug,
328 {
329 T::try_from(value.to_owned()).expect("valid identifier")
330 }
331
332 fn request_fixture() -> EngineProviderExecutionRequestV1 {
333 let task_id: TaskId = id("task-1");
334 let context_plan_id: ContextPlanId = id("context-plan-1");
335 let mut projection = ContextPlanProjectionV1 {
336 schema_version: 1,
337 context_plan_id: context_plan_id.clone(),
338 task_id: task_id.clone(),
339 projection_digest: None,
340 budget_tokens: 128,
341 selections: Vec::new(),
342 provider_stats: Default::default(),
343 policy_decision_refs: Vec::new(),
344 evidence: Vec::new(),
345 extensions: Default::default(),
346 };
347 projection.projection_digest = Some(projection.compute_projection_digest().unwrap());
348 let source_result = EngineContextPlanResponseV1 {
349 schema_version: 1,
350 transport_version: 1,
351 engine_interface_version: SemanticVersion::new("1.0.0").unwrap(),
352 plan: projection,
353 };
354 let source_plan =
355 EngineContextSourcePlanResponseV1::new(source_result, Vec::new()).expect("source plan");
356 let content = "context";
357 let materialization = EngineContextSourceMaterializationResponseV1 {
358 schema_version: 1,
359 transport_version: 1,
360 engine_interface_version: SemanticVersion::new("1.0.0").unwrap(),
361 plan: source_plan,
362 materialized_digest: sha256_digest(content.as_bytes()).unwrap(),
363 materialized_token_count: 1,
364 content: content.to_owned(),
365 };
366 EngineProviderExecutionRequestV1 {
367 schema_version: 1,
368 transport_version: 1,
369 engine_interface_version: SemanticVersion::new("1.0.0").unwrap(),
370 attempt_id: id("attempt-1"),
371 task: TaskEnvelopeV1 {
372 schema_version: 1,
373 task_id: task_id.clone(),
374 trace_id: id("trace-1"),
375 project_id: id("project-1"),
376 session_id: id("session-1"),
377 agent_id: id("agent-1"),
378 complexity: TaskComplexity::Medium,
379 created_at: "2026-09-20T00:00:00Z".to_owned(),
380 parent_task_id: None,
381 tenant_id: Some(id("tenant-1")),
382 intent: None,
383 task_class: None,
384 risk_class: None,
385 quality_requirement_milli: None,
386 cost_budget_micros: None,
387 latency_budget_ms: None,
388 data_classification: None,
389 region_policy_ref: None,
390 model_policy_ref: None,
391 context_state_ref: None,
392 outcome_contract_ref: None,
393 extensions: Default::default(),
394 },
395 plan: ExecutionPlanV1 {
396 schema_version: 1,
397 plan_id: id("plan-1"),
398 task_id: task_id.clone(),
399 context_budget_tokens: 128,
400 context_budget_policy: Some(ContextBudgetPolicyV1::TokenLimit { tokens: 128 }),
401 context_strategy: ContextStrategy::Balanced,
402 knowledge_refs: Vec::new(),
403 capability_ids: vec![CapabilityId::new("provider-call").unwrap()],
404 model: "model-1".to_owned(),
405 provider: "provider-1".to_owned(),
406 reasoning_allocation_milli: 0,
407 max_retries: 0,
408 fallback_refs: Vec::new(),
409 stop_condition: StopCondition::OnCompletion,
410 expected_cost_micros: 0,
411 expected_quality_milli: 0,
412 expected_latency_ms: 0,
413 estimates: None,
414 policy_decision_ref: None,
415 scheduler_decision_ref: None,
416 executor_agent_id: None,
417 context_plan_id: Some(context_plan_id),
418 capability_bindings: Vec::new(),
419 extensions: Default::default(),
420 },
421 materialization,
422 query: "find relevant context".to_owned(),
423 max_output_tokens: 128,
424 }
425 }
426
427 fn succeeded_response(
428 request: &EngineProviderExecutionRequestV1,
429 ) -> EngineProviderExecutionResponseV1 {
430 let content = "provider output";
431 EngineProviderExecutionResponseV1 {
432 schema_version: 1,
433 transport_version: 1,
434 engine_interface_version: SemanticVersion::new("1.0.0").unwrap(),
435 attempt_id: request.attempt_id.clone(),
436 task_id: request.task.task_id.clone(),
437 plan_id: request.plan.plan_id.clone(),
438 context_digest: request.materialization.materialized_digest.clone(),
439 request_digest: request.canonical_digest().unwrap(),
440 provider: request.plan.provider.clone(),
441 model: request.plan.model.clone(),
442 status: EngineProviderExecutionStatusV1::Succeeded,
443 acceptance: AcceptanceState::Unknown,
444 output: Some(EngineProviderOutputV1 {
445 content: content.to_owned(),
446 sha256_digest: sha256_digest(content.as_bytes()).unwrap(),
447 }),
448 usage: ViaProviderUsageV1::measured(1, 0, 0, 1).unwrap(),
449 cost: EngineProviderCostV1::Unavailable {},
450 failure: None,
451 }
452 }
453
454 #[test]
455 fn request_and_response_bind_full_provider_join() {
456 let request = request_fixture();
457 let response = succeeded_response(&request);
458 request.validate().unwrap();
459 response.validate_for(&request).unwrap();
460 assert_eq!(
461 request.canonical_digest().unwrap(),
462 request.canonical_digest().unwrap()
463 );
464 }
465
466 #[test]
467 fn request_rejects_local_native_or_retry_fallback_plan() {
468 let mut request = request_fixture();
469 request.plan.provider = "local-native".to_owned();
470 assert!(request.validate().is_err());
471 let mut request = request_fixture();
472 request.plan.provider = "provider-1".to_owned();
473 request.plan.max_retries = 1;
474 assert!(request.validate().is_err());
475 }
476
477 #[test]
478 fn response_rejects_unknown_fields_and_retryable_failures() {
479 let request = request_fixture();
480 let response = succeeded_response(&request);
481 let mut value = serde_json::to_value(&request).unwrap();
482 value["unknown"] = serde_json::json!(true);
483 assert!(serde_json::from_value::<EngineProviderExecutionRequestV1>(value).is_err());
484
485 let mut failed = response;
486 failed.status = EngineProviderExecutionStatusV1::Failed;
487 failed.output = None;
488 failed.failure = Some(EngineFailureV1 {
489 code: crate::EngineFailureCodeV1::Internal,
490 retryable_by_host: true,
491 recovery_ref: None,
492 });
493 assert!(failed.validate_for(&request).is_err());
494 }
495
496 #[test]
497 fn cost_states_are_explicit_and_safe() {
498 let request = request_fixture();
499 let mut response = succeeded_response(&request);
500 response.cost = EngineProviderCostV1::ObservedCharge { micros: 0 };
501 assert!(response.validate_for(&request).is_ok());
502 response.cost = EngineProviderCostV1::UsagePricedEstimate {
503 micros: MAX_SAFE_INTEGER + 1,
504 };
505 assert!(response.validate_for(&request).is_err());
506 }
507
508 #[test]
509 fn query_and_output_bounds_fail_closed() {
510 let mut request = request_fixture();
511 request.query = " ".to_owned();
512 assert!(request.validate().is_err());
513 let mut request = request_fixture();
514 request.max_output_tokens = 0;
515 assert!(request.validate().is_err());
516
517 let request = request_fixture();
518 let mut response = succeeded_response(&request);
519 response.output.as_mut().unwrap().content =
520 "x".repeat(MAX_ENGINE_PROVIDER_OUTPUT_BYTES + 1);
521 assert!(response.validate_for(&request).is_err());
522 }
523
524 #[test]
525 fn intent_is_stable_but_response_binds_every_attempt_and_query() {
526 let first = request_fixture();
527 let response = succeeded_response(&first);
528 let mut next = first.clone();
529 next.attempt_id = id("attempt-2");
530 assert_eq!(
531 first.canonical_intent_digest().unwrap(),
532 next.canonical_intent_digest().unwrap()
533 );
534 assert_ne!(
535 first.canonical_digest().unwrap(),
536 next.canonical_digest().unwrap()
537 );
538 assert!(response.validate_for(&next).is_err());
539 next = first.clone();
540 next.query = "different request".into();
541 assert_ne!(
542 first.canonical_intent_digest().unwrap(),
543 next.canonical_intent_digest().unwrap()
544 );
545 assert!(response.validate_for(&next).is_err());
546 next = first.clone();
547 next.max_output_tokens += 1;
548 assert!(response.validate_for(&next).is_err());
549 let mut unknown = response;
550 unknown.usage = ViaProviderUsageV1::unavailable();
551 unknown.cost = EngineProviderCostV1::UsagePricedEstimate { micros: 0 };
552 assert!(unknown.validate().is_err());
553 }
554}