Skip to main content

lifeloop/
receipt_contract.rs

1//! Lifecycle receipt wire shape and validation.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    CapabilityDegradation, FailureClass, IntegrationMode, LifecycleEventKind, PayloadReceipt,
7    ReceiptStatus, RetryClass, SCHEMA_VERSION, ValidationError, Warning, require_non_empty,
8};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
11#[serde(deny_unknown_fields)]
12pub struct LifecycleReceipt {
13    pub schema_version: String,
14    pub receipt_id: String,
15    pub idempotency_key: Option<String>,
16    pub client_id: String,
17    pub adapter_id: String,
18    pub invocation_id: String,
19    pub event: LifecycleEventKind,
20    pub event_id: String,
21    pub sequence: Option<u64>,
22    pub parent_receipt_id: Option<String>,
23    pub integration_mode: IntegrationMode,
24    pub status: ReceiptStatus,
25    pub at_epoch_s: u64,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub harness_session_id: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub harness_run_id: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub harness_task_id: Option<String>,
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub payload_receipts: Vec<PayloadReceipt>,
34    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
35    pub telemetry_summary: serde_json::Map<String, serde_json::Value>,
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub capability_degradations: Vec<CapabilityDegradation>,
38    pub failure_class: Option<FailureClass>,
39    pub retry_class: Option<RetryClass>,
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub warnings: Vec<Warning>,
42}
43
44impl LifecycleReceipt {
45    /// Wire keys that are required *and* nullable: the JSON object MUST carry
46    /// them even when the value is `null`. A producer that omits one of these
47    /// keys is rejected at deserialize time. See
48    /// `docs/specs/lifecycle-contract/body.md` ("required and nullable") and
49    /// `docs/specs/README.md` for the field-presence taxonomy.
50    pub const REQUIRED_NULLABLE_FIELDS: &'static [&'static str] = &[
51        "idempotency_key",
52        "sequence",
53        "parent_receipt_id",
54        "failure_class",
55        "retry_class",
56    ];
57}
58
59// `Option<T>` defaults to "missing key -> None" under serde, which would let an
60// inbound receipt drop a required-nullable key entirely and still deserialize.
61// The private wire shape keeps one extra Option layer for those fields so we
62// can distinguish missing from explicit null, then converts into the public
63// struct after validating field presence.
64impl<'de> Deserialize<'de> for LifecycleReceipt {
65    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
66    where
67        D: serde::Deserializer<'de>,
68    {
69        LifecycleReceiptWire::deserialize(deserializer)?.into_receipt()
70    }
71}
72
73#[derive(Deserialize)]
74#[serde(deny_unknown_fields)]
75struct LifecycleReceiptWire {
76    schema_version: Option<String>,
77    receipt_id: Option<String>,
78    #[serde(default, deserialize_with = "required_nullable")]
79    idempotency_key: Option<Option<String>>,
80    client_id: Option<String>,
81    adapter_id: Option<String>,
82    invocation_id: Option<String>,
83    event: Option<LifecycleEventKind>,
84    event_id: Option<String>,
85    #[serde(default, deserialize_with = "required_nullable")]
86    sequence: Option<Option<u64>>,
87    #[serde(default, deserialize_with = "required_nullable")]
88    parent_receipt_id: Option<Option<String>>,
89    integration_mode: Option<IntegrationMode>,
90    status: Option<ReceiptStatus>,
91    at_epoch_s: Option<u64>,
92    harness_session_id: Option<String>,
93    harness_run_id: Option<String>,
94    harness_task_id: Option<String>,
95    #[serde(default)]
96    payload_receipts: Vec<PayloadReceipt>,
97    #[serde(default)]
98    telemetry_summary: serde_json::Map<String, serde_json::Value>,
99    #[serde(default)]
100    capability_degradations: Vec<CapabilityDegradation>,
101    #[serde(default, deserialize_with = "required_nullable")]
102    failure_class: Option<Option<FailureClass>>,
103    #[serde(default, deserialize_with = "required_nullable")]
104    retry_class: Option<Option<RetryClass>>,
105    #[serde(default)]
106    warnings: Vec<Warning>,
107}
108
109impl LifecycleReceiptWire {
110    fn into_receipt<E: serde::de::Error>(self) -> Result<LifecycleReceipt, E> {
111        let missing_required_nullable = [
112            ("idempotency_key", self.idempotency_key.is_none()),
113            ("sequence", self.sequence.is_none()),
114            ("parent_receipt_id", self.parent_receipt_id.is_none()),
115            ("failure_class", self.failure_class.is_none()),
116            ("retry_class", self.retry_class.is_none()),
117        ]
118        .into_iter()
119        .filter_map(|(field, missing)| missing.then_some(field))
120        .collect::<Vec<_>>();
121
122        if !missing_required_nullable.is_empty() {
123            return Err(serde::de::Error::custom(format!(
124                "LifecycleReceipt is missing required-nullable field(s): {}; \
125                 these keys MUST be present even when their value is null",
126                missing_required_nullable.join(", ")
127            )));
128        }
129
130        Ok(LifecycleReceipt {
131            schema_version: required(self.schema_version, "schema_version")?,
132            receipt_id: required(self.receipt_id, "receipt_id")?,
133            idempotency_key: self.idempotency_key.expect("checked above"),
134            client_id: required(self.client_id, "client_id")?,
135            adapter_id: required(self.adapter_id, "adapter_id")?,
136            invocation_id: required(self.invocation_id, "invocation_id")?,
137            event: required(self.event, "event")?,
138            event_id: required(self.event_id, "event_id")?,
139            sequence: self.sequence.expect("checked above"),
140            parent_receipt_id: self.parent_receipt_id.expect("checked above"),
141            integration_mode: required(self.integration_mode, "integration_mode")?,
142            status: required(self.status, "status")?,
143            at_epoch_s: required(self.at_epoch_s, "at_epoch_s")?,
144            harness_session_id: self.harness_session_id,
145            harness_run_id: self.harness_run_id,
146            harness_task_id: self.harness_task_id,
147            payload_receipts: self.payload_receipts,
148            telemetry_summary: self.telemetry_summary,
149            capability_degradations: self.capability_degradations,
150            failure_class: self.failure_class.expect("checked above"),
151            retry_class: self.retry_class.expect("checked above"),
152            warnings: self.warnings,
153        })
154    }
155}
156
157fn required<T, E: serde::de::Error>(value: Option<T>, field: &'static str) -> Result<T, E> {
158    value.ok_or_else(|| serde::de::Error::missing_field(field))
159}
160
161fn required_nullable<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
162where
163    D: serde::Deserializer<'de>,
164    T: Deserialize<'de>,
165{
166    Option::<T>::deserialize(deserializer).map(Some)
167}
168
169impl LifecycleReceipt {
170    pub fn validate(&self) -> Result<(), ValidationError> {
171        if self.schema_version != SCHEMA_VERSION {
172            return Err(ValidationError::SchemaVersionMismatch {
173                expected: SCHEMA_VERSION.to_string(),
174                found: self.schema_version.clone(),
175            });
176        }
177        require_non_empty(&self.receipt_id, "receipt.receipt_id")?;
178        require_non_empty(&self.client_id, "receipt.client_id")?;
179        require_non_empty(&self.adapter_id, "receipt.adapter_id")?;
180        require_non_empty(&self.invocation_id, "receipt.invocation_id")?;
181        require_non_empty(&self.event_id, "receipt.event_id")?;
182        if let Some(idem) = &self.idempotency_key {
183            require_non_empty(idem, "receipt.idempotency_key")?;
184        }
185        if let Some(parent) = &self.parent_receipt_id {
186            require_non_empty(parent, "receipt.parent_receipt_id")?;
187        }
188        if matches!(self.event, LifecycleEventKind::ReceiptEmitted) {
189            return Err(ValidationError::InvalidReceipt(
190                "receipt.emitted is a notification event and must not itself produce a receipt"
191                    .into(),
192            ));
193        }
194        for pr in &self.payload_receipts {
195            pr.validate()?;
196        }
197        for deg in &self.capability_degradations {
198            deg.validate()?;
199        }
200        for w in &self.warnings {
201            w.validate()?;
202        }
203        match (
204            matches!(self.status, ReceiptStatus::Failed),
205            self.failure_class.is_some(),
206        ) {
207            (true, false) => {
208                return Err(ValidationError::InvalidReceipt(
209                    "status=failed requires failure_class".into(),
210                ));
211            }
212            (false, true) => {
213                return Err(ValidationError::InvalidReceipt(
214                    "failure_class is only valid on status=failed receipts".into(),
215                ));
216            }
217            _ => {}
218        }
219        if matches!(self.status, ReceiptStatus::Failed) && self.retry_class.is_none() {
220            return Err(ValidationError::InvalidReceipt(
221                "status=failed requires retry_class (clients must declare retry posture)".into(),
222            ));
223        }
224        Ok(())
225    }
226}