1use 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
17pub const MAX_ENGINE_OUTCOME_REQUEST_BYTES: usize = 1024 * 1024;
20
21#[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#[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#[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#[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#[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 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#[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 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 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 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}