lifeloop-cli 0.5.0

Provider-neutral lifecycle abstraction and normalizer for AI harnesses
Documentation
//! Failure-class mapping (issue #15).
//!
//! Fills the [`FailureMapper`] seam declared in `src/router/seams.rs`
//! (issue #7) and the validation-layer companion to the
//! `receipt.emitted` guard added at emission time in
//! `src/router/receipts.rs` (issue #14).
//!
//! # Boundary
//!
//! Owns:
//! * [`LifeloopFailureMapper`] — concrete [`FailureMapper`]
//!   implementation. Pure function, no state.
//! * [`failure_class_for_route_error`] / [`failure_class_for_receipt_error`]
//!   / [`failure_class_for_transport`] — free helpers used by the
//!   mapper and exposed for callers that hold one error shape and
//!   want a [`FailureClass`] without going through the trait.
//! * [`retry_class_for`] — a thin wrapper over
//!   [`crate::FailureClass::default_retry`] kept here so the
//!   per-failure retry rule is named in one place.
//! * [`TransportError`] — a small typed enum covering the IO/transport
//!   shapes a real callback transport would surface.
//! * [`validate_receipt_eligible`] — validation-layer guard that
//!   refuses to plan a receipt for a `receipt.emitted` event,
//!   complementing the emission-time guard in
//!   [`super::receipts::ReceiptError::ReceiptEmittedNotEmittable`].
//!
//! Does **not** own:
//! * negotiation outcome → status mapping (that lives in
//!   `src/router/receipts.rs::derive_status`);
//! * adapter-specific failure semantics. Per-adapter mapping fixtures
//!   live in `tests/router_failure_mapping.rs` and translate
//!   adapter-emitted strings to the *shared* [`FailureClass`]
//!   vocabulary; they do not extend that vocabulary.
//!
//! # Mapping rationale
//!
//! Every [`super::RouteError`] variant maps to exactly one
//! [`FailureClass`]:
//!
//! | RouteError variant            | FailureClass        |
//! |-------------------------------|---------------------|
//! | `SchemaVersionMismatch`       | `InvalidRequest`    |
//! | `EmptySentinel`               | `InvalidRequest`    |
//! | `UnknownEventName`            | `InvalidRequest`    |
//! | `UnknownEnumName`             | `InvalidRequest`    |
//! | `InvalidFrameContext`         | `InvalidRequest`    |
//! | `InvalidPayloadRef`           | `InvalidRequest`    |
//! | `InvalidEventEnvelope`        | `InvalidRequest`    |
//! | `AdapterIdNotFound`           | `AdapterUnavailable`|
//! | `AdapterVersionMismatch`      | `AdapterUnavailable`|
//!
//! Every [`super::ReceiptError`] variant maps to exactly one
//! [`FailureClass`]:
//!
//! | ReceiptError variant          | FailureClass        |
//! |-------------------------------|---------------------|
//! | `ReceiptEmittedNotEmittable`  | `InvalidRequest`    |
//! | `Conflict`                    | `StateConflict`     |
//! | `Invalid`                     | `InvalidRequest`    |

use crate::{FailureClass, LifecycleEventKind, NegotiationOutcome, RetryClass};

use super::plan::RoutingPlan;
use super::receipts::ReceiptError;
use super::seams::FailureMapper;
use super::validation::RouteError;

// ===========================================================================
// TransportError
// ===========================================================================

/// Coarse shape of a callback-transport failure.
///
/// A real callback transport (HTTP, IPC, in-process bridge) surfaces
/// errors at varying granularity. The mapper consumes this enum so
/// the trait is portable across transports and so the retry-class
/// derivation has a stable input vocabulary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportError {
    /// Network or pipe-level failure: connection refused, broken
    /// pipe, peer reset.
    Io(String),
    /// The remote did not respond within the configured deadline.
    Timeout,
    /// A non-IO crash inside the transport itself (serialization
    /// panic, internal bug). Distinct from `Io` because the retry
    /// class differs (`InternalError` -> `RetryAfterReread`).
    Internal(String),
}

impl std::fmt::Display for TransportError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(detail) => write!(f, "transport io error: {detail}"),
            Self::Timeout => f.write_str("transport timeout"),
            Self::Internal(detail) => write!(f, "transport internal error: {detail}"),
        }
    }
}

impl std::error::Error for TransportError {}

// ===========================================================================
// Free mapping helpers
// ===========================================================================

