frame-conv 0.2.0

Conversation patterns — request-response, subscription, pub/sub, and workflow over liminal
Documentation
//! Explicit leave (F-3b R1): typed, idempotent wrapping of the substrate's
//! terminal `LeaveRequest`, built on the 2026-07-23 characterization
//! findings recorded in the brief's S1 leave paragraph
//! (`docs/briefs/F-3b-pubsub-workflow.md`; standing probe
//! `tests/characterize_leave.rs`):
//!
//! - a live-binding leave COMMITS and names the binding it ended (1);
//! - a same-handle replay is refused client-side, typed `AlreadyDead` (2);
//! - re-attach after leave answers `ParticipantUnknown` — retirement is
//!   permanent (3);
//! - a durable-token replay across restart echoes the ORIGINAL commit —
//!   server-side idempotency rides the token (4);
//! - leave needs no live binding: the presented credential material is the
//!   authority, so the resumed path is restore -> leave, no attach leg (5).
//!
//! The `LeaveAttemptToken` is a pure function of the `JoinGrant`'s
//! persisted identity fields (conversation, participant), giving one
//! constant token per lineage — the A6(ii) ruling's constancy across
//! restart by construction; a per-call-minted token (idempotency only
//! within one handle's life) is exactly what the ruling refuses.

use liminal_protocol::client::ClientOperationRecordRefusalReason;
use liminal_protocol::wire::{
    AttachSecret, ClientRequest, Generation, LeaveAttemptToken, LeaveRequest, ServerValue,
};
use liminal_sdk::remote::RemoteParticipantHandle;

use crate::config::BusAttachment;
use crate::error::{AttachError, LeaveError};
use crate::id::{ConversationId, ConversationSeq, ParticipantRef};
use crate::outcome::{JoinGrant, LeaveOutcome};
use crate::seam::{StoreAdapter, classify_refusal};
use crate::store::ResumeStore;

use super::attach::{connect, map_restore_error};
use super::pump::SubmitFailure;
use super::state::ConversationHandle;

/// The lineage's one durable leave token: a pure function of the grant's
/// persisted identity, constant across restart by construction (the same
/// wire pair the server retires — 16 bytes carrying exactly the
/// conversation and participant ids).
fn leave_token(conversation: ConversationId, participant: ParticipantRef) -> LeaveAttemptToken {
    let mut bytes = [0_u8; 16];
    bytes[..8].copy_from_slice(&conversation.to_wire().to_le_bytes());
    bytes[8..].copy_from_slice(&participant.to_wire().to_le_bytes());
    LeaveAttemptToken::new(bytes)
}

fn map_attach_error(error: AttachError) -> LeaveError {
    match error {
        AttachError::Endpoint { detail } => LeaveError::Endpoint { detail },
        AttachError::ResumeRejected { detail } => LeaveError::ResumeRejected { detail },
        AttachError::Store { detail } => LeaveError::Store { detail },
        other => LeaveError::Protocol {
            detail: format!("{other}"),
        },
    }
}

impl<S: ResumeStore> ConversationHandle<S> {
    /// Leaves the conversation: terminal, explicit, idempotent (R1). The
    /// commit retires this participant PERMANENTLY — a later [`resume`] of
    /// the lineage is refused typed (`ParticipantUnknown`, probe finding
    /// 3), and every further operation on this handle fails typed.
    ///
    /// Calling again on the same handle returns
    /// [`LeaveOutcome::AlreadyLeft`] without wire traffic (finding 2).
    ///
    /// [`resume`]: Self::resume
    ///
    /// # Errors
    ///
    /// Returns typed refusal, transport, or answer-window failures; the
    /// already-left answers are outcomes, not errors.
    pub fn leave(&mut self) -> Result<LeaveOutcome, LeaveError> {
        let token = leave_token(self.conversation, self.me);
        let request = ClientRequest::Leave(LeaveRequest {
            conversation_id: self.conversation.to_wire(),
            participant_id: self.me.to_wire(),
            capability_generation: self.generation,
            attach_secret: AttachSecret::new(self.grant.credential.to_bytes()),
            leave_attempt_token: token,
        });
        let answer = match self.submit(request) {
            Ok(answer) => answer,
            Err(SubmitFailure::RecordRefused {
                reason: ClientOperationRecordRefusalReason::AlreadyDead,
                ..
            }) => return Ok(LeaveOutcome::AlreadyLeft),
            Err(other) => return Err(submit_to_leave(other)),
        };
        let outcome = classify_commit(&answer, token)?;
        // The commit ended this handle's binding (finding 1 names it in
        // the receipt); the handle is terminal from here.
        self.attached = false;
        Ok(outcome)
    }

