Skip to main content

liminal_protocol/lifecycle/operations/live_frontier/
checkpoint_restore.rs

1//! Restores executable frontier ownership only from a validated lifecycle capsule.
2
3use alloc::vec::Vec;
4
5use crate::algebra::{ResourceVector, WideResourceVector};
6use crate::lifecycle::{
7    ClosureAccounting, OrdinaryProjectionError, ParticipantConversation, RestoredLifecycleParts,
8    RestoredParticipantLifecycle, RetainedRecordCharge,
9};
10use crate::wire::CredentialDigest;
11
12use super::LiveFrontierOwner;
13
14/// Participant values moved from the validated credential-digest lifecycle capsule.
15pub type CheckpointParticipant = RestoredParticipantLifecycle<
16    CredentialDigest,
17    CredentialDigest,
18    CredentialDigest,
19    CredentialDigest,
20>;
21
22/// The original shell and participants beside their restored executable frontier.
23#[derive(Debug)]
24#[non_exhaustive]
25pub struct RestoredLiveFrontierOwner {
26    /// The capsule's original conversation shell.
27    pub conversation: ParticipantConversation,
28    /// The capsule's original participant values and tombstones.
29    pub participants: Vec<CheckpointParticipant>,
30    /// The coupled frontier, accounting and exact keyed retained charges.
31    pub owner: LiveFrontierOwner,
32    /// The exact saved cursor facts, present only for owed closure.
33    pub cursor_episode: Option<crate::lifecycle::NonzeroDebtCursorEpisode>,
34    pub(in crate::lifecycle) observer_progress: u64,
35}
36
37/// Named checkpoint omissions and joint-state validation failures.
38#[derive(Debug, PartialEq, Eq)]
39pub enum LiveFrontierRestoreError {
40    /// The checkpoint contains no enrolled lifecycle.
41    MissingLifecycle,
42    /// The checkpoint omits closure accounting.
43    MissingClosureAccounting,
44    /// The checkpoint omits keyed retained charges.
45    MissingRetainedCharges,
46    /// Owed closure omits its saved cursor episode.
47    MissingCursorEpisode,
48    /// Clear closure unexpectedly carries a cursor episode.
49    UnexpectedCursorEpisode,
50    /// The original accounting reader refused the source.
51    AccountingCodec(crate::lifecycle::FrontierAccountingDecodeError),
52    /// The original retained-charge reader refused the source.
53    RetainedChargeCodec(crate::lifecycle::CapsuleDecodeError),
54    /// The original joint cursor-episode validator refused the source.
55    CursorEpisode(crate::lifecycle::StorageRestoreError),
56    /// The original saved-episode writer refused canonical encoding.
57    CursorEpisodeEncoding(crate::lifecycle::CursorFactEncodeError),
58    /// The shell and restored frontier belong to different conversations.
59    ForeignConversation,
60    /// The capsule's closure and decoded accounting disagree.
61    ClosureMismatch,
62    /// Retained rows exceed their configured limit.
63    RetainedRecordLimit,
64    /// Native retained-row, marker or baseline validation refused.
65    Accounting(OrdinaryProjectionError),
66}
67
68impl LiveFrontierOwner {
69    /// Restores coupled ownership from the validated capsule and decoded saved values.
70    ///
71    /// No participant, frontier, closure, charge or accounting field is regenerated.
72    ///
73    /// # Errors
74    ///
75    /// Names every absent saved field and refuses inconsistent closure, retained keys,
76    /// marker ledgers, baseline or retention limit.
77    fn restore_checkpoint_parts(
78        parts: RestoredLifecycleParts,
79        accounting: ClosureAccounting,
80        charges: Vec<RetainedRecordCharge>,
81        cursor_episode: Option<crate::lifecycle::NonzeroDebtCursorEpisode>,
82        saved: FrontierCheckpointSource<'_>,
83    ) -> Result<RestoredLiveFrontierOwner, LiveFrontierRestoreError> {
84        let lifecycle = parts
85            .lifecycle
86            .ok_or(LiveFrontierRestoreError::MissingLifecycle)?;
87        if lifecycle.frontiers().conversation_id() != parts.conversation.conversation_id() {
88            return Err(LiveFrontierRestoreError::ForeignConversation);
89        }
90        if lifecycle.closure() != accounting.state() {
91            return Err(LiveFrontierRestoreError::ClosureMismatch);
92        }
93        // A drained binding terminal is retained above the cap it pended at
94        // (`LiveFrontierOwner::drain_pending_terminal`), by at most the
95        // identity-slot candidate bound; the frontier restore admits the
96        // same overage (11 October 2026).
97        if lifecycle.frontiers().retained_records().len() as u128
98            > u128::from(saved.retained_record_limit)
99                .saturating_add(u128::from(lifecycle.frontiers().identity_slot_limit()))
100        {
101            return Err(LiveFrontierRestoreError::RetainedRecordLimit);
102        }
103        validate_saved_accounting(
104            lifecycle.frontiers(),
105            &accounting,
106            &charges,
107            saved.marker_max,
108        )
109        .map_err(LiveFrontierRestoreError::Accounting)?;
110        let (participants, frontiers, _) = lifecycle.into_parts();
111        Ok(RestoredLiveFrontierOwner {
112            conversation: parts.conversation,
113            participants,
114            cursor_episode,
115            observer_progress: saved.observer_progress,
116            owner: Self {
117                frontiers,
118                closure_accounting: accounting,
119                retained_charges: charges,
120                retained_record_limit: saved.retained_record_limit,
121            },
122        })
123    }
124}
125
126fn validate_saved_accounting(
127    frontiers: &crate::lifecycle::ClaimFrontiers,
128    accounting: &ClosureAccounting,
129    charges: &[RetainedRecordCharge],
130    marker_max: ResourceVector,
131) -> Result<(), OrdinaryProjectionError> {
132    let records = frontiers.retained_records();
133    let high_end = u128::from(frontiers.sequence().ledger().high_watermark()) + 1;
134    if high_end.checked_sub(frontiers.retained_floor()) != Some(records.len() as u128)
135        || records.len() != charges.len()
136    {
137        return Err(OrdinaryProjectionError::RetainedSuffix);
138    }
139    // A credited marker is charged at `marker_max`, exactly as the live
140    // admission projection charges it (`retained_charge_at_floor`): its
141    // credit holds the whole reservation however small the marker record
142    // is. Summing the exact charge here instead refused every checkpoint of
143    // a conversation holding a marker smaller than the maximum (the door
144    // room, 11 October 2026: stored 94,481,201 bytes, derived 94,458,959),
145    // and every boot replayed the room from row 0.
146    let credited: Vec<_> = frontiers
147        .retained_marker_records()
148        .iter()
149        .map(|record| record.delivery_seq)
150        .collect();
151    let mut retained = WideResourceVector::default();
152    for (index, (record, charge)) in records.iter().zip(charges).enumerate() {
153        if u128::from(record.delivery_seq) != frontiers.retained_floor() + index as u128
154            || charge.delivery_seq() != record.delivery_seq
155            || charge.admission_order() != record.admission_order
156        {
157            return Err(OrdinaryProjectionError::RetainedChargeKey {
158                index: u64::try_from(index).map_err(|_| OrdinaryProjectionError::RetainedSuffix)?,
159            });
160        }
161        let resource = charge.encoded_charge();
162        if resource.entries != 1 {
163            return Err(OrdinaryProjectionError::RetainedEntryCharge {
164                delivery_seq: charge.delivery_seq(),
165                entries: resource.entries,
166            });
167        }
168        let resource = if credited.contains(&record.delivery_seq) {
169            marker_max
170        } else {
171            resource
172        };
173        retained =
174            super::super::ordinary_record_projection::checked_add_resource(retained, resource)?;
175    }
176    let pending = frontiers.pending_marker_candidate_count();
177    let retained_credits = u64::try_from(frontiers.retained_marker_records().len())
178        .map_err(|_| OrdinaryProjectionError::MarkerCredit)?;
179    let credits = retained_credits
180        .checked_add(pending)
181        .ok_or(OrdinaryProjectionError::MarkerCredit)?;
182    if credits != accounting.marker_capacity_credits() {
183        return Err(OrdinaryProjectionError::MarkerCreditAccounting {
184            derived: credits,
185            stored: accounting.marker_capacity_credits(),
186        });
187    }
188    let anchors = frontiers
189        .unaccepted_marker_anchor_count()
190        .checked_add(pending)
191        .ok_or(OrdinaryProjectionError::MarkerAnchor)?;
192    if anchors != accounting.marker_anchors() {
193        return Err(OrdinaryProjectionError::MarkerAnchorAccounting {
194            derived: anchors,
195            stored: accounting.marker_anchors(),
196        });
197    }
198    let baseline = super::super::ordinary_record_projection::retained_baseline_wide(
199        retained,
200        frontiers.identity_slot_limit(),
201        retained_credits,
202        marker_max,
203    )?;
204    if baseline != accounting.baseline() {
205        return Err(OrdinaryProjectionError::BaselineAccounting {
206            derived: baseline,
207            stored: accounting.baseline(),
208        });
209    }
210    Ok(())
211}
212
213impl LiveFrontierOwner {
214    /// Decodes every saved coupled field against a validated lifecycle capsule.
215    ///
216    /// # Errors
217    ///
218    /// Names absent fields, retains the original codec failures, and rejects
219    /// mismatched cursor facts, keyed charges, closure, marker ledgers or baseline.
220    pub fn restore_checkpoint(
221        parts: RestoredLifecycleParts,
222        saved: FrontierCheckpointSource<'_>,
223    ) -> Result<RestoredLiveFrontierOwner, LiveFrontierRestoreError> {
224        let lifecycle = parts
225            .lifecycle
226            .as_ref()
227            .ok_or(LiveFrontierRestoreError::MissingLifecycle)?;
228        let accounting = Self::decode_closure_accounting_source(
229            lifecycle.closure(),
230            saved
231                .closure_accounting
232                .ok_or(LiveFrontierRestoreError::MissingClosureAccounting)?,
233        )
234        .map_err(LiveFrontierRestoreError::AccountingCodec)?;
235        let charges = Self::decode_retained_charge_source(
236            saved
237                .retained_charges
238                .ok_or(LiveFrontierRestoreError::MissingRetainedCharges)?,
239        )
240        .map_err(LiveFrontierRestoreError::RetainedChargeCodec)?;
241        let episode = match lifecycle.closure() {
242            crate::lifecycle::ClosureState::Clear => {
243                if saved.cursor_episode.is_some() {
244                    return Err(LiveFrontierRestoreError::UnexpectedCursorEpisode);
245                }
246                None
247            }
248            crate::lifecycle::ClosureState::Owed { debt, .. } => {
249                let bytes = saved
250                    .cursor_episode
251                    .ok_or(LiveFrontierRestoreError::MissingCursorEpisode)?;
252                Some(
253                    crate::lifecycle::NonzeroDebtCursorEpisode::decode_checkpoint(
254                        bytes,
255                        lifecycle.frontiers(),
256                        debt,
257                        saved.observer_progress,
258                    )
259                    .map_err(LiveFrontierRestoreError::CursorEpisode)?,
260                )
261            }
262        };
263        Self::restore_checkpoint_parts(parts, accounting, charges, episode, saved)
264    }
265}
266
267/// Borrowed canonical saved fields and their required configured bounds.
268#[derive(Clone, Copy, Debug)]
269pub struct FrontierCheckpointSource<'a> {
270    /// Canonical closure-accounting scalar bytes; absence is a named refusal.
271    pub closure_accounting: Option<&'a [u8]>,
272    /// Canonical keyed retained-charge bytes; absence is a named refusal.
273    pub retained_charges: Option<&'a [u8]>,
274    /// Exact saved cursor episode, required for owed closure and absent for clear.
275    pub cursor_episode: Option<&'a [u8]>,
276    /// Saved observer progress used to validate the episode.
277    pub observer_progress: u64,
278    /// The configured bound on retained physical rows.
279    pub retained_record_limit: u64,
280    /// The configured maximum marker charge used by native baseline accounting.
281    pub marker_max: ResourceVector,
282}