Skip to main content

lean_ctx_protocol/
engine_provider_execution.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Versioned provider-execution transport for a host-prepared Engine context.
4//!
5//! This contract validates bounded joins and provenance labels. It does not
6//! authorize a tenant, select a provider, verify a signer, charge an account,
7//! publish a receipt, or admit an outcome.
8
9use 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
19/// Domain for the canonical request digest used by adapters.
20pub 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
25/// Request bound: the existing source transport plus bounded provider metadata.
26pub const MAX_ENGINE_PROVIDER_EXECUTION_REQUEST_BYTES: usize =
27    crate::MAX_ENGINE_SOURCE_PLAN_REQUEST_BYTES + 64 * 1024;
28
29/// Response bound, including bounded provider output.
30pub const MAX_ENGINE_PROVIDER_EXECUTION_RESPONSE_BYTES: usize = 1024 * 1024;
31
32/// Provider output is an artifact payload candidate, not a receipt or trust root.
33pub const MAX_ENGINE_PROVIDER_OUTPUT_BYTES: usize = 256 * 1024;
34
35/// Provider execution may not silently widen into an unbounded output request.
36pub 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/// Cost provenance is explicit; zero is valid only when the selected basis says
69/// it was actually observed or estimated as zero by the adapter.
70#[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/// Host-prepared request; its fields are correlation and integrity inputs, not
94/// caller authorization or provider-selection authority.
95#[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    /// Recursively sorted request JSON, without the digest domain prefix.
142    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    /// Digest over the fixed domain followed by canonical request bytes.
151    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    /// Stable admission identity before the store assigns an attempt ID.
159    /// Only attempt_id is excluded; every task, plan, query and context field binds.
160    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/// Provider result; it contains no signature, acceptance claim, savings claim,
175/// or artifact-fetch authority.
176#[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    /// Full canonical request digest, including the assigned attempt identity.
188    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}