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}