    /// Completes (or performs) a leave from the caller's persisted lineage
    /// WITHOUT re-attaching: restore the aggregate from `canonical_state`,
    /// then submit the lineage's durable leave token over the unbound
    /// connection. Total by findings 4 and 5: an already-left lineage
    /// receives the original commit's echo; a never-left lineage commits —
    /// the presented credential material is the authority, and no attach
    /// leg exists to be refused. This is the crash-window path: a caller
    /// that issued leave and died before observing the commit re-drives it
    /// here and gets the same typed outcome.
    ///
    /// # Errors
    ///
    /// Returns typed endpoint, resume-state, refusal, transport, or
    /// answer-window failures.
    pub fn leave_resumed(
        attachment: &BusAttachment,
        grant: &JoinGrant,
        canonical_state: &[u8],
        store: S,
    ) -> Result<LeaveOutcome, LeaveError> {
        let generation =
            Generation::new(grant.generation).ok_or_else(|| LeaveError::ResumeRejected {
                detail: "grant carries the forbidden zero generation".to_owned(),
            })?;
        let config = connect(attachment).map_err(map_attach_error)?;
        let sdk =
            RemoteParticipantHandle::restore(&config, StoreAdapter::new(store), canonical_state)
                .map_err(|error| map_attach_error(map_restore_error(error)))?;
        let mut handle = Self::bare(sdk, grant.conversation, attachment);
        handle.me = grant.participant;
        handle.generation = generation;
        handle.grant = grant.clone();
        let token = leave_token(grant.conversation, grant.participant);
        let request = ClientRequest::Leave(LeaveRequest {
            conversation_id: grant.conversation.to_wire(),
            participant_id: grant.participant.to_wire(),
            capability_generation: generation,
            attach_secret: AttachSecret::new(grant.credential.to_bytes()),
            leave_attempt_token: token,
        });
        let answer = match handle.submit(request) {
            Ok(answer) => answer,
            Err(SubmitFailure::RecordRefused {
                reason: ClientOperationRecordRefusalReason::AlreadyDead,
                ..
            }) => return Ok(LeaveOutcome::AlreadyLeft),
            Err(other) => return Err(submit_to_leave(other)),
        };
        classify_commit(&answer, token)
    }
}

/// Classifies the leave's correlated answer: the commit (echo-verified —
/// the answer must carry the exact durable token, the same discipline the
/// admission path holds) or a typed refusal.
fn classify_commit(
    answer: &ServerValue,
    token: LeaveAttemptToken,
) -> Result<LeaveOutcome, LeaveError> {
    match answer {
        ServerValue::LeaveCommitted(receipt) => {
            if receipt.leave_attempt_token() != token {
                return Err(LeaveError::Protocol {
                    detail: format!("leave commit echoed a foreign attempt token: {receipt:?}"),
                });
            }
            Ok(LeaveOutcome::Left {
                seq: ConversationSeq::new(receipt.left_delivery_seq()),
            })
        }
        other => {
            let (class, detail) = classify_refusal(other);
            Err(LeaveError::Refused { class, detail })
        }
    }
}

/// Maps a submit failure onto the leave error family without losing the
/// exact detail.
fn submit_to_leave(failure: SubmitFailure) -> LeaveError {
    match failure {
        SubmitFailure::RecordRefused { detail, .. }
        | SubmitFailure::State { detail }
        | SubmitFailure::InboundRefused { detail } => LeaveError::Protocol { detail },
        SubmitFailure::TransportLost { detail } | SubmitFailure::ConnectionFate { detail } => {
            LeaveError::ConnectionLost { detail }
        }
        SubmitFailure::AnswerTimeout { waited } => LeaveError::AnswerTimeout { waited },
    }
}