frame-conv 0.2.0

Conversation patterns — request-response, subscription, pub/sub, and workflow over liminal
Documentation
//! Typed pattern outcomes: request fates, inbound requests, subscription
//! items, and the join/resume grants.

use std::time::Duration;

use frame_core::error::FailureReason;
use serde::{Deserialize, Serialize};

use crate::id::{ConversationId, ConversationSeq, CorrelationId, ParticipantRef, PublicationId};

/// The closed outcome set of one request (F-3a R2). Exactly one outcome is
/// observed per exchange — never both, never neither (R6's race wall).
#[derive(Debug)]
pub enum RequestOutcome<R> {
    /// The correlated, schema-validated reply.
    Replied {
        /// The typed reply.
        reply: R,
        /// The peer that answered.
        responder: ParticipantRef,
        /// Sequence position of the reply record.
        seq: ConversationSeq,
    },
    /// The caller-supplied deadline elapsed. The deadline is the only thing
    /// that ends a request; an elapsed wait quantum never does (F-3a
    /// constraint 3). Named here so the caller knows which wall fired.
    DeadlineElapsed {
        /// The caller-supplied deadline that elapsed.
        deadline: Duration,
    },
    /// A conversation peer died while the request was open, surfaced through
    /// frame-core's tombstone-derived failure grammar (F-3a constraint 2).
    ///
    /// At published liminal 0.3.0/0.3.1 peer death produced NO delivery
    /// (upstream ASK-4 — F-0c-FINDINGS §R3), so no live path constructed
    /// this outcome; the mapping was unit-pinned against exactly that day.
    /// Since liminal 0.3.2 (the W2 death-delivery landing) the live
    /// constructor exists: peer loss surfaces this outcome before the
    /// deadline wall, proven at the socket by
    /// `responder_crash_mid_request_surfaces_the_typed_failure_before_the_deadline`
    /// (`tests/pattern_crash_resume.rs`).
    ResponderFailed {
        /// The dead peer.
        peer: ParticipantRef,
        /// Tombstone-derived typed failure shape.
        failure: FailureReason,
        /// Sequence position of the death record.
        seq: ConversationSeq,
    },
}

/// One decoded inbound request awaiting this participant's reply.
#[derive(Debug, Clone)]
pub struct IncomingRequest<Q> {
    /// The typed request body.
    pub body: Q,
    /// Correlation to answer with ([`reply`]).
    ///
    /// [`reply`]: crate::ConversationHandle::reply
    pub correlation: CorrelationId,
    /// The requesting peer.
    pub requester: ParticipantRef,
    /// Sequence position of the request record.
    pub seq: ConversationSeq,
}

/// Typed surface of one inbound request record: valid, or schema-invalid —
/// surfaced typed, never silently dropped, never a panic (F-3a R1).
#[derive(Debug, Clone)]
pub enum InboundRequest<Q> {
    /// The request decoded as the pattern's typed message.
    Valid(IncomingRequest<Q>),
    /// The request's payload failed typed validation.
    SchemaInvalid {
        /// Correlation of the invalid request (available for a typed refusal
        /// reply if the responder chooses to answer).
        correlation: CorrelationId,
        /// The requesting peer.
        requester: ParticipantRef,
        /// Sequence position of the invalid record.
        seq: ConversationSeq,
        /// Exact validation refusal.
        detail: String,
    },
}

