1use 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 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
59impl<'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}