frame-conv 0.2.0

Conversation patterns — request-response, subscription, pub/sub, and workflow over liminal
Documentation
//! The handle's state: one enrolled participant in one conversation, its
//! inboxes, exchange registry, cursor tracker, and anomaly surface.

use std::collections::{HashMap, VecDeque};
use std::time::Duration;

use frame_core::error::FailureReason;
use liminal_protocol::wire::Generation;
use liminal_sdk::remote::RemoteParticipantHandle;

use crate::anomaly::{Anomaly, AnomalyCounters};
use crate::envelope::Envelope;
use crate::id::{ConversationId, ConversationSeq, CorrelationId, ParticipantRef};
use crate::outcome::{DepartReason, JoinGrant};
use crate::seam::StoreAdapter;
use crate::store::ResumeStore;
use crate::track::{CursorTracker, ExchangeRegistry};

/// A reply record held for its requester, envelope decoded but payload not
/// yet validated against the caller's typed message.
#[derive(Debug)]
pub(crate) struct HeldReply {
    pub(crate) envelope: Envelope,
    pub(crate) responder: ParticipantRef,
    pub(crate) seq: ConversationSeq,
}

/// An inbound request record awaiting [`super::state::ConversationHandle::next_request`].
#[derive(Debug)]
pub(crate) struct HeldRequest {
    pub(crate) envelope: Envelope,
    pub(crate) requester: ParticipantRef,
    pub(crate) seq: ConversationSeq,
}

/// A queued subscription item before typed payload decode. `Clone` because
/// lifecycle and gap items fan into BOTH pattern inboxes (subscription and
/// pub/sub) — each consumption surface presents a complete stream.
#[derive(Debug, Clone)]
pub(crate) enum QueuedItem {
    Event {
        envelope: Envelope,
        publisher: ParticipantRef,
        seq: ConversationSeq,
    },
    Joined {
        peer: ParticipantRef,
        seq: ConversationSeq,
    },
    Departed {
        peer: ParticipantRef,
        seq: ConversationSeq,
        reason: DepartReason,
    },
    Failed {
        peer: ParticipantRef,
        seq: ConversationSeq,
        failure: FailureReason,
    },
    Compacted {
        seq: ConversationSeq,
    },
    Gap {
        expected: ConversationSeq,
        observed: ConversationSeq,
    },
}

/// A peer death observed while exchanges may be open (F-3a R2's
/// responder-failure wall; no live producer at liminal 0.3.0 — ASK-4).
#[derive(Debug, Clone)]
pub(crate) struct DeathNote {
    pub(crate) peer: ParticipantRef,
    pub(crate) failure: FailureReason,
    pub(crate) seq: ConversationSeq,
}

/// One enrolled participant in one conversation: the transport-agnostic
/// entry to the conversation patterns (design D4 — nothing in this type or
/// its methods names a transport; the embedded transport arrives later
/// behind this same handle).
///
/// A handle is single-owner and its pattern calls take `&mut self`; run one
/// handle per participant, one participant per thread. The handle is `Send`
/// so it may be moved into a thread after construction.
pub struct ConversationHandle<S: ResumeStore> {
    pub(crate) sdk: RemoteParticipantHandle<StoreAdapter<S>>,
    pub(crate) conversation: ConversationId,
    pub(crate) me: ParticipantRef,
    pub(crate) grant: JoinGrant,
    pub(crate) generation: Generation,
    pub(crate) answer_window: Duration,
    pub(crate) exchanges: ExchangeRegistry,
    pub(crate) replies: HashMap<CorrelationId, HeldReply>,
    pub(crate) requests_inbox: VecDeque<HeldRequest>,
    pub(crate) events_inbox: VecDeque<QueuedItem>,
    pub(crate) publications_inbox: VecDeque<QueuedItem>,
    /// Last publication-record sequence presented through
    /// `next_publication` — the O(1) exactly-once dedup state (F-3b R1's
    /// strong branch; no set, no window).
    pub(crate) last_presented_publication: Option<u64>,
    pub(crate) deaths: Vec<DeathNote>,
    pub(crate) anomalies: Vec<Anomaly>,
    pub(crate) counters: AnomalyCounters,
    pub(crate) tracker: CursorTracker,
    pub(crate) attached: bool,
}

impl<S: ResumeStore> std::fmt::Debug for ConversationHandle<S> {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("ConversationHandle")
            .field("conversation", &self.conversation)
            .field("participant", &self.me)
            .field("attached", &self.attached)
            .finish_non_exhaustive()
    }
}

impl<S: ResumeStore> ConversationHandle<S> {
    /// The conversation this handle is enrolled in.
    #[must_use]
    pub const fn conversation(&self) -> ConversationId {
        self.conversation
    }

    /// This participant's substrate-minted identity.
    #[must_use]
    pub const fn participant(&self) -> ParticipantRef {
        self.me
    }

    /// The current join grant: identity, attach credential, and generation.
    /// The credential rotates on every resume; callers persisting the grant
    /// must persist the CURRENT one.
    #[must_use]
    pub const fn grant(&self) -> &JoinGrant {
        &self.grant
    }

    /// Whether the handle's connection is still serviceable. Once false,
    /// every operation fails typed; the recovery path is
    /// [`resume`](Self::resume) from the caller's persisted state.
    #[must_use]
    pub const fn attached(&self) -> bool {
        self.attached
    }

    /// Drains the queued typed anomalies (R2's anomaly home, queue half).
    /// Counters are unaffected by draining.
    pub fn drain_anomalies(&mut self) -> Vec<Anomaly> {
        std::mem::take(&mut self.anomalies)
    }

    /// The named, readable anomaly counters (R2's anomaly home, counter
    /// half).
    #[must_use]
    pub const fn anomaly_counters(&self) -> AnomalyCounters {
        self.counters
    }
}