/// One typed item observed through a subscription (F-3a R3).
///
/// Delivery guarantee, stated at the strength the evidence proved
/// (F-0c-FINDINGS §Full characterization, assertion 5 — PROVEN): within a
/// live session, items arrive in send order on one per-conversation
/// sequence, ascending and contiguous, with the sender excluded from its own
/// records. Joining is membership-forward — history before the join is not
/// delivered and not ackable (the late-joiner finding); catch-up needs its
/// own upstream story and is deliberately not simulated here. Should the
/// substrate ever deliver a sequence skip, it surfaces as [`Self::Gap`] —
/// the stream carries news, never obligations (constraint 5), and the
/// documented resync path is [`resume`], which replays the unacked window
/// from the committed cursor (F-0c §R4).
///
/// [`resume`]: crate::ConversationHandle::resume
#[derive(Debug)]
pub enum SubscriptionItem<E> {
    /// A typed subscription event.
    Event {
        /// The typed event body.
        body: E,
        /// The publishing peer.
        publisher: ParticipantRef,
        /// Sequence position of the event record.
        seq: ConversationSeq,
    },
    /// An event record whose payload failed typed validation — surfaced
    /// typed, never dropped (F-3a R1).
    SchemaInvalid {
        /// The publishing peer.
        publisher: ParticipantRef,
        /// Sequence position of the invalid record.
        seq: ConversationSeq,
        /// Exact validation refusal.
        detail: String,
    },
    /// A peer joined the conversation.
    PeerJoined {
        /// The joining peer.
        peer: ParticipantRef,
        /// Sequence position of the lifecycle record.
        seq: ConversationSeq,
    },
    /// A peer's binding ended cleanly.
    PeerDeparted {
        /// The departed peer.
        peer: ParticipantRef,
        /// Sequence position of the lifecycle record.
        seq: ConversationSeq,
        /// Typed departure reason.
        reason: DepartReason,
    },
    /// A peer's binding ended with a death-class cause, in frame-core's
    /// failure grammar (constraint 2 — one failure vocabulary). Live since
    /// liminal 0.3.2 (the W2 death-delivery landing discharged upstream
    /// ASK-4): one death arrives exactly once on this surface, proven at the
    /// socket by the exactly-once `PeerFailed` pin in
    /// `tests/pattern_crash_resume.rs`.
    PeerFailed {
        /// The dead peer.
        peer: ParticipantRef,
        /// Sequence position of the lifecycle record.
        seq: ConversationSeq,
        /// Tombstone-derived typed failure shape.
        failure: FailureReason,
    },
    /// The conversation's retained history was compacted upstream.
    HistoryCompacted {
        /// Sequence position of the compaction record.
        seq: ConversationSeq,
    },
    /// A delivery-sequence gap was detected (constraint 5's typed gap
    /// surface). The documented resync path is [`resume`]: restore from the
    /// caller's persisted state and re-attach; the substrate replays the
    /// unacked obligation window from the committed cursor.
    ///
    /// [`resume`]: crate::ConversationHandle::resume
    Gap {
        /// The sequence position that was expected next.
        expected: ConversationSeq,
        /// The sequence position that was observed instead.
        observed: ConversationSeq,
    },
}

/// One typed item observed through a pub/sub topic subscription (F-3b R1).
///
/// Delivery guarantee: the same proven S1 strength the subscription
/// pattern documents on [`SubscriptionItem`] — send order on one
/// per-conversation sequence, ascending contiguous, sender excluded,
/// membership-forward at join — PLUS the pub/sub exactly-once claim:
/// duplicate presentation of an admitted RECORD is suppressed within this
/// handle's life by the O(1) last-presented-sequence comparison (R1's
/// strong dedup branch; suppressions are counted on the anomaly surface,
/// never silent). Exactly-once is per admitted record: a publisher that
/// violates its id-reuse obligation admits a NEW record under the old id,
/// observed again (A4 as superseded 2026-07-23 — no id-keyed dedup memory
/// exists or may exist). A typed [`Self::Gap`] suspends the contiguous
/// exactly-once claim until resume-replay commits.
#[derive(Debug)]
pub enum PublicationItem<P> {
    /// A typed publication.
    Publication {
        /// The caller-supplied publication identity, decoded from the
        /// record itself.
        id: PublicationId,
        /// The typed publication body.
        body: P,
        /// The publishing peer.
        publisher: ParticipantRef,
        /// Sequence position of the publication record.
        seq: ConversationSeq,
    },
    /// A publication whose payload failed typed validation — surfaced
    /// typed, never dropped.
    SchemaInvalid {
        /// The caller-supplied publication identity.
        id: PublicationId,
        /// The publishing peer.
        publisher: ParticipantRef,
        /// Sequence position of the invalid record.
        seq: ConversationSeq,
        /// Exact validation refusal.
        detail: String,
    },
    /// A peer joined the topic conversation (its epoch begins here — join
    /// linearizes before the epoch's first owed publication).
    PeerJoined {
        /// The joining peer.
        peer: ParticipantRef,
        /// Sequence position of the lifecycle record.
        seq: ConversationSeq,
    },
    /// A peer's binding ended cleanly (a `Left` reason ends its epoch —
    /// leave linearizes after the epoch's last owed publication).
    PeerDeparted {
        /// The departed peer.
        peer: ParticipantRef,
        /// Sequence position of the lifecycle record.
        seq: ConversationSeq,
        /// Typed departure reason.
        reason: DepartReason,
    },
    /// A peer's binding ended with a death-class cause, in frame-core's
    /// failure grammar (constraint 5 — one failure vocabulary).
    PeerFailed {
        /// The dead peer.
        peer: ParticipantRef,
        /// Sequence position of the lifecycle record.
        seq: ConversationSeq,
        /// Tombstone-derived typed failure shape.
        failure: FailureReason,
    },
    /// The conversation's retained history was compacted upstream.
    HistoryCompacted {
        /// Sequence position of the compaction record.
        seq: ConversationSeq,
    },
    /// A delivery-sequence gap was detected (constraint 4's typed gap
    /// surface): the exactly-once claim is suspended until the documented
    /// resync path — [`resume`], replaying the unacked window from the
    /// committed cursor — completes.
    ///
    /// [`resume`]: crate::ConversationHandle::resume
    Gap {
        /// The sequence position that was expected next.
        expected: ConversationSeq,
        /// The sequence position that was observed instead.
        observed: ConversationSeq,
    },
}

