Skip to main content

liminal_protocol/lifecycle/
attach.rs

1use alloc::boxed::Box;
2
3use crate::wire::{
4    AttachBound, AttachSecret, BindingEpoch, CredentialAttachRequest, DeliverySeq, Generation,
5};
6
7use super::{
8    ActiveBinding, AttachedLifecycleRecord, AttachedRecordPosition, BindingOrigin, BindingState,
9    ClosureState, CommittedBindingTerminal, CommittedBindingTerminalPosition,
10    CommittedDetachedTerminal, CommittedDiedTerminal, DetachCell, Event, FencedAttachCommit,
11    LiveMember, MembershipInvariantError, ObserverProgressProjection, OrdinaryBindingAuthority,
12    OrdinaryBindingFate, OrdinaryDetachedAttachAdmission, PendingFinalization,
13    detach::validate_pending_pair, lookup::AttachSecretProof,
14};
15
16/// Result allocation owned by one successful credential-attach transaction.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct AttachCommitParameters {
19    /// Newly committed binding epoch.
20    pub binding: ActiveBinding,
21    /// Newly minted result attach secret.
22    pub attach_secret: AttachSecret,
23    /// Assigned `Attached` lifecycle record position.
24    pub attached_position: AttachedRecordPosition,
25    /// Live receipt deadline.
26    pub receipt_expires_at: u128,
27    /// Provenance deadline.
28    pub provenance_expires_at: u128,
29}
30
31/// Failure while proving credential-attach authority before commit.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum AttachVerificationError {
34    /// Request names another conversation.
35    Conversation,
36    /// Request names another participant.
37    Participant,
38    /// Request generation is not the current live generation.
39    Generation,
40    /// Presented attach secret failed the constant-time verifier.
41    Secret,
42    /// Current binding state cannot execute the selected attach mode.
43    BindingState,
44    /// Ordinary/superseding attach presented a marker, or fenced proof differed.
45    MarkerProof,
46    /// Fenced proof names another participant or binding epoch.
47    RecoveryAuthority,
48    /// Pending finalization lacks a real terminal sequence or appears in another mode.
49    PendingTerminalSequence,
50    /// Result binding does not name this member.
51    ResultBinding,
52    /// Current generation is exhausted or result generation is not its successor.
53    ResultGeneration,
54    /// Supersession terminal and Attached record do not share one transaction major.
55    LifecycleOrder,
56    /// Retained committed-terminal history cannot prove the detached recovery epoch.
57    TerminalHistory,
58}
59
60/// Failure while atomically applying a previously verified attach.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum AttachCommitError {
63    /// A pending detach is not paired with fenced pending-finalization commit.
64    PendingDetach,
65    /// Detach cell belongs to another participant or generation.
66    DetachCellAuthority,
67    /// Binding finalization and pending detach cell do not describe one commit.
68    BindingCellState,
69    /// Committed detach cell and retained terminal history disagree.
70    TerminalHistory,
71    /// Rotated membership rejected the committed terminal.
72    MembershipInvariant(MembershipInvariantError),
73    /// Canonical attach receipt rejected the verified transaction.
74    ReceiptInvariant,
75}
76
77/// Binding-terminal effect selected by one successful attach.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum AttachTransition {
80    /// Already-detached member bound without creating another terminal.
81    Detached,
82    /// Old active epoch committed `Detached(Superseded)` in the handoff.
83    Superseded {
84        /// Exact old terminal including its real delivery sequence.
85        terminal: CommittedDetachedTerminal,
86    },
87    /// Marker-fenced recovery accepted the delivered marker atomically.
88    FencedRecovery {
89        /// Exact dead epoch that durably received the marker.
90        prior_binding_epoch: BindingEpoch,
91        /// Terminal committed from pending state in this transaction, if any.
92        composed_terminal: Option<CommittedBindingTerminal>,
93        /// Closure successor restricted to clear, projection, or compaction.
94        next_closure_state: ClosureState,
95    },
96}
97
98/// Complete atomic result of a credential attach.
99#[derive(Debug, PartialEq, Eq)]
100pub struct AttachCommit<F, V> {
101    /// Rotated membership with preserved or replaced terminal history.
102    pub member: LiveMember<F>,
103    /// Exact new authoritative binding.
104    pub binding_state: BindingState,
105    /// Fix 1 detach cell after terminalization/preservation.
106    pub detach_cell: DetachCell<V>,
107    /// Exact committed phase-2 `Attached` lifecycle record.
108    pub attached: AttachedLifecycleRecord,
109    /// Exact wire success payload.
110    pub outcome: AttachBound,
111    /// Typed old-binding or fenced-recovery terminal effect.
112    pub transition: AttachTransition,
113    binding_origin: BindingOrigin,
114    ordinary_binding_authority: Option<OrdinaryBindingAuthority>,
115    recovered_binding_authority: Option<FencedAttachCommit>,
116}
117
118/// Operational attach state separated from occurrence authority exactly once.
119#[derive(Debug, PartialEq, Eq)]
120pub struct InstalledAttachState<F, V> {
121    /// Rotated membership state.
122    pub member: LiveMember<F>,
123    /// Newly authoritative binding state.
124    pub binding_state: BindingState,
125    /// Detach-cell state after attach.
126    pub detach_cell: DetachCell<V>,
127    /// Committed Attached lifecycle record.
128    pub attached: AttachedLifecycleRecord,
129    /// Wire success payload.
130    pub outcome: AttachBound,
131    /// Typed terminal/attach transition audit.
132    pub transition: AttachTransition,
133    binding_origin: BindingOrigin,
134}
135
136/// One move-only binding-fate authority emitted by an attach commit.
137#[derive(Debug, PartialEq, Eq)]
138pub struct SealedBindingFateToken {
139    pub(in crate::lifecycle) ordinary: Option<OrdinaryBindingAuthority>,
140    pub(in crate::lifecycle) recovered: Option<FencedAttachCommit>,
141    pub(in crate::lifecycle) cursor: DeliverySeq,
142}
143
144impl<F, V> AttachCommit<F, V> {
145    /// Consumes this commit into operational state and exactly one fate token.
146    #[must_use]
147    pub fn into_slot_and_fate(self) -> (InstalledAttachState<F, V>, SealedBindingFateToken) {
148        let Self {
149            member,
150            binding_state,
151            detach_cell,
152            attached,
153            outcome,
154            transition,
155            binding_origin,
156            ordinary_binding_authority,
157            recovered_binding_authority,
158        } = self;
159        let cursor = member.cursor();
160        (
161            InstalledAttachState {
162                member,
163                binding_state,
164                detach_cell,
165                attached,
166                outcome,
167                transition,
168                binding_origin,
169            },
170            SealedBindingFateToken {
171                ordinary: ordinary_binding_authority,
172                recovered: recovered_binding_authority,
173                cursor,
174            },
175        )
176    }
177
178    /// Projects the binding-ending terminal committed by this attach, if any.
179    #[must_use]
180    pub fn observer_progress_projection(&self) -> Option<ObserverProgressProjection> {
181        let terminal = match self.transition {
182            AttachTransition::Detached
183            | AttachTransition::FencedRecovery {
184                composed_terminal: None,
185                ..
186            } => return None,
187            AttachTransition::Superseded { terminal } => terminal.into(),
188            AttachTransition::FencedRecovery {
189                composed_terminal: Some(terminal),
190                ..
191            } => terminal,
192        };
193        Some(ObserverProgressProjection::new(
194            terminal.conversation_id(),
195            terminal.delivery_seq(),
196        ))
197    }
198
199    /// Consumes one exact normal-ack event into this ordinary attach's cursor authority.
200    ///
201    /// Fenced recovery has no ordinary authority and therefore returns the
202    /// unchanged commit. The raw authority never crosses the public boundary.
203    ///
204    /// # Errors
205    ///
206    /// Returns the unchanged commit for fenced recovery or when the event names
207    /// another participant, epoch, previous cursor, or event class.
208    pub fn ordinary_cursor_progressed(mut self, event: Event) -> Result<Self, Box<Self>> {
209        let Some(authority) = self.ordinary_binding_authority.take() else {
210            return Err(Box::new(self));
211        };
212        match authority.cursor_progressed(event) {
213            Ok(authority) => {
214                self.ordinary_binding_authority = Some(authority);
215                Ok(self)
216            }
217            Err(authority) => {
218                self.ordinary_binding_authority = Some(authority);
219                Err(Box::new(self))
220            }
221        }
222    }
223
224    /// Consumes this ordinary attach and its exact durable death into cursor-release fate.
225    ///
226    /// A fenced attach cannot enter this path because it carries no ordinary
227    /// binding authority; recovered epochs still require the distinct
228    /// [`FencedAttachCommit`] provenance transition.
229    ///
230    /// # Errors
231    ///
232    /// Returns the unchanged commit for fenced recovery or unless the terminal
233    /// names this exact ordinary participant, conversation, and binding epoch.
234    pub fn ordinary_binding_fate(
235        mut self,
236        terminal: CommittedDiedTerminal,
237        resulting_floor: DeliverySeq,
238    ) -> Result<OrdinaryBindingFate, Box<Self>> {
239        let Some(authority) = self.ordinary_binding_authority.take() else {
240            return Err(Box::new(self));
241        };
242        match authority.binding_fate(terminal, resulting_floor) {
243            Ok(fate) => Ok(fate),
244            Err(authority) => {
245                self.ordinary_binding_authority = Some(authority);
246                Err(Box::new(self))
247            }
248        }
249    }
250
251    /// Returns no-marker fate authority only for a committed ordinary attach.
252    ///
253    /// Fenced recovery returns `None`; its recovered epoch must instead use the
254    /// [`FencedAttachCommit`] proof that authorized that commit.
255    #[must_use]
256    #[allow(
257        dead_code,
258        reason = "the crate-owned binding-fate operation consumes this sealed attach authority"
259    )]
260    pub(crate) const fn ordinary_binding_authority(&self) -> Option<OrdinaryBindingAuthority> {
261        self.ordinary_binding_authority
262    }
263
264    /// Returns the opaque binding origin emitted from the verified attach mode.
265    #[must_use]
266    #[allow(
267        dead_code,
268        reason = "the crate-owned event replay boundary persists this producer-emitted origin"
269    )]
270    pub(crate) const fn binding_origin(&self) -> BindingOrigin {
271        self.binding_origin
272    }
273}
274
275#[derive(Debug)]
276enum AttachMode {
277    Detached(OrdinaryDetachedAttachAdmission),
278    Superseded(CommittedDetachedTerminal),
279    Fenced {
280        proof: FencedAttachCommit,
281        pending: Option<(PendingFinalization, DeliverySeq)>,
282    },
283}
284
285/// Successful attach proof; fields are private so only mode verification mints it.
286#[derive(Debug)]
287pub struct VerifiedAttachCommit<F> {
288    member: LiveMember<F>,
289    request: CredentialAttachRequest,
290    parameters: AttachCommitParameters,
291    mode: AttachMode,
292}
293
294/// Fenced verification refusal that preserves both consumed authorities.
295#[derive(Debug)]
296pub struct FencedAttachVerificationRefusal<F> {
297    member: LiveMember<F>,
298    proof: FencedAttachCommit,
299    error: AttachVerificationError,
300}
301
302impl<F> FencedAttachVerificationRefusal<F> {
303    /// Returns the typed verification error.
304    #[must_use]
305    pub const fn error(&self) -> AttachVerificationError {
306        self.error
307    }
308
309    /// Returns the unchanged member and proof for same-lock serial retry.
310    #[must_use]
311    pub fn into_parts(self) -> (LiveMember<F>, FencedAttachCommit) {
312        (self.member, self.proof)
313    }
314}
315
316impl<F> LiveMember<F> {
317    /// Verifies an ordinary attach from an already detached member.
318    ///
319    /// # Errors
320    ///
321    /// Returns [`AttachVerificationError`] for authority, marker, state, or allocation mismatch.
322    pub fn verify_detached_attach(
323        self,
324        binding_state: BindingState,
325        closure_admission: OrdinaryDetachedAttachAdmission,
326        request: CredentialAttachRequest,
327        secret_proof: AttachSecretProof,
328        parameters: AttachCommitParameters,
329    ) -> Result<VerifiedAttachCommit<F>, AttachVerificationError> {
330        self.verify_attach_common(&request, secret_proof, &parameters)?;
331        if binding_state != BindingState::Detached {
332            return Err(AttachVerificationError::BindingState);
333        }
334        if request.accept_marker_delivery_seq.is_some() {
335            return Err(AttachVerificationError::MarkerProof);
336        }
337        Ok(VerifiedAttachCommit {
338            member: self,
339            request,
340            parameters,
341            mode: AttachMode::Detached(closure_admission),
342        })
343    }
344
345    /// Verifies an attach that atomically supersedes this member's active epoch.
346    ///
347    /// # Errors
348    ///
349    /// Returns [`AttachVerificationError`] when authority, marker absence,
350    /// handoff ordering, or result allocation is inconsistent.
351    pub fn verify_superseding_attach(
352        self,
353        active_binding: ActiveBinding,
354        request: CredentialAttachRequest,
355        secret_proof: AttachSecretProof,
356        terminal_position: CommittedBindingTerminalPosition,
357        parameters: AttachCommitParameters,
358    ) -> Result<VerifiedAttachCommit<F>, AttachVerificationError> {
359        self.verify_attach_common(&request, secret_proof, &parameters)?;
360        if active_binding.conversation_id != self.conversation_id()
361            || active_binding.participant_id != self.participant_id()
362            || active_binding.binding_epoch.capability_generation != self.generation()
363        {
364            return Err(AttachVerificationError::BindingState);
365        }
366        if request.accept_marker_delivery_seq.is_some() {
367            return Err(AttachVerificationError::MarkerProof);
368        }
369        if terminal_position.transaction_order() != parameters.attached_position.transaction_order()
370        {
371            return Err(AttachVerificationError::LifecycleOrder);
372        }
373        let terminal = active_binding.superseded(terminal_position);
374        Ok(VerifiedAttachCommit {
375            member: self,
376            request,
377            parameters,
378            mode: AttachMode::Superseded(terminal),
379        })
380    }
381
382    /// Verifies marker-fenced recovery proven by the closure-edge transition.
383    ///
384    /// # Errors
385    ///
386    /// Returns [`AttachVerificationError`] when credential authority,
387    /// marker/epoch proof, pending-terminal shape, or result allocation differs.
388    pub fn verify_fenced_attach(
389        self,
390        binding_state: BindingState,
391        request: CredentialAttachRequest,
392        secret_proof: AttachSecretProof,
393        proof: FencedAttachCommit,
394        pending_terminal_delivery_seq: Option<DeliverySeq>,
395        parameters: AttachCommitParameters,
396    ) -> Result<VerifiedAttachCommit<F>, Box<FencedAttachVerificationRefusal<F>>> {
397        let pending = match self.validate_fenced_attach(
398            binding_state,
399            &request,
400            secret_proof,
401            &proof,
402            pending_terminal_delivery_seq,
403            &parameters,
404        ) {
405            Ok(pending) => pending,
406            Err(error) => {
407                return Err(Box::new(FencedAttachVerificationRefusal {
408                    member: self,
409                    proof,
410                    error,
411                }));
412            }
413        };
414        Ok(VerifiedAttachCommit {
415            member: self,
416            request,
417            parameters,
418            mode: AttachMode::Fenced { proof, pending },
419        })
420    }
421
422    fn validate_fenced_attach(
423        &self,
424        binding_state: BindingState,
425        request: &CredentialAttachRequest,
426        secret_proof: AttachSecretProof,
427        proof: &FencedAttachCommit,
428        pending_terminal_delivery_seq: Option<DeliverySeq>,
429        parameters: &AttachCommitParameters,
430    ) -> Result<Option<(PendingFinalization, DeliverySeq)>, AttachVerificationError> {
431        self.verify_attach_common(request, secret_proof, parameters)?;
432        if request.accept_marker_delivery_seq != Some(proof.marker_delivery_seq()) {
433            return Err(AttachVerificationError::MarkerProof);
434        }
435        if proof.participant_id() != self.participant_id()
436            || proof.prior_binding_epoch().capability_generation != self.generation()
437            || proof.new_binding_epoch() != parameters.binding.binding_epoch
438        {
439            return Err(AttachVerificationError::RecoveryAuthority);
440        }
441        match (binding_state, pending_terminal_delivery_seq) {
442            (BindingState::Detached, None) => {
443                if self
444                    .latest_terminal()
445                    .is_none_or(|terminal| terminal.binding_epoch() != proof.prior_binding_epoch())
446                {
447                    return Err(AttachVerificationError::TerminalHistory);
448                }
449                Ok(None)
450            }
451            (BindingState::PendingFinalization(finalization), Some(sequence)) => {
452                let same_conversation = finalization.conversation_id() == self.conversation_id();
453                let same_participant = finalization.participant_id() == self.participant_id();
454                let same_prior_epoch = finalization.binding_epoch() == proof.prior_binding_epoch();
455                if !(same_conversation && same_participant && same_prior_epoch) {
456                    return Err(AttachVerificationError::BindingState);
457                }
458                Ok(Some((finalization, sequence)))
459            }
460            (BindingState::PendingFinalization(_), None) | (BindingState::Detached, Some(_)) => {
461                Err(AttachVerificationError::PendingTerminalSequence)
462            }
463            (BindingState::Bound(_), _) => Err(AttachVerificationError::BindingState),
464        }
465    }
466
467    fn verify_attach_common(
468        &self,
469        request: &CredentialAttachRequest,
470        secret_proof: AttachSecretProof,
471        parameters: &AttachCommitParameters,
472    ) -> Result<(), AttachVerificationError> {
473        if request.conversation_id != self.conversation_id() {
474            return Err(AttachVerificationError::Conversation);
475        }
476        if request.participant_id != self.participant_id() {
477            return Err(AttachVerificationError::Participant);
478        }
479        if request.capability_generation != self.generation() {
480            return Err(AttachVerificationError::Generation);
481        }
482        if secret_proof == AttachSecretProof::Mismatch {
483            return Err(AttachVerificationError::Secret);
484        }
485        if parameters.binding.conversation_id != self.conversation_id()
486            || parameters.binding.participant_id != self.participant_id()
487        {
488            return Err(AttachVerificationError::ResultBinding);
489        }
490        let Some(next_raw) = self.generation().get().checked_add(1) else {
491            return Err(AttachVerificationError::ResultGeneration);
492        };
493        let Some(next_generation) = Generation::new(next_raw) else {
494            return Err(AttachVerificationError::ResultGeneration);
495        };
496        if parameters.binding.binding_epoch.capability_generation != next_generation {
497            return Err(AttachVerificationError::ResultGeneration);
498        }
499        Ok(())
500    }
501}
502
503/// Atomically commits a verified attach and applies Fix 1's detach-cell rule.
504///
505/// # Errors
506///
507/// Returns [`AttachCommitError`] for cell/history mismatch, invalid rotation,
508/// or a canonical receipt invariant rejected after verification.
509pub fn commit_attach<F, V>(
510    verified: VerifiedAttachCommit<F>,
511    detach_cell: DetachCell<V>,
512) -> Result<AttachCommit<F, V>, AttachCommitError>
513where
514    V: Copy + Eq,
515{
516    let VerifiedAttachCommit {
517        member: previous_member,
518        request,
519        parameters,
520        mode,
521    } = verified;
522    let next_cell = transition_detach_cell(&mode, &previous_member, detach_cell)?;
523    let attached =
524        AttachedLifecycleRecord::from_binding(parameters.binding, parameters.attached_position);
525    let (persisted_cursor, terminal, transition, binding_origin, recovered_binding_authority) =
526        match mode {
527            AttachMode::Detached(_) => (
528                previous_member.cursor(),
529                None,
530                AttachTransition::Detached,
531                BindingOrigin::unfenced(attached),
532                None,
533            ),
534            AttachMode::Superseded(committed) => (
535                previous_member.cursor(),
536                Some(CommittedBindingTerminal::from(committed)),
537                AttachTransition::Superseded {
538                    terminal: committed,
539                },
540                BindingOrigin::unfenced(attached),
541                None,
542            ),
543            AttachMode::Fenced { proof, pending } => {
544                let composed_terminal =
545                    pending.map(|(finalization, sequence)| finalization.commit(sequence));
546                let marker_delivery_seq = proof.marker_delivery_seq();
547                let prior_binding_epoch = proof.prior_binding_epoch();
548                let next_closure_state = proof.next_state();
549                (
550                    marker_delivery_seq,
551                    composed_terminal,
552                    AttachTransition::FencedRecovery {
553                        prior_binding_epoch,
554                        composed_terminal,
555                        next_closure_state,
556                    },
557                    BindingOrigin::recovered(attached, marker_delivery_seq, prior_binding_epoch),
558                    Some(proof),
559                )
560            }
561        };
562    let result_generation = parameters.binding.binding_epoch.capability_generation;
563    let member = previous_member
564        .rotate(
565            result_generation,
566            parameters.attach_secret,
567            persisted_cursor,
568            terminal,
569        )
570        .map_err(AttachCommitError::MembershipInvariant)?;
571    let outcome = match transition {
572        AttachTransition::FencedRecovery { .. } => AttachBound::fenced(
573            request.conversation_id,
574            request.attach_attempt_token,
575            request.participant_id,
576            request.capability_generation,
577            parameters.attach_secret,
578            parameters.binding.binding_epoch,
579            persisted_cursor,
580            parameters.receipt_expires_at,
581            parameters.provenance_expires_at,
582        ),
583        AttachTransition::Detached | AttachTransition::Superseded { .. } => AttachBound::ordinary(
584            request.conversation_id,
585            request.attach_attempt_token,
586            request.participant_id,
587            request.capability_generation,
588            parameters.attach_secret,
589            parameters.binding.binding_epoch,
590            persisted_cursor,
591            parameters.receipt_expires_at,
592            parameters.provenance_expires_at,
593        ),
594    }
595    .ok_or(AttachCommitError::ReceiptInvariant)?;
596    let ordinary_binding_authority = match transition {
597        AttachTransition::Detached | AttachTransition::Superseded { .. } => Some(
598            OrdinaryBindingAuthority::new(parameters.binding, persisted_cursor),
599        ),
600        AttachTransition::FencedRecovery { .. } => None,
601    };
602    Ok(AttachCommit {
603        member,
604        binding_state: BindingState::Bound(parameters.binding),
605        detach_cell: next_cell,
606        attached,
607        outcome,
608        transition,
609        binding_origin,
610        ordinary_binding_authority,
611        recovered_binding_authority,
612    })
613}
614
615fn transition_detach_cell<F, V>(
616    mode: &AttachMode,
617    member: &LiveMember<F>,
618    detach_cell: DetachCell<V>,
619) -> Result<DetachCell<V>, AttachCommitError>
620where
621    V: Copy + Eq,
622{
623    match detach_cell {
624        DetachCell::Empty(cell) => Ok(DetachCell::Empty(cell)),
625        DetachCell::Pending(cell) => {
626            let AttachMode::Fenced {
627                pending: Some((binding_state, _)),
628                ..
629            } = mode
630            else {
631                return Err(AttachCommitError::PendingDetach);
632            };
633            validate_pending_pair(
634                BindingState::PendingFinalization(*binding_state),
635                &cell,
636                Some(member.conversation_id()),
637            )
638            .map_err(|_| AttachCommitError::BindingCellState)?;
639            Ok(DetachCell::Terminalized(cell.terminalize_after_attach()))
640        }
641        DetachCell::Committed(cell) => {
642            if matches!(mode, AttachMode::Superseded(_))
643                || cell.participant_id() != member.participant_id()
644                || cell.request_generation() != member.generation()
645            {
646                return Err(AttachCommitError::DetachCellAuthority);
647            }
648            let Some(terminal) = member.latest_terminal() else {
649                return Err(AttachCommitError::TerminalHistory);
650            };
651            if terminal.detached_cause() != Some(crate::wire::DetachedCause::CleanDeregister)
652                || terminal.binding_epoch() != cell.committed_binding_epoch()
653                || terminal.delivery_seq() != cell.detached_delivery_seq()
654            {
655                return Err(AttachCommitError::TerminalHistory);
656            }
657            Ok(DetachCell::Terminalized(cell.terminalize_after_attach()))
658        }
659        DetachCell::Terminalized(cell) => {
660            if cell.participant_id() != member.participant_id() {
661                return Err(AttachCommitError::DetachCellAuthority);
662            }
663            Ok(DetachCell::Terminalized(cell))
664        }
665    }
666}