1pub(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
15pub 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#[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
51fn 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 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
145fn 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
196fn 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 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 #[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 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}