Skip to main content

lean_ctx/core/execution_ledger/host/
outcome.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Explicit operator-attested outcomes, appended to an authenticated native run.
3
4use anyhow::{Result, ensure};
5use base64::{Engine as _, engine::general_purpose::STANDARD};
6use ed25519_dalek::Signer as _;
7use lean_ctx_ocla::{
8    DecisionSignerAdmissionV1, sign_decision_record, verify_decision_signature,
9    verify_receipt_signature,
10};
11use lean_ctx_protocol::{
12    AcceptanceState, ContextPlanProjectionV1, DecisionId, DecisionKind, DecisionRecordV1,
13    DecisionStageV1, EngineInvocationV1, EngineObservationV1, EngineOutcomeBindingV1, EvidenceKind,
14    EvidenceRefV1, ExecutionPlanV1, ReceiptDocumentV1, ReceiptEvidenceKindV1, ReceiptEvidenceRefV1,
15    ReceiptId, ReceiptOutcomeLinkV1, Sha256Digest, SignatureStatus, TaskEnvelopeV1,
16};
17use serde::{Deserialize, de::DeserializeOwned};
18
19use super::{HostReceiptAuthority, canonical_serialize, digest, now, task_lock};
20use crate::core::{
21    engine_artifact,
22    engine_interface::{persist_engine_artifact_content, planning::context_binding},
23    execution_ledger::{ExecutionEvent, PublishedCanonicalReceipt, publish_canonical_receipt},
24    execution_protocol::RecordedExecutionProtocolV1,
25    outcome::{
26        contracts::{OutcomeContractV1, TaskClass},
27        evaluator::evaluate_for_task,
28        signals::{OutcomeSignal, SignalType},
29    },
30};
31
32#[derive(Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct HostOutcomeRequest {
35    pub schema_version: u32,
36    pub receipt_digest: Sha256Digest,
37    pub context_decision_digest: Sha256Digest,
38    pub signals: Vec<OutcomeSignal>,
39    /// Former consent for local model/provider outcome learning. Accepted for
40    /// wire compatibility; since v4 nothing is learned (`learning_recorded`
41    /// is always false). Its scope check still applies.
42    pub learn: bool,
43}
44
45pub(super) struct HostOutcomeResult {
46    pub publication: PublishedCanonicalReceipt,
47    pub acceptance: AcceptanceState,
48    pub learning_recorded: bool,
49    pub already_recorded: bool,
50}
51
52impl HostReceiptAuthority {
53    pub fn observe_outcome(&self, request: &HostOutcomeRequest) -> Result<serde_json::Value> {
54        let result = self.observe_outcome_bound(request, None)?;
55        Ok(serde_json::json!({"schema_version":1,
56            "receipt_id":result.publication.receipt_id,
57            "receipt_digest":result.publication.receipt_digest,
58            "acceptance":result.acceptance,"learning_recorded":result.learning_recorded,
59            "already_recorded":result.already_recorded}))
60    }
61
62    pub(super) fn observe_outcome_bound(
63        &self,
64        request: &HostOutcomeRequest,
65        binding: Option<&EngineOutcomeBindingV1>,
66    ) -> Result<HostOutcomeResult> {
67        self.validate_current().map_err(anyhow::Error::msg)?;
68        ensure!(
69            self.allow_outcome_signing,
70            "host outcome signing not authorized"
71        );
72        ensure!(
73            request.schema_version == 1
74                && !request.signals.is_empty()
75                && request.signals.len() <= 16,
76            "invalid outcome request"
77        );
78        ensure!(
79            request
80                .signals
81                .iter()
82                .all(|signal| signal.evidence_ref.is_none()
83                    && signal.observed_at.is_none()
84                    && !matches!(
85                        signal.signal_type,
86                        SignalType::AgentCompletion | SignalType::RetryCount
87                    )),
88            "signals must be explicit operator attestations, not completion or caller evidence"
89        );
90        let original = publication(&request.receipt_digest)?;
91        let (initial, _) = original.read_canonical()?;
92        let planning_uri = format!(
93            "artifact://execution/evidence/{}",
94            request.context_decision_digest.hex()
95        );
96        // Older receipts have no planning pointer. If present, its exact signed
97        // bytes must be the record being evaluated, not another valid signature.
98        ensure!(
99            initial
100                .evidence_refs
101                .iter()
102                .filter(|reference| { reference.kind == ReceiptEvidenceKindV1::Runtime })
103                .all(
104                    |reference| reference.digest == request.context_decision_digest
105                        && reference.uri.as_str() == planning_uri
106                ),
107            "outcome planning evidence differs from the initial receipt"
108        );
109        ensure!(
110            binding.is_none()
111                || initial
112                    .evidence_refs
113                    .iter()
114                    .any(|reference| { reference.kind == ReceiptEvidenceKindV1::Runtime }),
115            "framed outcome requires a receipt-bound planning record"
116        );
117        let task: TaskEnvelopeV1 = read_evidence(&initial.lineage.task_ref)?;
118        let plan: ExecutionPlanV1 = read_evidence(&initial.lineage.plan_ref)?;
119        let invocation: EngineInvocationV1 = read_evidence(&initial.lineage.invocation_ref)?;
120        let observation_ref = initial
121            .evidence_refs
122            .iter()
123            .find(|entry| entry.uri.as_str() == "artifact://engine/observation")
124            .ok_or_else(|| anyhow::anyhow!("receipt observation missing"))?;
125        let observation: EngineObservationV1 = read_evidence(&observation_ref.digest)?;
126        let planning: DecisionRecordV1 = read_evidence(&request.context_decision_digest)?;
127        let verified_at = now().map_err(anyhow::Error::msg)?;
128        let key = self.signing_key.verifying_key();
129        verify_receipt_signature(&initial, &self.signer_admission, &key, &verified_at)?;
130        let mut grant = DecisionSignerAdmissionV1 {
131            key_admission: self.signer_admission.clone(),
132            task: task.clone(),
133            stage: DecisionStageV1::Planning,
134            kind: DecisionKind::ContextSelection,
135        };
136        verify_decision_signature(&planning, &task, &grant, &key, &verified_at)?;
137        if let Some(binding) = binding {
138            // These are host-supplied expectations, never authorization by
139            // themselves. Compare against signed lineage before any mutation.
140            ensure!(
141                binding.task_id == task.task_id
142                    && binding.tenant_id == task.tenant_id
143                    && binding.agent_id == task.agent_id,
144                "outcome task identity differs from the host binding"
145            );
146        }
147        ensure!(
148            initial.outcome.state == AcceptanceState::Unknown,
149            "initial receipt must be unobserved"
150        );
151        ensure!(
152            !request.learn
153                || (task.tenant_id.is_none() && task.project_id.as_str() != "unknown-project"),
154            "personal learning scope not admitted"
155        );
156        let handoff_ref = planning
157            .rationale_ref
158            .as_deref()
159            .and_then(|value| value.strip_prefix("artifact://execution/evidence/"))
160            .ok_or_else(|| anyhow::anyhow!("planning handoff missing"))?;
161        let handoff = read_bytes(&Sha256Digest::new(format!("sha256:{handoff_ref}"))?)?;
162        ensure!(
163            serde_json::from_slice::<serde_json::Value>(&handoff)? == planning.selected_result,
164            "planning payload mismatch"
165        );
166        context_binding::validate_handoff(&plan, &handoff).map_err(anyhow::Error::msg)?;
167        context_binding::validate_task_binding(&plan, &task).map_err(anyhow::Error::msg)?;
168        let projection: ContextPlanProjectionV1 =
169            serde_json::from_value(planning.selected_result[0].clone())?;
170        let class: TaskClass =
171            serde_json::from_value(planning.selected_result[1]["task_class"].clone())?;
172        let contract = OutcomeContractV1::for_task_class(class);
173        let mut prior_outcome = evaluate_for_task(
174            &contract,
175            &[],
176            "outcome:unobserved",
177            task.task_id.as_str(),
178            initial.issued_at.as_str(),
179        );
180        prior_outcome.plan_id = Some(plan.plan_id.clone());
181        prior_outcome.receipt_id = Some(ReceiptId::new(initial.receipt_id.as_str())?);
182        prior_outcome.decision_refs = vec![planning.decision_id.as_str().into()];
183        let mut protocol = RecordedExecutionProtocolV1 {
184            context_plan: projection,
185            execution_plan: plan,
186            invocation,
187            observation,
188            receipt: initial.clone(),
189            published_receipt: original,
190            accepted_outcome: prior_outcome,
191            decision: planning.clone(),
192        };
193        // Structural/Engine checks complement both trusted signatures above. The
194        // original planning bytes are restored, never a fabricated kernel decision.
195        protocol.validate_for(&task)?;
196        let identity = digest(&canonical_serialize(&(
197            "host-outcome/v1",
198            &request.receipt_digest,
199            &request.context_decision_digest,
200            &request.signals,
201        )))
202        .map_err(anyhow::Error::msg)?;
203        let outcome_id = format!("outcome:{}", identity.hex());
204        let evidence_bytes = serde_json::to_vec(&(&contract, &request.signals))?;
205        let evidence_digest = digest(&evidence_bytes).map_err(anyhow::Error::msg)?;
206        let _lock = task_lock(&task).map_err(anyhow::Error::msg)?;
207        let head = self
208            .ledger
209            .canonical_receipt_for_task_verified(task.task_id.as_str())?
210            .ok_or_else(|| anyhow::anyhow!("receipt is not recorded by this host"))?;
211        let head_publication = publication(&Sha256Digest::new(head.receipt_digest)?)?;
212        let (head_receipt, _) = head_publication.read_canonical()?;
213        verify_receipt_signature(&head_receipt, &self.signer_admission, &key, &verified_at)?;
214        let replay = head_receipt.receipt_id != initial.receipt_id;
215        ensure!(
216            !replay
217                || (head_receipt.chain.previous_receipt_id.as_ref() == Some(&initial.receipt_id)
218                    && head_receipt
219                        .outcome
220                        .outcome_id
221                        .as_ref()
222                        .map(lean_ctx_protocol::OutcomeId::as_str)
223                        == Some(outcome_id.as_str())
224                    && head_receipt.lineage == initial.lineage
225                    && head_receipt.chain.chain_id == initial.chain.chain_id
226                    && initial.chain.sequence_number.checked_add(1)
227                        == Some(head_receipt.chain.sequence_number)
228                    && head_receipt.chain.previous_signature_digest.as_ref()
229                        == Some(
230                            &digest(initial.signature.as_bytes()).map_err(anyhow::Error::msg)?
231                        )),
232            "conflicting or stale outcome"
233        );
234        let observed_at = if replay {
235            head_receipt.issued_at.clone()
236        } else {
237            verified_at.clone()
238        };
239        let observed_time = chrono::DateTime::parse_from_rfc3339(observed_at.as_str())?;
240        let age = observed_time.signed_duration_since(chrono::DateTime::parse_from_rfc3339(
241            initial.issued_at.as_str(),
242        )?);
243        ensure!(
244            observed_time <= chrono::DateTime::parse_from_rfc3339(verified_at.as_str())?
245                && age >= chrono::Duration::zero()
246                && age <= chrono::Duration::hours(i64::from(contract.expiry_window_hours)),
247            "outcome is outside its contract window"
248        );
249        let mut outcome = evaluate_for_task(
250            &contract,
251            &request.signals,
252            outcome_id,
253            task.task_id.as_str(),
254            observed_at.as_str(),
255        );
256        ensure!(
257            outcome.accepted != AcceptanceState::Unknown,
258            "contract has missing acceptance signals"
259        );
260        ensure!(
261            outcome
262                .evidence_refs
263                .iter()
264                .any(|entry| entry.digest == evidence_digest.as_str()),
265            "evaluator evidence mismatch"
266        );
267        // Snapshot evaluation before adding receipt/decision links: the receipt
268        // binds the result and its distinct inputs without a digest cycle.
269        let outcome_bytes = canonical_serialize(&outcome);
270        let outcome_digest = digest(&outcome_bytes).map_err(anyhow::Error::msg)?;
271        let acceptance_digest =
272            (outcome.accepted == AcceptanceState::Accepted).then_some(evidence_digest.clone());
273        ensure!(
274            !replay
275                || (head_receipt.outcome.state == outcome.accepted
276                    && head_receipt.outcome.outcome_ref.as_ref() == Some(&outcome_digest)
277                    && head_receipt.outcome.acceptance_evidence_digest == acceptance_digest),
278            "recorded outcome differs from evaluation"
279        );
280        persist(&evidence_bytes)?;
281        persist(&outcome_bytes)?;
282        let receipt = if replay {
283            head_receipt
284        } else {
285            let mut next = initial.clone();
286            next.chain.sequence_number = next
287                .chain
288                .sequence_number
289                .checked_add(1)
290                .ok_or_else(|| anyhow::anyhow!("receipt sequence exhausted"))?;
291            next.chain.previous_receipt_id = Some(initial.receipt_id.clone());
292            next.chain.previous_signature_digest =
293                Some(digest(initial.signature.as_bytes()).map_err(anyhow::Error::msg)?);
294            next.issued_at = observed_at.clone();
295            next.status = if outcome.accepted == AcceptanceState::Accepted {
296                lean_ctx_protocol::ReceiptTerminalStatusV1::Succeeded
297            } else {
298                lean_ctx_protocol::ReceiptTerminalStatusV1::Rejected
299            };
300            next.outcome = ReceiptOutcomeLinkV1 {
301                state: outcome.accepted,
302                outcome_id: Some(outcome.outcome_id.clone()),
303                outcome_ref: Some(outcome_digest.clone()),
304                acceptance_evidence_digest: acceptance_digest,
305            };
306            for (kind, hash) in [
307                (ReceiptEvidenceKindV1::Outcome, &outcome_digest),
308                (ReceiptEvidenceKindV1::Measurement, &evidence_digest),
309            ] {
310                next.evidence_refs.push(ReceiptEvidenceRefV1 {
311                    kind,
312                    uri: lean_ctx_protocol::ProtocolReference::new(format!(
313                        "artifact://execution/evidence/{}",
314                        hash.hex()
315                    ))?,
316                    digest: hash.clone(),
317                    media_type: "application/json".into(),
318                    signature_status: SignatureStatus::NotSigned,
319                });
320            }
321            next.receipt_id = next.derived_receipt_id()?;
322            next.signature =
323                STANDARD.encode(self.signing_key.sign(&next.signing_bytes()?).to_bytes());
324            next
325        };
326        receipt.validate()?;
327        outcome.evidence_refs.push(EvidenceRefV1 {
328            schema_version: Some(1),
329            kind: EvidenceKind::QualityMeasurement,
330            uri: format!("artifact://execution/evidence/{}", outcome_digest.hex()),
331            digest: outcome_digest.as_str().into(),
332            signature_status: SignatureStatus::NotSigned,
333            media_type: Some("application/json".into()),
334            extensions: Default::default(),
335        });
336        outcome.plan_id = Some(protocol.execution_plan.plan_id.clone());
337        outcome.receipt_id = Some(ReceiptId::new(receipt.receipt_id.as_str())?);
338        let mut decision = planning;
339        decision.decision_id = DecisionId::new(format!("decision:{}", identity.hex()))?;
340        decision.decision_stage = DecisionStageV1::Outcome;
341        decision.decision_kind = DecisionKind::Stop;
342        decision.input_refs.extend([
343            receipt.lineage.invocation_ref.as_str().into(),
344            receipt.receipt_id.as_str().into(),
345        ]);
346        decision.selected_result = serde_json::json!({"acceptance":outcome.accepted});
347        decision.rationale_code = "operator_attested_contract_outcome".into();
348        decision.rationale_ref = Some(format!(
349            "artifact://execution/evidence/{}",
350            evidence_digest.hex()
351        ));
352        decision.constraint_refs = vec![contract.reference()];
353        decision.evidence_refs.clone_from(&outcome.evidence_refs);
354        decision.observed_at = observed_at.as_str().into();
355        grant.stage = DecisionStageV1::Outcome;
356        grant.kind = DecisionKind::Stop;
357        decision.signature =
358            sign_decision_record(&decision, &task, &grant, &self.signing_key, &verified_at)?;
359        outcome.decision_refs = vec![decision.decision_id.as_str().into()];
360        // An exact replay republishes the same immutable artifact and repairs any
361        // interrupted ledger projection before retrying receipt-idempotent learning.
362        protocol.published_receipt = publish_canonical_receipt(
363            &receipt,
364            &self.ledger,
365            task.trace_id.as_str(),
366            &observed_at,
367        )?;
368        protocol.receipt = receipt;
369        protocol.accepted_outcome = outcome;
370        protocol.decision = decision;
371        // Signature, grant and lineage validation still gates every recorded outcome.
372        protocol.validated_for(
373            &task,
374            (&self.signer_admission, &key),
375            (&grant, &key),
376            None,
377            &verified_at,
378        )?;
379        persist(&canonical_serialize(&protocol.accepted_outcome))?;
380        persist(&canonical_serialize(&protocol.decision))?;
381        self.ledger.append(ExecutionEvent::OutcomeRecorded {
382            task_id: task.task_id.as_str().into(),
383            trace_id: task.trace_id.as_str().into(),
384            outcome_id: protocol.accepted_outcome.outcome_id.as_str().into(),
385            receipt_id: protocol.receipt.receipt_id.as_str().into(),
386            accepted: protocol.accepted_outcome.accepted,
387            timestamp: observed_at.as_str().into(),
388            sequence_number: 0,
389            prev_hash: String::new(),
390        })?;
391        // Model/provider outcome learning only fed adaptive routing, which v4
392        // removed; `learn` stays accepted on the wire but records nothing.
393        Ok(HostOutcomeResult {
394            publication: protocol.published_receipt,
395            acceptance: protocol.accepted_outcome.accepted,
396            learning_recorded: false,
397            already_recorded: replay,
398        })
399    }
400}
401
402pub(super) fn read_bytes(expected: &Sha256Digest) -> Result<Vec<u8>> {
403    let bytes = engine_artifact::read_content("execution/evidence", expected.hex(), "json")
404        .map_err(anyhow::Error::msg)?;
405    ensure!(
406        digest(&bytes).map_err(anyhow::Error::msg)? == *expected,
407        "evidence digest mismatch"
408    );
409    Ok(bytes)
410}
411
412pub(super) fn read_evidence<T: DeserializeOwned>(expected: &Sha256Digest) -> Result<T> {
413    Ok(serde_json::from_slice(&read_bytes(expected)?)?)
414}
415
416fn persist(bytes: &[u8]) -> Result<()> {
417    let hash = digest(bytes).map_err(anyhow::Error::msg)?;
418    drop(
419        persist_engine_artifact_content("execution/evidence", hash.hex(), "json", bytes)
420            .map_err(anyhow::Error::msg)?,
421    );
422    Ok(())
423}
424
425pub(super) fn publication(expected: &Sha256Digest) -> Result<PublishedCanonicalReceipt> {
426    let bytes = engine_artifact::read_content("execution/receipts", expected.hex(), "json")
427        .map_err(anyhow::Error::msg)?;
428    ensure!(
429        digest(&bytes).map_err(anyhow::Error::msg)? == *expected,
430        "receipt digest mismatch"
431    );
432    let receipt = ReceiptDocumentV1::from_canonical_bytes(&bytes)?;
433    Ok(PublishedCanonicalReceipt {
434        receipt_id: receipt.receipt_id.as_str().into(),
435        receipt_ref: format!("id:{}", expected.as_str()),
436        receipt_digest: expected.as_str().into(),
437        path: crate::core::data_dir::resolve_data_dir()
438            .map_err(anyhow::Error::msg)?
439            .join("execution/receipts")
440            .join(format!("{}.json", expected.hex())),
441    })
442}