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(Clone, 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}
116
117impl<F, V> AttachCommit<F, V> {
118    /// Projects the binding-ending terminal committed by this attach, if any.
119    #[must_use]
120    pub fn observer_progress_projection(&self) -> Option<ObserverProgressProjection> {
121        let terminal = match self.transition {
122            AttachTransition::Detached
123            | AttachTransition::FencedRecovery {
124                composed_terminal: None,
125                ..
126            } => return None,
127            AttachTransition::Superseded { terminal } => terminal.into(),
128            AttachTransition::FencedRecovery {
129                composed_terminal: Some(terminal),
130                ..
131            } => terminal,
132        };
133        Some(ObserverProgressProjection::new(
134            terminal.conversation_id(),
135            terminal.delivery_seq(),
136        ))
137    }
138
139    /// Consumes one exact normal-ack event into this ordinary attach's cursor authority.
140    ///
141    /// Fenced recovery has no ordinary authority and therefore returns the
142    /// unchanged commit. The raw authority never crosses the public boundary.
143    ///
144    /// # Errors
145    ///
146    /// Returns the unchanged commit for fenced recovery or when the event names
147    /// another participant, epoch, previous cursor, or event class.
148    pub fn ordinary_cursor_progressed(mut self, event: Event) -> Result<Self, Box<Self>> {
149        let Some(authority) = self.ordinary_binding_authority.take() else {
150            return Err(Box::new(self));
151        };
152        match authority.cursor_progressed(event) {
153            Ok(authority) => {
154                self.ordinary_binding_authority = Some(authority);
155                Ok(self)
156            }
157            Err(authority) => {
158                self.ordinary_binding_authority = Some(authority);
159                Err(Box::new(self))
160            }
161        }
162    }
163
164    /// Consumes this ordinary attach and its exact durable death into cursor-release fate.
165    ///
166    /// A fenced attach cannot enter this path because it carries no ordinary
167    /// binding authority; recovered epochs still require the distinct
168    /// [`FencedAttachCommit`] provenance transition.
169    ///
170    /// # Errors
171    ///
172    /// Returns the unchanged commit for fenced recovery or unless the terminal
173    /// names this exact ordinary participant, conversation, and binding epoch.
174    pub fn ordinary_binding_fate(
175        mut self,
176        terminal: CommittedDiedTerminal,
177        resulting_floor: DeliverySeq,
178    ) -> Result<OrdinaryBindingFate, Box<Self>> {
179        let Some(authority) = self.ordinary_binding_authority.take() else {
180            return Err(Box::new(self));
181        };
182        match authority.binding_fate(terminal, resulting_floor) {
183            Ok(fate) => Ok(fate),
184            Err(authority) => {
185                self.ordinary_binding_authority = Some(authority);
186                Err(Box::new(self))
187            }
188        }
189    }
190
191    /// Returns no-marker fate authority only for a committed ordinary attach.
192    ///
193    /// Fenced recovery returns `None`; its recovered epoch must instead use the
194    /// [`FencedAttachCommit`] proof that authorized that commit.
195    #[must_use]
196    #[allow(
197        dead_code,
198        reason = "the crate-owned binding-fate operation consumes this sealed attach authority"
199    )]
200    pub(crate) const fn ordinary_binding_authority(&self) -> Option<OrdinaryBindingAuthority> {
201        self.ordinary_binding_authority
202    }
203
204    /// Returns the opaque binding origin emitted from the verified attach mode.
205    #[must_use]
206    #[allow(
207        dead_code,
208        reason = "the crate-owned event replay boundary persists this producer-emitted origin"
209    )]
210    pub(crate) const fn binding_origin(&self) -> BindingOrigin {
211        self.binding_origin
212    }
213}
214
215#[derive(Clone, Copy, Debug)]
216enum AttachMode<'a> {
217    Detached(OrdinaryDetachedAttachAdmission),
218    Superseded(CommittedDetachedTerminal),
219    Fenced {
220        proof: &'a FencedAttachCommit,
221        pending: Option<(PendingFinalization, DeliverySeq)>,
222    },
223}
224
225/// Successful attach proof; fields are private so only mode verification mints it.
226#[derive(Clone, Debug)]
227pub struct VerifiedAttachCommit<'a, F> {
228    member: LiveMember<F>,
229    request: CredentialAttachRequest,
230    parameters: AttachCommitParameters,
231    mode: AttachMode<'a>,
232}
233
234impl<F> LiveMember<F> {
235    /// Verifies an ordinary attach from an already detached member.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`AttachVerificationError`] for authority, marker, state, or allocation mismatch.
240    pub fn verify_detached_attach(
241        self,
242        binding_state: BindingState,
243        closure_admission: OrdinaryDetachedAttachAdmission,
244        request: CredentialAttachRequest,
245        secret_proof: AttachSecretProof,
246        parameters: AttachCommitParameters,
247    ) -> Result<VerifiedAttachCommit<'static, F>, AttachVerificationError> {
248        self.verify_attach_common(&request, secret_proof, &parameters)?;
249        if binding_state != BindingState::Detached {
250            return Err(AttachVerificationError::BindingState);
251        }
252        if request.accept_marker_delivery_seq.is_some() {
253            return Err(AttachVerificationError::MarkerProof);
254        }
255        Ok(VerifiedAttachCommit {
256            member: self,
257            request,
258            parameters,
259            mode: AttachMode::Detached(closure_admission),
260        })
261    }
262
263    /// Verifies an attach that atomically supersedes this member's active epoch.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`AttachVerificationError`] when authority, marker absence,
268    /// handoff ordering, or result allocation is inconsistent.
269    pub fn verify_superseding_attach(
270        self,
271        active_binding: ActiveBinding,
272        request: CredentialAttachRequest,
273        secret_proof: AttachSecretProof,
274        terminal_position: CommittedBindingTerminalPosition,
275        parameters: AttachCommitParameters,
276    ) -> Result<VerifiedAttachCommit<'static, F>, AttachVerificationError> {
277        self.verify_attach_common(&request, secret_proof, &parameters)?;
278        if active_binding.conversation_id != self.conversation_id()
279            || active_binding.participant_id != self.participant_id()
280            || active_binding.binding_epoch.capability_generation != self.generation()
281        {
282            return Err(AttachVerificationError::BindingState);
283        }
284        if request.accept_marker_delivery_seq.is_some() {
285            return Err(AttachVerificationError::MarkerProof);
286        }
287        if terminal_position.transaction_order() != parameters.attached_position.transaction_order()
288        {
289            return Err(AttachVerificationError::LifecycleOrder);
290        }
291        let terminal = active_binding.superseded(terminal_position);
292        Ok(VerifiedAttachCommit {
293            member: self,
294            request,
295            parameters,
296            mode: AttachMode::Superseded(terminal),
297        })
298    }
299
300    /// Verifies marker-fenced recovery proven by the closure-edge transition.
301    ///
302    /// # Errors
303    ///
304    /// Returns [`AttachVerificationError`] when credential authority,
305    /// marker/epoch proof, pending-terminal shape, or result allocation differs.
306    pub fn verify_fenced_attach(
307        self,
308        binding_state: BindingState,
309        request: CredentialAttachRequest,
310        secret_proof: AttachSecretProof,
311        proof: &FencedAttachCommit,
312        pending_terminal_delivery_seq: Option<DeliverySeq>,
313        parameters: AttachCommitParameters,
314    ) -> Result<VerifiedAttachCommit<'_, F>, AttachVerificationError> {
315        self.verify_attach_common(&request, secret_proof, &parameters)?;
316        if request.accept_marker_delivery_seq != Some(proof.marker_delivery_seq()) {
317            return Err(AttachVerificationError::MarkerProof);
318        }
319        if proof.participant_id() != self.participant_id()
320            || proof.prior_binding_epoch().capability_generation != self.generation()
321            || proof.new_binding_epoch() != parameters.binding.binding_epoch
322        {
323            return Err(AttachVerificationError::RecoveryAuthority);
324        }
325        let pending = match (binding_state, pending_terminal_delivery_seq) {
326            (BindingState::Detached, None) => {
327                if self
328                    .latest_terminal()
329                    .is_none_or(|terminal| terminal.binding_epoch() != proof.prior_binding_epoch())
330                {
331                    return Err(AttachVerificationError::TerminalHistory);
332                }
333                None
334            }
335            (BindingState::PendingFinalization(finalization), Some(sequence)) => {
336                let same_conversation = finalization.conversation_id() == self.conversation_id();
337                let same_participant = finalization.participant_id() == self.participant_id();
338                let same_prior_epoch = finalization.binding_epoch() == proof.prior_binding_epoch();
339                if !(same_conversation && same_participant && same_prior_epoch) {
340                    return Err(AttachVerificationError::BindingState);
341                }
342                Some((finalization, sequence))
343            }
344            (BindingState::PendingFinalization(_), None) | (BindingState::Detached, Some(_)) => {
345                return Err(AttachVerificationError::PendingTerminalSequence);
346            }
347            (BindingState::Bound(_), _) => {
348                return Err(AttachVerificationError::BindingState);
349            }
350        };
351        Ok(VerifiedAttachCommit {
352            member: self,
353            request,
354            parameters,
355            mode: AttachMode::Fenced { proof, pending },
356        })
357    }
358
359    fn verify_attach_common(
360        &self,
361        request: &CredentialAttachRequest,
362        secret_proof: AttachSecretProof,
363        parameters: &AttachCommitParameters,
364    ) -> Result<(), AttachVerificationError> {
365        if request.conversation_id != self.conversation_id() {
366            return Err(AttachVerificationError::Conversation);
367        }
368        if request.participant_id != self.participant_id() {
369            return Err(AttachVerificationError::Participant);
370        }
371        if request.capability_generation != self.generation() {
372            return Err(AttachVerificationError::Generation);
373        }
374        if secret_proof == AttachSecretProof::Mismatch {
375            return Err(AttachVerificationError::Secret);
376        }
377        if parameters.binding.conversation_id != self.conversation_id()
378            || parameters.binding.participant_id != self.participant_id()
379        {
380            return Err(AttachVerificationError::ResultBinding);
381        }
382        let Some(next_raw) = self.generation().get().checked_add(1) else {
383            return Err(AttachVerificationError::ResultGeneration);
384        };
385        let Some(next_generation) = Generation::new(next_raw) else {
386            return Err(AttachVerificationError::ResultGeneration);
387        };
388        if parameters.binding.binding_epoch.capability_generation != next_generation {
389            return Err(AttachVerificationError::ResultGeneration);
390        }
391        Ok(())
392    }
393}
394
395/// Atomically commits a verified attach and applies Fix 1's detach-cell rule.
396///
397/// # Errors
398///
399/// Returns [`AttachCommitError`] for cell/history mismatch, invalid rotation,
400/// or a canonical receipt invariant rejected after verification.
401pub fn commit_attach<F, V>(
402    verified: VerifiedAttachCommit<'_, F>,
403    detach_cell: DetachCell<V>,
404) -> Result<AttachCommit<F, V>, AttachCommitError>
405where
406    V: Copy + Eq,
407{
408    let VerifiedAttachCommit {
409        member: previous_member,
410        request,
411        parameters,
412        mode,
413    } = verified;
414    let next_cell = transition_detach_cell(&mode, &previous_member, detach_cell)?;
415    let attached =
416        AttachedLifecycleRecord::from_binding(parameters.binding, parameters.attached_position);
417    let (persisted_cursor, terminal, transition, binding_origin) = match mode {
418        AttachMode::Detached(_) => (
419            previous_member.cursor(),
420            None,
421            AttachTransition::Detached,
422            BindingOrigin::unfenced(attached),
423        ),
424        AttachMode::Superseded(committed) => (
425            previous_member.cursor(),
426            Some(CommittedBindingTerminal::from(committed)),
427            AttachTransition::Superseded {
428                terminal: committed,
429            },
430            BindingOrigin::unfenced(attached),
431        ),
432        AttachMode::Fenced { proof, pending } => {
433            let composed_terminal =
434                pending.map(|(finalization, sequence)| finalization.commit(sequence));
435            (
436                proof.marker_delivery_seq(),
437                composed_terminal,
438                AttachTransition::FencedRecovery {
439                    prior_binding_epoch: proof.prior_binding_epoch(),
440                    composed_terminal,
441                    next_closure_state: proof.next_state(),
442                },
443                BindingOrigin::recovered(
444                    attached,
445                    proof.marker_delivery_seq(),
446                    proof.prior_binding_epoch(),
447                ),
448            )
449        }
450    };
451    let result_generation = parameters.binding.binding_epoch.capability_generation;
452    let member = previous_member
453        .rotate(
454            result_generation,
455            parameters.attach_secret,
456            persisted_cursor,
457            terminal,
458        )
459        .map_err(AttachCommitError::MembershipInvariant)?;
460    let outcome = match transition {
461        AttachTransition::FencedRecovery { .. } => AttachBound::fenced(
462            request.conversation_id,
463            request.attach_attempt_token,
464            request.participant_id,
465            request.capability_generation,
466            parameters.attach_secret,
467            parameters.binding.binding_epoch,
468            persisted_cursor,
469            parameters.receipt_expires_at,
470            parameters.provenance_expires_at,
471        ),
472        AttachTransition::Detached | AttachTransition::Superseded { .. } => AttachBound::ordinary(
473            request.conversation_id,
474            request.attach_attempt_token,
475            request.participant_id,
476            request.capability_generation,
477            parameters.attach_secret,
478            parameters.binding.binding_epoch,
479            persisted_cursor,
480            parameters.receipt_expires_at,
481            parameters.provenance_expires_at,
482        ),
483    }
484    .ok_or(AttachCommitError::ReceiptInvariant)?;
485    let ordinary_binding_authority = match transition {
486        AttachTransition::Detached | AttachTransition::Superseded { .. } => Some(
487            OrdinaryBindingAuthority::new(parameters.binding, persisted_cursor),
488        ),
489        AttachTransition::FencedRecovery { .. } => None,
490    };
491    Ok(AttachCommit {
492        member,
493        binding_state: BindingState::Bound(parameters.binding),
494        detach_cell: next_cell,
495        attached,
496        outcome,
497        transition,
498        binding_origin,
499        ordinary_binding_authority,
500    })
501}
502
503fn transition_detach_cell<F, V>(
504    mode: &AttachMode<'_>,
505    member: &LiveMember<F>,
506    detach_cell: DetachCell<V>,
507) -> Result<DetachCell<V>, AttachCommitError>
508where
509    V: Copy + Eq,
510{
511    match detach_cell {
512        DetachCell::Empty(cell) => Ok(DetachCell::Empty(cell)),
513        DetachCell::Pending(cell) => {
514            let AttachMode::Fenced {
515                pending: Some((binding_state, _)),
516                ..
517            } = mode
518            else {
519                return Err(AttachCommitError::PendingDetach);
520            };
521            validate_pending_pair(
522                BindingState::PendingFinalization(*binding_state),
523                &cell,
524                Some(member.conversation_id()),
525            )
526            .map_err(|_| AttachCommitError::BindingCellState)?;
527            Ok(DetachCell::Terminalized(cell.terminalize_after_attach()))
528        }
529        DetachCell::Committed(cell) => {
530            if matches!(mode, AttachMode::Superseded(_))
531                || cell.participant_id() != member.participant_id()
532                || cell.request_generation() != member.generation()
533            {
534                return Err(AttachCommitError::DetachCellAuthority);
535            }
536            let Some(terminal) = member.latest_terminal() else {
537                return Err(AttachCommitError::TerminalHistory);
538            };
539            if terminal.detached_cause() != Some(crate::wire::DetachedCause::CleanDeregister)
540                || terminal.binding_epoch() != cell.committed_binding_epoch()
541                || terminal.delivery_seq() != cell.detached_delivery_seq()
542            {
543                return Err(AttachCommitError::TerminalHistory);
544            }
545            Ok(DetachCell::Terminalized(cell.terminalize_after_attach()))
546        }
547        DetachCell::Terminalized(cell) => {
548            if cell.participant_id() != member.participant_id() {
549                return Err(AttachCommitError::DetachCellAuthority);
550            }
551            Ok(DetachCell::Terminalized(cell))
552        }
553    }
554}