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}