/// Map a [`RouteError`] to a [`FailureClass`].
///
/// Pure function: same variant always maps to the same class so a
/// receipt ledger replays consistently.
pub fn failure_class_for_route_error(err: &RouteError) -> FailureClass {
    match err {
        RouteError::SchemaVersionMismatch { .. }
        | RouteError::EmptySentinel { .. }
        | RouteError::UnknownEventName { .. }
        | RouteError::UnknownEnumName { .. }
        | RouteError::InvalidFrameContext { .. }
        | RouteError::InvalidPayloadRef { .. }
        | RouteError::InvalidEventEnvelope { .. } => FailureClass::InvalidRequest,
        RouteError::AdapterIdNotFound { .. } | RouteError::AdapterVersionMismatch { .. } => {
            FailureClass::AdapterUnavailable
        }
    }
}

/// Map a [`ReceiptError`] to a [`FailureClass`].
pub fn failure_class_for_receipt_error(err: &ReceiptError) -> FailureClass {
    match err {
        ReceiptError::ReceiptEmittedNotEmittable => FailureClass::InvalidRequest,
        ReceiptError::Conflict { .. } => FailureClass::StateConflict,
        ReceiptError::Invalid(_) => FailureClass::InvalidRequest,
    }
}

/// Map a [`TransportError`] to a [`FailureClass`].
pub fn failure_class_for_transport(err: &TransportError) -> FailureClass {
    match err {
        TransportError::Io(_) => FailureClass::TransportError,
        TransportError::Timeout => FailureClass::Timeout,
        TransportError::Internal(_) => FailureClass::InternalError,
    }
}

/// Map a [`NegotiationOutcome`] to a `(failure_class, retry_class)`
/// pair when the outcome blocks dispatch. Returns `None` for
/// non-blocking outcomes (`Satisfied`, `Degraded`).
///
/// `RequiresOperator` is the canonical operator-required surface:
/// it always pairs `OperatorRequired` with `RetryAfterOperator` so
/// the receipt has a deterministic retry hint.
pub fn classes_for_negotiation_outcome(
    outcome: NegotiationOutcome,
    explicit_failure_class: Option<FailureClass>,
) -> Option<(FailureClass, RetryClass)> {
    match outcome {
        NegotiationOutcome::Unsupported => {
            let fc = explicit_failure_class.unwrap_or(FailureClass::CapabilityUnsupported);
            Some((fc, fc.default_retry()))
        }
        NegotiationOutcome::RequiresOperator => {
            let fc = FailureClass::OperatorRequired;
            // OperatorRequired::default_retry() is RetryAfterOperator
            // by spec; assert the pairing explicitly so anyone
            // grepping for "operator-required surface" finds the
            // ground truth here.
            debug_assert_eq!(fc.default_retry(), RetryClass::RetryAfterOperator);
            Some((fc, RetryClass::RetryAfterOperator))
        }
        NegotiationOutcome::Satisfied | NegotiationOutcome::Degraded => None,
    }
}

/// Per-failure default retry-class hint.
///
/// Thin wrapper over [`FailureClass::default_retry`] kept here so the
/// per-class retry rule has a single discoverable name in the router
/// surface.
pub fn retry_class_for(failure_class: FailureClass) -> RetryClass {
    failure_class.default_retry()
}

// ===========================================================================
// LifeloopFailureMapper
// ===========================================================================

/// Concrete [`FailureMapper`] for issue #15.
///
/// Stateless and zero-sized — instances exist only so the type
/// participates in trait dispatch.
#[derive(Debug, Default, Clone, Copy)]
pub struct LifeloopFailureMapper;

impl LifeloopFailureMapper {
    pub fn new() -> Self {
        Self
    }

    /// Convenience: map a [`ReceiptError`] to the `(failure, retry)`
    /// pair a `failed` receipt would carry.
    pub fn map_receipt_error(&self, err: &ReceiptError) -> (FailureClass, RetryClass) {
        let fc = failure_class_for_receipt_error(err);
        (fc, retry_class_for(fc))
    }

    /// Convenience: map a [`TransportError`] to the `(failure, retry)`
    /// pair a `failed` receipt would carry.
    pub fn map_transport_error(&self, err: &TransportError) -> (FailureClass, RetryClass) {
        let fc = failure_class_for_transport(err);
        (fc, retry_class_for(fc))
    }
}

impl FailureMapper for LifeloopFailureMapper {
    fn map_route_error(&self, err: &RouteError) -> (FailureClass, RetryClass) {
        let fc = failure_class_for_route_error(err);
        (fc, retry_class_for(fc))
    }
}

// ===========================================================================
// From conversions
// ===========================================================================

impl From<&RouteError> for FailureClass {
    fn from(err: &RouteError) -> Self {
        failure_class_for_route_error(err)
    }
}

impl From<&ReceiptError> for FailureClass {
    fn from(err: &ReceiptError) -> Self {
        failure_class_for_receipt_error(err)
    }
}

