Skip to main content

liminal_protocol/lifecycle/
membership.rs

1use crate::outcome::CandidatePhase;
2use crate::wire::{
3    AttachSecret, BindingEpoch, ConversationId, DeliverySeq, DetachedCause, Generation,
4    LeaveAttemptToken, LeaveCommitted, LeaveRequest, ParticipantId, TransactionOrder,
5};
6
7use super::{
8    AdmissionOrder, BindingState, ClaimFrontiers, CommittedBindingTerminal, DetachCell,
9    PendingFinalization, detach::validate_pending_pair, lookup::AttachSecretProof,
10};
11
12/// Consuming-layer enrollment-token fingerprint with no protocol-invented width.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct EnrollmentFingerprint<F>(F);
15
16impl<F> EnrollmentFingerprint<F> {
17    /// Wraps the consuming cryptographic layer's canonical fingerprint value.
18    #[must_use]
19    pub const fn new(value: F) -> Self {
20        Self(value)
21    }
22
23    /// Borrows the consuming-layer fingerprint.
24    #[must_use]
25    pub const fn value(&self) -> &F {
26        &self.0
27    }
28
29    /// Consumes the wrapper and returns the fingerprint value.
30    #[must_use]
31    pub fn into_inner(self) -> F {
32        self.0
33    }
34}
35
36/// Consuming-layer canonical Leave-request fingerprint.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct LeaveFingerprint<F>(F);
39
40impl<F> LeaveFingerprint<F> {
41    /// Wraps the consuming cryptographic layer's canonical fingerprint value.
42    #[must_use]
43    pub const fn new(value: F) -> Self {
44        Self(value)
45    }
46
47    /// Borrows the consuming-layer fingerprint.
48    #[must_use]
49    pub const fn value(&self) -> &F {
50        &self.0
51    }
52
53    /// Consumes the wrapper and returns the fingerprint value.
54    #[must_use]
55    pub fn into_inner(self) -> F {
56        self.0
57    }
58}
59
60/// Complete persistence input for restoring one live member.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct LiveMemberRestore<F> {
63    /// Permanent participant identity/index.
64    pub participant_id: ParticipantId,
65    /// Owning conversation.
66    pub conversation_id: ConversationId,
67    /// Current credential generation.
68    pub generation: Generation,
69    /// Current attach secret.
70    pub attach_secret: AttachSecret,
71    /// Durable cumulative participant cursor.
72    pub cursor: DeliverySeq,
73    /// Permanent enrollment-token fingerprint.
74    pub enrollment_fingerprint: EnrollmentFingerprint<F>,
75    /// Most recent committed binding terminal, if any.
76    pub latest_terminal: Option<CommittedBindingTerminal>,
77}
78
79/// Invalid durable membership/history combination.
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum MembershipInvariantError {
82    /// Retained terminal names another participant or conversation.
83    TerminalIdentity,
84    /// Retained terminal belongs to a generation newer than the current credential.
85    TerminalGeneration,
86}
87
88/// Live participant membership plus permanent enrollment and terminal history.
89///
90/// Fields are private so a committed binding terminal cannot drift away from
91/// the identity that owns it. Persistence restoration must pass [`Self::restore`].
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct LiveMember<F> {
94    participant_id: ParticipantId,
95    conversation_id: ConversationId,
96    generation: Generation,
97    attach_secret: AttachSecret,
98    cursor: DeliverySeq,
99    enrollment_fingerprint: EnrollmentFingerprint<F>,
100    latest_terminal: Option<CommittedBindingTerminal>,
101}
102
103/// Crate-owned proof of one exact cumulative cursor transition.
104///
105/// Fields and construction remain crate-private so consuming servers can apply
106/// only an update emitted by a typed protocol operation.
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub(super) struct LiveMemberCursorUpdate {
109    conversation_id: ConversationId,
110    participant_id: ParticipantId,
111    generation: Generation,
112    from_cursor: DeliverySeq,
113    resulting_cursor: DeliverySeq,
114}
115
116impl LiveMemberCursorUpdate {
117    /// Captures the complete identity and old/new cursor prestate.
118    pub(super) const fn new(
119        conversation_id: ConversationId,
120        participant_id: ParticipantId,
121        generation: Generation,
122        from_cursor: DeliverySeq,
123        resulting_cursor: DeliverySeq,
124    ) -> Self {
125        Self {
126            conversation_id,
127            participant_id,
128            generation,
129            from_cursor,
130            resulting_cursor,
131        }
132    }
133
134    pub(super) const fn previous_cursor(self) -> DeliverySeq {
135        self.from_cursor
136    }
137
138    pub(super) const fn resulting_cursor(self) -> DeliverySeq {
139        self.resulting_cursor
140    }
141}
142
143/// Rejection while applying an opaque cursor update to durable membership.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub(super) enum LiveMemberCursorUpdateError {
146    /// Conversation differs from the commit proof.
147    Conversation {
148        expected: ConversationId,
149        actual: ConversationId,
150    },
151    /// Participant differs from the commit proof.
152    Participant {
153        expected: ParticipantId,
154        actual: ParticipantId,
155    },
156    /// Credential generation differs from the commit proof.
157    Generation {
158        expected: Generation,
159        actual: Generation,
160    },
161    /// Proposed update is not a strict cumulative advance.
162    NonAdvancing {
163        from_cursor: DeliverySeq,
164        resulting_cursor: DeliverySeq,
165    },
166    /// Member cursor is neither the old nor already-committed new prestate.
167    CursorPrestate {
168        expected_from_cursor: DeliverySeq,
169        resulting_cursor: DeliverySeq,
170        actual_cursor: DeliverySeq,
171    },
172}
173
174impl<F> LiveMember<F> {
175    /// Restores a durable member after checking retained terminal identity and generation.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`MembershipInvariantError`] when the terminal belongs to another
180    /// identity/conversation or a generation newer than the restored credential.
181    pub fn restore(state: LiveMemberRestore<F>) -> Result<Self, MembershipInvariantError> {
182        validate_terminal(
183            state.participant_id,
184            state.conversation_id,
185            state.generation,
186            state.latest_terminal,
187        )?;
188        Ok(Self {
189            participant_id: state.participant_id,
190            conversation_id: state.conversation_id,
191            generation: state.generation,
192            attach_secret: state.attach_secret,
193            cursor: state.cursor,
194            enrollment_fingerprint: state.enrollment_fingerprint,
195            latest_terminal: state.latest_terminal,
196        })
197    }
198
199    pub(crate) const fn from_enrollment(
200        participant_id: ParticipantId,
201        conversation_id: ConversationId,
202        attach_secret: AttachSecret,
203        enrollment_fingerprint: EnrollmentFingerprint<F>,
204    ) -> Self {
205        Self {
206            participant_id,
207            conversation_id,
208            generation: Generation::ONE,
209            attach_secret,
210            cursor: 0,
211            enrollment_fingerprint,
212            latest_terminal: None,
213        }
214    }
215
216    /// Returns the permanent participant identity/index.
217    #[must_use]
218    pub const fn participant_id(&self) -> ParticipantId {
219        self.participant_id
220    }
221
222    /// Returns the owning conversation.
223    #[must_use]
224    pub const fn conversation_id(&self) -> ConversationId {
225        self.conversation_id
226    }
227
228    /// Returns the current credential generation.
229    #[must_use]
230    pub const fn generation(&self) -> Generation {
231        self.generation
232    }
233
234    /// Returns the current attach secret.
235    #[must_use]
236    pub const fn attach_secret(&self) -> AttachSecret {
237        self.attach_secret
238    }
239
240    /// Returns the durable cumulative cursor.
241    #[must_use]
242    pub const fn cursor(&self) -> DeliverySeq {
243        self.cursor
244    }
245
246    /// Borrows the permanent enrollment-token fingerprint.
247    #[must_use]
248    pub const fn enrollment_fingerprint(&self) -> &EnrollmentFingerprint<F> {
249        &self.enrollment_fingerprint
250    }
251
252    /// Returns the most recent committed binding terminal.
253    #[must_use]
254    pub const fn latest_terminal(&self) -> Option<CommittedBindingTerminal> {
255        self.latest_terminal
256    }
257
258    /// Applies an exact old/new-prestate cursor proof without permitting regression.
259    pub(super) fn apply_cursor_update(
260        &mut self,
261        update: LiveMemberCursorUpdate,
262    ) -> Result<(), LiveMemberCursorUpdateError> {
263        if self.conversation_id != update.conversation_id {
264            return Err(LiveMemberCursorUpdateError::Conversation {
265                expected: update.conversation_id,
266                actual: self.conversation_id,
267            });
268        }
269        if self.participant_id != update.participant_id {
270            return Err(LiveMemberCursorUpdateError::Participant {
271                expected: update.participant_id,
272                actual: self.participant_id,
273            });
274        }
275        if self.generation != update.generation {
276            return Err(LiveMemberCursorUpdateError::Generation {
277                expected: update.generation,
278                actual: self.generation,
279            });
280        }
281        if update.resulting_cursor <= update.from_cursor {
282            return Err(LiveMemberCursorUpdateError::NonAdvancing {
283                from_cursor: update.from_cursor,
284                resulting_cursor: update.resulting_cursor,
285            });
286        }
287        if self.cursor == update.resulting_cursor {
288            return Ok(());
289        }
290        if self.cursor != update.from_cursor {
291            return Err(LiveMemberCursorUpdateError::CursorPrestate {
292                expected_from_cursor: update.from_cursor,
293                resulting_cursor: update.resulting_cursor,
294                actual_cursor: self.cursor,
295            });
296        }
297        self.cursor = update.resulting_cursor;
298        Ok(())
299    }
300
301    /// Replaces the latest binding terminal after checking its identity domain.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`MembershipInvariantError`] for mismatched identity or generation.
306    pub fn with_committed_terminal(
307        mut self,
308        terminal: CommittedBindingTerminal,
309    ) -> Result<Self, MembershipInvariantError> {
310        validate_terminal(
311            self.participant_id,
312            self.conversation_id,
313            self.generation,
314            Some(terminal),
315        )?;
316        self.latest_terminal = Some(terminal);
317        Ok(self)
318    }
319
320    pub(super) fn rotate(
321        mut self,
322        generation: Generation,
323        attach_secret: AttachSecret,
324        cursor: DeliverySeq,
325        terminal: Option<CommittedBindingTerminal>,
326    ) -> Result<Self, MembershipInvariantError> {
327        let latest_terminal = terminal.or(self.latest_terminal);
328        validate_terminal(
329            self.participant_id,
330            self.conversation_id,
331            generation,
332            latest_terminal,
333        )?;
334        self.generation = generation;
335        self.attach_secret = attach_secret;
336        self.cursor = cursor;
337        self.latest_terminal = latest_terminal;
338        Ok(self)
339    }
340}
341
342fn validate_terminal(
343    participant_id: ParticipantId,
344    conversation_id: ConversationId,
345    generation: Generation,
346    terminal: Option<CommittedBindingTerminal>,
347) -> Result<(), MembershipInvariantError> {
348    let Some(terminal) = terminal else {
349        return Ok(());
350    };
351    if terminal.participant_id() != participant_id || terminal.conversation_id() != conversation_id
352    {
353        return Err(MembershipInvariantError::TerminalIdentity);
354    }
355    if terminal.binding_epoch().capability_generation > generation {
356        return Err(MembershipInvariantError::TerminalGeneration);
357    }
358    Ok(())
359}
360
361/// Permanent retired identity tombstone.
362///
363/// The tombstone retains generic non-reversible fingerprints/verifier but no
364/// attach secret or request body.
365#[derive(Clone, Debug, PartialEq, Eq)]
366pub struct RetiredIdentity<EF, V, LF> {
367    participant_id: ParticipantId,
368    conversation_id: ConversationId,
369    retired_generation: Generation,
370    enrollment_fingerprint: EnrollmentFingerprint<EF>,
371    leave_attempt_token: LeaveAttemptToken,
372    leave_request_verifier: V,
373    leave_fingerprint: LeaveFingerprint<LF>,
374    left_admission_order: AdmissionOrder,
375    committed_result: LeaveCommitted,
376}
377
378impl<EF, V, LF> RetiredIdentity<EF, V, LF> {
379    /// Permanent participant id.
380    #[must_use]
381    pub const fn participant_id(&self) -> ParticipantId {
382        self.participant_id
383    }
384
385    /// Conversation containing the tombstone.
386    #[must_use]
387    pub const fn conversation_id(&self) -> ConversationId {
388        self.conversation_id
389    }
390
391    /// Permanent retired generation.
392    #[must_use]
393    pub const fn retired_generation(&self) -> Generation {
394        self.retired_generation
395    }
396
397    /// Permanent committed Leave token.
398    #[must_use]
399    pub const fn leave_attempt_token(&self) -> LeaveAttemptToken {
400        self.leave_attempt_token
401    }
402
403    /// Stored complete Leave result for exact replay.
404    #[must_use]
405    pub const fn committed_result(&self) -> &LeaveCommitted {
406        &self.committed_result
407    }
408
409    /// Returns the immutable causal key of the permanent `Left` record.
410    #[must_use]
411    pub const fn left_admission_order(&self) -> AdmissionOrder {
412        self.left_admission_order
413    }
414
415    /// Stored non-reversible secret-proof verifier.
416    #[must_use]
417    pub const fn leave_request_verifier(&self) -> &V {
418        &self.leave_request_verifier
419    }
420
421    /// Permanent enrollment mapping fingerprint.
422    #[must_use]
423    pub const fn enrollment_fingerprint(&self) -> &EnrollmentFingerprint<EF> {
424        &self.enrollment_fingerprint
425    }
426
427    /// Permanent canonical Leave fingerprint.
428    #[must_use]
429    pub const fn leave_fingerprint(&self) -> &LeaveFingerprint<LF> {
430        &self.leave_fingerprint
431    }
432
433    #[allow(clippy::too_many_arguments)]
434    pub(super) fn restore(
435        participant_id: ParticipantId,
436        conversation_id: ConversationId,
437        retired_generation: Generation,
438        enrollment_fingerprint: EnrollmentFingerprint<EF>,
439        leave_attempt_token: LeaveAttemptToken,
440        leave_request_verifier: V,
441        leave_fingerprint: LeaveFingerprint<LF>,
442        left_transaction_order: TransactionOrder,
443        committed_result: LeaveCommitted,
444    ) -> Result<Self, RetirementError> {
445        if committed_result.conversation_id() != conversation_id {
446            return Err(RetirementError::Conversation);
447        }
448        if committed_result.participant_id() != participant_id {
449            return Err(RetirementError::Participant);
450        }
451        if committed_result.presented_generation() != retired_generation {
452            return Err(RetirementError::Generation);
453        }
454        if committed_result.retired_generation() != retired_generation {
455            return Err(RetirementError::RetiredGeneration);
456        }
457        if committed_result.leave_attempt_token() != leave_attempt_token {
458            return Err(RetirementError::Token);
459        }
460        let left_admission_order = AdmissionOrder::new(
461            left_transaction_order,
462            CandidatePhase::MembershipExit,
463            participant_id,
464        );
465        Ok(Self {
466            participant_id,
467            conversation_id,
468            retired_generation,
469            enrollment_fingerprint,
470            leave_attempt_token,
471            leave_request_verifier,
472            leave_fingerprint,
473            left_admission_order,
474            committed_result,
475        })
476    }
477}
478
479/// Present participant identity state; absence is represented outside this enum.
480#[derive(Clone, Debug, PartialEq, Eq)]
481pub enum IdentityState<EF, V, LF> {
482    /// Live membership, whether bound or detached.
483    Live(LiveMember<EF>),
484    /// Permanent Leave tombstone.
485    Retired(RetiredIdentity<EF, V, LF>),
486}
487
488/// One indivisible committed Leave state update.
489///
490/// The identity tombstone and the claim frontiers resulting from the prepared
491/// Leave transition are deliberately carried together and are not cloneable.
492/// A durable binding must persist both parts in the same transaction; exposing
493/// only the tombstone would discard the consumed and relayed frontier authority.
494#[derive(Debug, PartialEq, Eq)]
495pub struct LeaveCommit<EF, V, LF> {
496    identity: IdentityState<EF, V, LF>,
497    frontiers: ClaimFrontiers,
498}
499
500impl<EF, V, LF> LeaveCommit<EF, V, LF> {
501    /// Borrows the permanent identity result committed by Leave.
502    #[must_use]
503    pub const fn identity(&self) -> &IdentityState<EF, V, LF> {
504        &self.identity
505    }
506
507    /// Borrows the exact claim frontiers committed with the identity result.
508    #[must_use]
509    pub const fn frontiers(&self) -> &ClaimFrontiers {
510        &self.frontiers
511    }
512
513    /// Consumes the atomic commit for one durable transaction.
514    #[must_use]
515    pub fn into_parts(self) -> (IdentityState<EF, V, LF>, ClaimFrontiers) {
516        (self.identity, self.frontiers)
517    }
518}
519
520/// Mismatch between a live member and proposed stored Leave result.
521#[derive(Clone, Copy, Debug, PartialEq, Eq)]
522pub enum RetirementError {
523    /// Result names another conversation.
524    Conversation,
525    /// Result names another participant.
526    Participant,
527    /// Result's presented generation differs from current generation.
528    Generation,
529    /// Result's retired generation differs from the current live generation.
530    RetiredGeneration,
531    /// Result's Leave token differs from the committing token.
532    Token,
533    /// Stored `Left` order is not phase 1 for the retired participant.
534    LeftAdmissionOrder,
535}
536
537/// Failure while proving a live member's exact Leave request authority.
538#[derive(Clone, Copy, Debug, PartialEq, Eq)]
539pub enum LeaveVerificationError {
540    /// Request names another conversation.
541    Conversation,
542    /// Request names another participant.
543    Participant,
544    /// Presented generation is not the current live generation.
545    Generation,
546    /// Presented attach secret failed the consuming layer's constant-time proof.
547    Secret,
548}
549
550/// Exact Leave request authority proven against one live member.
551pub struct VerifiedLeaveRequest<V, LF> {
552    conversation_id: ConversationId,
553    participant_id: ParticipantId,
554    generation: Generation,
555    leave_attempt_token: LeaveAttemptToken,
556    leave_request_verifier: V,
557    leave_fingerprint: LeaveFingerprint<LF>,
558}
559
560/// Allocation fields for settled bound or detached Leave.
561#[derive(Clone, Copy, Debug, PartialEq, Eq)]
562pub struct LeaveCommitParameters {
563    /// Assigned terminal `Left` record sequence.
564    pub left_delivery_seq: DeliverySeq,
565}
566
567/// Proof bound to one exact pending finalization and later `Left` major.
568#[derive(Debug, PartialEq, Eq)]
569struct NoInterveningTupleProof {
570    pending_order: AdmissionOrder,
571    left_transaction_order: TransactionOrder,
572}
573
574impl NoInterveningTupleProof {
575    fn matches(&self, pending: PendingFinalization) -> bool {
576        self.pending_order == pending.admission_order()
577            && self.left_transaction_order > self.pending_order.transaction_order()
578    }
579}
580
581/// Linear order-lane authority for one exact settled or positional Leave.
582///
583/// Construction consumes a completely validated [`ClaimFrontiers`] snapshot,
584/// removes and relays the exact `X` order handle, and seals the resulting lane
585/// inside this value. The frontier cannot be recovered by a caller or reused
586/// after a successful commit. Leave returns that authority only inside the
587/// indivisible [`LeaveCommit`] beside the resulting tombstone.
588///
589/// External code cannot implement a planner proof or initialize this authority
590/// from raw majors:
591///
592/// ```compile_fail
593/// use liminal_protocol::lifecycle::NoInterveningTuplePlannerProof;
594/// ```
595///
596/// ```compile_fail
597/// use liminal_protocol::lifecycle::PreparedLeaveAuthority;
598///
599/// let _ = PreparedLeaveAuthority { left_transaction_order: 7 };
600/// ```
601#[derive(Debug, PartialEq, Eq)]
602pub struct PreparedLeaveAuthority {
603    frontiers: ClaimFrontiers,
604    conversation_id: ConversationId,
605    participant_id: ParticipantId,
606    kind: PreparedLeaveKind,
607}
608
609#[derive(Debug, PartialEq, Eq)]
610enum PreparedLeaveKind {
611    Settled {
612        ended_binding_epoch: Option<BindingEpoch>,
613        left_transaction_order: TransactionOrder,
614    },
615    Pending {
616        binding_epoch: BindingEpoch,
617        no_intervening: NoInterveningTupleProof,
618    },
619}
620
621impl PreparedLeaveAuthority {
622    pub(super) const fn settled(
623        frontiers: ClaimFrontiers,
624        conversation_id: ConversationId,
625        participant_id: ParticipantId,
626        ended_binding_epoch: Option<BindingEpoch>,
627        left_transaction_order: TransactionOrder,
628    ) -> Self {
629        Self {
630            frontiers,
631            conversation_id,
632            participant_id,
633            kind: PreparedLeaveKind::Settled {
634                ended_binding_epoch,
635                left_transaction_order,
636            },
637        }
638    }
639
640    pub(super) const fn pending(
641        frontiers: ClaimFrontiers,
642        conversation_id: ConversationId,
643        participant_id: ParticipantId,
644        binding_epoch: BindingEpoch,
645        pending_order: AdmissionOrder,
646        left_transaction_order: TransactionOrder,
647    ) -> Self {
648        Self {
649            frontiers,
650            conversation_id,
651            participant_id,
652            kind: PreparedLeaveKind::Pending {
653                binding_epoch,
654                no_intervening: NoInterveningTupleProof {
655                    pending_order,
656                    left_transaction_order,
657                },
658            },
659        }
660    }
661
662    fn consume_settled(
663        self,
664        member_conversation_id: ConversationId,
665        member_participant_id: ParticipantId,
666        ended_binding_epoch: Option<BindingEpoch>,
667    ) -> Result<(ClaimFrontiers, TransactionOrder), LeaveCommitError> {
668        let Self {
669            frontiers,
670            conversation_id,
671            participant_id,
672            kind,
673        } = self;
674        let PreparedLeaveKind::Settled {
675            ended_binding_epoch: authorized_epoch,
676            left_transaction_order,
677        } = kind
678        else {
679            return Err(LeaveCommitError::PreparedAuthority);
680        };
681        if conversation_id != member_conversation_id
682            || participant_id != member_participant_id
683            || authorized_epoch != ended_binding_epoch
684        {
685            return Err(LeaveCommitError::PreparedAuthority);
686        }
687        Ok((frontiers, left_transaction_order))
688    }
689
690    fn consume_pending(
691        self,
692        pending: PendingFinalization,
693    ) -> Result<(ClaimFrontiers, TransactionOrder), LeaveCommitError> {
694        let Self {
695            frontiers,
696            conversation_id,
697            participant_id,
698            kind,
699        } = self;
700        let PreparedLeaveKind::Pending {
701            binding_epoch,
702            no_intervening,
703        } = kind
704        else {
705            return Err(LeaveCommitError::PreparedAuthority);
706        };
707        if conversation_id != pending.conversation_id()
708            || participant_id != pending.participant_id()
709            || binding_epoch != pending.binding_epoch()
710            || !no_intervening.matches(pending)
711        {
712            return Err(LeaveCommitError::PreparedAuthority);
713        }
714        Ok((frontiers, no_intervening.left_transaction_order))
715    }
716}
717
718/// Allocation and ordering proof for positional pending-terminal Leave.
719#[derive(Clone, Copy, Debug, PartialEq, Eq)]
720pub struct PendingLeaveCommitParameters {
721    /// Real sequence allocated to the pending binding terminal.
722    pub terminal_delivery_seq: DeliverySeq,
723    /// Real sequence allocated to the following `Left` record.
724    pub left_delivery_seq: DeliverySeq,
725}
726
727/// Failure while applying an already-authorized Leave transaction.
728#[derive(Clone, Copy, Debug, PartialEq, Eq)]
729pub enum LeaveCommitError {
730    /// Prepared order authority belongs to another state or Leave mode.
731    PreparedAuthority,
732    /// Verified request authority was minted for another live member.
733    VerifiedAuthority,
734    /// Bound or pending-finalization state belongs to another member/generation.
735    BindingAuthority,
736    /// Settled Leave was called while a binding terminal remains pending.
737    PendingTerminalRequiresComposition,
738    /// Positional proof does not cover the supplied pending finalization.
739    NoInterveningTuple,
740    /// A pending detach cell is not paired with explicit-detach finalization.
741    PendingDetachState,
742    /// Detach cell and retained committed terminal disagree.
743    TerminalHistory,
744    /// Prior terminal sequence is not strictly before the new `Left` sequence.
745    TerminalSequenceOrder,
746    /// The supplied record positions do not consume the next gap-free sequence values.
747    SequenceAuthority,
748    /// Consuming and relaying Leave claims could not produce a valid frontier.
749    ResultingFrontier,
750    /// Internal tombstone construction rejected an inconsistent result.
751    RetirementInvariant(RetirementError),
752}
753
754impl<F> LiveMember<F> {
755    /// Verifies an exact Leave request against current live credential authority.
756    ///
757    /// # Errors
758    ///
759    /// Returns [`LeaveVerificationError`] at the first mismatching authority component.
760    pub fn verify_leave_request<V, LF>(
761        &self,
762        request: &LeaveRequest,
763        secret_proof: AttachSecretProof,
764        leave_request_verifier: V,
765        leave_fingerprint: LeaveFingerprint<LF>,
766    ) -> Result<VerifiedLeaveRequest<V, LF>, LeaveVerificationError> {
767        if request.conversation_id != self.conversation_id {
768            return Err(LeaveVerificationError::Conversation);
769        }
770        if request.participant_id != self.participant_id {
771            return Err(LeaveVerificationError::Participant);
772        }
773        if request.capability_generation != self.generation {
774            return Err(LeaveVerificationError::Generation);
775        }
776        if secret_proof == AttachSecretProof::Mismatch {
777            return Err(LeaveVerificationError::Secret);
778        }
779        Ok(VerifiedLeaveRequest {
780            conversation_id: request.conversation_id,
781            participant_id: request.participant_id,
782            generation: request.capability_generation,
783            leave_attempt_token: request.leave_attempt_token,
784            leave_request_verifier,
785            leave_fingerprint,
786        })
787    }
788
789    fn retire<V, LF>(
790        self,
791        leave_attempt_token: LeaveAttemptToken,
792        leave_request_verifier: V,
793        leave_fingerprint: LeaveFingerprint<LF>,
794        left_admission_order: AdmissionOrder,
795        committed_result: LeaveCommitted,
796    ) -> Result<RetiredIdentity<F, V, LF>, RetirementError> {
797        if committed_result.conversation_id() != self.conversation_id {
798            return Err(RetirementError::Conversation);
799        }
800        if committed_result.participant_id() != self.participant_id {
801            return Err(RetirementError::Participant);
802        }
803        if committed_result.presented_generation() != self.generation {
804            return Err(RetirementError::Generation);
805        }
806        if committed_result.retired_generation() != self.generation {
807            return Err(RetirementError::RetiredGeneration);
808        }
809        if committed_result.leave_attempt_token() != leave_attempt_token {
810            return Err(RetirementError::Token);
811        }
812        if left_admission_order.candidate_phase() != CandidatePhase::MembershipExit
813            || left_admission_order.participant_index() != self.participant_id
814        {
815            return Err(RetirementError::LeftAdmissionOrder);
816        }
817        Ok(RetiredIdentity {
818            participant_id: self.participant_id,
819            conversation_id: self.conversation_id,
820            retired_generation: committed_result.retired_generation(),
821            enrollment_fingerprint: self.enrollment_fingerprint,
822            leave_attempt_token,
823            leave_request_verifier,
824            leave_fingerprint,
825            left_admission_order,
826            committed_result,
827        })
828    }
829}
830
831/// Commits bound or already-detached Leave, deriving all optional result fields.
832///
833/// # Errors
834///
835/// Returns [`LeaveCommitError`] when authority, binding, history/cell, or order
836/// is inconsistent. Pending finalization must use [`commit_pending_leave`].
837pub fn commit_leave<EF, V, LF, D>(
838    member: LiveMember<EF>,
839    binding_state: BindingState,
840    detach_cell: DetachCell<D>,
841    verified: VerifiedLeaveRequest<V, LF>,
842    authority: PreparedLeaveAuthority,
843    parameters: LeaveCommitParameters,
844) -> Result<LeaveCommit<EF, V, LF>, LeaveCommitError> {
845    validate_verified(&member, &verified)?;
846    let ended_binding_epoch = match binding_state {
847        BindingState::Detached => None,
848        BindingState::Bound(active) => {
849            validate_active(
850                &member,
851                active.participant_id,
852                active.conversation_id,
853                active.binding_epoch,
854            )?;
855            Some(active.binding_epoch)
856        }
857        BindingState::PendingFinalization(_) => {
858            return Err(LeaveCommitError::PendingTerminalRequiresComposition);
859        }
860    };
861    validate_settled_cell(&member, binding_state, &detach_cell)?;
862    let (frontiers, left_transaction_order) = authority.consume_settled(
863        member.conversation_id,
864        member.participant_id,
865        ended_binding_epoch,
866    )?;
867    let prior_terminal_delivery_seq = member
868        .latest_terminal
869        .map(CommittedBindingTerminal::delivery_seq);
870    validate_sequence_order(prior_terminal_delivery_seq, parameters.left_delivery_seq)?;
871    finish_leave(
872        member,
873        verified,
874        ended_binding_epoch,
875        prior_terminal_delivery_seq,
876        None,
877        left_transaction_order,
878        parameters.left_delivery_seq,
879        detach_cell,
880        frontiers,
881    )
882}
883
884/// Positionally commits one pending binding terminal immediately before `Left`.
885///
886/// A separately drained terminal must first update [`LiveMember`] through its
887/// committed terminal and then use ordinary [`commit_leave`].
888///
889/// # Errors
890///
891/// Returns [`LeaveCommitError`] when the planner proof, pending state/cell,
892/// authority, or allocated sequence order is inconsistent.
893pub fn commit_pending_leave<EF, V, LF, D>(
894    member: LiveMember<EF>,
895    pending: PendingFinalization,
896    detach_cell: DetachCell<D>,
897    verified: VerifiedLeaveRequest<V, LF>,
898    authority: PreparedLeaveAuthority,
899    parameters: PendingLeaveCommitParameters,
900) -> Result<LeaveCommit<EF, V, LF>, LeaveCommitError> {
901    let PendingLeaveCommitParameters {
902        terminal_delivery_seq,
903        left_delivery_seq,
904    } = parameters;
905    validate_verified(&member, &verified)?;
906    validate_pending(&member, pending)?;
907    let (frontiers, left_transaction_order) = authority.consume_pending(pending)?;
908    validate_pending_cell(member.conversation_id, pending, &detach_cell)?;
909    if terminal_delivery_seq >= left_delivery_seq {
910        return Err(LeaveCommitError::TerminalSequenceOrder);
911    }
912    let committed_terminal = pending.commit(terminal_delivery_seq);
913    validate_terminal(
914        member.participant_id,
915        member.conversation_id,
916        member.generation,
917        Some(committed_terminal),
918    )
919    .map_err(|_| LeaveCommitError::BindingAuthority)?;
920    finish_leave(
921        member,
922        verified,
923        None,
924        Some(committed_terminal.delivery_seq()),
925        Some(committed_terminal),
926        left_transaction_order,
927        left_delivery_seq,
928        detach_cell,
929        frontiers,
930    )
931}
932
933fn validate_verified<EF, V, LF>(
934    member: &LiveMember<EF>,
935    verified: &VerifiedLeaveRequest<V, LF>,
936) -> Result<(), LeaveCommitError> {
937    if verified.conversation_id != member.conversation_id
938        || verified.participant_id != member.participant_id
939        || verified.generation != member.generation
940    {
941        return Err(LeaveCommitError::VerifiedAuthority);
942    }
943    Ok(())
944}
945
946fn validate_active<EF>(
947    member: &LiveMember<EF>,
948    participant_id: ParticipantId,
949    conversation_id: ConversationId,
950    binding_epoch: BindingEpoch,
951) -> Result<(), LeaveCommitError> {
952    if participant_id != member.participant_id
953        || conversation_id != member.conversation_id
954        || binding_epoch.capability_generation != member.generation
955    {
956        return Err(LeaveCommitError::BindingAuthority);
957    }
958    Ok(())
959}
960
961fn validate_pending<EF>(
962    member: &LiveMember<EF>,
963    pending: PendingFinalization,
964) -> Result<(), LeaveCommitError> {
965    validate_active(
966        member,
967        pending.participant_id(),
968        pending.conversation_id(),
969        pending.binding_epoch(),
970    )
971}
972
973fn validate_sequence_order(
974    prior: Option<DeliverySeq>,
975    left: DeliverySeq,
976) -> Result<(), LeaveCommitError> {
977    if prior.is_some_and(|sequence| sequence >= left) {
978        return Err(LeaveCommitError::TerminalSequenceOrder);
979    }
980    Ok(())
981}
982
983fn validate_settled_cell<EF, D>(
984    member: &LiveMember<EF>,
985    binding_state: BindingState,
986    detach_cell: &DetachCell<D>,
987) -> Result<(), LeaveCommitError> {
988    match detach_cell {
989        DetachCell::Empty(_) => Ok(()),
990        DetachCell::Pending(_) => Err(LeaveCommitError::PendingDetachState),
991        DetachCell::Committed(cell) => {
992            if binding_state != BindingState::Detached
993                || cell.participant_id() != member.participant_id
994                || cell.request_generation() != member.generation
995            {
996                return Err(LeaveCommitError::TerminalHistory);
997            }
998            let Some(terminal) = member.latest_terminal else {
999                return Err(LeaveCommitError::TerminalHistory);
1000            };
1001            if terminal.detached_cause() != Some(DetachedCause::CleanDeregister)
1002                || terminal.binding_epoch() != cell.committed_binding_epoch()
1003                || terminal.delivery_seq() != cell.detached_delivery_seq()
1004            {
1005                return Err(LeaveCommitError::TerminalHistory);
1006            }
1007            Ok(())
1008        }
1009        DetachCell::Terminalized(cell) => {
1010            if cell.participant_id() != member.participant_id || member.latest_terminal.is_none() {
1011                return Err(LeaveCommitError::TerminalHistory);
1012            }
1013            Ok(())
1014        }
1015    }
1016}
1017
1018fn validate_pending_cell<D>(
1019    conversation_id: ConversationId,
1020    pending: PendingFinalization,
1021    detach_cell: &DetachCell<D>,
1022) -> Result<(), LeaveCommitError> {
1023    match detach_cell {
1024        DetachCell::Pending(cell) => validate_pending_pair(
1025            BindingState::PendingFinalization(pending),
1026            cell,
1027            Some(conversation_id),
1028        )
1029        .map(|_| ())
1030        .map_err(|_| LeaveCommitError::PendingDetachState),
1031        DetachCell::Committed(_) => Err(LeaveCommitError::TerminalHistory),
1032        DetachCell::Terminalized(cell) if cell.participant_id() != pending.participant_id() => {
1033            Err(LeaveCommitError::TerminalHistory)
1034        }
1035        DetachCell::Empty(_) | DetachCell::Terminalized(_) => Ok(()),
1036    }
1037}
1038
1039#[allow(
1040    clippy::too_many_arguments,
1041    reason = "the final Leave constructor keeps every verified authority and both atomic result halves explicit"
1042)]
1043fn finish_leave<EF, V, LF, D>(
1044    member: LiveMember<EF>,
1045    verified: VerifiedLeaveRequest<V, LF>,
1046    ended_binding_epoch: Option<BindingEpoch>,
1047    prior_terminal_delivery_seq: Option<DeliverySeq>,
1048    committed_terminal: Option<CommittedBindingTerminal>,
1049    left_transaction_order: TransactionOrder,
1050    left_delivery_seq: DeliverySeq,
1051    detach_cell: DetachCell<D>,
1052    frontiers: ClaimFrontiers,
1053) -> Result<LeaveCommit<EF, V, LF>, LeaveCommitError> {
1054    let VerifiedLeaveRequest {
1055        conversation_id,
1056        participant_id,
1057        generation,
1058        leave_attempt_token,
1059        leave_request_verifier,
1060        leave_fingerprint,
1061    } = verified;
1062    if generation != member.generation {
1063        return Err(LeaveCommitError::VerifiedAuthority);
1064    }
1065    let Some(committed_result) = LeaveCommitted::new(
1066        conversation_id,
1067        leave_attempt_token,
1068        participant_id,
1069        member.generation,
1070        ended_binding_epoch,
1071        prior_terminal_delivery_seq,
1072        left_delivery_seq,
1073    ) else {
1074        return Err(LeaveCommitError::TerminalSequenceOrder);
1075    };
1076    let frontiers = frontiers.finish_leave_claims(
1077        participant_id,
1078        ended_binding_epoch,
1079        committed_terminal,
1080        left_delivery_seq,
1081        left_transaction_order,
1082    )?;
1083    let retired = member
1084        .retire(
1085            leave_attempt_token,
1086            leave_request_verifier,
1087            leave_fingerprint,
1088            AdmissionOrder::new(
1089                left_transaction_order,
1090                CandidatePhase::MembershipExit,
1091                participant_id,
1092            ),
1093            committed_result,
1094        )
1095        .map_err(LeaveCommitError::RetirementInvariant)?;
1096    let _detach_cell_replaced_by_tombstone = detach_cell;
1097    Ok(LeaveCommit {
1098        identity: IdentityState::Retired(retired),
1099        frontiers,
1100    })
1101}