frame-conv 0.2.0

Conversation patterns — request-response, subscription, pub/sub, and workflow over liminal
Documentation
//! The single crate-internal seam onto the liminal substrate: error-fate
//! classification, failure-grammar mapping, refusal classification, and the
//! caller-store adapter. Nothing in this module is public API; the public
//! surface speaks frame vocabulary only (design ground 6).

use frame_core::error::{FailureReason, TombstoneKind};
use liminal_protocol::wire::{DetachedCause, DiedCause, ServerValue};
use liminal_sdk::SdkError;
use liminal_sdk::remote::{ParticipantResumeStore, RemoteParticipantError};

use crate::error::RefusalClass;
use crate::outcome::DepartReason;
use crate::store::ResumeStore;

/// The two fates of a failed receive at the substrate seam.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReceiveFate {
    /// The IO quantum elapsed with nothing to read — a benign re-arm, not a
    /// protocol outcome (F-0c assertion 8; the receive-cancel ruling).
    QuantumElapsed,
    /// A real transport fate: the connection is gone.
    ConnectionFate,
}

/// Classifies a receive error into elapse-versus-death.
///
/// This classification is by error-STRING content, which is exactly the
/// recorded upstream ASK-2 wart (F-0c-FINDINGS assertion 8, re-measured at
/// published 0.3.0): an elapsed quantum and a dead socket both surface as
/// `Transport(_)` and differ only in description text, and there is no
/// `Ok(None)` re-arm at the SDK seam. This is the crate's ONLY string-typed
/// branch, it exists solely because no typed seam exists upstream yet, and
/// it becomes a typed match the day ASK-2 lands. The classification's two
/// pins: the F-0c characterization proved an elapse is NOT a connection
/// fate (the same connection serves afterwards), and the two strings below
/// are the exact observed shapes recorded in the findings.
pub(crate) fn classify_receive_error(error: &RemoteParticipantError) -> ReceiveFate {
    match error {
        RemoteParticipantError::Transport(sdk_error) => {
            let text = sdk_error.to_string();
            if text.contains("os error 35")
                || text.contains("Resource temporarily unavailable")
                || text.contains("timed out")
            {
                ReceiveFate::QuantumElapsed
            } else {
                ReceiveFate::ConnectionFate
            }
        }
        _ => ReceiveFate::ConnectionFate,
    }
}

/// Maps a substrate death cause into frame-core's tombstone-derived failure
/// grammar — the ONE crash vocabulary (F-3a constraint 2). No live path
/// produces a `Died` delivery at published 0.3.0 (upstream ASK-4); this
/// mapping is the surface the pattern inherits the day the upstream
/// death-delivery unit lands, unit-pinned below.
pub(crate) fn failure_from_died(cause: DiedCause) -> FailureReason {
    let (tombstone, detail) = match cause {
        DiedCause::ConnectionLost => (
            TombstoneKind::NoConnection,
            "conversation peer lost its connection".to_owned(),
        ),
        DiedCause::ProcessKilled => (
            TombstoneKind::Killed,
            "conversation peer process was killed".to_owned(),
        ),
        DiedCause::ProtocolError => (
            TombstoneKind::Error,
            "conversation peer committed a protocol error".to_owned(),
        ),
        DiedCause::UncleanServerRestart {
            prior_server_incarnation,
        } => (
            TombstoneKind::Error,
            format!(
                "peer binding recovered after unclean server restart \
                 (prior incarnation {prior_server_incarnation})"
            ),
        ),
    };
    FailureReason {
        child: None,
        tombstone: Some(tombstone),
        detail,
    }
}

/// Maps a substrate detach cause into the typed departure vocabulary.
pub(crate) const fn depart_from_detached(cause: DetachedCause) -> DepartReason {
    match cause {
        DetachedCause::CleanDeregister => DepartReason::Deregistered,
        DetachedCause::Superseded => DepartReason::Superseded,
        DetachedCause::ServerShutdown => DepartReason::ServerShutdown,
    }
}