impl From<&TransportError> for FailureClass {
    fn from(err: &TransportError) -> Self {
        failure_class_for_transport(err)
    }
}

// ===========================================================================
// Validation-layer receipt-eligibility guard
// ===========================================================================

/// Validation-layer guard: refuse to plan receipt synthesis for a
/// `receipt.emitted` event.
///
/// Complements the emission-time guard in
/// [`super::receipts::LifeloopReceiptEmitter::synthesize_and_emit`]:
/// the emit-time guard catches the same misuse when a caller already
/// holds a [`super::NegotiatedPlan`]; this validation-layer guard
/// catches it earlier, against a [`RoutingPlan`], so a misuse can be
/// rejected before negotiation runs.
///
/// Returns [`RouteError::InvalidEventEnvelope`] on rejection so the
/// failure-class mapping is `InvalidRequest` — consistent with how
/// the same misuse is mapped when caught at deserialize time.
pub fn validate_receipt_eligible(plan: &RoutingPlan) -> Result<(), RouteError> {
    if matches!(plan.event, LifecycleEventKind::ReceiptEmitted) {
        return Err(RouteError::InvalidEventEnvelope {
            detail: "receipt.emitted is a notification event and must not produce \
                     a lifecycle receipt"
                .into(),
        });
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn route_error_maps_to_invalid_request_or_adapter_unavailable() {
        let cases: Vec<(RouteError, FailureClass)> = vec![
            (
                RouteError::SchemaVersionMismatch {
                    expected: "a".into(),
                    found: "b".into(),
                },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::EmptySentinel { field: "x" },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::UnknownEventName {
                    received: "bogus".into(),
                },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::UnknownEnumName {
                    field: "integration_mode",
                    received: "weird".into(),
                },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::InvalidFrameContext {
                    detail: "missing".into(),
                },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::InvalidPayloadRef {
                    index: 0,
                    detail: "empty".into(),
                },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::InvalidEventEnvelope { detail: "x".into() },
                FailureClass::InvalidRequest,
            ),
            (
                RouteError::AdapterIdNotFound {
                    adapter_id: "ghost".into(),
                },
                FailureClass::AdapterUnavailable,
            ),
            (
                RouteError::AdapterVersionMismatch {
                    adapter_id: "codex".into(),
                    requested: "0.0.0".into(),
                    registered: "0.1.0".into(),
                },
                FailureClass::AdapterUnavailable,
            ),
        ];
        let mapper = LifeloopFailureMapper::new();
        for (err, expected) in cases {
            let (fc, rc) = mapper.map_route_error(&err);
            assert_eq!(fc, expected, "route error -> failure class: {err:?}");
            assert_eq!(rc, fc.default_retry(), "retry class follows default");
            // From impl agrees with the trait method.
            let via_from: FailureClass = (&err).into();
            assert_eq!(via_from, fc);
        }
    }

    #[test]
    fn receipt_error_mapping() {
        let mapper = LifeloopFailureMapper::new();
        assert_eq!(
            mapper.map_receipt_error(&ReceiptError::ReceiptEmittedNotEmittable),
            (FailureClass::InvalidRequest, RetryClass::DoNotRetry),
        );
        assert_eq!(
            mapper.map_receipt_error(&ReceiptError::Conflict {
                idempotency_key: "k".into()
            }),
            (FailureClass::StateConflict, RetryClass::RetryAfterReread),
        );
    }

    #[test]
    fn transport_error_mapping_distinguishes_io_timeout_internal() {
        let mapper = LifeloopFailureMapper::new();
        assert_eq!(
            mapper
                .map_transport_error(&TransportError::Io("EPIPE".into()))
                .0,
            FailureClass::TransportError,
        );
        assert_eq!(
            mapper.map_transport_error(&TransportError::Timeout).0,
            FailureClass::Timeout,
        );
        assert_eq!(
            mapper
                .map_transport_error(&TransportError::Internal("panic".into()))
                .0,
            FailureClass::InternalError,
        );
    }

    #[test]
    fn negotiation_requires_operator_uses_operator_required_pair() {
        let pair = classes_for_negotiation_outcome(NegotiationOutcome::RequiresOperator, None);
        assert_eq!(
            pair,
            Some((
                FailureClass::OperatorRequired,
                RetryClass::RetryAfterOperator
            ))
        );
    }

    #[test]
    fn negotiation_satisfied_and_degraded_yield_no_blocking_pair() {
        assert!(classes_for_negotiation_outcome(NegotiationOutcome::Satisfied, None).is_none());
        assert!(classes_for_negotiation_outcome(NegotiationOutcome::Degraded, None).is_none());
    }
}