use serde::{Deserialize, Serialize};
use crate::{
CapabilityDegradation, FailureClass, IntegrationMode, LifecycleEventKind, PayloadReceipt,
ReceiptStatus, RetryClass, SCHEMA_VERSION, ValidationError, Warning, require_non_empty,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct LifecycleReceipt {
pub schema_version: String,
pub receipt_id: String,
pub idempotency_key: Option<String>,
pub client_id: String,
pub adapter_id: String,
pub invocation_id: String,
pub event: LifecycleEventKind,
pub event_id: String,
pub sequence: Option<u64>,
pub parent_receipt_id: Option<String>,
pub integration_mode: IntegrationMode,
pub status: ReceiptStatus,
pub at_epoch_s: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_task_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub payload_receipts: Vec<PayloadReceipt>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub telemetry_summary: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capability_degradations: Vec<CapabilityDegradation>,
pub failure_class: Option<FailureClass>,
pub retry_class: Option<RetryClass>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<Warning>,
}
impl LifecycleReceipt {
pub const REQUIRED_NULLABLE_FIELDS: &'static [&'static str] = &[
"idempotency_key",
"sequence",
"parent_receipt_id",
"failure_class",
"retry_class",
];
}
impl<'de> Deserialize<'de> for LifecycleReceipt {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
LifecycleReceiptWire::deserialize(deserializer)?.into_receipt()
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LifecycleReceiptWire {
schema_version: Option<String>,
receipt_id: Option<String>,
#[serde(default, deserialize_with = "required_nullable")]
idempotency_key: Option<Option<String>>,
client_id: Option<String>,
adapter_id: Option<String>,
invocation_id: Option<String>,
event: Option<LifecycleEventKind>,
event_id: Option<String>,
#[serde(default, deserialize_with = "required_nullable")]
sequence: Option<Option<u64>>,
#[serde(default, deserialize_with = "required_nullable")]
parent_receipt_id: Option<Option<String>>,
integration_mode: Option<IntegrationMode>,
status: Option<ReceiptStatus>,
at_epoch_s: Option<u64>,
harness_session_id: Option<String>,
harness_run_id: Option<String>,
harness_task_id: Option<String>,
#[serde(default)]
payload_receipts: Vec<PayloadReceipt>,
#[serde(default)]
telemetry_summary: serde_json::Map<String, serde_json::Value>,
#[serde(default)]
capability_degradations: Vec<CapabilityDegradation>,
#[serde(default, deserialize_with = "required_nullable")]
failure_class: Option<Option<FailureClass>>,
#[serde(default, deserialize_with = "required_nullable")]
retry_class: Option<Option<RetryClass>>,
#[serde(default)]
warnings: Vec<Warning>,
}
impl LifecycleReceiptWire {
fn into_receipt<E: serde::de::Error>(self) -> Result<LifecycleReceipt, E> {
let missing_required_nullable = [
("idempotency_key", self.idempotency_key.is_none()),
("sequence", self.sequence.is_none()),
("parent_receipt_id", self.parent_receipt_id.is_none()),
("failure_class", self.failure_class.is_none()),
("retry_class", self.retry_class.is_none()),
]
.into_iter()
.filter_map(|(field, missing)| missing.then_some(field))
.collect::<Vec<_>>();
if !missing_required_nullable.is_empty() {
return Err(serde::de::Error::custom(format!(
"LifecycleReceipt is missing required-nullable field(s): {}; \
these keys MUST be present even when their value is null",
missing_required_nullable.join(", ")
)));
}
Ok(LifecycleReceipt {
schema_version: required(self.schema_version, "schema_version")?,
receipt_id: required(self.receipt_id, "receipt_id")?,
idempotency_key: self.idempotency_key.expect("checked above"),
client_id: required(self.client_id, "client_id")?,
adapter_id: required(self.adapter_id, "adapter_id")?,
invocation_id: required(self.invocation_id, "invocation_id")?,
event: required(self.event, "event")?,
event_id: required(self.event_id, "event_id")?,
sequence: self.sequence.expect("checked above"),
parent_receipt_id: self.parent_receipt_id.expect("checked above"),
integration_mode: required(self.integration_mode, "integration_mode")?,
status: required(self.status, "status")?,
at_epoch_s: required(self.at_epoch_s, "at_epoch_s")?,
harness_session_id: self.harness_session_id,
harness_run_id: self.harness_run_id,
harness_task_id: self.harness_task_id,
payload_receipts: self.payload_receipts,
telemetry_summary: self.telemetry_summary,
capability_degradations: self.capability_degradations,
failure_class: self.failure_class.expect("checked above"),
retry_class: self.retry_class.expect("checked above"),
warnings: self.warnings,
})
}
}
fn required<T, E: serde::de::Error>(value: Option<T>, field: &'static str) -> Result<T, E> {
value.ok_or_else(|| serde::de::Error::missing_field(field))
}
fn required_nullable<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
impl LifecycleReceipt {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.schema_version != SCHEMA_VERSION {
return Err(ValidationError::SchemaVersionMismatch {
expected: SCHEMA_VERSION.to_string(),
found: self.schema_version.clone(),
});
}
require_non_empty(&self.receipt_id, "receipt.receipt_id")?;
require_non_empty(&self.client_id, "receipt.client_id")?;
require_non_empty(&self.adapter_id, "receipt.adapter_id")?;
require_non_empty(&self.invocation_id, "receipt.invocation_id")?;
require_non_empty(&self.event_id, "receipt.event_id")?;
if let Some(idem) = &self.idempotency_key {
require_non_empty(idem, "receipt.idempotency_key")?;
}
if let Some(parent) = &self.parent_receipt_id {
require_non_empty(parent, "receipt.parent_receipt_id")?;
}
if matches!(self.event, LifecycleEventKind::ReceiptEmitted) {
return Err(ValidationError::InvalidReceipt(
"receipt.emitted is a notification event and must not itself produce a receipt"
.into(),
));
}
for pr in &self.payload_receipts {
pr.validate()?;
}
for deg in &self.capability_degradations {
deg.validate()?;
}
for w in &self.warnings {
w.validate()?;
}
match (
matches!(self.status, ReceiptStatus::Failed),
self.failure_class.is_some(),
) {
(true, false) => {
return Err(ValidationError::InvalidReceipt(
"status=failed requires failure_class".into(),
));
}
(false, true) => {
return Err(ValidationError::InvalidReceipt(
"failure_class is only valid on status=failed receipts".into(),
));
}
_ => {}
}
if matches!(self.status, ReceiptStatus::Failed) && self.retry_class.is_none() {
return Err(ValidationError::InvalidReceipt(
"status=failed requires retry_class (clients must declare retry posture)".into(),
));
}
Ok(())
}
}