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}