frame-conv 0.2.0

Conversation patterns — request-response, subscription, pub/sub, and workflow over liminal
Documentation
//! Typed refusals and failures: the `CONV_*` slug family, envelope refusals,
//! attach/publish/call errors, and cursor-commit outcomes.
//!
//! Every variant answers "what should the caller do now" (F-3a R1). Nothing
//! here is a silent fallback; every substrate verdict is surfaced typed.

use std::time::Duration;

use thiserror::Error;

use crate::id::{ConversationSeq, CorrelationId};

/// Closed refusal-slug family for the conversation contract layer (design
/// §2.2 — golden-vectored in the crate's vector suite).
pub mod slug {
    /// Inbound bytes were not a well-formed conversation envelope.
    pub const CONV_ENVELOPE_MALFORMED: &str = "CONV_ENVELOPE_MALFORMED";
    /// The envelope named a pattern outside the closed contract set.
    pub const CONV_PATTERN_UNKNOWN: &str = "CONV_PATTERN_UNKNOWN";
    /// The envelope's kind is not in the named pattern's closed kind set.
    pub const CONV_KIND_INVALID: &str = "CONV_KIND_INVALID";
    /// The envelope's correlation field was not a typed correlation identity.
    pub const CONV_CORRELATION_INVALID: &str = "CONV_CORRELATION_INVALID";
    /// A typed payload failed (de)serialization — v1's schema IS the typed
    /// Rust message and validation IS serde at both ends (F-3a R1).
    pub const CONV_SCHEMA_INVALID: &str = "CONV_SCHEMA_INVALID";
}

/// A typed envelope refusal carrying its closed slug and the exact detail.
#[derive(Debug, Clone, Error)]
#[error("{slug}: {detail}")]
pub struct EnvelopeError {
    /// The closed `CONV_*` refusal slug.
    pub slug: &'static str,
    /// Exact refusal detail naming the real source.
    pub detail: String,
}

/// Substrate refusal classes surfaced on operations that reached the server.
///
/// The class is frame vocabulary over the substrate's closed answer set; the
/// exact upstream answer is always preserved in the accompanying detail.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefusalClass {
    /// A declared capacity closure refused the operation.
    Capacity,
    /// The operation's authority (binding, generation, receipt) was refused.
    Authority,
    /// The participant or enrollment identity was refused.
    Identity,
    /// The conversation's sequence/order budget refused the operation.
    Order,
    /// Any other typed protocol answer, preserved verbatim in the detail.
    Protocol,
}

/// Failure opening, joining, or resuming a conversation.
#[derive(Debug, Error)]
pub enum AttachError {
    /// The bus endpoint was rejected or unreachable.
    #[error("bus endpoint failed: {detail}")]
    Endpoint {
        /// Exact transport-layer detail.
        detail: String,
    },
    /// The substrate refused the enrollment or re-attach with a typed answer.
    #[error("attach refused ({class:?}): {detail}")]
    Refused {
        /// Refusal class in frame vocabulary.
        class: RefusalClass,
        /// Exact upstream answer.
        detail: String,
    },
    /// The connection was lost before the attach answer arrived.
    #[error("attach lost the connection: {detail}")]
    ConnectionLost {
        /// Exact transport fate.
        detail: String,
    },
    /// The declared answer window elapsed without a correlated attach answer.
    #[error("attach answer window elapsed after {waited:?}")]
    AnswerTimeout {
        /// Total time waited.
        waited: Duration,
    },
    /// The caller-supplied resume state failed decode/validate/restore.
    #[error("resume state rejected: {detail}")]
    ResumeRejected {
        /// Exact typed decode/restore refusal.
        detail: String,
    },
    /// The caller-owned durable store failed a persistence barrier.
    #[error("resume store failed: {detail}")]
    Store {
        /// Exact store failure.
        detail: String,
    },
    /// A protocol-shape violation at the attach seam.
    #[error("attach protocol violation: {detail}")]
    Protocol {
        /// Exact violation detail.
        detail: String,
    },
}

/// Failure publishing a typed message into the conversation.
#[derive(Debug, Error)]
pub enum PublishError {
    /// The typed value failed serialization — refused BEFORE the wire
    /// (F-3a R1; slug [`slug::CONV_SCHEMA_INVALID`]).
    #[error("{}: {detail}", slug::CONV_SCHEMA_INVALID)]
    SchemaInvalid {
        /// Exact serialization refusal.
        detail: String,
    },
    /// The substrate answered the admission with a typed refusal.
    #[error("publish refused ({class:?}): {detail}")]
    Refused {
        /// Refusal class in frame vocabulary.
        class: RefusalClass,
        /// Exact upstream answer.
        detail: String,
    },
    /// The connection was lost. Send outcomes are transport-write testimony
    /// only (finding G1 — `Sent` is not receipt); the handle drives slot
    /// recovery through the substrate's transport-loss surface (finding G2)
    /// and every subsequent operation fails typed until [`resume`] runs.
    ///
    /// [`resume`]: crate::ConversationHandle::resume
    #[error("publish lost the connection: {detail}")]
    ConnectionLost {
        /// Exact transport fate, including the operation and reconnect fates
        /// the substrate recorded.
        detail: String,
    },
    /// The declared answer window elapsed without the admission's correlated
    /// terminal answer (the G2 wall, bounded loudly instead of spun on).
    #[error("publish answer window elapsed after {waited:?}")]
    AnswerTimeout {
        /// Total time waited.
        waited: Duration,
    },
    /// A protocol-shape violation at the publish seam.
    #[error("publish protocol violation: {detail}")]
    Protocol {
        /// Exact violation detail.
        detail: String,
    },
}

