Skip to main content

liminal_protocol/lifecycle/
conversation.rs

1//! Opaque event-sourced participant-conversation shell.
2//!
3//! The shell owns event ordering and durable replay without serializing any
4//! lifecycle authority directly. Protocol operations will add private event
5//! bodies over time; storage bindings persist only the canonical event bytes
6//! and may use resulting state only after consuming the corresponding commit.
7
8use alloc::vec::Vec;
9
10use crate::wire::ConversationId;
11
12use super::conversation_codec as codec;
13use super::operation_event::ConversationOperation;
14
15/// Immutable genesis configuration for one participant conversation.
16///
17/// Fields are private so later configuration additions cannot be bypassed by a
18/// storage binding constructing a partial genesis value.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct ConversationGenesis {
21    conversation_id: ConversationId,
22}
23
24impl ConversationGenesis {
25    /// Creates the protocol-owned genesis configuration for a conversation.
26    #[must_use]
27    pub const fn new(conversation_id: ConversationId) -> Self {
28        Self { conversation_id }
29    }
30
31    /// Returns the stable conversation identifier.
32    #[must_use]
33    pub const fn conversation_id(self) -> ConversationId {
34        self.conversation_id
35    }
36}
37
38/// Event-sourced participant conversation.
39///
40/// This aggregate is intentionally not `Clone`: at most one owner may prepare
41/// the next event ordinal. Its lifecycle state remains private and has no raw
42/// `into_parts` escape hatch.
43///
44/// ```compile_fail
45/// use liminal_protocol::lifecycle::ParticipantConversation;
46///
47/// fn require_clone<T: Clone>() {}
48/// require_clone::<ParticipantConversation>();
49/// ```
50#[derive(Debug, PartialEq, Eq)]
51pub struct ParticipantConversation {
52    genesis: ConversationGenesis,
53    next_event_ordinal: u64,
54    genesis_validated: bool,
55}
56
57impl ParticipantConversation {
58    /// Starts an empty aggregate from immutable genesis configuration.
59    #[must_use]
60    pub const fn from_genesis(genesis: ConversationGenesis) -> Self {
61        Self {
62            genesis,
63            next_event_ordinal: 0,
64            genesis_validated: false,
65        }
66    }
67
68    /// Returns this aggregate's stable conversation identifier.
69    #[must_use]
70    pub const fn conversation_id(&self) -> ConversationId {
71        self.genesis.conversation_id
72    }
73
74    /// Returns the exact ordinal required of the next durable event.
75    #[must_use]
76    pub const fn next_event_ordinal(&self) -> u64 {
77        self.next_event_ordinal
78    }
79
80    /// Reports whether the one-shot genesis-validation event has committed.
81    #[must_use]
82    pub const fn genesis_validated(&self) -> bool {
83        self.genesis_validated
84    }
85
86    /// Consumes the aggregate into a durability decision for genesis validation.
87    ///
88    /// A successful decision owns the pre-state inside [`ConversationCommit`],
89    /// preventing another event from being prepared at the same ordinal while
90    /// durable append is pending.
91    #[must_use]
92    pub const fn decide_genesis_validation(self) -> ConversationDecision {
93        if self.genesis_validated {
94            return ConversationDecision::Refused(ConversationRefusal {
95                conversation: self,
96                reason: ConversationRefusalReason::GenesisAlreadyValidated,
97            });
98        }
99        let Some(resulting_event_ordinal) = self.next_event_ordinal.checked_add(1) else {
100            let ordinal = self.next_event_ordinal;
101            return ConversationDecision::Refused(ConversationRefusal {
102                conversation: self,
103                reason: ConversationRefusalReason::EventOrdinalExhausted { ordinal },
104            });
105        };
106        let event = ConversationEvent {
107            header: ConversationEventHeader {
108                conversation_id: self.genesis.conversation_id,
109                ordinal: self.next_event_ordinal,
110            },
111            body: ConversationEventBody::GenesisValidated,
112        };
113        ConversationDecision::Commit(ConversationCommit {
114            conversation: self,
115            event,
116            resulting_event_ordinal,
117        })
118    }
119
120    /// Consumes the aggregate into a durability decision for one lifecycle
121    /// operation.
122    ///
123    /// Every operation payload's public producer consumes one of the crate's
124    /// own sealed commit values (each payload type documents its exact sealed
125    /// input), so the shell records only operations that actually committed;
126    /// validated cold restore is the one raw promotion path into those
127    /// inputs. Every arm carries the
128    /// conversation named by its producing commit, and a mismatch against
129    /// this shell's conversation is refused before any event is selected. A
130    /// successful decision owns the pre-state inside [`ConversationCommit`]
131    /// exactly as genesis validation does: no state advances until the
132    /// durable append is confirmed by consuming
133    /// [`ConversationCommit::commit`].
134    #[must_use]
135    pub const fn decide_operation(self, operation: ConversationOperation) -> ConversationDecision {
136        if !self.genesis_validated {
137            return ConversationDecision::Refused(ConversationRefusal {
138                conversation: self,
139                reason: ConversationRefusalReason::GenesisNotValidated,
140            });
141        }
142        let actual = operation.conversation_id();
143        if actual != self.genesis.conversation_id {
144            let expected = self.genesis.conversation_id;
145            return ConversationDecision::Refused(ConversationRefusal {
146                conversation: self,
147                reason: ConversationRefusalReason::OperationConversationMismatch {
148                    expected,
149                    actual,
150                },
151            });
152        }
153        let Some(resulting_event_ordinal) = self.next_event_ordinal.checked_add(1) else {
154            let ordinal = self.next_event_ordinal;
155            return ConversationDecision::Refused(ConversationRefusal {
156                conversation: self,
157                reason: ConversationRefusalReason::EventOrdinalExhausted { ordinal },
158            });
159        };
160        let event = ConversationEvent {
161            header: ConversationEventHeader {
162                conversation_id: self.genesis.conversation_id,
163                ordinal: self.next_event_ordinal,
164            },
165            body: ConversationEventBody::Operation(operation),
166        };
167        ConversationDecision::Commit(ConversationCommit {
168            conversation: self,
169            event,
170            resulting_event_ordinal,
171        })
172    }
173
174    /// Consumes one decoded durable event into the next replay state.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`ConversationReplayFailure`] retaining the unchanged pre-state
179    /// unless conversation identifier, ordinal, body precondition, and the
180    /// resulting ordinal all validate before mutation.
181    #[allow(
182        clippy::needless_pass_by_value,
183        reason = "replay consumes the non-Clone decoded occurrence so its private body cannot be reused as live transition authority"
184    )]
185    pub fn replay(mut self, event: ConversationEvent) -> Result<Self, ConversationReplayFailure> {
186        let resulting_event_ordinal = match self.validate_event(&event) {
187            Ok(resulting_event_ordinal) => resulting_event_ordinal,
188            Err(reason) => {
189                return Err(ConversationReplayFailure {
190                    conversation: self,
191                    reason,
192                });
193            }
194        };
195        self.apply_validated_event(&event, resulting_event_ordinal);
196        Ok(self)
197    }
198
199    fn validate_event(&self, event: &ConversationEvent) -> Result<u64, ConversationReplayError> {
200        if event.header.conversation_id != self.genesis.conversation_id {
201            return Err(ConversationReplayError::ConversationMismatch {
202                expected: self.genesis.conversation_id,
203                actual: event.header.conversation_id,
204            });
205        }
206        if event.header.ordinal != self.next_event_ordinal {
207            return Err(ConversationReplayError::OrdinalMismatch {
208                expected: self.next_event_ordinal,
209                actual: event.header.ordinal,
210            });
211        }
212        match event.body {
213            ConversationEventBody::GenesisValidated if self.genesis_validated => {
214                Err(ConversationReplayError::GenesisAlreadyValidated)
215            }
216            ConversationEventBody::Operation(_) if !self.genesis_validated => {
217                Err(ConversationReplayError::GenesisNotValidated)
218            }
219            ConversationEventBody::GenesisValidated | ConversationEventBody::Operation(_) => {
220                event.header.ordinal.checked_add(1).ok_or(
221                    ConversationReplayError::EventOrdinalExhausted {
222                        ordinal: event.header.ordinal,
223                    },
224                )
225            }
226        }
227    }
228
229    const fn apply_validated_event(
230        &mut self,
231        event: &ConversationEvent,
232        resulting_event_ordinal: u64,
233    ) {
234        match &event.body {
235            ConversationEventBody::GenesisValidated => {
236                self.genesis_validated = true;
237                self.next_event_ordinal = resulting_event_ordinal;
238            }
239            ConversationEventBody::Operation(_) => {
240                self.next_event_ordinal = resulting_event_ordinal;
241            }
242        }
243    }
244
245    /// Rebuilds the shell from a validated checkpoint capsule's section; the
246    /// capsule has already checked that the ordinal and genesis validation
247    /// agree.
248    pub(super) const fn from_checkpoint(
249        genesis: ConversationGenesis,
250        next_event_ordinal: u64,
251        genesis_validated: bool,
252    ) -> Self {
253        Self {
254            genesis,
255            next_event_ordinal,
256            genesis_validated,
257        }
258    }
259
260    #[cfg(test)]
261    pub(super) const fn from_test_state(
262        genesis: ConversationGenesis,
263        next_event_ordinal: u64,
264        genesis_validated: bool,
265    ) -> Self {
266        Self {
267            genesis,
268            next_event_ordinal,
269            genesis_validated,
270        }
271    }
272}
273
274/// Header of every durable participant-conversation event.
275#[derive(Debug, PartialEq, Eq)]
276struct ConversationEventHeader {
277    conversation_id: ConversationId,
278    ordinal: u64,
279}
280
281/// Private typed body of a durable participant-conversation event.
282///
283/// `pub(super)` only so the sibling canonical codec can encode and rebuild
284/// bodies; no path outside the lifecycle module can name this type.
285#[derive(Debug, PartialEq, Eq)]
286pub(super) enum ConversationEventBody {
287    GenesisValidated,
288    Operation(ConversationOperation),
289}
290
291/// Opaque durable event emitted only by a protocol decision or canonical decode.
292///
293/// Neither header nor body fields are public, so a binding cannot manufacture a
294/// typed event from raw lifecycle values.
295///
296/// ```compile_fail
297/// use liminal_protocol::lifecycle::ConversationEvent;
298///
299/// fn fabricate() {
300///     let _ = ConversationEvent {
301///         conversation_id: 7,
302///         ordinal: 0,
303///     };
304/// }
305/// ```
306#[derive(Debug, PartialEq, Eq)]
307pub struct ConversationEvent {
308    header: ConversationEventHeader,
309    body: ConversationEventBody,
310}
311
312impl ConversationEvent {
313    /// Returns the event's owning conversation.
314    #[must_use]
315    pub const fn conversation_id(&self) -> ConversationId {
316        self.header.conversation_id
317    }
318
319    /// Returns the event's contiguous log ordinal.
320    #[must_use]
321    pub const fn ordinal(&self) -> u64 {
322        self.header.ordinal
323    }
324
325    /// Returns the exact canonical byte length of this event.
326    #[must_use]
327    pub const fn encoded_len(&self) -> usize {
328        codec::EVENT_HEADER_LEN + codec::encoded_body_len(&self.body) as usize
329    }
330
331    /// Encodes the stable v1 event envelope in network byte order.
332    #[must_use]
333    pub fn encode_canonical(&self) -> Vec<u8> {
334        codec::encode_event(self.header.conversation_id, self.header.ordinal, &self.body)
335    }
336
337    /// Decodes one exact canonical v1 event envelope.
338    ///
339    /// # Errors
340    ///
341    /// Returns [`ConversationEventDecodeError`] for truncation, an invalid
342    /// magic prefix, an unsupported codec version or body tag, or any declared
343    /// body length that differs from the complete supplied frame.
344    pub fn decode_canonical(input: &[u8]) -> Result<Self, ConversationEventDecodeError> {
345        let (conversation_id, ordinal, body) = codec::decode_event(input)?;
346        Ok(Self {
347            header: ConversationEventHeader {
348                conversation_id,
349                ordinal,
350            },
351            body,
352        })
353    }
354}
355
356/// Stable canonical event-decode failure.
357#[derive(Clone, Copy, Debug, PartialEq, Eq)]
358pub enum ConversationEventDecodeError {
359    /// Fewer bytes than the fixed envelope or selected field requires.
360    Truncated {
361        /// Minimum bytes required at the detected boundary.
362        required: usize,
363        /// Bytes supplied by the caller.
364        available: usize,
365    },
366    /// The event does not begin with the assigned `LPCE` magic prefix.
367    InvalidMagic,
368    /// The event codec version is not supported by this crate.
369    UnsupportedCodec {
370        /// Presented codec major version.
371        major: u16,
372        /// Presented codec minor version.
373        minor: u16,
374    },
375    /// The private body tag is unassigned.
376    UnknownEventKind {
377        /// Unrecognized body tag.
378        tag: u16,
379    },
380    /// Declared and supplied body lengths differ, or the declared length is
381    /// not the canonical length for the selected body kind.
382    NonCanonicalLength {
383        /// Length declared in the stable envelope.
384        declared_body_len: u32,
385        /// Complete bytes supplied after the fixed header.
386        actual_body_len: usize,
387    },
388    /// A structurally complete body violates a canonical field invariant, such
389    /// as a zero generation, a non-generation-one enrollment epoch, an
390    /// unassigned Leave flag bit, or an invalid permanent Leave result.
391    NonCanonicalBody {
392        /// Body tag whose field invariants failed.
393        tag: u16,
394    },
395    /// A platform length conversion or offset addition overflowed.
396    LengthOverflow,
397}
398
399/// Semantic durable-replay failure for a structurally decoded event.
400#[derive(Clone, Copy, Debug, PartialEq, Eq)]
401pub enum ConversationReplayError {
402    /// Event belongs to another conversation stream.
403    ConversationMismatch {
404        /// Conversation required by the aggregate.
405        expected: ConversationId,
406        /// Conversation carried by the event.
407        actual: ConversationId,
408    },
409    /// Event is not the exact next contiguous ordinal.
410    OrdinalMismatch {
411        /// Next ordinal required by the aggregate.
412        expected: u64,
413        /// Ordinal carried by the event.
414        actual: u64,
415    },
416    /// The one-shot genesis validation already committed.
417    GenesisAlreadyValidated,
418    /// A lifecycle operation event preceded the genesis-validation event.
419    GenesisNotValidated,
420    /// No later event ordinal is representable.
421    EventOrdinalExhausted {
422        /// Last representable event ordinal.
423        ordinal: u64,
424    },
425}
426
427/// Failed replay retaining the byte-for-byte unchanged pre-state.
428#[derive(Debug, PartialEq, Eq)]
429pub struct ConversationReplayFailure {
430    conversation: ParticipantConversation,
431    reason: ConversationReplayError,
432}
433
434impl ConversationReplayFailure {
435    /// Returns the exact semantic replay failure.
436    #[must_use]
437    pub const fn reason(&self) -> ConversationReplayError {
438        self.reason
439    }
440
441    /// Recovers the unchanged replay pre-state.
442    #[must_use]
443    pub const fn into_conversation(self) -> ParticipantConversation {
444        self.conversation
445    }
446}
447
448/// Reason a protocol decision emitted no durable event.
449#[derive(Clone, Copy, Debug, PartialEq, Eq)]
450pub enum ConversationRefusalReason {
451    /// The one-shot genesis validation already committed.
452    GenesisAlreadyValidated,
453    /// A lifecycle operation was decided before genesis validation committed.
454    GenesisNotValidated,
455    /// The operation's provenance names another conversation.
456    OperationConversationMismatch {
457        /// Conversation required by the aggregate.
458        expected: ConversationId,
459        /// Conversation named by the operation's provenance.
460        actual: ConversationId,
461    },
462    /// No later event ordinal is representable.
463    EventOrdinalExhausted {
464        /// Last representable event ordinal.
465        ordinal: u64,
466    },
467}
468
469/// Refused decision retaining the unchanged aggregate for continued use.
470#[derive(Debug, PartialEq, Eq)]
471pub struct ConversationRefusal {
472    conversation: ParticipantConversation,
473    reason: ConversationRefusalReason,
474}
475
476impl ConversationRefusal {
477    /// Returns the stable refusal reason.
478    #[must_use]
479    pub const fn reason(&self) -> ConversationRefusalReason {
480        self.reason
481    }
482
483    /// Recovers the unchanged pre-state.
484    #[must_use]
485    pub const fn into_conversation(self) -> ParticipantConversation {
486        self.conversation
487    }
488}
489
490/// Protocol decision that either owns one pending durable commit or refuses it.
491#[derive(Debug, PartialEq, Eq)]
492pub enum ConversationDecision {
493    /// Append this event before consuming the commit into usable state.
494    Commit(ConversationCommit),
495    /// No event was selected; the unchanged aggregate is recoverable.
496    Refused(ConversationRefusal),
497}
498
499/// Ownership barrier between event selection and durable append.
500///
501/// The aggregate is private while the event is speculative. It becomes usable
502/// only through consuming [`ConversationCommit::commit`], or returns unchanged
503/// through consuming [`ConversationCommit::abort`] if append fails.
504///
505/// ```compile_fail
506/// use liminal_protocol::lifecycle::ConversationCommit;
507///
508/// fn leak(commit: ConversationCommit) {
509///     let _ = commit.conversation;
510/// }
511/// ```
512#[derive(Debug, PartialEq, Eq)]
513pub struct ConversationCommit {
514    conversation: ParticipantConversation,
515    event: ConversationEvent,
516    resulting_event_ordinal: u64,
517}
518
519impl ConversationCommit {
520    /// Borrows the exact event that must be durably appended.
521    #[must_use]
522    pub const fn event(&self) -> &ConversationEvent {
523        &self.event
524    }
525
526    /// Consumes the durability barrier and advances to the committed state.
527    #[must_use]
528    pub const fn commit(mut self) -> ParticipantConversation {
529        self.conversation
530            .apply_validated_event(&self.event, self.resulting_event_ordinal);
531        self.conversation
532    }
533
534    /// Cancels a failed append and recovers the byte-for-byte unchanged pre-state.
535    #[must_use]
536    pub const fn abort(self) -> ParticipantConversation {
537        self.conversation
538    }
539}