Skip to main content

liminal_protocol/lifecycle/
checkpoint.rs

1//! Lifecycle checkpoint capsule: every protocol-owned value of one
2//! conversation's lifecycle at a log position, in canonical bytes.
3//!
4//! A storage binding lends its live values to [`LifecycleCapsule::capture`]
5//! through [`LifecycleView`], which holds references only, so a binding can
6//! lend what it has but never fabricate a value. The capsule's bytes are this
7//! module's own stable v1 `LPCK` envelope; the binding stores them beside its
8//! own state and never reads their fields. [`LifecycleCapsule::restore`] is
9//! the only way from a capsule back to live values, and it validates the
10//! capsule as a whole first.
11//!
12//! The capsule cannot be built by a struct literal:
13//!
14//! ```compile_fail
15//! use liminal_protocol::lifecycle::LifecycleCapsule;
16//!
17//! let _ = LifecycleCapsule { conversation_id: 1, covered_log_sequence: 0 };
18//! ```
19
20use alloc::collections::BTreeSet;
21use alloc::vec::Vec;
22
23use crate::wire::{ConversationId, ParticipantId};
24
25use super::checkpoint_codec::{
26    CLOSURE_SECTION_TAG, DigestDescription, FRONTIERS_SECTION_TAG, PARTICIPANTS_SECTION_TAG,
27    SEALED_SECTION_TAG, decode_description, decode_sealed_description, encode_description,
28    encode_sealed_description,
29};
30use super::claim_frontier::ClaimFrontiers;
31use super::conversation::{ConversationGenesis, ParticipantConversation};
32use super::edge::ClosureState;
33use super::membership::RetiredIdentity;
34use super::storage::{
35    ConversationDescribeError, ConversationStateRestoreError, ParticipantConversationState,
36    ParticipantLifecycleRestore, ParticipantLifecycleView, StorageRestoreError,
37    describe_conversation,
38};
39
40/// A participant view over the 32-byte digests every server stores.
41pub type DigestParticipantView<'a> =
42    ParticipantLifecycleView<'a, [u8; 32], [u8; 32], [u8; 32], [u8; 32]>;
43
44/// Whole-conversation state over the 32-byte digests every server stores.
45pub type DigestConversationState =
46    ParticipantConversationState<[u8; 32], [u8; 32], [u8; 32], [u8; 32]>;
47
48/// One retired identity over the 32-byte digests every server stores.
49pub type DigestRetiredIdentity = RetiredIdentity<[u8; 32], [u8; 32], [u8; 32]>;
50
51type DigestParticipantRestore = ParticipantLifecycleRestore<
52    [u8; DIGEST_LEN],
53    [u8; DIGEST_LEN],
54    [u8; DIGEST_LEN],
55    [u8; DIGEST_LEN],
56>;
57
58const CAPSULE_MAGIC: [u8; 4] = *b"LPCK";
59const CAPSULE_CODEC_MAJOR: u16 = 1;
60/// Minor 0: the conversation section, then the three lifecycle sections or
61/// none. Every capsule written before the seal section decodes as minor 0.
62const CAPSULE_CODEC_MINOR: u16 = 0;
63/// Minor 1: the conversation section, the participants section and the
64/// sealed section. Written only for a sealed conversation.
65const CAPSULE_CODEC_MINOR_SEALED: u16 = 1;
66/// Magic, major, minor, conversation id, covered log sequence, body length.
67const CAPSULE_HEADER_LEN: usize = 28;
68/// Section tag and section body length.
69const SECTION_HEADER_LEN: u32 = 6;
70const CONVERSATION_SECTION_TAG: u16 = 1;
71/// Next event ordinal and flags.
72const CONVERSATION_SECTION_LEN: u32 = 9;
73const GENESIS_VALIDATED_FLAG: u8 = 0b1;
74/// The conversation section with its header, which every capsule carries.
75const CONVERSATION_FRAME_LEN: u32 = SECTION_HEADER_LEN + CONVERSATION_SECTION_LEN;
76/// Every assigned section tag. The conversation section is always present;
77/// the participants, frontiers and closure sections follow it in this order,
78/// all three or none, or, for a sealed conversation, the participants section
79/// and the sealed section.
80const SECTION_TAGS: [u16; 5] = [
81    CONVERSATION_SECTION_TAG,
82    PARTICIPANTS_SECTION_TAG,
83    FRONTIERS_SECTION_TAG,
84    CLOSURE_SECTION_TAG,
85    SEALED_SECTION_TAG,
86];
87/// Width of every generic digest the capsule carries: the enrollment and
88/// Leave fingerprints and both request verifiers.
89const DIGEST_LEN: usize = 32;
90
91/// What a storage binding lends to [`LifecycleCapsule::capture`]: references
92/// to the protocol values it holds, and the log position they cover.
93#[derive(Debug)]
94pub struct LifecycleView<'a> {
95    /// The conversation's event-sourced shell.
96    pub conversation: &'a ParticipantConversation,
97    /// The participants, frontiers and closure, once the conversation has
98    /// any; `None` before its first enrollment and after its seal.
99    pub lifecycle: Option<ConversationLifecycleView<'a>>,
100    /// The retired identities and the final ledger watermark, once the
101    /// conversation is sealed; `None` otherwise, and never beside `lifecycle`.
102    pub sealed: Option<SealedLifecycleView<'a>>,
103    /// The last log sequence whose effects these values include.
104    pub covered_log_sequence: u64,
105}
106
107/// What a sealed conversation lends: the seal retired its frontier owner, and
108/// the observer sources it still covers need its final sequence-ledger high
109/// watermark beside the retired generations.
110#[derive(Debug)]
111pub struct SealedLifecycleView<'a> {
112    /// Every retired participant, strictly ascending by participant id; a
113    /// seal leaves no live slot.
114    pub participants: Vec<DigestParticipantView<'a>>,
115    /// The retired sequence ledger's high watermark at the seal.
116    pub ledger_high_watermark: u64,
117}
118
119/// The lifecycle values of one enrolled conversation, lent by reference.
120#[derive(Debug)]
121pub struct ConversationLifecycleView<'a> {
122    /// Every live and retired participant, strictly ascending by participant
123    /// id.
124    pub participants: Vec<DigestParticipantView<'a>>,
125    /// Both coupled claim frontiers.
126    pub frontiers: &'a ClaimFrontiers,
127    /// The signed cap on retained record facts the frontier owner keeps.
128    pub retained_record_limit: u64,
129    /// The current closure debt and edge.
130    pub closure: &'a ClosureState,
131}
132
133/// Every protocol-owned value of one conversation's lifecycle at a log
134/// position. Built only by [`LifecycleCapsule::capture`] or
135/// [`LifecycleCapsule::decode_canonical`].
136#[derive(Debug, PartialEq, Eq)]
137pub struct LifecycleCapsule {
138    conversation_id: ConversationId,
139    covered_log_sequence: u64,
140    conversation: ConversationSection,
141    lifecycle: Option<LifecycleSections>,
142    sealed: Option<SealedSections>,
143    /// Exact body length after the fixed header, proven to fit at capture or
144    /// read from the header at decode.
145    body_len: u32,
146}
147
148/// The described retired participants and the sealed watermark, beside their
149/// exact canonical section bytes (headers included).
150#[derive(Debug, PartialEq, Eq)]
151struct SealedSections {
152    participants: Vec<DigestParticipantRestore>,
153    ledger_high_watermark: u64,
154    encoded: Vec<u8>,
155}
156
157/// The conversation shell's state: its genesis is the conversation id.
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159struct ConversationSection {
160    next_event_ordinal: u64,
161    genesis_validated: bool,
162}
163
164impl ConversationSection {
165    /// Genesis validation is the first event and happens once, so a shell
166    /// past ordinal 0 has validated its genesis and one at ordinal 0 has not.
167    const fn is_consistent(self) -> bool {
168        self.genesis_validated == (self.next_event_ordinal > 0)
169    }
170}
171
172/// The described participants, frontiers and closure, beside their exact
173/// canonical section bytes (headers included).
174#[derive(Debug, PartialEq, Eq)]
175struct LifecycleSections {
176    description: DigestDescription<DIGEST_LEN>,
177    encoded: Vec<u8>,
178}
179
180impl LifecycleCapsule {
181    /// Captures the lent values. Nothing is consumed; generic digests are
182    /// copied out of the borrowed values.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`CapsuleCaptureError`] when the frontiers belong to another
187    /// conversation, the participants are not strictly ascending, the live
188    /// closure cannot be described, or a count or length does not fit `u32`.
189    pub fn capture(view: &LifecycleView<'_>) -> Result<Self, CapsuleCaptureError> {
190        let conversation_id = view.conversation.conversation_id();
191        if view.lifecycle.is_some() && view.sealed.is_some() {
192            return Err(CapsuleCaptureError::SealedBesideLifecycle);
193        }
194        let lifecycle = view
195            .lifecycle
196            .as_ref()
197            .map(|lifecycle| capture_lifecycle(conversation_id, lifecycle))
198            .transpose()?;
199        let sealed = view.sealed.as_ref().map(capture_sealed).transpose()?;
200        let lifecycle_len = lifecycle
201            .as_ref()
202            .map_or(0, |sections| sections.encoded.len())
203            .checked_add(sealed.as_ref().map_or(0, |sections| sections.encoded.len()))
204            .ok_or(CapsuleCaptureError::LengthOverflow)?;
205        let body_len = u32::try_from(lifecycle_len)
206            .map_err(|_| CapsuleCaptureError::LengthOverflow)?
207            .checked_add(CONVERSATION_FRAME_LEN)
208            .ok_or(CapsuleCaptureError::LengthOverflow)?;
209        Ok(Self {
210            conversation_id,
211            covered_log_sequence: view.covered_log_sequence,
212            conversation: ConversationSection {
213                next_event_ordinal: view.conversation.next_event_ordinal(),
214                genesis_validated: view.conversation.genesis_validated(),
215            },
216            lifecycle,
217            sealed,
218            body_len,
219        })
220    }
221
222    /// Whether the capsule carries a sealed conversation.
223    #[must_use]
224    pub const fn is_sealed(&self) -> bool {
225        self.sealed.is_some()
226    }
227
228    /// The conversation these values belong to.
229    #[must_use]
230    pub const fn conversation_id(&self) -> ConversationId {
231        self.conversation_id
232    }
233
234    /// The last log sequence whose effects the capsule includes.
235    #[must_use]
236    pub const fn covered_log_sequence(&self) -> u64 {
237        self.covered_log_sequence
238    }
239
240    /// Encodes the stable v1 capsule envelope in network byte order.
241    #[must_use]
242    pub fn encode_canonical(&self) -> Vec<u8> {
243        let mut encoded = Vec::with_capacity(CAPSULE_HEADER_LEN + self.body_len as usize);
244        let minor = if self.sealed.is_some() {
245            CAPSULE_CODEC_MINOR_SEALED
246        } else {
247            CAPSULE_CODEC_MINOR
248        };
249        encoded.extend_from_slice(&CAPSULE_MAGIC);
250        encoded.extend_from_slice(&CAPSULE_CODEC_MAJOR.to_be_bytes());
251        encoded.extend_from_slice(&minor.to_be_bytes());
252        encoded.extend_from_slice(&self.conversation_id.to_be_bytes());
253        encoded.extend_from_slice(&self.covered_log_sequence.to_be_bytes());
254        encoded.extend_from_slice(&self.body_len.to_be_bytes());
255        encoded.extend_from_slice(&CONVERSATION_SECTION_TAG.to_be_bytes());
256        encoded.extend_from_slice(&CONVERSATION_SECTION_LEN.to_be_bytes());
257        encoded.extend_from_slice(&self.conversation.next_event_ordinal.to_be_bytes());
258        encoded.push(if self.conversation.genesis_validated {
259            GENESIS_VALIDATED_FLAG
260        } else {
261            0
262        });
263        if let Some(sections) = &self.lifecycle {
264            encoded.extend_from_slice(&sections.encoded);
265        }
266        if let Some(sections) = &self.sealed {
267            encoded.extend_from_slice(&sections.encoded);
268        }
269        encoded
270    }
271
272    /// Decodes one exact canonical v1 capsule.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`CapsuleDecodeError`] for truncation, an invalid magic prefix,
277    /// an unsupported codec version, a declared length that differs from the
278    /// supplied bytes, a section out of order, missing, unknown or of the wrong
279    /// length, a section whose fields break their invariants, participants out
280    /// of order, or frontiers that belong to another conversation.
281    pub fn decode_canonical(input: &[u8]) -> Result<Self, CapsuleDecodeError> {
282        if input.len() < CAPSULE_HEADER_LEN {
283            return Err(CapsuleDecodeError::Truncated {
284                required: CAPSULE_HEADER_LEN,
285                available: input.len(),
286            });
287        }
288        if input.get(0..4) != Some(CAPSULE_MAGIC.as_slice()) {
289            return Err(CapsuleDecodeError::InvalidMagic);
290        }
291        let major = take_u16(input, 4)?;
292        let minor = take_u16(input, 6)?;
293        if major != CAPSULE_CODEC_MAJOR || minor > CAPSULE_CODEC_MINOR_SEALED {
294            return Err(CapsuleDecodeError::UnsupportedCodec { major, minor });
295        }
296        let conversation_id = take_u64(input, 8)?;
297        let covered_log_sequence = take_u64(input, 16)?;
298        let declared_body_len = take_u32(input, 24)?;
299        let actual_body_len = input.len() - CAPSULE_HEADER_LEN;
300        if usize::try_from(declared_body_len).map_err(|_| CapsuleDecodeError::LengthOverflow)?
301            != actual_body_len
302        {
303            return Err(CapsuleDecodeError::NonCanonicalLength {
304                declared_body_len,
305                actual_body_len,
306            });
307        }
308        let mut at = CAPSULE_HEADER_LEN;
309        let conversation =
310            decode_conversation(take_section(input, &mut at, CONVERSATION_SECTION_TAG)?)?;
311        let (lifecycle, sealed) = if input.len() - at < SECTION_HEADER_LEN as usize {
312            (None, None)
313        } else {
314            let start = at;
315            let participants = take_section(input, &mut at, PARTICIPANTS_SECTION_TAG)?;
316            let next_tag = if input.len() - at < SECTION_HEADER_LEN as usize {
317                None
318            } else {
319                Some(take_u16(input, at)?)
320            };
321            if next_tag == Some(SEALED_SECTION_TAG) {
322                if minor != CAPSULE_CODEC_MINOR_SEALED {
323                    return Err(CapsuleDecodeError::UnknownSection {
324                        tag: SEALED_SECTION_TAG,
325                    });
326                }
327                let sealed = take_section(input, &mut at, SEALED_SECTION_TAG)?;
328                let (participants, ledger_high_watermark) =
329                    decode_sealed_description::<DIGEST_LEN>(participants, sealed)?;
330                let encoded = input
331                    .get(start..at)
332                    .ok_or(CapsuleDecodeError::LengthOverflow)?
333                    .to_vec();
334                (
335                    None,
336                    Some(SealedSections {
337                        participants,
338                        ledger_high_watermark,
339                        encoded,
340                    }),
341                )
342            } else {
343                (
344                    Some(decode_lifecycle(
345                        input,
346                        &mut at,
347                        start,
348                        participants,
349                        conversation_id,
350                    )?),
351                    None,
352                )
353            }
354        };
355        if minor == CAPSULE_CODEC_MINOR_SEALED && sealed.is_none() {
356            return Err(CapsuleDecodeError::MissingSection {
357                tag: SEALED_SECTION_TAG,
358            });
359        }
360        if at != input.len() {
361            return Err(CapsuleDecodeError::TrailingBytes {
362                trailing: input.len() - at,
363            });
364        }
365        Ok(Self {
366            conversation_id,
367            covered_log_sequence,
368            conversation,
369            lifecycle,
370            sealed,
371            body_len: declared_body_len,
372        })
373    }
374
375    /// Associates exact origin sources before restoring the complete capsule.
376    ///
377    /// # Errors
378    ///
379    /// Refuses malformed, unordered, missing or foreign origin sources, then
380    /// propagates the existing joint lifecycle validation failure.
381    #[doc(hidden)]
382    pub fn restore_with_binding_sources(
383        self,
384        sources: &[(ParticipantId, &[u8])],
385    ) -> Result<RestoredLifecycle, BindingOriginSourceError> {
386        let mut sources = sources.iter();
387        if let Some(lifecycle) = &self.lifecycle {
388            for participant in &lifecycle.description.restore.participants {
389                if let super::storage::ParticipantLifecycleRestore::Live {
390                    identity,
391                    binding_origin: Some(expected),
392                    ..
393                } = participant
394                {
395                    let Some(&(participant_id, bytes)) = sources.next() else {
396                        return Err(BindingOriginSourceError::Association {
397                            participant_id: identity.participant_id,
398                        });
399                    };
400                    let origin = super::checkpoint_codec::decode_binding_origin_source(bytes)
401                        .map_err(|error| BindingOriginSourceError::Decode {
402                            participant_id,
403                            error,
404                        })?;
405                    if participant_id != identity.participant_id || origin != *expected {
406                        return Err(BindingOriginSourceError::Association { participant_id });
407                    }
408                }
409            }
410        }
411        if let Some(&(participant_id, _)) = sources.next() {
412            return Err(BindingOriginSourceError::Association { participant_id });
413        }
414        self.restore().map_err(BindingOriginSourceError::Restore)
415    }
416
417    /// Validates the capsule as a whole and hands back the live values.
418    ///
419    /// The lifecycle goes through the one joint participant-conversation
420    /// validation, through its crate-private entry that also checks and
421    /// installs the fenced-marker issuance latch the capsule carried: a latch
422    /// naming a take the restored state could not have granted is refused.
423    ///
424    /// # Errors
425    ///
426    /// Returns [`CapsuleRestoreError`] when the capsule's sections are
427    /// inconsistent with each other or with their own invariants.
428    pub fn restore(self) -> Result<RestoredLifecycle, CapsuleRestoreError> {
429        if !self.conversation.is_consistent() {
430            return Err(CapsuleRestoreError::InconsistentConversation);
431        }
432        let lifecycle = self
433            .lifecycle
434            .map(|sections| sections.description.into_state())
435            .transpose()
436            .map_err(CapsuleRestoreError::Lifecycle)?;
437        let sealed = self.sealed.map(restore_sealed).transpose()?;
438        Ok(RestoredLifecycle {
439            covered_log_sequence: self.covered_log_sequence,
440            restored_fenced_sources: BTreeSet::new(),
441            conversation: ParticipantConversation::from_checkpoint(
442                ConversationGenesis::new(self.conversation_id),
443                self.conversation.next_event_ordinal,
444                self.conversation.genesis_validated,
445            ),
446            lifecycle,
447            sealed,
448        })
449    }
450}
451
452/// Describes and encodes one lent lifecycle, refusing a view the capsule
453/// could not restore as the same conversation.
454fn capture_lifecycle(
455    conversation_id: ConversationId,
456    view: &ConversationLifecycleView<'_>,
457) -> Result<LifecycleSections, CapsuleCaptureError> {
458    if view.frontiers.conversation_id() != conversation_id {
459        return Err(CapsuleCaptureError::ForeignConversation);
460    }
461    for pair in view.participants.windows(2) {
462        if let [earlier, later] = pair
463            && earlier.participant_id() >= later.participant_id()
464        {
465            return Err(CapsuleCaptureError::ParticipantOutOfOrder {
466                participant_id: later.participant_id(),
467            });
468        }
469    }
470    let description = describe_conversation(
471        &view.participants,
472        view.frontiers,
473        view.retained_record_limit,
474        *view.closure,
475    )
476    .map_err(CapsuleCaptureError::Describe)?;
477    let encoded = encode_description(&description).ok_or(CapsuleCaptureError::LengthOverflow)?;
478    Ok(LifecycleSections {
479        description,
480        encoded,
481    })
482}
483
484/// Describes and encodes one lent sealed conversation, refusing a live
485/// participant or an unordered one.
486fn capture_sealed(view: &SealedLifecycleView<'_>) -> Result<SealedSections, CapsuleCaptureError> {
487    for pair in view.participants.windows(2) {
488        if let [earlier, later] = pair
489            && earlier.participant_id() >= later.participant_id()
490        {
491            return Err(CapsuleCaptureError::ParticipantOutOfOrder {
492                participant_id: later.participant_id(),
493            });
494        }
495    }
496    if let Some(live) = view
497        .participants
498        .iter()
499        .find(|participant| matches!(participant, DigestParticipantView::Live { .. }))
500    {
501        return Err(CapsuleCaptureError::LiveParticipantSealed {
502            participant_id: live.participant_id(),
503        });
504    }
505    let participants: Vec<_> = view
506        .participants
507        .iter()
508        .map(ParticipantLifecycleView::storage_description)
509        .collect();
510    let encoded = encode_sealed_description(&participants, view.ledger_high_watermark)
511        .ok_or(CapsuleCaptureError::LengthOverflow)?;
512    Ok(SealedSections {
513        participants,
514        ledger_high_watermark: view.ledger_high_watermark,
515        encoded,
516    })
517}
518
519/// Restores every retired identity a sealed capsule carries.
520fn restore_sealed(sections: SealedSections) -> Result<SealedLifecycle, CapsuleRestoreError> {
521    let mut retired = Vec::with_capacity(sections.participants.len());
522    for participant in sections.participants {
523        match participant {
524            ParticipantLifecycleRestore::Retired(identity) => {
525                retired.push(identity.restore().map_err(CapsuleRestoreError::Sealed)?);
526            }
527            ParticipantLifecycleRestore::Live { identity, .. } => {
528                return Err(CapsuleRestoreError::SealedLiveParticipant {
529                    participant_id: identity.participant_id,
530                });
531            }
532        }
533    }
534    Ok(SealedLifecycle {
535        retired,
536        ledger_high_watermark: sections.ledger_high_watermark,
537    })
538}
539
540/// Decodes the frontiers and closure sections, which follow the participants
541/// section `participants` (taken from `start`) all together.
542fn decode_lifecycle(
543    input: &[u8],
544    at: &mut usize,
545    start: usize,
546    participants: &[u8],
547    conversation_id: ConversationId,
548) -> Result<LifecycleSections, CapsuleDecodeError> {
549    let frontiers = take_section(input, at, FRONTIERS_SECTION_TAG)?;
550    let closure = take_section(input, at, CLOSURE_SECTION_TAG)?;
551    let description = decode_description::<DIGEST_LEN>(participants, frontiers, closure)?;
552    if description.restore.frontiers.conversation_id != conversation_id {
553        return Err(CapsuleDecodeError::ForeignConversation {
554            tag: FRONTIERS_SECTION_TAG,
555        });
556    }
557    let encoded = input
558        .get(start..*at)
559        .ok_or(CapsuleDecodeError::LengthOverflow)?
560        .to_vec();
561    Ok(LifecycleSections {
562        description,
563        encoded,
564    })
565}
566
567/// Takes the next section, which must carry `expected`, and moves past it.
568fn take_section<'a>(
569    input: &'a [u8],
570    at: &mut usize,
571    expected: u16,
572) -> Result<&'a [u8], CapsuleDecodeError> {
573    if *at == input.len() {
574        return Err(CapsuleDecodeError::MissingSection { tag: expected });
575    }
576    let tag = take_u16(input, *at)?;
577    let len = take_u32(input, *at + 2)?;
578    let start = *at + SECTION_HEADER_LEN as usize;
579    let end = start
580        .checked_add(usize::try_from(len).map_err(|_| CapsuleDecodeError::LengthOverflow)?)
581        .ok_or(CapsuleDecodeError::LengthOverflow)?;
582    let body = input.get(start..end).ok_or(CapsuleDecodeError::Truncated {
583        required: end,
584        available: input.len(),
585    })?;
586    if tag != expected {
587        return Err(if SECTION_TAGS.contains(&tag) {
588            CapsuleDecodeError::SectionOutOfOrder { tag }
589        } else {
590            CapsuleDecodeError::UnknownSection { tag }
591        });
592    }
593    *at = end;
594    Ok(body)
595}
596
597/// Live values restored from one validated capsule.
598#[derive(Debug, PartialEq, Eq)]
599pub struct RestoredLifecycle {
600    covered_log_sequence: u64,
601    restored_fenced_sources: BTreeSet<ParticipantId>,
602    conversation: ParticipantConversation,
603    lifecycle: Option<DigestConversationState>,
604    sealed: Option<SealedLifecycle>,
605}
606
607/// A sealed conversation's restored values: every retired identity and the
608/// sequence-ledger high watermark its retired frontier owner reached.
609#[derive(Debug, PartialEq, Eq)]
610pub struct SealedLifecycle {
611    retired: Vec<DigestRetiredIdentity>,
612    ledger_high_watermark: u64,
613}
614
615impl SealedLifecycle {
616    /// Every retired identity, strictly ascending by participant id.
617    #[must_use]
618    pub fn retired(&self) -> &[DigestRetiredIdentity] {
619        &self.retired
620    }
621
622    /// The retired sequence ledger's high watermark at the seal.
623    #[must_use]
624    pub const fn ledger_high_watermark(&self) -> u64 {
625        self.ledger_high_watermark
626    }
627
628    /// Takes every retired identity and the high watermark.
629    #[must_use]
630    pub fn into_parts(self) -> (Vec<DigestRetiredIdentity>, u64) {
631        (self.retired, self.ledger_high_watermark)
632    }
633}
634
635impl RestoredLifecycle {
636    /// Restores one original observer source inside its associated joint capsule.
637    ///
638    /// Native producer fields and their exact projection are decoded together;
639    /// neither the current frontier maximum nor a guessed scalar is a source.
640    ///
641    /// # Errors
642    /// Refuses malformed, foreign or unassociated source evidence.
643    #[doc(hidden)]
644    pub fn restore_checkpoint_observer_projection(
645        &self,
646        bytes: &[u8],
647        participant_id: ParticipantId,
648    ) -> Result<super::ObserverProgressProjection, super::OwnerCheckpointRestoreError> {
649        let (conversation_id, progress, source) =
650            super::checkpoint_codec::decode_observer_source(bytes)?;
651        if conversation_id != self.conversation.conversation_id()
652            || source.participant_id() != participant_id
653        {
654            return Err(super::OwnerCheckpointRestoreError::Field(
655                "observer_projection.capsule_association",
656            ));
657        }
658        let high_watermark = match (&self.lifecycle, &self.sealed) {
659            (Some(lifecycle), _) => lifecycle.frontiers().sequence().ledger().high_watermark(),
660            (None, Some(sealed)) => sealed.ledger_high_watermark,
661            (None, None) => {
662                return Err(super::OwnerCheckpointRestoreError::Field(
663                    "observer_projection.lifecycle",
664                ));
665            }
666        };
667        if u128::from(progress) > u128::from(high_watermark) + 1 {
668            return Err(super::OwnerCheckpointRestoreError::Field(
669                "observer_projection.delivery_coverage",
670            ));
671        }
672        // The generation the source's binding epoch is bounded by. A live
673        // conversation names every participant a source can belong to. A
674        // sealed one names only the retired: the Died drain that sealed it
675        // erased its last identity entirely, so a source of that participant
676        // has no identity left to bound it and keeps the epoch the seal left.
677        let generation = match (&self.lifecycle, &self.sealed) {
678            (Some(lifecycle), _) => match lifecycle
679                .participants()
680                .iter()
681                .find(|participant| participant.view().participant_id() == participant_id)
682            {
683                Some(super::RestoredParticipantLifecycle::Live { member, .. }) => {
684                    Some(member.generation())
685                }
686                Some(super::RestoredParticipantLifecycle::Retired(retired)) => {
687                    Some(retired.retired_generation())
688                }
689                None => {
690                    return Err(super::OwnerCheckpointRestoreError::Field(
691                        "observer_projection.participant",
692                    ));
693                }
694            },
695            (None, Some(sealed)) => sealed
696                .retired
697                .iter()
698                .find(|retired| retired.participant_id() == participant_id)
699                .map(super::RetiredIdentity::retired_generation),
700            (None, None) => {
701                return Err(super::OwnerCheckpointRestoreError::Field(
702                    "observer_projection.lifecycle",
703                ));
704            }
705        };
706        if let Some(generation) = generation
707            && source
708                .binding_epoch()
709                .is_some_and(|epoch| epoch.capability_generation.get() > generation.get())
710        {
711            return Err(super::OwnerCheckpointRestoreError::Field(
712                "observer_projection.binding_generation",
713            ));
714        }
715        Ok(super::ObserverProgressProjection::sourced(
716            conversation_id,
717            progress,
718            source,
719        ))
720    }
721
722    /// Restores one saved fenced predecessor through this joint lifecycle.
723    ///
724    /// The retained marker must be the exact delivered marker backing the
725    /// participant's validated recovered origin. A source is issued once;
726    /// refusals leave the lifecycle and issuance set unchanged.
727    ///
728    /// # Errors
729    ///
730    /// Refuses absent, foreign, duplicate or noncanonical source authority and
731    /// propagates every native predecessor and successor validation failure.
732    #[doc(hidden)]
733    pub fn restore_checkpoint_fenced_attach(
734        &mut self,
735        source: super::FencedAttachCommitRestore,
736        marker_record: &[u8],
737    ) -> Result<super::FencedAttachCommit, super::OwnerCheckpointRestoreError> {
738        use super::OwnerCheckpointRestoreError as Error;
739        if self
740            .restored_fenced_sources
741            .contains(&source.participant_id)
742        {
743            return Err(Error::Field("fenced_source.duplicate"));
744        }
745        let lifecycle = self
746            .lifecycle
747            .as_ref()
748            .ok_or(Error::Field("fenced_source.lifecycle"))?;
749        let origin = lifecycle
750            .participants()
751            .iter()
752            .find_map(|participant| match participant {
753                super::RestoredParticipantLifecycle::Live {
754                    member,
755                    binding_origin: Some(origin),
756                    ..
757                } if member.participant_id() == source.participant_id => Some(*origin),
758                _ => None,
759            })
760            .ok_or(Error::Field("fenced_source.binding_origin"))?;
761        if origin.binding_epoch() != source.new_binding_epoch
762            || origin.recovered_marker()
763                != Some((source.marker_delivery_seq, source.prior_binding_epoch))
764        {
765            return Err(Error::Field("fenced_source.origin_association"));
766        }
767        let record = lifecycle
768            .frontiers()
769            .checkpoint_recovered_marker_record(origin)
770            .ok_or(Error::Field("fenced_source.historical_marker"))?;
771        if super::checkpoint_codec::encode_marker_source(&record) != marker_record {
772            return Err(Error::Field("fenced_source.marker_bytes"));
773        }
774        let restored = source
775            .restore_with_record(&record)
776            .map_err(Error::Provenance);
777        record.consume();
778        let restored = restored?;
779        self.restored_fenced_sources.insert(source.participant_id);
780        Ok(restored)
781    }
782
783    /// The last log sequence whose effects the restored values include; a
784    /// binding replays its log from the row after it.
785    #[must_use]
786    pub const fn covered_log_sequence(&self) -> u64 {
787        self.covered_log_sequence
788    }
789
790    /// Moves the restored values out, for the binding to install.
791    #[must_use]
792    pub fn into_parts(self) -> RestoredLifecycleParts {
793        RestoredLifecycleParts {
794            conversation: self.conversation,
795            lifecycle: self.lifecycle,
796            sealed: self.sealed,
797        }
798    }
799
800    /// The sealed conversation's retired identities and final watermark, when
801    /// the capsule carried a seal.
802    #[must_use]
803    pub const fn sealed(&self) -> Option<&SealedLifecycle> {
804        self.sealed.as_ref()
805    }
806}
807
808/// The restored values, each for the binding to install where it keeps it.
809/// It cannot be built outside the protocol: a binding receives one only from
810/// [`RestoredLifecycle::into_parts`].
811#[derive(Debug)]
812#[non_exhaustive]
813pub struct RestoredLifecycleParts {
814    /// The conversation's event-sourced shell.
815    pub conversation: ParticipantConversation,
816    /// Every participant, both frontiers and the closure, validated together,
817    /// with the fenced-marker issuance latch the capsule carried; `None` for a
818    /// conversation captured before its first enrollment or after its seal.
819    pub lifecycle: Option<DigestConversationState>,
820    /// The retired identities and final watermark of a sealed conversation;
821    /// `None` for one that is not sealed.
822    pub sealed: Option<SealedLifecycle>,
823}
824
825/// Live values that cannot be captured into a capsule.
826#[derive(Clone, Copy, Debug, PartialEq, Eq)]
827#[non_exhaustive]
828pub enum CapsuleCaptureError {
829    /// The lent frontiers belong to another conversation than the shell.
830    ForeignConversation,
831    /// The lent participants are not strictly ascending by participant id.
832    ParticipantOutOfOrder {
833        /// The first participant id not above its predecessor.
834        participant_id: ParticipantId,
835    },
836    /// The live conversation cannot be described back into restore input.
837    Describe(ConversationDescribeError),
838    /// A count or length does not fit its `u32` field.
839    LengthOverflow,
840    /// A view lent a lifecycle and a seal together; a sealed conversation has
841    /// retired its frontier owner, so it has no lifecycle to lend.
842    SealedBesideLifecycle,
843    /// A sealed conversation lent a live participant.
844    LiveParticipantSealed {
845        /// The participant that is not retired.
846        participant_id: ParticipantId,
847    },
848}
849
850/// Stable canonical capsule-decode failure.
851#[derive(Clone, Copy, Debug, PartialEq, Eq)]
852#[non_exhaustive]
853pub enum CapsuleDecodeError {
854    /// Fewer bytes than the fixed envelope or selected field requires.
855    Truncated {
856        /// Minimum bytes required at the detected boundary.
857        required: usize,
858        /// Bytes supplied by the caller.
859        available: usize,
860    },
861    /// The capsule does not begin with the assigned `LPCK` magic prefix.
862    InvalidMagic,
863    /// The capsule codec version is not supported by this crate.
864    UnsupportedCodec {
865        /// Presented codec major version.
866        major: u16,
867        /// Presented codec minor version.
868        minor: u16,
869    },
870    /// The declared body length differs from the bytes supplied after the
871    /// fixed header.
872    NonCanonicalLength {
873        /// Length declared in the envelope.
874        declared_body_len: u32,
875        /// Bytes supplied after the fixed header.
876        actual_body_len: usize,
877    },
878    /// The body ended before a section the canonical order requires.
879    MissingSection {
880        /// Tag of the first absent section.
881        tag: u16,
882    },
883    /// A known section appeared where the canonical order has another.
884    SectionOutOfOrder {
885        /// Tag of the misplaced section.
886        tag: u16,
887    },
888    /// A section tag is unassigned.
889    UnknownSection {
890        /// Unrecognized section tag.
891        tag: u16,
892    },
893    /// A section's declared length is not its canonical length.
894    NonCanonicalSectionLength {
895        /// Tag of the section.
896        tag: u16,
897        /// Length the section declared.
898        declared: u32,
899    },
900    /// A complete section violates a field invariant.
901    NonCanonicalSection {
902        /// Tag of the section whose invariants failed.
903        tag: u16,
904    },
905    /// The participants section is not strictly ascending by participant id.
906    ParticipantOutOfOrder {
907        /// The first participant id not above its predecessor.
908        participant_id: ParticipantId,
909    },
910    /// A section names another conversation than the capsule header.
911    ForeignConversation {
912        /// Tag of the section naming the other conversation.
913        tag: u16,
914    },
915    /// Bytes remain after the last canonical section.
916    TrailingBytes {
917        /// Bytes after the last section.
918        trailing: usize,
919    },
920    /// A platform length conversion or offset addition overflowed.
921    LengthOverflow,
922}
923
924/// A capsule whose sections do not form one valid lifecycle.
925#[derive(Clone, Debug, PartialEq, Eq)]
926#[non_exhaustive]
927pub enum CapsuleRestoreError {
928    /// The shell's ordinal and genesis validation disagree.
929    InconsistentConversation,
930    /// The participants, frontiers and closure failed the joint
931    /// participant-conversation validation.
932    Lifecycle(ConversationStateRestoreError),
933    /// A sealed conversation's retired identity failed its own restore.
934    Sealed(StorageRestoreError),
935    /// A sealed conversation carried a live participant.
936    SealedLiveParticipant {
937        /// The participant that is not retired.
938        participant_id: ParticipantId,
939    },
940}
941
942fn decode_conversation(body: &[u8]) -> Result<ConversationSection, CapsuleDecodeError> {
943    if body.len() != CONVERSATION_SECTION_LEN as usize {
944        return Err(CapsuleDecodeError::NonCanonicalSectionLength {
945            tag: CONVERSATION_SECTION_TAG,
946            declared: u32::try_from(body.len()).map_err(|_| CapsuleDecodeError::LengthOverflow)?,
947        });
948    }
949    let next_event_ordinal = take_u64(body, 0)?;
950    let flags = body.get(8).copied().ok_or(CapsuleDecodeError::Truncated {
951        required: 9,
952        available: body.len(),
953    })?;
954    if flags & !GENESIS_VALIDATED_FLAG != 0 {
955        return Err(CapsuleDecodeError::NonCanonicalSection {
956            tag: CONVERSATION_SECTION_TAG,
957        });
958    }
959    let section = ConversationSection {
960        next_event_ordinal,
961        genesis_validated: flags & GENESIS_VALIDATED_FLAG != 0,
962    };
963    if !section.is_consistent() {
964        return Err(CapsuleDecodeError::NonCanonicalSection {
965            tag: CONVERSATION_SECTION_TAG,
966        });
967    }
968    Ok(section)
969}
970
971fn take<const N: usize>(input: &[u8], start: usize) -> Result<[u8; N], CapsuleDecodeError> {
972    let end = start
973        .checked_add(N)
974        .ok_or(CapsuleDecodeError::LengthOverflow)?;
975    input
976        .get(start..end)
977        .ok_or(CapsuleDecodeError::Truncated {
978            required: end,
979            available: input.len(),
980        })?
981        .try_into()
982        .map_err(|_| CapsuleDecodeError::LengthOverflow)
983}
984
985fn take_u16(input: &[u8], start: usize) -> Result<u16, CapsuleDecodeError> {
986    take(input, start).map(u16::from_be_bytes)
987}
988
989fn take_u32(input: &[u8], start: usize) -> Result<u32, CapsuleDecodeError> {
990    take(input, start).map(u32::from_be_bytes)
991}
992
993fn take_u64(input: &[u8], start: usize) -> Result<u64, CapsuleDecodeError> {
994    take(input, start).map(u64::from_be_bytes)
995}
996
997/// An origin source that cannot join the validated capsule.
998#[doc(hidden)]
999#[derive(Clone, Debug, PartialEq, Eq)]
1000pub enum BindingOriginSourceError {
1001    /// The canonical source reader refused the original bytes.
1002    Decode {
1003        /// Permanent participant selected by the source.
1004        participant_id: ParticipantId,
1005        /// Original canonical decode failure.
1006        error: CapsuleDecodeError,
1007    },
1008    /// Source identity, ordering or origin differs from the capsule.
1009    Association {
1010        /// Permanent participant at the refused association.
1011        participant_id: ParticipantId,
1012    },
1013    /// The complete capsule failed its existing joint validation.
1014    Restore(CapsuleRestoreError),
1015}
1016
1017impl RestoredLifecycle {
1018    /// Restores saved cursor facts against this capsule's validated frontier and debt.
1019    ///
1020    /// # Errors
1021    ///
1022    /// Refuses absent lifecycle or debt and every mismatched saved episode fact.
1023    pub fn restore_cursor_episode(
1024        self,
1025        bytes: &[u8],
1026        observer_progress: u64,
1027    ) -> Result<RestoredLifecycleWithCursorEpisode, super::StorageRestoreError> {
1028        let lifecycle = self
1029            .lifecycle
1030            .as_ref()
1031            .ok_or(super::StorageRestoreError::CursorEpisode)?;
1032        let ClosureState::Owed { debt, .. } = lifecycle.closure() else {
1033            return Err(super::StorageRestoreError::CursorEpisode);
1034        };
1035        let episode = super::NonzeroDebtCursorEpisode::decode_checkpoint(
1036            bytes,
1037            lifecycle.frontiers(),
1038            debt,
1039            observer_progress,
1040        )?;
1041        Ok(RestoredLifecycleWithCursorEpisode {
1042            lifecycle: self,
1043            episode,
1044        })
1045    }
1046}
1047
1048/// One capsule and the saved cursor facts validated against its own frontier.
1049#[derive(Debug)]
1050pub struct RestoredLifecycleWithCursorEpisode {
1051    lifecycle: RestoredLifecycle,
1052    episode: super::NonzeroDebtCursorEpisode,
1053}
1054impl RestoredLifecycleWithCursorEpisode {
1055    /// Moves both jointly validated values to their operational owners.
1056    #[must_use]
1057    pub fn into_parts(self) -> (RestoredLifecycleParts, super::NonzeroDebtCursorEpisode) {
1058        (self.lifecycle.into_parts(), self.episode)
1059    }
1060}
1061
1062impl LifecycleCapsule {
1063    /// Captures borrowed participants using their retained canonical origin sources.
1064    ///
1065    /// # Errors
1066    ///
1067    /// Refuses missing, malformed, unordered or foreign sources, then propagates
1068    /// the ordinary capsule-capture error. Decoded origins must match the sealed
1069    /// frontier and the exact member and binding before any view borrows them.
1070    pub fn capture_with_binding_sources(
1071        view: &LifecycleView<'_>,
1072        sources: &[(ParticipantId, &[u8])],
1073    ) -> Result<Self, BindingOriginCaptureError> {
1074        let Some(lifecycle) = view.lifecycle.as_ref() else {
1075            if let Some(&(participant_id, _)) = sources.first() {
1076                return Err(BindingOriginCaptureError::Association { participant_id });
1077            }
1078            return Self::capture(view).map_err(BindingOriginCaptureError::Capture);
1079        };
1080        let mut remaining = sources.iter().peekable();
1081        let mut origins = Vec::new();
1082        for participant in &lifecycle.participants {
1083            match participant {
1084                DigestParticipantView::Retired(_) => origins.push(None),
1085                DigestParticipantView::Live {
1086                    member,
1087                    binding,
1088                    binding_origin,
1089                    ..
1090                } => {
1091                    let participant_id = member.participant_id();
1092                    let origin_required = !matches!(binding, super::BindingState::Detached)
1093                        || member.latest_terminal().is_some()
1094                        || binding_origin.is_some();
1095                    let Some(&&(source_id, bytes)) = remaining.peek() else {
1096                        if origin_required {
1097                            return Err(BindingOriginCaptureError::Association { participant_id });
1098                        }
1099                        origins.push(None);
1100                        continue;
1101                    };
1102                    if source_id != participant_id {
1103                        if origin_required || source_id < participant_id {
1104                            return Err(BindingOriginCaptureError::Association { participant_id });
1105                        }
1106                        origins.push(None);
1107                        continue;
1108                    }
1109                    remaining.next();
1110                    let origin = super::checkpoint_codec::decode_binding_origin_source(bytes)
1111                        .map_err(|error| BindingOriginCaptureError::Decode {
1112                            participant_id,
1113                            error,
1114                        })?;
1115                    super::storage::validate_binding_origin(origin, member, **binding).map_err(
1116                        |error| BindingOriginCaptureError::Validation {
1117                            participant_id,
1118                            error,
1119                        },
1120                    )?;
1121                    if binding_origin.is_some_and(|expected| *expected != origin) {
1122                        return Err(BindingOriginCaptureError::Association { participant_id });
1123                    }
1124                    origins.push(Some(origin));
1125                }
1126            }
1127        }
1128        if let Some(&(participant_id, _)) = remaining.next() {
1129            return Err(BindingOriginCaptureError::Association { participant_id });
1130        }
1131        let participants = lifecycle
1132            .participants
1133            .iter()
1134            .zip(&origins)
1135            .map(|(participant, origin)| match participant {
1136                DigestParticipantView::Live {
1137                    member,
1138                    binding,
1139                    detach_cell,
1140                    ..
1141                } => DigestParticipantView::Live {
1142                    member,
1143                    binding,
1144                    detach_cell,
1145                    binding_origin: origin.as_ref(),
1146                },
1147                DigestParticipantView::Retired(retired) => DigestParticipantView::Retired(retired),
1148            })
1149            .collect();
1150        let capsule = Self::capture(&LifecycleView {
1151            sealed: None,
1152            conversation: view.conversation,
1153            covered_log_sequence: view.covered_log_sequence,
1154            lifecycle: Some(ConversationLifecycleView {
1155                participants,
1156                frontiers: lifecycle.frontiers,
1157                retained_record_limit: lifecycle.retained_record_limit,
1158                closure: lifecycle.closure,
1159            }),
1160        })
1161        .map_err(BindingOriginCaptureError::Capture)?;
1162        let canonical = capsule.encode_canonical();
1163        capsule
1164            .restore()
1165            .map_err(BindingOriginCaptureError::Restore)?;
1166        Self::decode_canonical(&canonical).map_err(BindingOriginCaptureError::Canonical)
1167    }
1168}
1169
1170/// Canonical source decoding and exact live capture association failures.
1171#[derive(Clone, Debug, PartialEq, Eq)]
1172pub enum BindingOriginCaptureError {
1173    /// The complete capsule failed its native joint history and frontier validation.
1174    Restore(CapsuleRestoreError),
1175    /// The canonical capsule reader refused the captured bytes.
1176    Canonical(CapsuleDecodeError),
1177    /// The original canonical source reader refused these bytes.
1178    Decode {
1179        /// The permanent participant selected by this source.
1180        participant_id: ParticipantId,
1181        /// The original canonical source decode failure.
1182        error: CapsuleDecodeError,
1183    },
1184    /// The source does not belong to this participant and sealed frontier.
1185    Association {
1186        /// The permanent participant at the refused association.
1187        participant_id: ParticipantId,
1188    },
1189    /// The source does not match the existing member and binding.
1190    Validation {
1191        /// The permanent participant selected by this source.
1192        participant_id: ParticipantId,
1193        /// The original native member and binding validation failure.
1194        error: super::StorageRestoreError,
1195    },
1196    /// The ordinary capsule writer refused the borrowed values.
1197    Capture(CapsuleCaptureError),
1198}