/// Classifies a non-success substrate answer into the frame refusal class,
/// preserving the exact upstream answer in the detail.
pub(crate) fn classify_refusal(value: &ServerValue) -> (RefusalClass, String) {
    let class = match value {
        ServerValue::ConnectionConversationCapacityExceeded(_)
        | ServerValue::MarkerClosureCapacityExceeded(_)
        | ServerValue::ReceiptCapacityExceeded(_)
        | ServerValue::IdentityCapacityExceeded(_)
        | ServerValue::ObserverBackpressure(_) => RefusalClass::Capacity,
        ServerValue::StaleAuthority(_)
        | ServerValue::StaleOrUnknownReceipt(_)
        | ServerValue::ReceiptExpired(_)
        | ServerValue::NoBinding(_)
        | ServerValue::Retired(_) => RefusalClass::Authority,
        ServerValue::ParticipantUnknown(_)
        | ServerValue::EnrollmentKnown(_)
        | ServerValue::ConnectionConversationBindingOccupied(_)
        | ServerValue::AttemptTokenBodyConflict(_) => RefusalClass::Identity,
        ServerValue::ConversationOrderExhausted(_)
        | ServerValue::ConversationSequenceExhausted(_) => RefusalClass::Order,
        _ => RefusalClass::Protocol,
    };
    (class, format!("{value:?}"))
}

/// Mints a 16-byte attempt/enrollment token from process entropy.
pub(crate) fn mint_token16() -> [u8; 16] {
    *uuid::Uuid::new_v4().as_bytes()
}

/// Adapter carrying the caller's frame-vocabulary [`ResumeStore`] across the
/// substrate's storage boundary.
pub(crate) struct StoreAdapter<S> {
    inner: S,
}

impl<S> StoreAdapter<S> {
    pub(crate) const fn new(inner: S) -> Self {
        Self { inner }
    }
}

impl<S: ResumeStore> ParticipantResumeStore for StoreAdapter<S> {
    fn persist(&mut self, canonical_lpcr: &[u8]) -> Result<(), SdkError> {
        self.inner
            .persist(canonical_lpcr)
            .map_err(|error| SdkError::Store {
                description: error.detail,
            })
    }
}

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

    #[test]
    fn died_causes_map_to_tombstone_grammar() {
        let lost = failure_from_died(DiedCause::ConnectionLost);
        assert_eq!(lost.tombstone, Some(TombstoneKind::NoConnection));
        assert_eq!(lost.child, None);
        assert!(lost.detail.contains("lost its connection"));

        let killed = failure_from_died(DiedCause::ProcessKilled);
        assert_eq!(killed.tombstone, Some(TombstoneKind::Killed));

        let protocol = failure_from_died(DiedCause::ProtocolError);
        assert_eq!(protocol.tombstone, Some(TombstoneKind::Error));

        let restart = failure_from_died(DiedCause::UncleanServerRestart {
            prior_server_incarnation: 7,
        });
        assert_eq!(restart.tombstone, Some(TombstoneKind::Error));
        assert!(restart.detail.contains("incarnation 7"));
    }

    #[test]
    fn detached_causes_map_to_departure_vocabulary() {
        assert_eq!(
            depart_from_detached(DetachedCause::CleanDeregister),
            DepartReason::Deregistered
        );
        assert_eq!(
            depart_from_detached(DetachedCause::Superseded),
            DepartReason::Superseded
        );
        assert_eq!(
            depart_from_detached(DetachedCause::ServerShutdown),
            DepartReason::ServerShutdown
        );
    }

    #[test]
    fn quantum_elapse_strings_classify_as_elapse() {
        // The exact observed shapes recorded in F-0c-FINDINGS (ASK-2).
        let elapse = RemoteParticipantError::Transport(SdkError::Connection {
            description: "failed to read frame from server: Resource temporarily \
                          unavailable (os error 35)"
                .to_owned(),
        });
        assert_eq!(classify_receive_error(&elapse), ReceiveFate::QuantumElapsed);

        let death = RemoteParticipantError::Transport(SdkError::Connection {
            description: "server closed the connection before a full frame arrived".to_owned(),
        });
        assert_eq!(classify_receive_error(&death), ReceiveFate::ConnectionFate);

        let state = RemoteParticipantError::StateUnavailable;
        assert_eq!(classify_receive_error(&state), ReceiveFate::ConnectionFate);
    }
}