Skip to main content

lean_ctx_protocol/
engine_outcome.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Typed transport for operator-attested outcomes.
4//!
5//! This module carries bounded request/response data only.  It does not admit
6//! signer keys, authenticate an actor, verify a receipt signature, or create a
7//! second outcome ledger; those authorities remain in the native host.
8
9use serde::{Deserialize, Serialize};
10
11use crate::engine_context_source_execution::sha256_digest;
12use crate::{
13    AcceptanceState, AgentId, ReceiptDocumentV1, SemanticVersion, Sha256Digest, TaskId, TenantId,
14    ValidationError, deserialize_schema_version,
15};
16
17/// The bounded request transport limit used by the existing Engine process
18/// boundary.
19pub const MAX_ENGINE_OUTCOME_REQUEST_BYTES: usize = 1024 * 1024;
20
21/// A public request cannot carry completion/agent-lifecycle evidence or caller
22/// metadata; the host turns these attestations into its existing local signal
23/// values after authenticating and binding the receipt.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum EngineOutcomeSignalTypeV1 {
27    BuildSuccess,
28    TestsPassing,
29    LintClean,
30    TypecheckPassing,
31    HumanAcceptance,
32    PrMerge,
33    CiPassing,
34    Correction,
35    Rollback,
36}
37
38/// Values preserve the existing evaluator's semantics: Boolean values pass
39/// when true, Count values pass when greater than zero, and Unknown never
40/// passes.  Count is u32 to match the native local signal representation.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum EngineOutcomeSignalValueV1 {
44    Boolean(bool),
45    Count(u32),
46    Unknown,
47}
48
49/// One explicit operator attestation with no evidence path or caller time.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct EngineOutcomeSignalV1 {
53    pub signal_type: EngineOutcomeSignalTypeV1,
54    pub value: EngineOutcomeSignalValueV1,
55}
56
57/// Server-owned identity expectations checked against the signed task loaded
58/// by the host.  These fields are bindings, not authentication claims.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct EngineOutcomeBindingV1 {
62    pub task_id: TaskId,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub tenant_id: Option<TenantId>,
65    pub agent_id: AgentId,
66}
67
68/// Strict V1 request for the existing host outcome authority.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct EngineOutcomeRequestV1 {
72    #[serde(deserialize_with = "deserialize_schema_version")]
73    pub schema_version: u32,
74    pub transport_version: u32,
75    pub engine_interface_version: SemanticVersion,
76    pub receipt_digest: Sha256Digest,
77    pub context_decision_digest: Sha256Digest,
78    pub binding: EngineOutcomeBindingV1,
79    pub signals: Vec<EngineOutcomeSignalV1>,
80}
81
82impl EngineOutcomeRequestV1 {
83    /// Validate transport identity and bounded, metadata-free attestations.
84    pub fn validate(&self) -> Result<(), ValidationError> {
85        if self.schema_version != 1
86            || self.transport_version != 1
87            || self.engine_interface_version.as_str() != "1.0.0"
88            || self.signals.is_empty()
89            || self.signals.len() > 16
90        {
91            return Err(invalid_request());
92        }
93        bounded_wire_size(self, MAX_ENGINE_OUTCOME_REQUEST_BYTES)
94    }
95}
96
97/// Versioned result carrying the exact canonical successor receipt bytes.
98///
99/// Receipt parsing below proves canonical shape, content identity, and the
100/// requested outcome state only.  It intentionally does not admit the signer;
101/// the existing HostReceiptAuthority performs that cryptographic/ledger check.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct EngineOutcomeResponseV1 {
105    #[serde(deserialize_with = "deserialize_schema_version")]
106    pub schema_version: u32,
107    pub receipt_id: Sha256Digest,
108    pub receipt_digest: Sha256Digest,
109    /// Digest of the initial unknown receipt supplied in the request.  This is
110    /// an explicit transport join, not an independent cryptographic link.
111    pub original_receipt_digest: Sha256Digest,
112    pub acceptance: AcceptanceState,
113    pub already_recorded: bool,
114    pub receipt_document_json: String,
115}
116
117impl EngineOutcomeResponseV1 {
118    /// Validate exact canonical UTF-8 receipt bytes and response bounds.
119    pub fn validate(&self) -> Result<(), ValidationError> {
120        if self.schema_version != 1
121            || self.acceptance == AcceptanceState::Unknown
122            || self.receipt_document_json.len() > crate::MAX_ENGINE_SOURCE_RECEIPT_DOCUMENT_BYTES
123        {
124            return Err(invalid_response());
125        }
126        let bytes = self.receipt_document_json.as_bytes();
127        if sha256_digest(bytes)? != self.receipt_digest {
128            return Err(invalid_response());
129        }
130        let document = ReceiptDocumentV1::from_canonical_bytes(bytes)?;
131        if document.receipt_id != self.receipt_id
132            || document.outcome.state != self.acceptance
133            || document.chain.previous_receipt_id.is_none()
134        {
135            return Err(invalid_response());
136        }
137        let encoded = serde_json::to_vec(self).map_err(|_| invalid_response())?;
138        if encoded.len() > crate::MAX_ENGINE_SOURCE_EXECUTION_V2_RESPONSE_BYTES {
139            return Err(invalid_response());
140        }
141        Ok(())
142    }
143
144    /// Validate the server-owned task and planning-evidence joins available on
145    /// the response wire.  Signature admission and previous-receipt lineage
146    /// remain host responsibilities.
147    pub fn validate_against(
148        &self,
149        request: &EngineOutcomeRequestV1,
150    ) -> Result<(), ValidationError> {
151        request.validate()?;
152        self.validate()?;
153        if self.original_receipt_digest != request.receipt_digest {
154            return Err(invalid_response());
155        }
156        let document =
157            ReceiptDocumentV1::from_canonical_bytes(self.receipt_document_json.as_bytes())?;
158        if document.lineage.task_id != request.binding.task_id {
159            return Err(invalid_response());
160        }
161        let runtime_refs: Vec<_> = document
162            .evidence_refs
163            .iter()
164            .filter(|entry| entry.kind == crate::ReceiptEvidenceKindV1::Runtime)
165            .collect();
166        if runtime_refs.is_empty()
167            || !runtime_refs.iter().all(|entry| {
168                entry.digest == request.context_decision_digest
169                    && entry.uri.as_str()
170                        == format!(
171                            "artifact://execution/evidence/{}",
172                            request.context_decision_digest.hex()
173                        )
174            })
175        {
176            return Err(invalid_response());
177        }
178        Ok(())
179    }
180}
181
182fn bounded_wire_size<T: Serialize>(value: &T, limit: usize) -> Result<(), ValidationError> {
183    let encoded = serde_json::to_vec(value).map_err(|_| invalid_request())?;
184    if encoded.len() > limit {
185        return Err(invalid_request());
186    }
187    Ok(())
188}
189
190fn invalid_request() -> ValidationError {
191    ValidationError::new("invalid Engine outcome request")
192}
193
194fn invalid_response() -> ValidationError {
195    ValidationError::new("Engine outcome response does not bind a canonical receipt")
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn request() -> EngineOutcomeRequestV1 {
203        EngineOutcomeRequestV1 {
204            schema_version: 1,
205            transport_version: 1,
206            engine_interface_version: SemanticVersion::new("1.0.0").expect("version"),
207            receipt_digest: Sha256Digest::new(format!("sha256:{}", "a".repeat(64)))
208                .expect("digest"),
209            context_decision_digest: Sha256Digest::new(format!("sha256:{}", "b".repeat(64)))
210                .expect("digest"),
211            binding: EngineOutcomeBindingV1 {
212                task_id: TaskId::new("task-1").expect("task"),
213                tenant_id: Some(TenantId::new("tenant-1").expect("tenant")),
214                agent_id: AgentId::new("agent-1").expect("agent"),
215            },
216            signals: vec![EngineOutcomeSignalV1 {
217                signal_type: EngineOutcomeSignalTypeV1::HumanAcceptance,
218                value: EngineOutcomeSignalValueV1::Boolean(true),
219            }],
220        }
221    }
222
223    #[test]
224    fn request_rejects_missing_attestation_and_unknown_authority_fields() {
225        let mut value = request();
226        assert!(value.validate().is_ok());
227        let mut json = serde_json::to_value(&value).expect("request");
228        json["learn"] = serde_json::json!(true);
229        assert!(serde_json::from_value::<EngineOutcomeRequestV1>(json).is_err());
230        value.signals.clear();
231        assert!(value.validate().is_err());
232    }
233
234    #[test]
235    fn wire_rejects_forbidden_completion_signal() {
236        let json = r#"{
237            "schema_version":1,
238            "transport_version":1,
239            "engine_interface_version":"1.0.0",
240            "receipt_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
241            "context_decision_digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
242            "binding":{"task_id":"task-1","tenant_id":"tenant-1","agent_id":"agent-1"},
243            "signals":[{"signal_type":"agent_completion","value":{"boolean":true}}]
244        }"#;
245        assert!(serde_json::from_str::<EngineOutcomeRequestV1>(json).is_err());
246    }
247}