made_core/value_objects/execution/
execution_receipt.rs1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use super::{
5 ExecutionConnectorId, ExecutionOperationId, ExecutionReceiptId, ExecutionRecoveryCapability,
6 ExecutionRequestDigest, ExternalOperationId,
7};
8use crate::error::DomainError;
9use crate::value_objects::artifact::{ArtifactRef, ArtifactSourceKind};
10use crate::value_objects::ceremony::{StepClaimFence, StepResult};
11use crate::value_objects::MeasuredBudgetQuantities;
12
13pub const MAX_EXECUTION_ARTIFACTS: usize = 100;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct ExecutionReceipt {
18 receipt_id: ExecutionReceiptId,
19 operation_id: ExecutionOperationId,
20 request_digest: ExecutionRequestDigest,
21 producer_claim_fence: StepClaimFence,
22 connector_id: ExecutionConnectorId,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 external_operation_id: Option<ExternalOperationId>,
25 recovery_capability: ExecutionRecoveryCapability,
26 source_kind: ArtifactSourceKind,
27 result: StepResult,
28 artifacts: Vec<ArtifactRef>,
29 #[serde(default, skip_serializing_if = "MeasuredBudgetQuantities::is_unknown")]
30 budget_measurement: MeasuredBudgetQuantities,
31 #[serde(with = "time::serde::rfc3339")]
32 observed_at: OffsetDateTime,
33}
34
35impl ExecutionReceipt {
36 #[allow(clippy::too_many_arguments)]
37 pub fn new(
38 operation_id: ExecutionOperationId,
39 request_digest: ExecutionRequestDigest,
40 producer_claim_fence: StepClaimFence,
41 connector_id: ExecutionConnectorId,
42 external_operation_id: Option<ExternalOperationId>,
43 recovery_capability: ExecutionRecoveryCapability,
44 source_kind: ArtifactSourceKind,
45 result: StepResult,
46 artifacts: Vec<ArtifactRef>,
47 observed_at: OffsetDateTime,
48 ) -> Result<Self, DomainError> {
49 if !source_kind.is_execution_source() {
50 return Err(DomainError::InvariantViolated {
51 reason: "execution receipt requires an execution source kind",
52 });
53 }
54 if artifacts.len() > MAX_EXECUTION_ARTIFACTS {
55 return Err(DomainError::OutOfRange {
56 field: "execution_receipt.artifacts",
57 value: artifacts.len() as f64,
58 min: 0.0,
59 max: MAX_EXECUTION_ARTIFACTS as f64,
60 });
61 }
62 let receipt_id = ExecutionReceiptId::for_operation(&operation_id);
63 for artifact in &artifacts {
64 let provenance = artifact.provenance();
65 if provenance.execution_receipt_id() != Some(&receipt_id)
66 || provenance.operation_id() != Some(&operation_id)
67 || provenance.accepted_claim_fence() != Some(&producer_claim_fence)
68 || provenance.source_kind() != source_kind
69 {
70 return Err(DomainError::InvariantViolated {
71 reason: "execution receipt artifact provenance does not match its execution",
72 });
73 }
74 }
75 Ok(Self {
76 receipt_id,
77 operation_id,
78 request_digest,
79 producer_claim_fence,
80 connector_id,
81 external_operation_id,
82 recovery_capability,
83 source_kind,
84 result,
85 artifacts,
86 budget_measurement: MeasuredBudgetQuantities::default(),
87 observed_at,
88 })
89 }
90
91 #[must_use]
92 pub fn with_budget_measurement(mut self, measured: MeasuredBudgetQuantities) -> Self {
93 self.budget_measurement = measured;
94 self
95 }
96
97 #[must_use]
98 pub const fn receipt_id(&self) -> &ExecutionReceiptId {
99 &self.receipt_id
100 }
101
102 #[must_use]
103 pub const fn operation_id(&self) -> &ExecutionOperationId {
104 &self.operation_id
105 }
106
107 #[must_use]
108 pub const fn request_digest(&self) -> &ExecutionRequestDigest {
109 &self.request_digest
110 }
111
112 #[must_use]
113 pub const fn producer_claim_fence(&self) -> &StepClaimFence {
114 &self.producer_claim_fence
115 }
116
117 #[must_use]
118 pub const fn connector_id(&self) -> &ExecutionConnectorId {
119 &self.connector_id
120 }
121
122 #[must_use]
123 pub const fn external_operation_id(&self) -> Option<&ExternalOperationId> {
124 self.external_operation_id.as_ref()
125 }
126
127 #[must_use]
128 pub const fn recovery_capability(&self) -> ExecutionRecoveryCapability {
129 self.recovery_capability
130 }
131
132 #[must_use]
133 pub const fn source_kind(&self) -> ArtifactSourceKind {
134 self.source_kind
135 }
136
137 #[must_use]
138 pub const fn result(&self) -> &StepResult {
139 &self.result
140 }
141
142 #[must_use]
143 pub fn artifacts(&self) -> &[ArtifactRef] {
144 &self.artifacts
145 }
146
147 #[must_use]
148 pub const fn budget_measurement(&self) -> MeasuredBudgetQuantities {
149 self.budget_measurement
150 }
151
152 #[must_use]
153 pub const fn observed_at(&self) -> OffsetDateTime {
154 self.observed_at
155 }
156
157 pub fn validate(&self) -> Result<(), DomainError> {
159 if self.receipt_id != ExecutionReceiptId::for_operation(&self.operation_id) {
160 return Err(DomainError::InvariantViolated {
161 reason: "execution receipt id does not match its operation",
162 });
163 }
164 if !self.source_kind.is_execution_source() {
165 return Err(DomainError::InvariantViolated {
166 reason: "execution receipt requires an execution source kind",
167 });
168 }
169 if self.artifacts.len() > MAX_EXECUTION_ARTIFACTS {
170 return Err(DomainError::OutOfRange {
171 field: "execution_receipt.artifacts",
172 value: self.artifacts.len() as f64,
173 min: 0.0,
174 max: MAX_EXECUTION_ARTIFACTS as f64,
175 });
176 }
177 for artifact in &self.artifacts {
178 artifact.validate()?;
179 let provenance = artifact.provenance();
180 if provenance.execution_receipt_id() != Some(&self.receipt_id)
181 || provenance.operation_id() != Some(&self.operation_id)
182 || provenance.accepted_claim_fence() != Some(&self.producer_claim_fence)
183 || provenance.source_kind() != self.source_kind
184 {
185 return Err(DomainError::InvariantViolated {
186 reason: "execution receipt artifact provenance does not match its execution",
187 });
188 }
189 }
190 Ok(())
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use serde_json::json;
197
198 use super::*;
199 use crate::value_objects::{
200 ArtifactDigest, ArtifactId, ArtifactMediaType, ArtifactProvenance, ArtifactSizeBytes,
201 StepOutput,
202 };
203
204 fn receipt() -> ExecutionReceipt {
205 let operation_id = ExecutionOperationId::new("1".repeat(64)).unwrap();
206 let receipt_id = ExecutionReceiptId::for_operation(&operation_id);
207 let fence = StepClaimFence::new("2".repeat(64)).unwrap();
208 let provenance = ArtifactProvenance::execution(
209 ArtifactSourceKind::NoOp,
210 receipt_id,
211 operation_id.clone(),
212 fence.clone(),
213 OffsetDateTime::UNIX_EPOCH,
214 )
215 .unwrap();
216 ExecutionReceipt::new(
217 operation_id,
218 ExecutionRequestDigest::new("3".repeat(64)).unwrap(),
219 fence,
220 ExecutionConnectorId::new("test").unwrap(),
221 None,
222 ExecutionRecoveryCapability::IdempotentByOperationId,
223 ArtifactSourceKind::NoOp,
224 StepResult::completed(StepOutput::empty()).unwrap(),
225 vec![ArtifactRef::new(
226 ArtifactId::new("artifact").unwrap(),
227 ArtifactDigest::new(format!("sha256:{}", "4".repeat(64))).unwrap(),
228 ArtifactSizeBytes::new(1),
229 ArtifactMediaType::new("text/plain").unwrap(),
230 provenance,
231 )],
232 OffsetDateTime::UNIX_EPOCH,
233 )
234 .unwrap()
235 }
236
237 #[test]
238 fn deserialized_receipt_revalidates_identity_and_collection_bound() {
239 let original = receipt();
240 original.validate().unwrap();
241
242 let mut wrong_identity = serde_json::to_value(&original).unwrap();
243 wrong_identity["receipt_id"] = json!("5".repeat(64));
244 let wrong_identity: ExecutionReceipt = serde_json::from_value(wrong_identity).unwrap();
245 assert!(wrong_identity.validate().is_err());
246
247 let mut too_many = serde_json::to_value(&original).unwrap();
248 let artifact = too_many["artifacts"][0].clone();
249 too_many["artifacts"] = json!(vec![artifact; MAX_EXECUTION_ARTIFACTS + 1]);
250 let too_many: ExecutionReceipt = serde_json::from_value(too_many).unwrap();
251 assert!(too_many.validate().is_err());
252 }
253}