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    #[cfg(test)]
246    pub(super) const fn from_test_state(
247        genesis: ConversationGenesis,
248        next_event_ordinal: u64,
249        genesis_validated: bool,
250    ) -> Self {
251        Self {
252            genesis,
253            next_event_ordinal,
254            genesis_validated,
255        }
256    }
257}
258
259/// Header of every durable participant-conversation event.
260#[derive(Debug, PartialEq, Eq)]
261struct ConversationEventHeader {
262    conversation_id: ConversationId,
263    ordinal: u64,
264}
265
266/// Private typed body of a durable participant-conversation event.
267///
268/// `pub(super)` only so the sibling canonical codec can encode and rebuild
269/// bodies; no path outside the lifecycle module can name this type.
270#[derive(Debug, PartialEq, Eq)]
271pub(super) enum ConversationEventBody {
272    GenesisValidated,
273    Operation(ConversationOperation),
274}
275
276/// Opaque durable event emitted only by a protocol decision or canonical decode.
277///
278/// Neither header nor body fields are public, so a binding cannot manufacture a
279/// typed event from raw lifecycle values.
280///
281/// ```compile_fail
282/// use liminal_protocol::lifecycle::ConversationEvent;
283///
284/// fn fabricate() {
285///     let _ = ConversationEvent {
286///         conversation_id: 7,
287///         ordinal: 0,
288///     };
289/// }
290/// ```
291#[derive(Debug, PartialEq, Eq)]
292pub struct ConversationEvent {
293    header: ConversationEventHeader,
294    body: ConversationEventBody,
295}
296
297impl ConversationEvent {
298    /// Returns the event's owning conversation.
299    #[must_use]
300    pub const fn conversation_id(&self) -> ConversationId {
301        self.header.conversation_id
302    }
303
304    /// Returns the event's contiguous log ordinal.
305    #[must_use]
306    pub const fn ordinal(&self) -> u64 {
307        self.header.ordinal
308    }
309
310    /// Returns the exact canonical byte length of this event.
311    #[must_use]
312    pub const fn encoded_len(&self) -> usize {
313        codec::EVENT_HEADER_LEN + codec::encoded_body_len(&self.body) as usize
314    }
315
316    /// Encodes the stable v1 event envelope in network byte order.
317    #[must_use]
318    pub fn encode_canonical(&self) -> Vec<u8> {
319        codec::encode_event(self.header.conversation_id, self.header.ordinal, &self.body)
320    }
321
322    /// Decodes one exact canonical v1 event envelope.
323    ///
324    /// # Errors
325    ///
326    /// Returns [`ConversationEventDecodeError`] for truncation, an invalid
327    /// magic prefix, an unsupported codec version or body tag, or any declared
328    /// body length that differs from the complete supplied frame.
329    pub fn decode_canonical(input: &[u8]) -> Result<Self, ConversationEventDecodeError> {
330        let (conversation_id, ordinal, body) = codec::decode_event(input)?;
331        Ok(Self {
332            header: ConversationEventHeader {
333                conversation_id,
334                ordinal,
335            },
336            body,
337        })
338    }
339}
340
341/// Stable canonical event-decode failure.
342#[derive(Clone, Copy, Debug, PartialEq, Eq)]
343pub enum ConversationEventDecodeError {
344    /// Fewer bytes than the fixed envelope or selected field requires.
345    Truncated {
346        /// Minimum bytes required at the detected boundary.
347        required: usize,
348        /// Bytes supplied by the caller.
349        available: usize,
350    },
351    /// The event does not begin with the assigned `LPCE` magic prefix.
352    InvalidMagic,
353    /// The event codec version is not supported by this crate.
354    UnsupportedCodec {
355        /// Presented codec major version.
356        major: u16,
357        /// Presented codec minor version.
358        minor: u16,
359    },
360    /// The private body tag is unassigned.
361    UnknownEventKind {
362        /// Unrecognized body tag.
363        tag: u16,
364    },
365    /// Declared and supplied body lengths differ, or the declared length is
366    /// not the canonical length for the selected body kind.
367    NonCanonicalLength {
368        /// Length declared in the stable envelope.
369        declared_body_len: u32,
370        /// Complete bytes supplied after the fixed header.
371        actual_body_len: usize,
372    },
373    /// A structurally complete body violates a canonical field invariant, such
374    /// as a zero generation, a non-generation-one enrollment epoch, an
375    /// unassigned Leave flag bit, or an invalid permanent Leave result.
376    NonCanonicalBody {
377        /// Body tag whose field invariants failed.
378        tag: u16,
379    },
380    /// A platform length conversion or offset addition overflowed.
381    LengthOverflow,
382}
383
384/// Semantic durable-replay failure for a structurally decoded event.
385#[derive(Clone, Copy, Debug, PartialEq, Eq)]
386pub enum ConversationReplayError {
387    /// Event belongs to another conversation stream.
388    ConversationMismatch {
389        /// Conversation required by the aggregate.
390        expected: ConversationId,
391        /// Conversation carried by the event.
392        actual: ConversationId,
393    },
394    /// Event is not the exact next contiguous ordinal.
395    OrdinalMismatch {
396        /// Next ordinal required by the aggregate.
397        expected: u64,
398        /// Ordinal carried by the event.
399        actual: u64,
400    },
401    /// The one-shot genesis validation already committed.
402    GenesisAlreadyValidated,
403    /// A lifecycle operation event preceded the genesis-validation event.
404    GenesisNotValidated,
405    /// No later event ordinal is representable.
406    EventOrdinalExhausted {
407        /// Last representable event ordinal.
408        ordinal: u64,
409    },
410}
411
412/// Failed replay retaining the byte-for-byte unchanged pre-state.
413#[derive(Debug, PartialEq, Eq)]
414pub struct ConversationReplayFailure {
415    conversation: ParticipantConversation,
416    reason: ConversationReplayError,
417}
418
419impl ConversationReplayFailure {
420    /// Returns the exact semantic replay failure.
421    #[must_use]
422    pub const fn reason(&self) -> ConversationReplayError {
423        self.reason
424    }
425
426    /// Recovers the unchanged replay pre-state.
427    #[must_use]
428    pub const fn into_conversation(self) -> ParticipantConversation {
429        self.conversation
430    }
431}
432
433/// Reason a protocol decision emitted no durable event.
434#[derive(Clone, Copy, Debug, PartialEq, Eq)]
435pub enum ConversationRefusalReason {
436    /// The one-shot genesis validation already committed.
437    GenesisAlreadyValidated,
438    /// A lifecycle operation was decided before genesis validation committed.
439    GenesisNotValidated,
440    /// The operation's provenance names another conversation.
441    OperationConversationMismatch {
442        /// Conversation required by the aggregate.
443        expected: ConversationId,
444        /// Conversation named by the operation's provenance.
445        actual: ConversationId,
446    },
447    /// No later event ordinal is representable.
448    EventOrdinalExhausted {
449        /// Last representable event ordinal.
450        ordinal: u64,
451    },
452}
453
454/// Refused decision retaining the unchanged aggregate for continued use.
455#[derive(Debug, PartialEq, Eq)]
456pub struct ConversationRefusal {
457    conversation: ParticipantConversation,
458    reason: ConversationRefusalReason,
459}
460
461impl ConversationRefusal {
462    /// Returns the stable refusal reason.
463    #[must_use]
464    pub const fn reason(&self) -> ConversationRefusalReason {
465        self.reason
466    }
467
468    /// Recovers the unchanged pre-state.
469    #[must_use]
470    pub const fn into_conversation(self) -> ParticipantConversation {
471        self.conversation
472    }
473}
474
475/// Protocol decision that either owns one pending durable commit or refuses it.
476#[derive(Debug, PartialEq, Eq)]
477pub enum ConversationDecision {
478    /// Append this event before consuming the commit into usable state.
479    Commit(ConversationCommit),
480    /// No event was selected; the unchanged aggregate is recoverable.
481    Refused(ConversationRefusal),
482}
483
484/// Ownership barrier between event selection and durable append.
485///
486/// The aggregate is private while the event is speculative. It becomes usable
487/// only through consuming [`ConversationCommit::commit`], or returns unchanged
488/// through consuming [`ConversationCommit::abort`] if append fails.
489///
490/// ```compile_fail
491/// use liminal_protocol::lifecycle::ConversationCommit;
492///
493/// fn leak(commit: ConversationCommit) {
494///     let _ = commit.conversation;
495/// }
496/// ```
497#[derive(Debug, PartialEq, Eq)]
498pub struct ConversationCommit {
499    conversation: ParticipantConversation,
500    event: ConversationEvent,
501    resulting_event_ordinal: u64,
502}
503
504impl ConversationCommit {
505    /// Borrows the exact event that must be durably appended.
506    #[must_use]
507    pub const fn event(&self) -> &ConversationEvent {
508        &self.event
509    }
510
511    /// Consumes the durability barrier and advances to the committed state.
512    #[must_use]
513    pub const fn commit(mut self) -> ParticipantConversation {
514        self.conversation
515            .apply_validated_event(&self.event, self.resulting_event_ordinal);
516        self.conversation
517    }
518
519    /// Cancels a failed append and recovers the byte-for-byte unchanged pre-state.
520    #[must_use]
521    pub const fn abort(self) -> ParticipantConversation {
522        self.conversation
523    }
524}