lifeloop-cli 0.3.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Lifecycle receipt wire shape and validation.

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 {
    /// Wire keys that are required *and* nullable: the JSON object MUST carry
    /// them even when the value is `null`. A producer that omits one of these
    /// keys is rejected at deserialize time. See
    /// `docs/specs/lifecycle-contract/body.md` ("required and nullable") and
    /// `docs/specs/README.md` for the field-presence taxonomy.
    pub const REQUIRED_NULLABLE_FIELDS: &'static [&'static str] = &[
        "idempotency_key",
        "sequence",
        "parent_receipt_id",
        "failure_class",
        "retry_class",
    ];
}

// `Option<T>` defaults to "missing key -> None" under serde, which would let an
// inbound receipt drop a required-nullable key entirely and still deserialize.
// The private wire shape keeps one extra Option layer for those fields so we
// can distinguish missing from explicit null, then converts into the public
// struct after validating field presence.
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(())
    }
}