Skip to main content

made_core/value_objects/execution/
execution_receipt.rs

1use 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/// Immutable terminal observation of one semantic external operation.
16#[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    /// Re-check all derived and nested invariants after deserialization.
158    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}