Skip to main content

lean_ctx/core/
execution_protocol.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Strict validation for the typed execution protocol carried by one lifecycle run.
4
5mod 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/// Authoritative artifacts produced by the owners of stages 5 through 13.
24///
25/// This is intentionally attach-only. A surface that only knows an HTTP status,
26/// process exit code, or MCP result must not fabricate protocol lineage.
27#[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
39/// Borrowed protocol with trusted receipt/decision signatures and validated lineage.
40///
41/// Private construction and immutable borrowing prevent callers from forging an
42/// admission flag or replacing artifacts between validation and consumption.
43/// An Unknown receipt authenticates operational lineage, not an outcome observation;
44/// its optional partial score is not authority to train on quality.
45pub 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    /// Scope authenticated by the signed receipt's canonical task digest.
53    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    /// Keep the full authenticated planning payload bound at the learning consumer.
78    /// The shortened autopilot ID and projection alone omit executable policies.
79    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    /// Authenticate both purposes and lifecycle lineage before issuing a token.
110    ///
111    /// The host must resolve the signer snapshot and public key from its trusted
112    /// configuration, never from receipt-supplied key material. Admission must be
113    /// valid both at issuance and at the host's verification time. This authenticates
114    /// the receipt signer's claims, not independently signed evaluator artifacts.
115    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    /// Validate the complete Task -> Context -> Plan -> Invocation -> Receipt ->
156    /// Outcome -> Decision chain without repairing or synthesizing any artifact.
157    /// This structural check does not establish signer trust; use `validated_for`
158    /// before admitting an outcome to learning.
159    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        // Canonical Unknown receipts forbid outcome references. Keep operational
255        // bindings mandatory without inventing an observation to satisfy this join.
256        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            // Exercise the real host publication path, which must not invent
552            // an outcome or acceptance reference for an unobserved result.
553            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        // Explicit test-host authority, never inferred from a supplied record.
721        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                    // An attacker relabels real transport evidence as quality.
864                    // This must fail the signed state binding, not DTO shape validation.
865                    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        // Even an admitted alternative key cannot verify another key's receipt.
960        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}