1mod decision_authentication;
6
7use std::fmt::Write as _;
8
9use ed25519_dalek::VerifyingKey;
10use lean_ctx_protocol::{
11 AcceptedOutcomeV1, ContextPlanProjectionV1, DecisionRecordV1, DecisionStageV1,
12 EngineInvocationV1, EngineObservationV1, ExecutionPlanV1, ReceiptDocumentV1, TaskEnvelopeV1,
13 UtcTimestamp,
14};
15use sha2::{Digest, Sha256};
16
17use crate::core::{
18 engine_interface::read_verified_engine_receipt, execution_ledger::PublishedCanonicalReceipt,
19 execution_ledger::ReceiptSignerAdmissionV1,
20 execution_ledger::receipt_inputs::join_receipt_document_inputs,
21};
22
23#[derive(Clone, Debug)]
28pub struct RecordedExecutionProtocolV1 {
29 pub context_plan: ContextPlanProjectionV1,
30 pub execution_plan: ExecutionPlanV1,
31 pub invocation: EngineInvocationV1,
32 pub observation: EngineObservationV1,
33 pub receipt: ReceiptDocumentV1,
34 pub published_receipt: PublishedCanonicalReceipt,
35 pub accepted_outcome: AcceptedOutcomeV1,
36 pub decision: DecisionRecordV1,
37}
38
39pub struct ValidatedExecutionProtocolV1<'a> {
46 protocol: &'a RecordedExecutionProtocolV1,
47 project_id: lean_ctx_protocol::ProjectId,
48 tenant_id: Option<lean_ctx_protocol::TenantId>,
49}
50
51impl ValidatedExecutionProtocolV1<'_> {
52 pub fn project_id(&self) -> &lean_ctx_protocol::ProjectId {
54 &self.project_id
55 }
56
57 pub fn tenant_id(&self) -> Option<&lean_ctx_protocol::TenantId> {
58 self.tenant_id.as_ref()
59 }
60
61 pub fn context_plan(&self) -> &ContextPlanProjectionV1 {
62 &self.protocol.context_plan
63 }
64
65 pub fn execution_plan(&self) -> &ExecutionPlanV1 {
66 &self.protocol.execution_plan
67 }
68
69 pub fn outcome(&self) -> &AcceptedOutcomeV1 {
70 &self.protocol.accepted_outcome
71 }
72
73 pub fn receipt_id(&self) -> &str {
74 self.protocol.receipt.receipt_id.as_str()
75 }
76
77 pub(crate) fn validate_learning_handoff(
80 &self,
81 context: &crate::core::context_kernel::autopilot::TaskAutopilotDecision,
82 ) -> Result<(), ExecutionProtocolError> {
83 if self.protocol.decision.decision_stage == DecisionStageV1::Planning {
84 let bytes = context.canonical_bytes().map_err(protocol_error)?;
85 let selected: serde_json::Value =
86 serde_json::from_slice(&bytes).map_err(protocol_error)?;
87 if selected != self.protocol.decision.selected_result {
88 return Err(fail(
89 "learning handoff differs from authenticated planning payload",
90 ));
91 }
92 }
93 Ok(())
94 }
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct ExecutionProtocolError(String);
99
100impl std::fmt::Display for ExecutionProtocolError {
101 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 formatter.write_str(&self.0)
103 }
104}
105
106impl std::error::Error for ExecutionProtocolError {}
107
108impl RecordedExecutionProtocolV1 {
109 pub fn validated_for(
116 &self,
117 task: &TaskEnvelopeV1,
118 receipt_authority: (&ReceiptSignerAdmissionV1, &VerifyingKey),
119 decision_authority: (&lean_ctx_ocla::DecisionSignerAdmissionV1, &VerifyingKey),
120 actual_context: Option<&crate::core::context_kernel::autopilot::TaskAutopilotDecision>,
121 verified_at: &UtcTimestamp,
122 ) -> Result<ValidatedExecutionProtocolV1<'_>, ExecutionProtocolError> {
123 let (trusted_signer, verifying_key) = receipt_authority;
124 lean_ctx_ocla::verify_receipt_signature(
125 &self.receipt,
126 trusted_signer,
127 verifying_key,
128 verified_at,
129 )
130 .map_err(|error| match error {
131 error @ lean_ctx_ocla::ReceiptVerificationError::SignerNotAdmitted => protocol_error(
132 crate::core::execution_ledger::ExecutionLedgerError::InvalidRecord(
133 error.to_string(),
134 ),
135 ),
136 error => protocol_error(error),
137 })?;
138 lean_ctx_ocla::verify_decision_signature(
139 &self.decision,
140 task,
141 decision_authority.0,
142 decision_authority.1,
143 verified_at,
144 )
145 .map_err(protocol_error)?;
146 self.validate_for(task)?;
147 decision_authentication::validate(self, task, actual_context)?;
148 Ok(ValidatedExecutionProtocolV1 {
149 protocol: self,
150 project_id: task.project_id.clone(),
151 tenant_id: task.tenant_id.clone(),
152 })
153 }
154
155 pub fn validate_for(&self, task: &TaskEnvelopeV1) -> Result<(), ExecutionProtocolError> {
160 task.validate().map_err(protocol_error)?;
161 self.context_plan.validate().map_err(protocol_error)?;
162 self.execution_plan.validate().map_err(protocol_error)?;
163 self.invocation.validate().map_err(protocol_error)?;
164 self.observation
165 .validate_for(&self.invocation)
166 .map_err(protocol_error)?;
167 self.receipt.validate().map_err(protocol_error)?;
168 self.accepted_outcome.validate().map_err(protocol_error)?;
169 self.decision.validate().map_err(protocol_error)?;
170
171 let projection_digest = self
172 .context_plan
173 .projection_digest
174 .as_ref()
175 .ok_or_else(|| fail("context projection requires its canonical digest"))?;
176 if self.context_plan.task_id != task.task_id
177 || self.execution_plan.task_id != task.task_id
178 || self.accepted_outcome.task_id != task.task_id
179 || self.decision.task_id != task.task_id
180 {
181 return Err(fail("protocol task_id lineage mismatch"));
182 }
183 crate::core::engine_interface::planning::context_binding::validate_projection(
184 &self.execution_plan,
185 &self.context_plan,
186 )
187 .map_err(protocol_error)?;
188 let capability = &self.invocation.operation;
189 if !self
190 .execution_plan
191 .capability_ids
192 .contains(&capability.capability_id)
193 || !self
194 .execution_plan
195 .capability_bindings
196 .iter()
197 .any(|binding| {
198 binding.capability_id == capability.capability_id
199 && binding.version == capability.capability_version.as_str()
200 })
201 {
202 return Err(fail(
203 "execution plan does not bind the invoked capability version",
204 ));
205 }
206 if self.execution_plan.policy_decision_ref.as_deref()
207 != Some(self.invocation.policy_admission.policy_ref.as_str())
208 {
209 return Err(fail("execution plan policy does not bind Engine admission"));
210 }
211
212 let receipt_link = self
213 .observation
214 .receipt_link
215 .as_ref()
216 .ok_or_else(|| fail("Engine observation requires a verified receipt link"))?;
217 let mut unsigned_observation = self.observation.clone();
218 unsigned_observation.receipt_link = None;
219 let verified = read_verified_engine_receipt(
220 &receipt_link.receipt_digest,
221 &self.invocation,
222 &unsigned_observation,
223 )
224 .map_err(protocol_error)?;
225 let expected =
226 join_receipt_document_inputs(task, &self.execution_plan, &verified, receipt_link)
227 .map_err(protocol_error)?;
228 if self.receipt.lineage != expected.lineage {
229 return Err(fail(
230 "canonical receipt lineage disagrees with verified Engine inputs",
231 ));
232 }
233
234 let (persisted, receipt_bytes) = self
235 .published_receipt
236 .read_canonical()
237 .map_err(protocol_error)?;
238 if persisted != self.receipt
239 || self.published_receipt.receipt_id != self.receipt.receipt_id.as_str()
240 {
241 return Err(fail(
242 "published canonical receipt disagrees with lifecycle receipt",
243 ));
244 }
245 let persisted_digest = sha256_digest(&receipt_bytes);
246 if self.published_receipt.receipt_digest != persisted_digest
247 || self.published_receipt.receipt_ref != format!("id:{persisted_digest}")
248 {
249 return Err(fail(
250 "published receipt reference does not bind canonical bytes",
251 ));
252 }
253
254 let has_outcome_observation =
257 self.accepted_outcome.accepted != lean_ctx_protocol::AcceptanceState::Unknown;
258 if self.accepted_outcome.plan_id.as_ref() != Some(&self.execution_plan.plan_id)
259 || self
260 .accepted_outcome
261 .receipt_id
262 .as_ref()
263 .map(lean_ctx_protocol::ReceiptId::as_str)
264 != Some(self.receipt.receipt_id.as_str())
265 || self.receipt.outcome.state != self.accepted_outcome.accepted
266 || (has_outcome_observation
267 && self.receipt.outcome.outcome_id.as_ref()
268 != Some(&self.accepted_outcome.outcome_id))
269 {
270 return Err(fail(
271 "accepted outcome does not bind plan and canonical receipt",
272 ));
273 }
274 if has_outcome_observation {
275 let outcome_digest = self
276 .receipt
277 .outcome
278 .outcome_ref
279 .as_ref()
280 .ok_or_else(|| fail("terminal outcome requires a receipt outcome reference"))?;
281 if !self
282 .accepted_outcome
283 .evidence_refs
284 .iter()
285 .any(|evidence| evidence.digest == outcome_digest.as_str())
286 {
287 return Err(fail(
288 "accepted outcome evidence does not bind receipt outcome",
289 ));
290 }
291 }
292
293 if self.decision.decision_stage == DecisionStageV1::Admission
294 || self.decision.plan_id.as_ref() != Some(&self.execution_plan.plan_id)
295 || !self
296 .accepted_outcome
297 .decision_refs
298 .iter()
299 .any(|reference| reference == self.decision.decision_id.as_str())
300 {
301 return Err(fail("post-admission decision does not bind task outcome"));
302 }
303 let planning = self.decision.decision_stage == DecisionStageV1::Planning;
304 for required in [
305 projection_digest.as_str(),
306 self.receipt.lineage.plan_ref.as_str(),
307 ]
308 .into_iter()
309 .chain((!planning).then_some(self.receipt.lineage.invocation_ref.as_str()))
310 .chain((!planning).then_some(self.receipt.receipt_id.as_str()))
311 {
312 if !self
313 .decision
314 .input_refs
315 .iter()
316 .any(|reference| reference == required)
317 {
318 return Err(fail(
319 "decision input_refs omit authoritative protocol lineage",
320 ));
321 }
322 }
323 if has_outcome_observation
324 && !planning
325 && !self.decision.evidence_refs.iter().any(|decision_evidence| {
326 self.accepted_outcome
327 .evidence_refs
328 .iter()
329 .any(|outcome_evidence| outcome_evidence.digest == decision_evidence.digest)
330 })
331 {
332 return Err(fail(
333 "decision evidence does not bind accepted outcome evidence",
334 ));
335 }
336 Ok(())
337 }
338}
339
340fn sha256_digest(bytes: &[u8]) -> String {
341 let mut value = String::with_capacity(71);
342 value.push_str("sha256:");
343 for byte in Sha256::digest(bytes) {
344 write!(&mut value, "{byte:02x}").expect("writing to String cannot fail");
345 }
346 value
347}
348
349fn protocol_error(error: impl std::fmt::Display) -> ExecutionProtocolError {
350 fail(&error.to_string())
351}
352
353fn fail(message: &str) -> ExecutionProtocolError {
354 ExecutionProtocolError(message.to_owned())
355}
356
357#[cfg(test)]
358pub(crate) mod test_support {
359 use std::collections::BTreeMap;
360
361 use ed25519_dalek::{SigningKey, VerifyingKey};
362 use lean_ctx_protocol::{
363 AcceptanceState, CapabilityBindingV1, ContextBalanceV1, ContextPlanId,
364 ContextPlanProjectionV1, ContextStrategy, DecisionId, DecisionKind, DecisionRecordV1,
365 DecisionStageV1, EvidenceKind, EvidenceRefV1, ExecutionPlanV1, OutcomeId, OutcomeSignalsV1,
366 PlanId, ReceiptChainLinkV1, ReceiptDocumentV1, ReceiptEvidenceKindV1, ReceiptEvidenceRefV1,
367 ReceiptOutcomeLinkV1, ReceiptTerminalStatusV1, Sha256Digest, SignalState, SignatureStatus,
368 StopCondition, TaskEnvelopeV1, UtcTimestamp,
369 };
370
371 use super::{RecordedExecutionProtocolV1, sha256_digest};
372 use crate::core::{
373 data_dir::{IsolatedDataDir, isolated_data_dir},
374 engine_interface::NativeContextEngine,
375 execution_ledger::{
376 CanonicalReceiptRecordV1, ExecutionLedgerStore, ReceiptSignerAdmissionV1,
377 record_canonical_engine_receipt,
378 },
379 };
380
381 pub(crate) struct ProtocolFixture {
382 pub protocol: RecordedExecutionProtocolV1,
383 pub signer_admission: ReceiptSignerAdmissionV1,
384 pub verifying_key: VerifyingKey,
385 pub decision_admission: lean_ctx_ocla::DecisionSignerAdmissionV1,
386 pub verified_at: UtcTimestamp,
387 _data_dir: IsolatedDataDir,
388 _engine_root: tempfile::TempDir,
389 }
390
391 impl ProtocolFixture {
392 pub(crate) fn validated_for(
393 &self,
394 task: &TaskEnvelopeV1,
395 ) -> Result<super::ValidatedExecutionProtocolV1<'_>, super::ExecutionProtocolError>
396 {
397 self.protocol.validated_for(
398 task,
399 (&self.signer_admission, &self.verifying_key),
400 (&self.decision_admission, &self.verifying_key),
401 None,
402 &self.verified_at,
403 )
404 }
405 }
406
407 pub(crate) fn build(task: &TaskEnvelopeV1) -> ProtocolFixture {
408 build_for_context(task, None, None, AcceptanceState::Accepted)
409 }
410
411 pub(crate) fn build_for_context(
412 task: &TaskEnvelopeV1,
413 projection: Option<&ContextPlanProjectionV1>,
414 autopilot: Option<&crate::core::context_kernel::autopilot::TaskAutopilotDecision>,
415 acceptance: AcceptanceState,
416 ) -> ProtocolFixture {
417 let data_dir = isolated_data_dir();
418 let engine_root = tempfile::tempdir().expect("engine root");
419 let source = engine_root.path().join("fixture.md");
420 std::fs::write(&source, "stable native context").expect("source fixture");
421 let policy = lean_ctx_protocol::EnginePolicyAdmissionV1 {
422 policy_ref: lean_ctx_protocol::ProtocolReference::new("policy:fixture")
423 .expect("policy ref"),
424 decision: lean_ctx_protocol::EnginePolicyDecisionV1::Admitted,
425 };
426 let engine = NativeContextEngine::with_root(engine_root.path()).expect("engine root");
427 let mut context_plan = ContextPlanProjectionV1 {
428 schema_version: 1,
429 context_plan_id: ContextPlanId::try_from("context-plan-1".to_owned()).unwrap(),
430 task_id: task.task_id.clone(),
431 projection_digest: None,
432 budget_tokens: 1_000,
433 selections: Vec::new(),
434 provider_stats: BTreeMap::new(),
435 policy_decision_refs: vec!["policy:fixture".to_owned()],
436 evidence: Vec::new(),
437 extensions: Default::default(),
438 };
439 context_plan.projection_digest = Some(
440 context_plan
441 .compute_projection_digest()
442 .expect("context projection digest"),
443 );
444 if let Some(projection) = projection {
445 context_plan = projection.clone();
446 }
447 let plan = ExecutionPlanV1 {
448 schema_version: 1,
449 plan_id: PlanId::try_from("plan-1".to_owned()).unwrap(),
450 task_id: task.task_id.clone(),
451 context_budget_tokens: context_plan.budget_tokens,
452 context_budget_policy: None,
453 context_strategy: ContextStrategy::Balanced,
454 knowledge_refs: Vec::new(),
455 capability_ids: vec![
456 lean_ctx_protocol::CapabilityId::new(crate::core::engine_interface::CAPABILITY_ID)
457 .unwrap(),
458 ],
459 model: "local-native".to_owned(),
460 provider: "local-native".to_owned(),
461 reasoning_allocation_milli: 0,
462 max_retries: 0,
463 fallback_refs: Vec::new(),
464 stop_condition: StopCondition::OnCompletion,
465 expected_cost_micros: 0,
466 estimates: None,
467 expected_quality_milli: 900,
468 expected_latency_ms: 100,
469 policy_decision_ref: Some("policy:fixture".to_owned()),
470 scheduler_decision_ref: None,
471 executor_agent_id: Some(task.agent_id.clone()),
472 context_plan_id: Some(context_plan.context_plan_id.clone()),
473 capability_bindings: vec![CapabilityBindingV1 {
474 capability_id: lean_ctx_protocol::CapabilityId::new(
475 crate::core::engine_interface::CAPABILITY_ID,
476 )
477 .unwrap(),
478 version: crate::core::engine_interface::CAPABILITY_VERSION.to_owned(),
479 manifest_digest: None,
480 }],
481 extensions: Default::default(),
482 };
483
484 let plan = autopilot.map_or_else(
485 || plan.clone(),
486 |handoff| {
487 handoff
488 .bind_execution_plan(plan.clone())
489 .expect("bind autopilot plan")
490 },
491 );
492
493 let canonical_source = std::fs::canonicalize(&source).unwrap();
494 let (invocation, observation) = engine
495 .execute_ctx_read_rooted_snapshot_with_plan(
496 canonical_source.to_str().unwrap(),
497 "stable native context",
498 policy,
499 task,
500 &plan,
501 )
502 .expect("native Engine invocation binds pre-execution task and plan");
503
504 let engine_receipt_digest = observation
505 .receipt_link
506 .as_ref()
507 .expect("Engine receipt link")
508 .receipt_digest
509 .clone();
510 let outcome_digest =
511 Sha256Digest::new(sha256_digest(format!("{acceptance:?} outcome").as_bytes())).unwrap();
512 let acceptance_digest =
513 Sha256Digest::new(sha256_digest(b"acceptance measurement")).unwrap();
514 let outcome_id = OutcomeId::try_from("outcome-1".to_owned()).unwrap();
515 let receipt_evidence = vec![
516 receipt_evidence(
517 ReceiptEvidenceKindV1::Measurement,
518 "artifact://engine/receipt",
519 engine_receipt_digest,
520 ),
521 receipt_evidence(
522 ReceiptEvidenceKindV1::Outcome,
523 "artifact://outcome/observation",
524 outcome_digest.clone(),
525 ),
526 receipt_evidence(
527 ReceiptEvidenceKindV1::Measurement,
528 "artifact://outcome/acceptance",
529 acceptance_digest.clone(),
530 ),
531 ];
532 let signing_key = SigningKey::from_bytes(&[17; 32]);
533 let issued_at = UtcTimestamp::new("2026-08-23T12:00:00Z").unwrap();
534 let signer_admission = ReceiptSignerAdmissionV1 {
535 key_id: "test-key".to_owned(),
536 public_key_digest: Sha256Digest::new(sha256_digest(
537 signing_key.verifying_key().as_bytes(),
538 ))
539 .expect("public key digest"),
540 admitted_at: UtcTimestamp::new("2026-01-01T00:00:00Z").expect("admission time"),
541 expires_at: UtcTimestamp::new(if acceptance == AcceptanceState::Unknown {
542 "9999-01-01T00:00:00Z"
543 } else {
544 "2027-01-01T00:00:00Z"
545 })
546 .expect("expiry time"),
547 revoked_at: None,
548 };
549 let ledger = ExecutionLedgerStore::new(engine_root.path().join("ledger.jsonl"));
550 let published = if acceptance == AcceptanceState::Unknown {
551 let settings = serde_json::to_vec(&serde_json::json!({
554 "schema_version": 1,
555 "signing_key_hex": "11".repeat(32),
556 "signer": {
557 "key_id": signer_admission.key_id,
558 "public_key_digest": signer_admission.public_key_digest,
559 "admitted_at": signer_admission.admitted_at,
560 "expires_at": signer_admission.expires_at,
561 "revoked_at": signer_admission.revoked_at,
562 },
563 "ledger_path": engine_root.path().join("ledger.jsonl"),
564 }))
565 .expect("host settings");
566 let authority = crate::core::execution_ledger::host::HostReceiptAuthority::from_reader(
567 &mut settings.as_slice(),
568 )
569 .expect("trusted host authority");
570 let attempt = authority.begin(task, &plan).expect("host task admission");
571 authority
572 .publish(&attempt, &invocation, &observation, "stable native context")
573 .expect("publish actual Unknown receipt")
574 } else {
575 record_canonical_engine_receipt(
576 task,
577 &plan,
578 &invocation,
579 &observation,
580 CanonicalReceiptRecordV1 {
581 context_balance: ContextBalanceV1 {
582 original_tokens: 100,
583 materialized_tokens: 80,
584 delivered_tokens: 60,
585 provider_billed_tokens: 60,
586 },
587 status: if acceptance == AcceptanceState::Rejected {
588 ReceiptTerminalStatusV1::Rejected
589 } else {
590 ReceiptTerminalStatusV1::Succeeded
591 },
592 values: Vec::new(),
593 outcome: ReceiptOutcomeLinkV1 {
594 state: acceptance,
595 outcome_id: (acceptance != AcceptanceState::Unknown)
596 .then(|| outcome_id.clone()),
597 outcome_ref: (acceptance != AcceptanceState::Unknown)
598 .then(|| outcome_digest.clone()),
599 acceptance_evidence_digest: (acceptance == AcceptanceState::Accepted)
600 .then(|| acceptance_digest.clone()),
601 },
602 evidence_refs: receipt_evidence,
603 chain: ReceiptChainLinkV1 {
604 chain_id: "chain-1".to_owned(),
605 sequence_number: 1,
606 previous_receipt_id: None,
607 previous_signature_digest: None,
608 },
609 issued_at: issued_at.clone(),
610 signer_admission: signer_admission.clone(),
611 },
612 &signing_key,
613 &ledger,
614 )
615 .expect("record canonical receipt exactly once")
616 };
617 let receipt = ReceiptDocumentV1::from_canonical_bytes(
618 &std::fs::read(&published.path).expect("published receipt"),
619 )
620 .expect("strict receipt document");
621 let outcome_evidence = protocol_evidence(
622 EvidenceKind::QualityMeasurement,
623 "artifact://outcome/observation",
624 outcome_digest.as_str(),
625 );
626 let acceptance_evidence = protocol_evidence(
627 EvidenceKind::QualityMeasurement,
628 "artifact://outcome/acceptance",
629 acceptance_digest.as_str(),
630 );
631 let decision_id = DecisionId::try_from("decision-1".to_owned()).unwrap();
632 let accepted_outcome = lean_ctx_protocol::AcceptedOutcomeV1 {
633 schema_version: 1,
634 outcome_id,
635 task_id: task.task_id.clone(),
636 accepted: acceptance,
637 quality_score_milli: (acceptance != AcceptanceState::Unknown).then_some(
638 if acceptance == AcceptanceState::Accepted {
639 1_000
640 } else {
641 0
642 },
643 ),
644 signals: OutcomeSignalsV1 {
645 build: None,
646 tests: Some(match acceptance {
647 AcceptanceState::Accepted => SignalState::Passed,
648 AcceptanceState::Rejected => SignalState::Failed,
649 AcceptanceState::Unknown => SignalState::Unknown,
650 }),
651 lint: None,
652 typecheck: None,
653 completion: Some(SignalState::Passed),
654 pr: None,
655 correction: None,
656 rollback: None,
657 retry: None,
658 },
659 contract_ref: Some("contract:test".to_owned()),
660 evidence_refs: if acceptance == AcceptanceState::Unknown {
661 Vec::new()
662 } else {
663 vec![outcome_evidence.clone(), acceptance_evidence]
664 },
665 observed_at: receipt.issued_at.as_str().to_owned(),
666 plan_id: Some(plan.plan_id.clone()),
667 receipt_id: Some(
668 lean_ctx_protocol::ReceiptId::try_from(receipt.receipt_id.as_str().to_owned())
669 .unwrap(),
670 ),
671 decision_refs: vec![decision_id.as_str().to_owned()],
672 extensions: Default::default(),
673 };
674 let mut decision = DecisionRecordV1 {
675 schema_version: 1,
676 decision_id,
677 task_id: task.task_id.clone(),
678 plan_id: Some(plan.plan_id.clone()),
679 decision_stage: DecisionStageV1::Outcome,
680 decision_kind: DecisionKind::Stop,
681 input_refs: vec![
682 sha256_digest(&task.canonical_bytes().unwrap()),
683 context_plan
684 .projection_digest
685 .as_ref()
686 .unwrap()
687 .as_str()
688 .to_owned(),
689 receipt.lineage.plan_ref.as_str().to_owned(),
690 receipt.lineage.invocation_ref.as_str().to_owned(),
691 receipt.receipt_id.as_str().to_owned(),
692 ],
693 constraint_refs: vec!["contract:test".to_owned()],
694 selected_result: if acceptance == AcceptanceState::Unknown {
695 serde_json::json!({"acceptance": "unknown"})
696 } else {
697 serde_json::json!({"accepted": acceptance == AcceptanceState::Accepted})
698 },
699 rationale_code: if acceptance == AcceptanceState::Unknown {
700 "outcome_unobserved"
701 } else {
702 "evidence_satisfied"
703 }
704 .to_owned(),
705 rationale_ref: None,
706 policy_ref: Some("policy:fixture".to_owned()),
707 decision_system_name: "outcome-evaluator".to_owned(),
708 decision_system_version: "1.0.0".to_owned(),
709 evidence_refs: if acceptance == AcceptanceState::Unknown {
710 Vec::new()
711 } else {
712 vec![outcome_evidence]
713 },
714 observed_at: receipt.issued_at.as_str().to_owned(),
715 signature: String::new(),
716 supersedes: None,
717 extensions: Default::default(),
718 };
719
720 let decision_admission = lean_ctx_ocla::DecisionSignerAdmissionV1 {
722 key_admission: signer_admission.clone(),
723 task: task.clone(),
724 stage: DecisionStageV1::Outcome,
725 kind: DecisionKind::Stop,
726 };
727 decision.signature = lean_ctx_ocla::sign_decision_record(
728 &decision,
729 task,
730 &decision_admission,
731 &signing_key,
732 &receipt.issued_at,
733 )
734 .unwrap();
735
736 ProtocolFixture {
737 signer_admission,
738 decision_admission,
739 verifying_key: signing_key.verifying_key(),
740 verified_at: receipt.issued_at.clone(),
741 protocol: RecordedExecutionProtocolV1 {
742 context_plan,
743 execution_plan: plan,
744 invocation,
745 observation,
746 receipt,
747 published_receipt: published,
748 accepted_outcome,
749 decision,
750 },
751 _data_dir: data_dir,
752 _engine_root: engine_root,
753 }
754 }
755
756 fn receipt_evidence(
757 kind: ReceiptEvidenceKindV1,
758 uri: &str,
759 digest: Sha256Digest,
760 ) -> ReceiptEvidenceRefV1 {
761 ReceiptEvidenceRefV1 {
762 kind,
763 uri: lean_ctx_protocol::ProtocolReference::new(uri).unwrap(),
764 digest,
765 media_type: "application/json".to_owned(),
766 signature_status: SignatureStatus::NotSigned,
767 }
768 }
769
770 fn protocol_evidence(kind: EvidenceKind, uri: &str, digest: &str) -> EvidenceRefV1 {
771 EvidenceRefV1 {
772 schema_version: Some(1),
773 kind,
774 uri: uri.to_owned(),
775 digest: digest.to_owned(),
776 signature_status: SignatureStatus::NotSigned,
777 media_type: Some("application/json".to_owned()),
778 extensions: Default::default(),
779 }
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use base64::{Engine as _, engine::general_purpose::STANDARD};
786 use ed25519_dalek::SigningKey;
787 use lean_ctx_protocol::{AcceptanceState, Sha256Digest, TaskEnvelopeV1, UtcTimestamp};
788
789 use super::test_support;
790
791 fn task() -> TaskEnvelopeV1 {
792 serde_json::from_value(serde_json::json!({
793 "schema_version": 1,
794 "task_id": "task-1",
795 "trace_id": "trace-1",
796 "project_id": "project-1",
797 "session_id": "session-1",
798 "agent_id": "agent-1",
799 "complexity": "medium",
800 "created_at": "2026-08-23T11:59:00Z"
801 }))
802 .unwrap()
803 }
804
805 #[test]
806 fn producer_recorded_chain_validates_end_to_end() {
807 let task = task();
808 let fixture = test_support::build(&task);
809 fixture.protocol.validate_for(&task).unwrap();
810 fixture.validated_for(&task).expect("trusted signed chain");
811 }
812
813 #[test]
814 fn host_unknown_receipt_validates_without_quality_or_outcome_claims() {
815 let task = task();
816 let fixture = test_support::build_for_context(&task, None, None, AcceptanceState::Unknown);
817 let receipt_outcome = &fixture.protocol.receipt.outcome;
818 assert_eq!(receipt_outcome.state, AcceptanceState::Unknown);
819 assert!(receipt_outcome.outcome_id.is_none());
820 assert!(receipt_outcome.outcome_ref.is_none());
821 assert!(receipt_outcome.acceptance_evidence_digest.is_none());
822 assert!(
823 fixture.protocol.accepted_outcome.evidence_refs.is_empty(),
824 "{:?}",
825 fixture.protocol.accepted_outcome.evidence_refs
826 );
827 assert!(
828 fixture
829 .protocol
830 .accepted_outcome
831 .quality_score_milli
832 .is_none()
833 );
834 assert!(
835 fixture.protocol.decision.evidence_refs.is_empty(),
836 "{:?}",
837 fixture.protocol.decision.evidence_refs
838 );
839 let admitted = fixture
840 .validated_for(&task)
841 .expect("authenticated operational Unknown");
842 assert_eq!(admitted.project_id(), &task.project_id);
843 assert_eq!(admitted.outcome().accepted, AcceptanceState::Unknown);
844 }
845
846 #[test]
847 fn unknown_receipt_still_requires_trusted_scope_and_complete_operational_lineage() {
848 let task = task();
849 let mut fixture =
850 test_support::build_for_context(&task, None, None, AcceptanceState::Unknown);
851 fixture.validated_for(&task).expect("valid Unknown control");
852 let original = fixture.protocol.clone();
853 for change in 0..8 {
854 fixture.protocol = original.clone();
855 match change {
856 0 => fixture.protocol.receipt.signature = STANDARD.encode([0_u8; 64]),
857 1 => fixture.protocol.context_plan.budget_tokens += 1,
858 2 => fixture.protocol.decision.input_refs.clear(),
859 3 => fixture.protocol.accepted_outcome.plan_id = None,
860 4 => fixture.protocol.accepted_outcome.receipt_id = None,
861 5 => fixture.protocol.accepted_outcome.decision_refs.clear(),
862 6 | 7 => {
863 let measurement = fixture
866 .protocol
867 .receipt
868 .evidence_refs
869 .first()
870 .expect("actual host measurement");
871 let outcome = &mut fixture.protocol.accepted_outcome;
872 outcome.accepted = if change == 6 {
873 AcceptanceState::Accepted
874 } else {
875 AcceptanceState::Rejected
876 };
877 outcome.quality_score_milli = Some(if change == 6 { 1_000 } else { 0 });
878 outcome
879 .evidence_refs
880 .push(lean_ctx_protocol::EvidenceRefV1 {
881 schema_version: Some(1),
882 kind: lean_ctx_protocol::EvidenceKind::QualityMeasurement,
883 uri: measurement.uri.as_str().to_owned(),
884 digest: measurement.digest.as_str().to_owned(),
885 signature_status: lean_ctx_protocol::SignatureStatus::NotSigned,
886 media_type: Some(measurement.media_type.clone()),
887 extensions: Default::default(),
888 });
889 outcome
890 .validate()
891 .expect("shape-valid forged known outcome");
892 }
893 _ => unreachable!("bounded test cases"),
894 }
895 assert!(fixture.validated_for(&task).is_err(), "mutation {change}");
896 }
897 fixture.protocol = original;
898 let mut other_task = task.clone();
899 other_task.project_id = lean_ctx_protocol::ProjectId::new("other-project").expect("scope");
900 assert!(fixture.validated_for(&other_task).is_err());
901 let mut other_tenant = task.clone();
902 other_tenant.tenant_id =
903 Some(lean_ctx_protocol::TenantId::new("other-tenant").expect("tenant scope"));
904 assert!(fixture.validated_for(&other_tenant).is_err());
905 fixture
906 .validated_for(&task)
907 .expect("unchanged Unknown control");
908 }
909
910 #[test]
911 fn structurally_valid_forged_signature_cannot_issue_learning_token() {
912 let task = task();
913 let mut fixture = test_support::build(&task);
914 fixture
915 .validated_for(&task)
916 .expect("valid signature control");
917 fixture.protocol.receipt.signature = STANDARD.encode([0_u8; 64]);
918 let bytes = fixture
919 .protocol
920 .receipt
921 .canonical_bytes()
922 .expect("canonical bytes");
923 let digest = Sha256Digest::new(super::sha256_digest(&bytes)).expect("receipt digest");
924 drop(
925 crate::core::engine_interface::persist_engine_artifact_content(
926 "execution/receipts",
927 digest.hex(),
928 "json",
929 &bytes,
930 )
931 .expect("persist untrusted receipt at its actual digest"),
932 );
933 fixture.protocol.published_receipt.path = fixture
934 .protocol
935 .published_receipt
936 .path
937 .with_file_name(format!("{}.json", digest.hex()));
938 fixture.protocol.published_receipt.receipt_digest = digest.as_str().to_owned();
939 fixture.protocol.published_receipt.receipt_ref = format!("id:{}", digest.as_str());
940 fixture
941 .protocol
942 .validate_for(&task)
943 .expect("structural checks pass");
944 assert!(fixture.validated_for(&task).is_err());
945 }
946
947 #[test]
948 fn signer_identity_key_digest_and_signature_key_must_all_match() {
949 let task = task();
950 let mut fixture = test_support::build(&task);
951 let trusted = fixture.signer_admission.clone();
952 fixture.signer_admission.key_id = "different-key".to_owned();
953 assert!(fixture.validated_for(&task).is_err());
954
955 fixture.signer_admission = trusted;
956 fixture.verifying_key = SigningKey::from_bytes(&[99; 32]).verifying_key();
957 assert!(fixture.validated_for(&task).is_err());
958
959 fixture.signer_admission.public_key_digest =
961 Sha256Digest::new(super::sha256_digest(fixture.verifying_key.as_bytes()))
962 .expect("key digest");
963 assert!(fixture.validated_for(&task).is_err());
964 }
965
966 #[test]
967 fn signer_admission_must_cover_issuance_and_current_verification() {
968 let task = task();
969 let mut fixture = test_support::build(&task);
970 let time = |value| UtcTimestamp::new(value).expect("test timestamp");
971 let trusted = fixture.signer_admission.clone();
972 let now = time("2026-08-23T13:00:00Z");
973 fixture.verified_at = now.clone();
974 fixture.validated_for(&task).expect("valid trust window");
975
976 fixture.verified_at = time("2026-08-23T11:59:59Z");
977 assert!(fixture.validated_for(&task).is_err());
978 fixture.verified_at = now.clone();
979
980 fixture.signer_admission.admitted_at = time("2026-08-23T12:00:01Z");
981 assert!(fixture.validated_for(&task).is_err());
982 fixture.signer_admission = trusted.clone();
983
984 for expires_at in [fixture.protocol.receipt.issued_at.clone(), now.clone()] {
985 fixture.signer_admission.expires_at = expires_at;
986 assert!(fixture.validated_for(&task).is_err());
987 }
988 fixture.signer_admission = trusted.clone();
989
990 for revoked_at in [fixture.protocol.receipt.issued_at.clone(), now.clone()] {
991 fixture.signer_admission.revoked_at = Some(revoked_at);
992 assert!(fixture.validated_for(&task).is_err());
993 }
994 fixture.signer_admission = trusted;
995 fixture.signer_admission.admitted_at = fixture.protocol.receipt.issued_at.clone();
996 fixture.signer_admission.revoked_at = Some(time("2026-08-23T13:00:01Z"));
997 fixture
998 .validated_for(&task)
999 .expect("inclusive admission, future revocation");
1000 }
1001
1002 #[test]
1003 fn tampered_context_projection_is_rejected() {
1004 let task = task();
1005 let mut fixture = test_support::build(&task);
1006 fixture.protocol.context_plan.budget_tokens += 1;
1007 assert!(fixture.protocol.validate_for(&task).is_err());
1008 }
1009
1010 #[test]
1011 fn shared_signer_verification_preserves_engine_diagnostic() {
1012 let task = task();
1013 let mut fixture = test_support::build(&task);
1014 fixture.signer_admission.expires_at = fixture.protocol.receipt.issued_at.clone();
1015 let error = fixture
1016 .validated_for(&task)
1017 .err()
1018 .expect("expired signer must fail");
1019 assert_eq!(
1020 error.to_string(),
1021 "execution ledger record is invalid: receipt signing key is not admitted by the server trust snapshot"
1022 );
1023 }
1024
1025 #[test]
1026 fn missing_decision_lineage_is_rejected() {
1027 let task = task();
1028 let mut fixture = test_support::build(&task);
1029 fixture.protocol.decision.input_refs.clear();
1030 assert!(fixture.protocol.validate_for(&task).is_err());
1031 }
1032}