1use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::BTreeMap;
11use thiserror::Error;
12
13pub const EXECUTION_IDENTITY_SCHEMA_V1: &str = "a3s.code.execution-identity.v1";
14pub const MODEL_CALL_IDENTITY_DOMAIN_V1: &str = "a3s.code.model-call.identity.v1";
15pub const TOOL_INVOCATION_IDENTITY_DOMAIN_V1: &str = "a3s.code.tool-invocation.identity.v1";
16pub const FLOW_DECISION_IDENTITY_DOMAIN_V1: &str = "a3s.code.flow-decision.identity.v1";
17pub const FLOW_STEP_IDENTITY_DOMAIN_V1: &str = "a3s.code.flow-step.identity.v1";
23pub const TASK_ADMISSION_SCOPE_IDENTITY_DOMAIN_V1: &str =
29 "a3s.code.task-admission-scope.identity.v1";
30pub const MODEL_GENERATION_POOL_IDENTITY_DOMAIN_V1: &str =
36 "a3s.code.model-generation-pool.identity.v1";
37pub const DYNAMIC_WORKFLOW_INPUT_IDENTITY_DOMAIN_V1: &str =
39 "a3s.code.dynamic-workflow.input.identity.v1";
40pub const DYNAMIC_WORKFLOW_CONTINUATION_IDENTITY_DOMAIN_V1: &str =
43 "a3s.code.dynamic-workflow.continuation.identity.v1";
44pub const DYNAMIC_WORKFLOW_CLAIM_IDENTITY_DOMAIN_V1: &str =
47 "a3s.code.dynamic-workflow.claim.identity.v1";
48pub const EXECUTION_PLAN_IDENTITY_DOMAIN_V1: &str = "a3s.code.execution-plan.identity.v1";
50pub const WORKFLOW_STEP_IDENTITY_DOMAIN_V1: &str = "a3s.code.workflow-step.identity.v1";
51pub const WORKFLOW_STEP_EVIDENCE_DOMAIN_V1: &str = "a3s.code.workflow-step.evidence.v1";
52pub const WORKFLOW_STEP_RESULT_DOMAIN_V1: &str = "a3s.code.workflow-step.result.v1";
53pub const EVALUATION_DISPATCH_IDENTITY_DOMAIN_V1: &str = "a3s.code.evaluation-dispatch.identity.v1";
54pub const EVALUATION_DISPATCH_REQUEST_DOMAIN_V1: &str = "a3s.code.evaluation-dispatch.request.v1";
55pub const EXECUTION_RESULT_RECEIPT_SCHEMA_V1: &str = "a3s.code.execution-result-receipt.v1";
56pub const EXECUTION_RESULT_MAX_BYTES: u64 = 1024 * 1024;
57
58#[derive(Debug, Clone, PartialEq, Eq, Error)]
59pub enum ExecutionIdentityError {
60 #[error("execution identity domain is empty or invalid")]
61 InvalidDomain,
62 #[error("execution identity serialization failed: {0}")]
63 Serialization(String),
64 #[error("execution identity digest is invalid")]
65 InvalidDigest,
66 #[error("execution identity digest does not match the value")]
67 DigestMismatch,
68 #[error("execution claim field `{0}` is empty")]
69 InvalidClaimField(&'static str),
70 #[error("execution result receipt field `{0}` is invalid")]
71 InvalidReceiptField(&'static str),
72 #[error("execution result receipt exceeds its byte limit")]
73 ReceiptSizeLimit,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct ExecutionIdentityV1 {
80 pub schema: String,
81 pub domain: String,
82 pub digest: String,
83}
84
85impl ExecutionIdentityV1 {
86 pub fn derive<T: Serialize>(
87 domain: impl Into<String>,
88 value: &T,
89 ) -> Result<Self, ExecutionIdentityError> {
90 let domain = domain.into();
91 validate_domain(&domain)?;
92 let canonical = serde_json::to_value(value)
93 .map(canonicalize)
94 .map_err(|error| ExecutionIdentityError::Serialization(error.to_string()))?;
95 let bytes = serde_json::to_vec(&canonical)
96 .map_err(|error| ExecutionIdentityError::Serialization(error.to_string()))?;
97 let mut hasher = Sha256::new();
98 hasher.update(domain.as_bytes());
99 hasher.update([0]);
100 hasher.update(bytes);
101 let digest = format!("sha256:{:x}", hasher.finalize());
102 Ok(Self {
103 schema: EXECUTION_IDENTITY_SCHEMA_V1.to_string(),
104 domain,
105 digest,
106 })
107 }
108
109 pub fn validate(&self) -> Result<(), ExecutionIdentityError> {
110 if self.schema != EXECUTION_IDENTITY_SCHEMA_V1 {
111 return Err(ExecutionIdentityError::InvalidDomain);
112 }
113 validate_domain(&self.domain)?;
114 let Some(hex) = self.digest.strip_prefix("sha256:") else {
115 return Err(ExecutionIdentityError::InvalidDigest);
116 };
117 if hex.len() != 64
118 || !hex
119 .bytes()
120 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
121 {
122 return Err(ExecutionIdentityError::InvalidDigest);
123 }
124 Ok(())
125 }
126
127 pub fn key(&self) -> &str {
128 &self.digest
129 }
130
131 pub fn validate_for<T: Serialize>(&self, value: &T) -> Result<(), ExecutionIdentityError> {
132 self.validate()?;
133 let expected = Self::derive(&self.domain, value)?;
134 if expected.digest != self.digest {
135 return Err(ExecutionIdentityError::DigestMismatch);
136 }
137 Ok(())
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum ExecutionResultOutcomeV1 {
144 Succeeded,
145 Failed,
146 Cancelled,
147 TimedOut,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct ExecutionResultReceiptV1 {
155 pub schema: String,
156 pub identity: ExecutionIdentityV1,
157 pub evidence_digest: String,
158 pub outcome: ExecutionResultOutcomeV1,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub result_digest: Option<String>,
161 pub result_bytes: u64,
162}
163
164impl ExecutionResultReceiptV1 {
165 pub fn new(
166 identity: ExecutionIdentityV1,
167 evidence_digest: impl Into<String>,
168 outcome: ExecutionResultOutcomeV1,
169 result_digest: Option<String>,
170 result_bytes: u64,
171 ) -> Result<Self, ExecutionIdentityError> {
172 let receipt = Self {
173 schema: EXECUTION_RESULT_RECEIPT_SCHEMA_V1.to_string(),
174 identity,
175 evidence_digest: evidence_digest.into(),
176 outcome,
177 result_digest,
178 result_bytes,
179 };
180 receipt.validate()?;
181 Ok(receipt)
182 }
183
184 pub fn validate(&self) -> Result<(), ExecutionIdentityError> {
185 if self.schema != EXECUTION_RESULT_RECEIPT_SCHEMA_V1 {
186 return Err(ExecutionIdentityError::InvalidReceiptField("schema"));
187 }
188 self.identity.validate()?;
189 validate_digest(&self.evidence_digest)
190 .map_err(|_| ExecutionIdentityError::InvalidReceiptField("evidence_digest"))?;
191 if self.result_bytes > EXECUTION_RESULT_MAX_BYTES {
192 return Err(ExecutionIdentityError::ReceiptSizeLimit);
193 }
194 match (&self.result_digest, self.result_bytes, self.outcome) {
195 (Some(digest), bytes, ExecutionResultOutcomeV1::Succeeded) => {
196 validate_digest(digest)
197 .map_err(|_| ExecutionIdentityError::InvalidReceiptField("result_digest"))?;
198 if bytes == 0 {
199 return Err(ExecutionIdentityError::InvalidReceiptField("result_bytes"));
200 }
201 }
202 (None, 0, ExecutionResultOutcomeV1::Failed)
203 | (None, 0, ExecutionResultOutcomeV1::Cancelled)
204 | (None, 0, ExecutionResultOutcomeV1::TimedOut) => {}
205 _ => return Err(ExecutionIdentityError::InvalidReceiptField("outcome")),
206 }
207 Ok(())
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
216pub(crate) struct ExecutionClaimV1 {
217 identity: ExecutionIdentityV1,
218 record_id: String,
219 ledger_key: String,
220 owner_id: String,
221}
222
223impl ExecutionClaimV1 {
224 pub(crate) fn new(
225 identity: ExecutionIdentityV1,
226 record_id: impl Into<String>,
227 ledger_key: impl Into<String>,
228 owner_id: impl Into<String>,
229 ) -> Result<Self, ExecutionIdentityError> {
230 identity.validate()?;
231 let record_id = record_id.into();
232 let ledger_key = ledger_key.into();
233 let owner_id = owner_id.into();
234 if record_id.is_empty() {
235 return Err(ExecutionIdentityError::InvalidClaimField("record_id"));
236 }
237 if ledger_key.is_empty() {
238 return Err(ExecutionIdentityError::InvalidClaimField("ledger_key"));
239 }
240 if owner_id.is_empty() {
241 return Err(ExecutionIdentityError::InvalidClaimField("owner_id"));
242 }
243 Ok(Self {
244 identity,
245 record_id,
246 ledger_key,
247 owner_id,
248 })
249 }
250
251 pub(crate) fn identity(&self) -> &ExecutionIdentityV1 {
252 &self.identity
253 }
254
255 pub(crate) fn record_id(&self) -> &str {
256 &self.record_id
257 }
258
259 pub(crate) fn ledger_key(&self) -> &str {
260 &self.ledger_key
261 }
262
263 pub(crate) fn owner_id(&self) -> &str {
264 &self.owner_id
265 }
266
267 pub(crate) fn result_receipt(
268 &self,
269 evidence_digest: impl Into<String>,
270 outcome: ExecutionResultOutcomeV1,
271 result_digest: Option<String>,
272 result_bytes: u64,
273 ) -> Result<ExecutionResultReceiptV1, ExecutionIdentityError> {
274 ExecutionResultReceiptV1::new(
275 self.identity.clone(),
276 evidence_digest,
277 outcome,
278 result_digest,
279 result_bytes,
280 )
281 }
282}
283
284fn canonicalize(value: serde_json::Value) -> serde_json::Value {
285 match value {
286 serde_json::Value::Array(values) => {
287 serde_json::Value::Array(values.into_iter().map(canonicalize).collect())
288 }
289 serde_json::Value::Object(values) => {
290 let values = values
291 .into_iter()
292 .map(|(key, value)| (key, canonicalize(value)))
293 .collect::<BTreeMap<_, _>>();
294 serde_json::Value::Object(values.into_iter().collect())
295 }
296 value => value,
297 }
298}
299
300fn validate_digest(value: &str) -> Result<(), ExecutionIdentityError> {
301 let Some(hex) = value.strip_prefix("sha256:") else {
302 return Err(ExecutionIdentityError::InvalidDigest);
303 };
304 if hex.len() != 64
305 || !hex
306 .bytes()
307 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
308 {
309 return Err(ExecutionIdentityError::InvalidDigest);
310 }
311 Ok(())
312}
313
314fn validate_domain(domain: &str) -> Result<(), ExecutionIdentityError> {
315 if domain.is_empty()
316 || domain.len() > 128
317 || domain.contains('\0')
318 || domain.lines().count() != 1
319 || !domain.bytes().all(|byte| {
320 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'-' | b'_')
321 })
322 {
323 return Err(ExecutionIdentityError::InvalidDomain);
324 }
325 Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn identity_is_domain_separated_and_replay_stable() {
334 let value = serde_json::json!({"name": "read", "args": {"path": "src/lib.rs"}});
335 let first =
336 ExecutionIdentityV1::derive(TOOL_INVOCATION_IDENTITY_DOMAIN_V1, &value).unwrap();
337 let second =
338 ExecutionIdentityV1::derive(TOOL_INVOCATION_IDENTITY_DOMAIN_V1, &value).unwrap();
339 let other = ExecutionIdentityV1::derive(MODEL_CALL_IDENTITY_DOMAIN_V1, &value).unwrap();
340
341 assert_eq!(first, second);
342 assert_ne!(first.digest, other.digest);
343 first.validate().unwrap();
344 first.validate_for(&value).unwrap();
345
346 let left = serde_json::json!({"a": 1, "b": 2});
347 let right = serde_json::json!({"b": 2, "a": 1});
348 assert_eq!(
349 ExecutionIdentityV1::derive("a3s.test", &left).unwrap(),
350 ExecutionIdentityV1::derive("a3s.test", &right).unwrap()
351 );
352 }
353
354 #[test]
355 fn identity_rejects_malformed_domains_and_digests() {
356 assert!(matches!(
357 ExecutionIdentityV1::derive("Bad Domain", &"value"),
358 Err(ExecutionIdentityError::InvalidDomain)
359 ));
360 let mut identity = ExecutionIdentityV1::derive("a3s.test", &"value").unwrap();
361 identity.digest = "sha256:ABC".to_string();
362 assert!(matches!(
363 identity.validate(),
364 Err(ExecutionIdentityError::InvalidDigest)
365 ));
366 }
367
368 #[test]
369 fn identity_validation_detects_content_tampering() {
370 let value = serde_json::json!({"a": 1, "b": {"c": true}});
371 let identity = ExecutionIdentityV1::derive("a3s.test", &value).unwrap();
372 let changed = serde_json::json!({"a": 2, "b": {"c": true}});
373 assert!(matches!(
374 identity.validate_for(&changed),
375 Err(ExecutionIdentityError::DigestMismatch)
376 ));
377 }
378
379 #[test]
380 fn claim_binds_canonical_identity_to_a_legacy_ledger_key() {
381 let payload = serde_json::json!({"secret": "do-not-persist"});
382 let identity = ExecutionIdentityV1::derive("a3s.test", &payload).unwrap();
383 let claim = ExecutionClaimV1::new(identity.clone(), "dispatch-1", "legacy-hash", "owner-1")
384 .unwrap();
385
386 assert_eq!(claim.identity(), &identity);
387 assert_eq!(claim.record_id(), "dispatch-1");
388 assert_eq!(claim.ledger_key(), "legacy-hash");
389 assert_eq!(claim.owner_id(), "owner-1");
390 let debug = format!("{claim:?}");
391 assert!(!debug.contains("do-not-persist"));
392 }
393
394 #[test]
395 fn result_receipt_is_digest_only_and_bounded() {
396 let identity = ExecutionIdentityV1::derive("a3s.test", &"request").unwrap();
397 let claim =
398 ExecutionClaimV1::new(identity.clone(), "record-1", "legacy-hash", "owner-1").unwrap();
399 let receipt = claim
400 .result_receipt(
401 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
402 ExecutionResultOutcomeV1::Succeeded,
403 Some(
404 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
405 .into(),
406 ),
407 12,
408 )
409 .unwrap();
410 receipt.validate().unwrap();
411 assert_eq!(receipt.identity, identity);
412 assert!(!format!("{receipt:?}").contains("request"));
413
414 let invalid = ExecutionResultReceiptV1 {
415 schema: EXECUTION_RESULT_RECEIPT_SCHEMA_V1.into(),
416 identity: receipt.identity,
417 evidence_digest: receipt.evidence_digest,
418 outcome: ExecutionResultOutcomeV1::Succeeded,
419 result_digest: None,
420 result_bytes: 0,
421 };
422 assert!(matches!(
423 invalid.validate(),
424 Err(ExecutionIdentityError::InvalidReceiptField("outcome"))
425 ));
426 }
427}