1use 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 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 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 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 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 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 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 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 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}