Skip to main content

liminal_protocol/lifecycle/
operation_event.rs

1//! Public durable-fact vocabulary for the six lifecycle operations recorded
2//! by the participant-conversation shell.
3//!
4//! Every public producer consumes one of the crate's own sealed commit values
5//! (`docs/design/LP-GAP-CLOSURE-GOAL.md` item A1): enrollment facts come from
6//! [`EnrollmentCommit`] and attach facts from [`AttachCommit`] (both carry
7//! private fields, so only [`super::commit_enrollment`] and
8//! [`super::commit_attach`] mint them, and the recorded fact kind is bound to
9//! the producing commit kind); detach facts consume the whole atomic
10//! [`CommittedDetachTransition`], whose committed replay cell — born in the
11//! same detach commit as its terminal — supplies the recorded attempt token;
12//! leave
13//! facts come from the permanent [`RetiredIdentity`] tombstone, which carries
14//! both the canonical [`LeaveCommitted`] result and the congruence-checked
15//! `Left` transaction order; fate facts come from the two private-field fate
16//! authorities, which carry the conversation validated at their minting
17//! transitions; and ack facts come from the nonzero-debt ack commit. The one
18//! raw promotion path into any of those inputs is validated whole-participant
19//! cold restore ([`super::ParticipantLifecycleRestore::restore`] and the
20//! sealed stored-edge restores), each of which re-validates provenance before
21//! minting. These payloads describe committed operations for the ordering
22//! shell; they are not executable lifecycle authority, and no path exists from
23//! a decoded or constructed payload to a typed lifecycle state, stored edge,
24//! or binding origin. Decoded events rebuild payloads through crate-private
25//! constructors that re-validate every canonical field invariant.
26
27use crate::wire::{
28    BindingEpoch, ConversationId, DeliverySeq, DetachAttemptToken, DetachedCause, Generation,
29    LeaveCommitted, ParticipantId, TransactionOrder,
30};
31
32use super::{
33    AttachCommit, CommittedDetachTransition, EnrollmentCommit, NonzeroParticipantAckCommit,
34    OrdinaryBindingFate, RecoveredBindingFate, RetiredIdentity,
35};
36
37/// Durable facts of one committed enrollment, as recorded by the shell.
38///
39/// The only public producer consumes the crate's own enrollment commit
40/// ([`EnrollmentCommit`] carries a private field, so only
41/// [`super::commit_enrollment`] mints it), so an event can exist only
42/// downstream of a real enrollment commit and can never be minted from an
43/// attach commit's record.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct EnrolledOperation {
46    conversation_id: ConversationId,
47    participant_id: ParticipantId,
48    binding_epoch: BindingEpoch,
49    attached_transaction_order: TransactionOrder,
50    attached_delivery_seq: DeliverySeq,
51}
52
53impl EnrolledOperation {
54    /// Creates the enrollment event body from the enrollment commit itself.
55    ///
56    /// [`super::commit_enrollment`] refuses any non-generation-one origin
57    /// epoch before constructing [`EnrollmentCommit`], so the canonical
58    /// generation-one body invariant re-validated on decode holds for every
59    /// value this producer can observe.
60    #[must_use]
61    pub const fn new<F>(commit: &EnrollmentCommit<F>) -> Self {
62        let attached = commit.attached;
63        Self {
64            conversation_id: attached.conversation_id(),
65            participant_id: attached.participant_id(),
66            binding_epoch: attached.binding_epoch(),
67            attached_transaction_order: attached.admission_order().transaction_order(),
68            attached_delivery_seq: attached.delivery_seq(),
69        }
70    }
71
72    pub(super) fn from_decoded(
73        conversation_id: ConversationId,
74        participant_id: ParticipantId,
75        binding_epoch: BindingEpoch,
76        attached_transaction_order: TransactionOrder,
77        attached_delivery_seq: DeliverySeq,
78    ) -> Option<Self> {
79        if binding_epoch.capability_generation != Generation::ONE {
80            return None;
81        }
82        Some(Self {
83            conversation_id,
84            participant_id,
85            binding_epoch,
86            attached_transaction_order,
87            attached_delivery_seq,
88        })
89    }
90
91    /// Returns the conversation that enrolled the participant.
92    #[must_use]
93    pub const fn conversation_id(self) -> ConversationId {
94        self.conversation_id
95    }
96
97    /// Returns the permanent participant identifier/index.
98    #[must_use]
99    pub const fn participant_id(self) -> ParticipantId {
100        self.participant_id
101    }
102
103    /// Returns the generation-one origin binding epoch.
104    #[must_use]
105    pub const fn binding_epoch(self) -> BindingEpoch {
106        self.binding_epoch
107    }
108
109    /// Returns the `Attached` record's immutable transaction-order major.
110    #[must_use]
111    pub const fn attached_transaction_order(self) -> TransactionOrder {
112        self.attached_transaction_order
113    }
114
115    /// Returns the `Attached` record's committed delivery sequence.
116    #[must_use]
117    pub const fn attached_delivery_seq(self) -> DeliverySeq {
118        self.attached_delivery_seq
119    }
120}
121
122/// Durable facts of one committed credential attach, as recorded by the shell.
123///
124/// The only public producer consumes the crate's own attach commit
125/// ([`AttachCommit`] carries private fields, so only [`super::commit_attach`]
126/// mints it), so an enrollment's generation-one record cannot be relabeled as
127/// an attach fact.
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub struct AttachedOperation {
130    conversation_id: ConversationId,
131    participant_id: ParticipantId,
132    binding_epoch: BindingEpoch,
133    attached_transaction_order: TransactionOrder,
134    attached_delivery_seq: DeliverySeq,
135}
136
137impl AttachedOperation {
138    /// Creates the attach event body from the attach commit itself.
139    ///
140    /// The producer consumes the whole sealed commit rather than the shared
141    /// `Attached` record, so an enrollment commit cannot be relabeled as a
142    /// credential-attach fact:
143    ///
144    /// ```compile_fail
145    /// use liminal_protocol::lifecycle::{AttachedOperation, EnrollmentCommit};
146    ///
147    /// fn relabel(commit: &EnrollmentCommit<[u8; 4]>) -> AttachedOperation {
148    ///     AttachedOperation::new(commit)
149    /// }
150    /// ```
151    #[must_use]
152    pub const fn new<F, V>(commit: &AttachCommit<F, V>) -> Self {
153        let attached = commit.attached;
154        Self {
155            conversation_id: attached.conversation_id(),
156            participant_id: attached.participant_id(),
157            binding_epoch: attached.binding_epoch(),
158            attached_transaction_order: attached.admission_order().transaction_order(),
159            attached_delivery_seq: attached.delivery_seq(),
160        }
161    }
162
163    pub(super) fn from_decoded(
164        conversation_id: ConversationId,
165        participant_id: ParticipantId,
166        binding_epoch: BindingEpoch,
167        attached_transaction_order: TransactionOrder,
168        attached_delivery_seq: DeliverySeq,
169    ) -> Option<Self> {
170        // Every attach commit increments the member generation, so a
171        // committed attach epoch is generation two or later; a generation-one
172        // "attach" is an enrollment fact relabeled on the wire and is refused
173        // exactly as the enrolled decoder refuses non-generation-one bodies.
174        if binding_epoch.capability_generation == Generation::ONE {
175            return None;
176        }
177        Some(Self {
178            conversation_id,
179            participant_id,
180            binding_epoch,
181            attached_transaction_order,
182            attached_delivery_seq,
183        })
184    }
185
186    /// Returns the conversation whose participant attached.
187    #[must_use]
188    pub const fn conversation_id(self) -> ConversationId {
189        self.conversation_id
190    }
191
192    /// Returns the permanent participant identifier/index.
193    #[must_use]
194    pub const fn participant_id(self) -> ParticipantId {
195        self.participant_id
196    }
197
198    /// Returns the newly committed authoritative binding epoch.
199    #[must_use]
200    pub const fn binding_epoch(self) -> BindingEpoch {
201        self.binding_epoch
202    }
203
204    /// Returns the `Attached` record's immutable transaction-order major.
205    #[must_use]
206    pub const fn attached_transaction_order(self) -> TransactionOrder {
207        self.attached_transaction_order
208    }
209
210    /// Returns the `Attached` record's committed delivery sequence.
211    #[must_use]
212    pub const fn attached_delivery_seq(self) -> DeliverySeq {
213        self.attached_delivery_seq
214    }
215}
216
217/// Durable facts of one committed clean detach, as recorded by the shell.
218///
219/// The atomic detach transaction (terminal append, floor transition, cell
220/// replacement, and binding release) is summarized by its committed
221/// `Detached(CleanDeregister)` terminal plus the replayable attempt token
222/// retained inside the committed replay cell.
223#[derive(Clone, Copy, Debug, PartialEq, Eq)]
224pub struct DetachedOperation {
225    detach_attempt_token: DetachAttemptToken,
226    conversation_id: ConversationId,
227    participant_id: ParticipantId,
228    committed_binding_epoch: BindingEpoch,
229    detached_transaction_order: TransactionOrder,
230    detached_delivery_seq: DeliverySeq,
231}
232
233impl DetachedOperation {
234    /// Creates the detach event body from the whole atomic detach transition.
235    ///
236    /// The producer consumes the crate's own sealed commit
237    /// ([`CommittedDetachTransition`] has no public constructor, so only
238    /// [`super::commit_detach`] and [`super::complete_pending_detach`] mint
239    /// it), exactly like the other five producers: the recorded terminal and
240    /// the replay cell supplying the recorded attempt token were born in one
241    /// detach commit with full conversation context, so pairing a terminal
242    /// with a cell from another detach — or another conversation whose
243    /// participant, epoch, and delivery-sequence fields collide — is
244    /// unrepresentable:
245    ///
246    /// ```compile_fail
247    /// use liminal_protocol::lifecycle::{CommittedDetachTransition, DetachedOperation};
248    ///
249    /// fn mispair(
250    ///     conversation_a: &CommittedDetachTransition<(), [u8; 32]>,
251    ///     conversation_b: &CommittedDetachTransition<(), [u8; 32]>,
252    /// ) -> Option<DetachedOperation> {
253    ///     DetachedOperation::new(conversation_a.terminal(), conversation_b.cell())
254    /// }
255    /// ```
256    ///
257    /// A standalone terminal cannot be presented either, so a supersession
258    /// terminal still belongs only to the attach event that committed it:
259    ///
260    /// ```compile_fail
261    /// use liminal_protocol::lifecycle::{CommittedDetachedTerminal, DetachedOperation};
262    ///
263    /// fn relabel(terminal: &CommittedDetachedTerminal) -> Option<DetachedOperation> {
264    ///     DetachedOperation::new(terminal)
265    /// }
266    /// ```
267    ///
268    /// Returns `None` unless the transition's terminal cause is
269    /// `CleanDeregister` and its cell names that terminal's exact
270    /// participant, ended binding epoch, and committed delivery sequence —
271    /// re-validation of invariants the detach commit constructors already
272    /// guarantee, surfaced by the aggregate commit as a typed pairing fault
273    /// rather than a recorded lie.
274    #[must_use]
275    pub fn new<EF, V>(transition: &CommittedDetachTransition<EF, V>) -> Option<Self> {
276        let terminal = transition.terminal();
277        let cell = transition.cell();
278        if terminal.cause() != DetachedCause::CleanDeregister {
279            return None;
280        }
281        let participant_matches = cell.participant_id() == terminal.participant_id();
282        let epoch_matches = cell.committed_binding_epoch() == terminal.binding_epoch();
283        let delivery_matches = cell.detached_delivery_seq() == terminal.delivery_seq();
284        if !(participant_matches && epoch_matches && delivery_matches) {
285            return None;
286        }
287        Some(Self {
288            detach_attempt_token: cell.token(),
289            conversation_id: terminal.conversation_id(),
290            participant_id: terminal.participant_id(),
291            committed_binding_epoch: terminal.binding_epoch(),
292            detached_transaction_order: terminal.admission_order().transaction_order(),
293            detached_delivery_seq: terminal.delivery_seq(),
294        })
295    }
296
297    pub(super) const fn from_decoded(
298        detach_attempt_token: DetachAttemptToken,
299        conversation_id: ConversationId,
300        participant_id: ParticipantId,
301        committed_binding_epoch: BindingEpoch,
302        detached_transaction_order: TransactionOrder,
303        detached_delivery_seq: DeliverySeq,
304    ) -> Self {
305        Self {
306            detach_attempt_token,
307            conversation_id,
308            participant_id,
309            committed_binding_epoch,
310            detached_transaction_order,
311            detached_delivery_seq,
312        }
313    }
314
315    /// Returns the stable detach attempt token retained for replay.
316    #[must_use]
317    pub const fn detach_attempt_token(self) -> DetachAttemptToken {
318        self.detach_attempt_token
319    }
320
321    /// Returns the conversation whose participant detached.
322    #[must_use]
323    pub const fn conversation_id(self) -> ConversationId {
324        self.conversation_id
325    }
326
327    /// Returns the permanent participant identifier/index.
328    #[must_use]
329    pub const fn participant_id(self) -> ParticipantId {
330        self.participant_id
331    }
332
333    /// Returns the binding epoch ended by the detach.
334    #[must_use]
335    pub const fn committed_binding_epoch(self) -> BindingEpoch {
336        self.committed_binding_epoch
337    }
338
339    /// Returns the terminal's immutable transaction-order major.
340    #[must_use]
341    pub const fn detached_transaction_order(self) -> TransactionOrder {
342        self.detached_transaction_order
343    }
344
345    /// Returns the committed `Detached` record's delivery sequence.
346    #[must_use]
347    pub const fn detached_delivery_seq(self) -> DeliverySeq {
348        self.detached_delivery_seq
349    }
350}
351
352/// Durable facts of one permanent Leave, as recorded by the shell.
353#[derive(Clone, Debug, PartialEq, Eq)]
354pub struct LeftOperation {
355    committed: LeaveCommitted,
356    left_transaction_order: TransactionOrder,
357}
358
359impl LeftOperation {
360    /// Creates the leave event body from the permanent retirement tombstone.
361    ///
362    /// [`RetiredIdentity`] carries both the canonical [`LeaveCommitted`]
363    /// result and the congruence-checked `Left` admission order, so the
364    /// recorded fact and its transaction order both come from a committed
365    /// leave ([`super::commit_leave`] or [`super::commit_pending_leave`])
366    /// rather than from caller-chosen raw values. The one raw promotion path
367    /// into [`RetiredIdentity`] is validated whole-participant cold restore
368    /// ([`super::ParticipantLifecycleRestore::restore`]), which re-validates
369    /// every stored field against the stored result before minting the
370    /// tombstone.
371    ///
372    /// A leave fact cannot be minted from wire-constructible raw values:
373    ///
374    /// ```compile_fail
375    /// use liminal_protocol::lifecycle::LeftOperation;
376    /// use liminal_protocol::wire::LeaveCommitted;
377    ///
378    /// fn fabricate(committed: LeaveCommitted) -> LeftOperation {
379    ///     LeftOperation::new(committed, 17)
380    /// }
381    /// ```
382    #[must_use]
383    pub fn new<EF, V, LF>(retired: &RetiredIdentity<EF, V, LF>) -> Self {
384        Self {
385            committed: retired.committed_result().clone(),
386            left_transaction_order: retired.left_admission_order().transaction_order(),
387        }
388    }
389
390    pub(super) const fn from_decoded(
391        committed: LeaveCommitted,
392        left_transaction_order: TransactionOrder,
393    ) -> Self {
394        Self {
395            committed,
396            left_transaction_order,
397        }
398    }
399
400    /// Borrows the canonical permanent Leave result.
401    #[must_use]
402    pub const fn committed(&self) -> &LeaveCommitted {
403        &self.committed
404    }
405
406    /// Returns the immutable transaction-order major of the `Left` record.
407    #[must_use]
408    pub const fn left_transaction_order(&self) -> TransactionOrder {
409        self.left_transaction_order
410    }
411}
412
413/// Durable facts of one observed binding fate (crash/death), as recorded by
414/// the shell.
415///
416/// Both fate authorities are private-field types constructible only through
417/// the crate's own transitions, so this event cannot assert a fate that never
418/// committed. Each authority carries the conversation validated at its
419/// minting transition (the committed `Died` terminal for ordinary fate, the
420/// frontier-validated marker provenance for recovered fate), so the shell's
421/// conversation-congruence refusal applies to this arm exactly as it does to
422/// the other five.
423#[derive(Clone, Copy, Debug, PartialEq, Eq)]
424pub struct BindingFateOperation {
425    conversation_id: ConversationId,
426    participant_id: ParticipantId,
427    last_dead_binding_epoch: BindingEpoch,
428    resulting_floor: DeliverySeq,
429}
430
431impl BindingFateOperation {
432    /// Creates the fate event body from an ordinary binding death.
433    #[must_use]
434    pub const fn from_ordinary(fate: &OrdinaryBindingFate) -> Self {
435        Self {
436            conversation_id: fate.conversation_id(),
437            participant_id: fate.participant_id(),
438            last_dead_binding_epoch: fate.last_dead_binding_epoch(),
439            resulting_floor: fate.resulting_floor(),
440        }
441    }
442
443    /// Creates the fate event body from a recovered-epoch binding death.
444    #[must_use]
445    pub const fn from_recovered(fate: &RecoveredBindingFate) -> Self {
446        Self {
447            conversation_id: fate.conversation_id(),
448            participant_id: fate.participant_id(),
449            last_dead_binding_epoch: fate.last_dead_binding_epoch(),
450            resulting_floor: fate.resulting_floor(),
451        }
452    }
453
454    pub(super) const fn from_decoded(
455        conversation_id: ConversationId,
456        participant_id: ParticipantId,
457        last_dead_binding_epoch: BindingEpoch,
458        resulting_floor: DeliverySeq,
459    ) -> Self {
460        Self {
461            conversation_id,
462            participant_id,
463            last_dead_binding_epoch,
464            resulting_floor,
465        }
466    }
467
468    /// Returns the conversation whose binding fate was observed.
469    #[must_use]
470    pub const fn conversation_id(self) -> ConversationId {
471        self.conversation_id
472    }
473
474    /// Returns the participant whose binding died.
475    #[must_use]
476    pub const fn participant_id(self) -> ParticipantId {
477        self.participant_id
478    }
479
480    /// Returns the exact dead binding epoch whose fate was observed.
481    #[must_use]
482    pub const fn last_dead_binding_epoch(self) -> BindingEpoch {
483        self.last_dead_binding_epoch
484    }
485
486    /// Returns the floor measured in the binding-fate transaction.
487    #[must_use]
488    pub const fn resulting_floor(self) -> DeliverySeq {
489        self.resulting_floor
490    }
491}
492
493/// Durable facts of one nonzero-debt participant cursor acknowledgement, as
494/// recorded by the shell.
495#[derive(Clone, Copy, Debug, PartialEq, Eq)]
496pub struct NonzeroDebtAckOperation {
497    conversation_id: ConversationId,
498    participant_id: ParticipantId,
499    capability_generation: Generation,
500    through_seq: DeliverySeq,
501}
502
503impl NonzeroDebtAckOperation {
504    /// Creates the ack event body from the crate's nonzero-debt ack commit.
505    #[must_use]
506    pub const fn new(commit: &NonzeroParticipantAckCommit) -> Self {
507        let request = commit.outcome().request();
508        Self {
509            conversation_id: request.conversation_id,
510            participant_id: request.participant_id,
511            capability_generation: request.capability_generation,
512            through_seq: request.through_seq,
513        }
514    }
515
516    pub(super) const fn from_decoded(
517        conversation_id: ConversationId,
518        participant_id: ParticipantId,
519        capability_generation: Generation,
520        through_seq: DeliverySeq,
521    ) -> Self {
522        Self {
523            conversation_id,
524            participant_id,
525            capability_generation,
526            through_seq,
527        }
528    }
529
530    /// Returns the conversation whose participant acknowledged.
531    #[must_use]
532    pub const fn conversation_id(self) -> ConversationId {
533        self.conversation_id
534    }
535
536    /// Returns the acknowledging participant.
537    #[must_use]
538    pub const fn participant_id(self) -> ParticipantId {
539        self.participant_id
540    }
541
542    /// Returns the presented capability generation.
543    #[must_use]
544    pub const fn capability_generation(self) -> Generation {
545        self.capability_generation
546    }
547
548    /// Returns the committed cumulative cursor boundary.
549    #[must_use]
550    pub const fn through_seq(self) -> DeliverySeq {
551        self.through_seq
552    }
553}
554
555/// One of the six lifecycle operations recordable by the conversation shell.
556#[derive(Clone, Debug, PartialEq, Eq)]
557pub enum ConversationOperation {
558    /// A participant enrolled with its generation-one origin binding.
559    Enrolled(EnrolledOperation),
560    /// A participant committed a credential attach.
561    Attached(AttachedOperation),
562    /// A participant committed a clean detach.
563    Detached(DetachedOperation),
564    /// A participant permanently left the conversation.
565    Left(LeftOperation),
566    /// A binding's crash/death fate was durably observed.
567    BindingFate(BindingFateOperation),
568    /// A participant advanced its cursor during a nonzero-debt episode.
569    NonzeroDebtAck(NonzeroDebtAckOperation),
570}
571
572impl ConversationOperation {
573    /// Returns the conversation named by the operation's provenance.
574    pub(super) const fn conversation_id(&self) -> ConversationId {
575        match self {
576            Self::Enrolled(operation) => operation.conversation_id(),
577            Self::Attached(operation) => operation.conversation_id(),
578            Self::Detached(operation) => operation.conversation_id(),
579            Self::Left(operation) => operation.committed().conversation_id(),
580            Self::BindingFate(operation) => operation.conversation_id(),
581            Self::NonzeroDebtAck(operation) => operation.conversation_id(),
582        }
583    }
584}