/// Failure leaving a conversation (the F-3b R1 explicit-leave surface,
/// wrapping the substrate's terminal `LeaveRequest` — brief §Gate fill S1
/// leave paragraph, probe findings 2026-07-23). The typed already-left
/// answers are OUTCOMES ([`LeaveOutcome`]), never errors.
///
/// [`LeaveOutcome`]: crate::outcome::LeaveOutcome
#[derive(Debug, Error)]
pub enum LeaveError {
    /// The substrate answered the leave with a typed refusal (stale
    /// authority, generation conflict, identity refusal — the exact
    /// upstream answer is preserved in the detail).
    #[error("leave refused ({class:?}): {detail}")]
    Refused {
        /// Refusal class in frame vocabulary.
        class: RefusalClass,
        /// Exact upstream answer.
        detail: String,
    },
    /// The connection was lost before the leave's correlated answer.
    #[error("leave lost the connection: {detail}")]
    ConnectionLost {
        /// Exact transport fate.
        detail: String,
    },
    /// The declared answer window elapsed without the leave's correlated
    /// terminal answer.
    #[error("leave answer window elapsed after {waited:?}")]
    AnswerTimeout {
        /// Total time waited.
        waited: Duration,
    },
    /// The bus endpoint was rejected or unreachable (the resumed-leave
    /// path's connect leg).
    #[error("bus endpoint failed: {detail}")]
    Endpoint {
        /// Exact transport-layer detail.
        detail: String,
    },
    /// The caller-supplied resume state failed decode/validate/restore
    /// (the resumed-leave path's restore leg).
    #[error("resume state rejected: {detail}")]
    ResumeRejected {
        /// Exact typed decode/restore refusal.
        detail: String,
    },
    /// The caller-owned durable store failed a persistence barrier.
    #[error("resume store failed: {detail}")]
    Store {
        /// Exact store failure.
        detail: String,
    },
    /// A protocol-shape violation at the leave seam.
    #[error("leave protocol violation: {detail}")]
    Protocol {
        /// Exact violation detail.
        detail: String,
    },
}

/// Failure on the receive/wait side of a pattern call.
#[derive(Debug, Error)]
pub enum CallError {
    /// The admission leg of the call failed.
    #[error(transparent)]
    Publish(#[from] PublishError),
    /// The connection died while waiting (a connection fate, distinct from
    /// every conversation outcome).
    #[error("connection lost while waiting: {detail}")]
    ConnectionLost {
        /// Exact transport fate.
        detail: String,
    },
    /// A correlated reply arrived but failed typed payload validation —
    /// surfaced typed, never dropped (F-3a R1/R6).
    #[error(
        "{}: reply to {correlation} invalid: {detail}",
        slug::CONV_SCHEMA_INVALID
    )]
    ReplySchemaInvalid {
        /// The exchange whose reply failed validation.
        correlation: CorrelationId,
        /// Exact deserialization refusal.
        detail: String,
    },
    /// A protocol-shape violation while waiting.
    #[error("call protocol violation: {detail}")]
    Protocol {
        /// Exact violation detail.
        detail: String,
    },
}

/// Failure from a caller-owned resume store (frame vocabulary over the
/// caller-owned persistence boundary — F-0c §R4: the caller owns cursor and
/// resume-state persistence; the substrate owns barrier ordering).
#[derive(Debug, Clone, Error)]
#[error("resume store failure: {detail}")]
pub struct StoreError {
    /// Exact store failure detail.
    pub detail: String,
}

impl StoreError {
    /// Constructs a store failure with its exact detail.
    #[must_use]
    pub fn new(detail: impl Into<String>) -> Self {
        Self {
            detail: detail.into(),
        }
    }
}

/// Typed outcome of committing the caller-owned cursor (honest acking is the
/// consumer's lever on replay volume — F-0c §R4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorCommit {
    /// The cursor advanced to the requested boundary.
    Committed {
        /// The committed cumulative boundary.
        through: ConversationSeq,
    },
    /// The requested boundary did not advance the cursor.
    NoOp {
        /// The unchanged cursor.
        current: ConversationSeq,
    },
    /// The requested boundary skipped undischarged obligations; the cursor
    /// is unchanged. A joiner's cursor space begins at its membership —
    /// history before the join is not ackable (F-0c late-joiner finding).
    Gap {
        /// The refused boundary.
        requested: ConversationSeq,
        /// The unchanged cursor.
        current: ConversationSeq,
    },
    /// The requested boundary regressed below the committed cursor; the
    /// cursor is unchanged.
    Regression {
        /// The refused boundary.
        requested: ConversationSeq,
    },
}