Skip to main content

frame_conv/handle/
leave.rs

1//! Explicit leave (F-3b R1): typed, idempotent wrapping of the substrate's
2//! terminal `LeaveRequest`, built on the 2026-07-23 characterization
3//! findings recorded in the brief's S1 leave paragraph
4//! (`docs/briefs/F-3b-pubsub-workflow.md`; standing probe
5//! `tests/characterize_leave.rs`):
6//!
7//! - a live-binding leave COMMITS and names the binding it ended (1);
8//! - a same-handle replay is refused client-side, typed `AlreadyDead` (2);
9//! - re-attach after leave answers `ParticipantUnknown` — retirement is
10//!   permanent (3);
11//! - a durable-token replay across restart echoes the ORIGINAL commit —
12//!   server-side idempotency rides the token (4);
13//! - leave needs no live binding: the presented credential material is the
14//!   authority, so the resumed path is restore -> leave, no attach leg (5).
15//!
16//! The `LeaveAttemptToken` is a pure function of the `JoinGrant`'s
17//! persisted identity fields (conversation, participant), giving one
18//! constant token per lineage — the A6(ii) ruling's constancy across
19//! restart by construction; a per-call-minted token (idempotency only
20//! within one handle's life) is exactly what the ruling refuses.
21
22use liminal_protocol::client::ClientOperationRecordRefusalReason;
23use liminal_protocol::wire::{
24    AttachSecret, ClientRequest, Generation, LeaveAttemptToken, LeaveRequest, ServerValue,
25};
26use liminal_sdk::remote::RemoteParticipantHandle;
27
28use crate::config::BusAttachment;
29use crate::error::{AttachError, LeaveError};
30use crate::id::{ConversationId, ConversationSeq, ParticipantRef};
31use crate::outcome::{JoinGrant, LeaveOutcome};
32use crate::seam::{StoreAdapter, classify_refusal};
33use crate::store::ResumeStore;
34
35use super::attach::{connect, map_restore_error};
36use super::pump::SubmitFailure;
37use super::state::ConversationHandle;
38
39/// The lineage's one durable leave token: a pure function of the grant's
40/// persisted identity, constant across restart by construction (the same
41/// wire pair the server retires — 16 bytes carrying exactly the
42/// conversation and participant ids).
43fn leave_token(conversation: ConversationId, participant: ParticipantRef) -> LeaveAttemptToken {
44    let mut bytes = [0_u8; 16];
45    bytes[..8].copy_from_slice(&conversation.to_wire().to_le_bytes());
46    bytes[8..].copy_from_slice(&participant.to_wire().to_le_bytes());
47    LeaveAttemptToken::new(bytes)
48}
49
50fn map_attach_error(error: AttachError) -> LeaveError {
51    match error {
52        AttachError::Endpoint { detail } => LeaveError::Endpoint { detail },
53        AttachError::ResumeRejected { detail } => LeaveError::ResumeRejected { detail },
54        AttachError::Store { detail } => LeaveError::Store { detail },
55        other => LeaveError::Protocol {
56            detail: format!("{other}"),
57        },
58    }
59}
60
61impl<S: ResumeStore> ConversationHandle<S> {
62    /// Leaves the conversation: terminal, explicit, idempotent (R1). The
63    /// commit retires this participant PERMANENTLY — a later [`resume`] of
64    /// the lineage is refused typed (`ParticipantUnknown`, probe finding
65    /// 3), and every further operation on this handle fails typed.
66    ///
67    /// Calling again on the same handle returns
68    /// [`LeaveOutcome::AlreadyLeft`] without wire traffic (finding 2).
69    ///
70    /// [`resume`]: Self::resume
71    ///
72    /// # Errors
73    ///
74    /// Returns typed refusal, transport, or answer-window failures; the
75    /// already-left answers are outcomes, not errors.
76    pub fn leave(&mut self) -> Result<LeaveOutcome, LeaveError> {
77        let token = leave_token(self.conversation, self.me);
78        let request = ClientRequest::Leave(LeaveRequest {
79            conversation_id: self.conversation.to_wire(),
80            participant_id: self.me.to_wire(),
81            capability_generation: self.generation,
82            attach_secret: AttachSecret::new(self.grant.credential.to_bytes()),
83            leave_attempt_token: token,
84        });
85        let answer = match self.submit(request) {
86            Ok(answer) => answer,
87            Err(SubmitFailure::RecordRefused {
88                reason: ClientOperationRecordRefusalReason::AlreadyDead,
89                ..
90            }) => return Ok(LeaveOutcome::AlreadyLeft),
91            Err(other) => return Err(submit_to_leave(other)),
92        };
93        let outcome = classify_commit(&answer, token)?;
94        // The commit ended this handle's binding (finding 1 names it in
95        // the receipt); the handle is terminal from here.
96        self.attached = false;
97        Ok(outcome)
98    }
99
100    /// Completes (or performs) a leave from the caller's persisted lineage
101    /// WITHOUT re-attaching: restore the aggregate from `canonical_state`,
102    /// then submit the lineage's durable leave token over the unbound
103    /// connection. Total by findings 4 and 5: an already-left lineage
104    /// receives the original commit's echo; a never-left lineage commits —
105    /// the presented credential material is the authority, and no attach
106    /// leg exists to be refused. This is the crash-window path: a caller
107    /// that issued leave and died before observing the commit re-drives it
108    /// here and gets the same typed outcome.
109    ///
110    /// # Errors
111    ///
112    /// Returns typed endpoint, resume-state, refusal, transport, or
113    /// answer-window failures.
114    pub fn leave_resumed(
115        attachment: &BusAttachment,
116        grant: &JoinGrant,
117        canonical_state: &[u8],
118        store: S,
119    ) -> Result<LeaveOutcome, LeaveError> {
120        let generation =
121            Generation::new(grant.generation).ok_or_else(|| LeaveError::ResumeRejected {
122                detail: "grant carries the forbidden zero generation".to_owned(),
123            })?;
124        let config = connect(attachment).map_err(map_attach_error)?;
125        let sdk =
126            RemoteParticipantHandle::restore(&config, StoreAdapter::new(store), canonical_state)
127                .map_err(|error| map_attach_error(map_restore_error(error)))?;
128        let mut handle = Self::bare(sdk, grant.conversation, attachment);
129        handle.me = grant.participant;
130        handle.generation = generation;
131        handle.grant = grant.clone();
132        let token = leave_token(grant.conversation, grant.participant);
133        let request = ClientRequest::Leave(LeaveRequest {
134            conversation_id: grant.conversation.to_wire(),
135            participant_id: grant.participant.to_wire(),
136            capability_generation: generation,
137            attach_secret: AttachSecret::new(grant.credential.to_bytes()),
138            leave_attempt_token: token,
139        });
140        let answer = match handle.submit(request) {
141            Ok(answer) => answer,
142            Err(SubmitFailure::RecordRefused {
143                reason: ClientOperationRecordRefusalReason::AlreadyDead,
144                ..
145            }) => return Ok(LeaveOutcome::AlreadyLeft),
146            Err(other) => return Err(submit_to_leave(other)),
147        };
148        classify_commit(&answer, token)
149    }
150}
151
152/// Classifies the leave's correlated answer: the commit (echo-verified —
153/// the answer must carry the exact durable token, the same discipline the
154/// admission path holds) or a typed refusal.
155fn classify_commit(
156    answer: &ServerValue,
157    token: LeaveAttemptToken,
158) -> Result<LeaveOutcome, LeaveError> {
159    match answer {
160        ServerValue::LeaveCommitted(receipt) => {
161            if receipt.leave_attempt_token() != token {
162                return Err(LeaveError::Protocol {
163                    detail: format!("leave commit echoed a foreign attempt token: {receipt:?}"),
164                });
165            }
166            Ok(LeaveOutcome::Left {
167                seq: ConversationSeq::new(receipt.left_delivery_seq()),
168            })
169        }
170        other => {
171            let (class, detail) = classify_refusal(other);
172            Err(LeaveError::Refused { class, detail })
173        }
174    }
175}
176
177/// Maps a submit failure onto the leave error family without losing the
178/// exact detail.
179fn submit_to_leave(failure: SubmitFailure) -> LeaveError {
180    match failure {
181        SubmitFailure::RecordRefused { detail, .. }
182        | SubmitFailure::State { detail }
183        | SubmitFailure::InboundRefused { detail } => LeaveError::Protocol { detail },
184        SubmitFailure::TransportLost { detail } | SubmitFailure::ConnectionFate { detail } => {
185            LeaveError::ConnectionLost { detail }
186        }
187        SubmitFailure::AnswerTimeout { waited } => LeaveError::AnswerTimeout { waited },
188    }
189}