/// The closed outcome set of one explicit leave (F-3b R1: leave is an
/// explicit, idempotent operation with typed already-left outcomes;
/// wraps the substrate's terminal `LeaveRequest` — probe findings
/// 2026-07-23, brief §Gate fill S1 leave paragraph).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LeaveOutcome {
    /// The leave committed at the substrate — the first commit, or the
    /// byte-identical durable-token echo when the lineage already left
    /// (server-side idempotency rides the durable leave token across
    /// restart; probe finding 4). The receipt cannot distinguish the two,
    /// which is exactly what idempotent means.
    Left {
        /// Sequence position of the conversation's `Left` lifecycle
        /// record.
        seq: ConversationSeq,
    },
    /// This handle's lineage already left and the client crate refused the
    /// replay locally — typed `AlreadyDead`, no wire traffic (probe
    /// finding 2: within one handle's life, already-left is answered
    /// client-side).
    AlreadyLeft,
}

/// Typed departure reasons for a cleanly ended peer binding.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DepartReason {
    /// The peer deregistered cleanly.
    Deregistered,
    /// The peer's binding was superseded by a newer attach.
    Superseded,
    /// The server shut down in an orderly fashion.
    ServerShutdown,
    /// The peer left the conversation.
    Left,
}

/// The credential a joined participant holds for later [`resume`] — opaque
/// bytes in frame vocabulary; the caller persists it durably alongside the
/// resume state (never in binding props — design §2.4 / contract ruling 8).
///
/// [`resume`]: crate::ConversationHandle::resume
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JoinCredential([u8; 32]);

impl JoinCredential {
    /// Reconstructs a credential from its persisted bytes.
    #[must_use]
    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }

    /// The credential's persistable bytes.
    #[must_use]
    pub const fn to_bytes(&self) -> [u8; 32] {
        self.0
    }
}

impl std::fmt::Debug for JoinCredential {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // The secret bytes never appear in debug output.
        formatter.write_str("JoinCredential(..)")
    }
}

/// Everything a joined participant must hold to identify itself and later
/// resume: content-verified from the binding receipt, serializable so the
/// caller can persist it durably across processes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JoinGrant {
    /// The conversation joined.
    pub conversation: ConversationId,
    /// This participant's substrate-minted identity.
    pub participant: ParticipantRef,
    /// The attach credential for resume (rotated on every re-attach).
    pub credential: JoinCredential,
    /// The capability generation the credential was minted at.
    pub generation: u64,
}

/// Receipt for one committed publish: the correlated terminal answer to the
/// admission (delivery evidence is a correlated answer or nothing — finding
/// G1; the answer echoes the client-selected attempt identity — the G2
/// contract at 0.3.0).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PublishReceipt {
    /// The committed record's sequence position.
    pub seq: ConversationSeq,
}