Skip to main content

lean_ctx/core/engine_interface/
planning.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Host projection of the frozen scheduler contract into one actual native call.
4
5pub(crate) mod context_binding;
6
7use lean_ctx_protocol::{
8    CapabilityBindingV1, ContextBudgetPolicyV1, EnginePolicyAdmissionV1, EnginePolicyDecisionV1,
9    ExecutionPlanEstimatesV1, ExecutionPlanV1, PlanId, ProtocolReference, TaskEnvelopeV1,
10};
11
12use super::{CAPABILITY_ID, CAPABILITY_VERSION, MAX_TRANSPORT_BUDGET_TOKENS};
13use crate::core::{canonical::canonical_serialize, ocla::adapters::native_context::manifest};
14
15/// Select only the installed native operation; a recommendation is not admission.
16pub fn native_plan(
17    task: &TaskEnvelopeV1,
18    admission: &EnginePolicyAdmissionV1,
19) -> Result<ExecutionPlanV1, String> {
20    let decision = native_decision(task, admission)?;
21    let decision_ref = persist_evidence(&decision)?;
22    let context = context_binding::NativeContextBinding::capture(task)?;
23    finalize_native_plan(
24        task,
25        &decision,
26        &format!("artifact://execution/evidence/{}", decision_ref.hex()),
27        context.as_ref(),
28    )
29}
30
31/// Persisted native scheduling evidence (`artifact://execution/evidence/<digest>`).
32/// Names, order and values are frozen: validation recomputes the decision and
33/// compares it with the one earlier releases stored.
34#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
35#[serde(deny_unknown_fields)]
36pub(crate) struct NativeDecision {
37    pub selected: ExecutionPlanV1,
38    pub fallback: ExecutionPlanV1,
39    pub decision_ref: String,
40    pub rationale_code: String,
41    pub confidence_milli: u32,
42    pub candidates_evaluated: u32,
43    pub candidates_excluded: u32,
44}
45
46const NATIVE_MODEL: &str = "local-native";
47const FALLBACK_CAPABILITY: &str = "capability://leanctx/passthrough";
48const FALLBACK_MODEL: &str = "manual";
49const FALLBACK_PROVIDER: &str = "leanctx";
50
51/// Select only the installed native operation; a recommendation is not admission.
52/// The decision keeps the projection of the former reference scheduler for its
53/// single local candidate: it publishes no cost, quality or latency estimate,
54/// so a task that bounds any of them has no permitted candidate.
55fn native_decision(
56    task: &TaskEnvelopeV1,
57    admission: &EnginePolicyAdmissionV1,
58) -> Result<NativeDecision, String> {
59    task.validate().map_err(|error| error.to_string())?;
60    if admission.decision != EnginePolicyDecisionV1::Admitted {
61        return Err("native plan requires actual policy admission".into());
62    }
63    let manifest = manifest();
64    if !manifest.local
65        || manifest.remote
66        || task
67            .data_classification
68            .as_ref()
69            .is_some_and(|class| !manifest.supported_classifications.contains(class))
70        || task.region_policy_ref.is_some()
71        || task.model_policy_ref.is_some()
72    {
73        return Err("native plan cannot satisfy unresolved task policy".into());
74    }
75    let adapter = crate::core::ocla::registry::OclaRegistry::global()
76        .adapters
77        .lookup(CAPABILITY_ID, CAPABILITY_VERSION)
78        .ok_or("native runtime capability is not registered")?;
79    if adapter.manifest() != manifest {
80        return Err("registered native runtime contract differs from pinned manifest".into());
81    }
82    let available = adapter.health_check().map_err(|error| error.to_string())?;
83    manifest
84        .validate()
85        .map_err(|error| format!("invalid capability manifest: {error}"))?;
86    if !available
87        || task.cost_budget_micros.is_some()
88        || task.quality_requirement_milli.is_some()
89        || task.latency_budget_ms.is_some()
90    {
91        return Err("native plan has no single policy-permitted candidate".into());
92    }
93    let mut selected = reference_plan(
94        task,
95        manifest.capability_id.as_str(),
96        NATIVE_MODEL,
97        &manifest.provider,
98        None,
99    )?;
100    selected.context_budget_policy = Some(ContextBudgetPolicyV1::NoTokenLimit {});
101    selected.context_budget_tokens = 0;
102    selected.expected_cost_micros = 0;
103    selected.expected_quality_milli = 0;
104    selected.expected_latency_ms = 0;
105    selected.estimates = Some(ExecutionPlanEstimatesV1::default());
106    selected.policy_decision_ref = Some(admission.policy_ref.as_str().into());
107    selected.capability_bindings = vec![CapabilityBindingV1 {
108        capability_id: manifest.capability_id.clone(),
109        version: manifest.version.clone(),
110        manifest_digest: Some(
111            super::sha256_digest(&canonical_serialize(manifest))?
112                .as_str()
113                .into(),
114        ),
115    }];
116    // The manual fallback is a template that is never run; its bytes stay as
117    // persisted decisions carry them.
118    let mut fallback = reference_plan(
119        task,
120        FALLBACK_CAPABILITY,
121        FALLBACK_MODEL,
122        FALLBACK_PROVIDER,
123        Some("fallback"),
124    )?;
125    fallback.estimates = None;
126    let seed = format!(
127        "reference-v1|permit:{}:{NATIVE_MODEL}:{}|fallback:{FALLBACK_CAPABILITY}:{FALLBACK_MODEL}:{FALLBACK_PROVIDER}",
128        manifest.capability_id.as_str(),
129        manifest.provider,
130    );
131    Ok(NativeDecision {
132        selected,
133        fallback,
134        decision_ref: format!(
135            "decision:reference-v1:{}",
136            blake3::hash(seed.as_bytes()).to_hex()
137        ),
138        rationale_code: "reference_score_cost_latency_quality".into(),
139        confidence_milli: 1000,
140        candidates_evaluated: 1,
141        candidates_excluded: 0,
142    })
143}
144
145/// Plan template with the identity scheme of persisted native decisions.
146fn reference_plan(
147    task: &TaskEnvelopeV1,
148    capability_id: &str,
149    model: &str,
150    provider: &str,
151    fallback_marker: Option<&str>,
152) -> Result<ExecutionPlanV1, String> {
153    let seed = format!(
154        "reference-v1:{}:{capability_id}:{model}:{provider}",
155        task.task_id.as_str()
156    );
157    let plan = ExecutionPlanV1 {
158        schema_version: 1,
159        plan_id: PlanId::try_from(format!("plan:{}", blake3::hash(seed.as_bytes()).to_hex()))
160            .map_err(|error| format!("invalid deterministic plan id: {error}"))?,
161        task_id: task.task_id.clone(),
162        context_budget_tokens: 0,
163        context_budget_policy: None,
164        context_strategy: lean_ctx_protocol::ContextStrategy::Balanced,
165        knowledge_refs: Vec::new(),
166        capability_ids: vec![
167            lean_ctx_protocol::CapabilityId::try_from(capability_id.to_owned())
168                .map_err(|error| format!("invalid capability: {error}"))?,
169        ],
170        model: model.to_owned(),
171        provider: provider.to_owned(),
172        reasoning_allocation_milli: 0,
173        max_retries: 0,
174        fallback_refs: fallback_marker.map(str::to_owned).into_iter().collect(),
175        stop_condition: lean_ctx_protocol::StopCondition::OnCompletion,
176        expected_cost_micros: 0,
177        estimates: Some(ExecutionPlanEstimatesV1 {
178            cost_micros: fallback_marker.map(|_| 0_u64),
179            quality_milli: None,
180            latency_ms: None,
181        }),
182        expected_quality_milli: 0,
183        expected_latency_ms: 0,
184        policy_decision_ref: None,
185        scheduler_decision_ref: Some("scheduler:reference-v1".to_owned()),
186        executor_agent_id: Some(task.agent_id.clone()),
187        context_plan_id: None,
188        capability_bindings: Vec::new(),
189        extensions: Default::default(),
190    };
191    plan.validate()
192        .map_err(|error| format!("invalid deterministic execution plan: {error}"))?;
193    Ok(plan)
194}
195
196// Noncyclic projection: scheduler recommendation -> evidence link -> final plan ID.
197// The persisted decision intentionally cannot contain its own content hash.
198fn finalize_native_plan(
199    task: &TaskEnvelopeV1,
200    decision: &NativeDecision,
201    reference: &str,
202    context: Option<&context_binding::NativeContextBinding>,
203) -> Result<ExecutionPlanV1, String> {
204    let mut plan = decision.selected.clone();
205    plan.scheduler_decision_ref = Some(reference.into());
206    if let Some(context) = context {
207        context.validate_task(task)?;
208        context.apply(&mut plan)?;
209    }
210    let identity = super::sha256_digest(&canonical_serialize(&("native-plan-v1", task, &plan)))?;
211    plan.plan_id =
212        PlanId::new(format!("plan:{}", identity.hex())).map_err(|error| error.to_string())?;
213    plan.validate().map_err(|error| error.to_string())?;
214    Ok(plan)
215}
216
217pub fn validate_native_plan(
218    task: &TaskEnvelopeV1,
219    plan: &ExecutionPlanV1,
220    admission: &EnginePolicyAdmissionV1,
221) -> Result<(), String> {
222    task.validate().map_err(|error| error.to_string())?;
223    plan.validate().map_err(|error| error.to_string())?;
224    if task.task_id != plan.task_id
225        || plan
226            .executor_agent_id
227            .as_ref()
228            .is_some_and(|agent| agent != &task.agent_id)
229        || plan.capability_ids.len() != 1
230        || plan.capability_ids[0].as_str() != CAPABILITY_ID
231        || plan.model != "local-native"
232        || ![manifest().provider.as_str(), "local-native"].contains(&plan.provider.as_str())
233        || plan.capability_bindings.len() != 1
234        || plan
235            .capability_bindings
236            .iter()
237            .any(|binding| binding.version != CAPABILITY_VERSION)
238        || plan.policy_decision_ref.as_deref() != Some(admission.policy_ref.as_str())
239        || admission.decision != EnginePolicyDecisionV1::Admitted
240        || plan
241            .context_token_limit()
242            .is_some_and(|limit| limit == 0 || limit > MAX_TRANSPORT_BUDGET_TOKENS)
243    {
244        return Err("native execution plan does not bind admitted task and operation".into());
245    }
246    if plan.provider == manifest().provider {
247        let reference = plan
248            .scheduler_decision_ref
249            .as_deref()
250            .ok_or("native scheduler evidence is required")?;
251        let digest = reference
252            .strip_prefix("artifact://execution/evidence/")
253            .ok_or("native scheduler evidence reference is invalid")?;
254        let bytes = super::artifact_store::read_content("execution/evidence", digest, "json")?;
255        let decision: NativeDecision =
256            serde_json::from_slice(&bytes).map_err(|_| "native scheduler evidence is invalid")?;
257        let context = context_binding::NativeContextBinding::from_plan(plan)?;
258        if decision != native_decision(task, admission)?
259            || &finalize_native_plan(task, &decision, reference, context.as_ref())? != plan
260        {
261            return Err("native plan differs from its verified scheduler recommendation".into());
262        }
263    } else {
264        // Explicit caller plans are declarations, not native scheduler evidence.
265        // Context-plan references are checked by the owning lifecycle; the limited
266        // standalone host rejects them before this shared execution boundary.
267        if plan.scheduler_decision_ref.is_some()
268            || !plan.knowledge_refs.is_empty()
269            || plan.capability_bindings[0]
270                .manifest_digest
271                .as_deref()
272                .is_some_and(|digest| {
273                    !super::sha256_digest(&canonical_serialize(manifest()))
274                        .is_ok_and(|actual| actual.as_str() == digest)
275                })
276        {
277            return Err("host plan carries unsupported native planning evidence".into());
278        }
279    }
280    Ok(())
281}
282
283pub(crate) fn binding_refs(
284    task: &TaskEnvelopeV1,
285    plan: &ExecutionPlanV1,
286) -> Result<[ProtocolReference; 2], String> {
287    let task_ref = super::sha256_digest(&canonical_serialize(task))?;
288    let plan_ref = super::sha256_digest(&canonical_serialize(plan))?;
289    Ok([
290        ProtocolReference::new(format!("task:{}", task_ref.as_str()))
291            .map_err(|error| error.to_string())?,
292        ProtocolReference::new(format!("plan:{}", plan_ref.as_str()))
293            .map_err(|error| error.to_string())?,
294    ])
295}
296
297pub(crate) fn persist_evidence<T: serde::Serialize>(
298    value: &T,
299) -> Result<lean_ctx_protocol::Sha256Digest, String> {
300    let bytes = canonical_serialize(value);
301    let digest = super::sha256_digest(&bytes)?;
302    drop(super::persist_engine_artifact_content(
303        "execution/evidence",
304        digest.hex(),
305        "json",
306        &bytes,
307    )?);
308    Ok(digest)
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::core::engine_interface::{NativeContextEngine, verified_output_view};
315
316    fn task() -> TaskEnvelopeV1 {
317        serde_json::from_value(serde_json::json!({
318            "schema_version":1, "task_id":"native-plan-task", "trace_id":"native-plan-trace",
319            "project_id":"native-plan-project", "session_id":"native-plan-session",
320            "agent_id":"native-plan-agent", "complexity":"medium",
321            "created_at":"2026-08-23T11:59:00Z"
322        }))
323        .unwrap()
324    }
325
326    fn admission() -> EnginePolicyAdmissionV1 {
327        EnginePolicyAdmissionV1 {
328            policy_ref: ProtocolReference::new("policy:native-plan-test").unwrap(),
329            decision: EnginePolicyDecisionV1::Admitted,
330        }
331    }
332
333    #[test]
334    fn native_plan_rejects_missing_descriptor_only_and_unhealthy_runtime_bindings() {
335        use crate::core::ocla::{
336            adapters::{AdapterRegistry, NativeContextAdapter},
337            registry::{OclaRegistry, with_test_registry},
338        };
339        let missing_root = tempfile::tempdir().unwrap();
340        for mode in 0..3 {
341            let mut registry = OclaRegistry::with_builtins();
342            registry.adapters = AdapterRegistry::new();
343            match mode {
344                0 => {}
345                1 => registry
346                    .adapters
347                    .register_descriptor(manifest().clone())
348                    .unwrap(),
349                _ => registry
350                    .adapters
351                    .register(NativeContextAdapter::with_root(
352                        missing_root.path().join("not-created"),
353                    ))
354                    .unwrap(),
355            }
356            let _registry = with_test_registry(registry);
357            assert!(
358                native_decision(&task(), &admission()).is_err(),
359                "mode={mode}"
360            );
361        }
362    }
363
364    /// Stored native plans are revalidated by recomputing this decision: its
365    /// bytes must never change (they were proven equal to the former
366    /// reference scheduler's before it was removed, 2026-10-16).
367    #[test]
368    fn native_decision_bytes_are_frozen() {
369        let decision = native_decision(&task(), &admission()).unwrap();
370        assert_eq!(
371            super::super::sha256_digest(&canonical_serialize(&decision))
372                .unwrap()
373                .as_str(),
374            "sha256:b320c7a5da703c1bd531058499c1816a63397c93ec6758c77edd6c55aac55847"
375        );
376    }
377
378    #[test]
379    fn native_plan_is_deterministic_manifest_bound_and_honest_about_unknowns() {
380        let _data = crate::core::data_dir::isolated_data_dir();
381        let task = task();
382        let plan = native_plan(&task, &admission()).unwrap();
383        assert_eq!(plan, native_plan(&task, &admission()).unwrap());
384        assert_eq!(plan.context_token_limit(), None);
385        assert_eq!(plan.estimates, Some(ExecutionPlanEstimatesV1::default()));
386        assert_eq!(plan.provider, manifest().provider);
387        assert_eq!(plan.capability_bindings[0].version, CAPABILITY_VERSION);
388        assert!(plan.capability_bindings[0].manifest_digest.is_some());
389        assert_eq!(
390            plan.policy_decision_ref.as_deref(),
391            Some(admission().policy_ref.as_str())
392        );
393        let decision_ref = plan.scheduler_decision_ref.as_ref().unwrap();
394        let digest = decision_ref
395            .strip_prefix("artifact://execution/evidence/")
396            .unwrap();
397        let bytes = crate::core::engine_interface::artifact_store::read_content(
398            "execution/evidence",
399            digest,
400            "json",
401        )
402        .unwrap();
403        let decision: NativeDecision = serde_json::from_slice(&bytes).unwrap();
404        assert_eq!(
405            decision.selected.capability_bindings,
406            plan.capability_bindings
407        );
408        assert_eq!(decision.selected.estimates, plan.estimates);
409        assert_eq!(
410            decision.selected.policy_decision_ref,
411            plan.policy_decision_ref
412        );
413    }
414
415    #[test]
416    fn native_plan_rejects_unproved_constraints_and_rejected_admission() {
417        let _data = crate::core::data_dir::isolated_data_dir();
418        for kind in 0..5 {
419            let mut task = task();
420            match kind {
421                0 => task.quality_requirement_milli = Some(500),
422                1 => task.cost_budget_micros = Some(100),
423                2 => task.latency_budget_ms = Some(100),
424                3 => task.region_policy_ref = Some("policy:region".into()),
425                _ => task.model_policy_ref = Some("policy:model".into()),
426            }
427            assert!(native_plan(&task, &admission()).is_err());
428        }
429        let mut rejected = admission();
430        rejected.decision = EnginePolicyDecisionV1::Rejected;
431        assert!(native_plan(&task(), &rejected).is_err());
432        assert!(
433            !crate::core::data_dir::lean_ctx_data_dir()
434                .unwrap()
435                .join("engine-interface/v1/outputs")
436                .exists()
437        );
438    }
439
440    #[test]
441    fn historical_native_decision_with_legacy_fallback_still_revalidates() {
442        let _data = crate::core::data_dir::isolated_data_dir();
443        let task = task();
444        let admission = admission();
445        let mut historical = native_decision(&task, &admission).unwrap();
446        // The historical fallback predates explicit nullable estimate projections.
447        historical.fallback.estimates = None;
448        let digest = persist_evidence(&historical).unwrap();
449        let reference = format!("artifact://execution/evidence/{}", digest.hex());
450        let plan = finalize_native_plan(&task, &historical, &reference, None).unwrap();
451        assert_eq!(plan.estimates, Some(ExecutionPlanEstimatesV1::default()));
452        validate_native_plan(&task, &plan, &admission).unwrap();
453    }
454
455    #[test]
456    fn actual_invocation_binds_exact_documents_and_preserves_native_unlimited_output() {
457        let _data = crate::core::data_dir::isolated_data_dir();
458        let root = tempfile::tempdir().unwrap();
459        let root = std::fs::canonicalize(root.path()).unwrap();
460        let path = root.join("snapshot.rs");
461        let source = "fn bound_snapshot() {}\n".repeat(40);
462        let engine = NativeContextEngine::with_root(&root).unwrap();
463        let task = task();
464        let plan = native_plan(&task, &admission()).unwrap();
465        let (invocation, observation) = engine
466            .execute_ctx_read_rooted_snapshot_with_plan(
467                path.to_str().unwrap(),
468                &source,
469                admission(),
470                &task,
471                &plan,
472            )
473            .unwrap();
474        assert!(
475            binding_refs(&task, &plan)
476                .unwrap()
477                .iter()
478                .all(|r| invocation.source_refs.contains(r))
479        );
480        assert_eq!(invocation.source_refs, observation.source_lineage);
481        let view = verified_output_view(&invocation, &observation).unwrap();
482        let (legacy_invocation, legacy_observation) = engine
483            .execute_ctx_read_rooted_snapshot(path.to_str().unwrap(), &source, admission())
484            .unwrap();
485        assert_ne!(invocation.invocation_id, legacy_invocation.invocation_id);
486        assert_eq!(
487            view.text,
488            verified_output_view(&legacy_invocation, &legacy_observation)
489                .unwrap()
490                .text
491        );
492        let mut changed = task.clone();
493        changed.intent = Some("same task id, different intent".into());
494        assert!(validate_native_plan(&changed, &plan, &admission()).is_err());
495        let changed_plan = native_plan(&changed, &admission()).unwrap();
496        let (changed_invocation, _) = engine
497            .execute_ctx_read_rooted_snapshot_with_plan(
498                path.to_str().unwrap(),
499                &source,
500                admission(),
501                &changed,
502                &changed_plan,
503            )
504            .unwrap();
505        assert_ne!(changed_invocation.invocation_id, invocation.invocation_id);
506        let mut bad_plan = plan.clone();
507        bad_plan.task_id = lean_ctx_protocol::TaskId::new("wrong-task").unwrap();
508        assert!(
509            engine
510                .execute_ctx_read_rooted_snapshot_with_plan(
511                    path.to_str().unwrap(),
512                    &source,
513                    admission(),
514                    &task,
515                    &bad_plan,
516                )
517                .is_err()
518        );
519        bad_plan = plan;
520        bad_plan.context_budget_policy = None;
521        assert!(validate_native_plan(&task, &bad_plan, &admission()).is_err());
522    }
523
524    #[test]
525    fn generated_plan_rejects_mutation_missing_proof_and_corrupted_scheduler_artifact() {
526        let _data = crate::core::data_dir::isolated_data_dir();
527        let task = task();
528        let plan = native_plan(&task, &admission()).unwrap();
529        for kind in 0..7 {
530            let mut changed = plan.clone();
531            match kind {
532                0 => changed.scheduler_decision_ref = None,
533                1 => changed.capability_bindings.clear(),
534                2 => changed.capability_bindings[0].manifest_digest = None,
535                3 => changed.knowledge_refs.push("knowledge:foreign".into()),
536                4 => changed.max_retries = 1,
537                5 => changed.stop_condition = lean_ctx_protocol::StopCondition::OnAcceptance,
538                _ => {
539                    changed.context_budget_tokens = 50;
540                    changed.context_budget_policy = None;
541                }
542            }
543            assert!(validate_native_plan(&task, &changed, &admission()).is_err());
544        }
545        let digest = plan
546            .scheduler_decision_ref
547            .as_ref()
548            .unwrap()
549            .strip_prefix("artifact://execution/evidence/")
550            .unwrap();
551        let path = crate::core::data_dir::lean_ctx_data_dir()
552            .unwrap()
553            .join("execution/evidence")
554            .join(format!("{digest}.json"));
555        std::fs::write(path, b"{}").unwrap();
556        assert!(validate_native_plan(&task, &plan, &admission()).is_err());
557    }
558}