Skip to main content

liminal_protocol/client/
resume.rs

1use alloc::vec::Vec;
2
3use super::{
4    ClientBindingState, ClientParticipantAggregate, DetachReplayStatus, DetachReplayTerminal,
5    ExpectedOperationState, LostAuthorityKind, LostAuthorityTestimony, ReconnectAggregate,
6    RestoredExpectedOperationAbandonment, RestoredExpectedOperationAbandonmentReason,
7    SdkDetachReplayAggregate, reconnect::ReconnectMachineState, replay::DetachReplayState,
8};
9use super::{resume_decode::decode_facts, resume_encode::encode_aggregate};
10use crate::wire::{ClientRequest, CodecError};
11
12pub(super) const MAGIC: [u8; 4] = *b"LPCR";
13pub(super) const VERSION: u16 = 1;
14pub(super) const HEADER_LEN: usize = 14;
15
16/// Section whose tag or nested canonical frame was invalid.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum ClientResumeRecordSection {
19    /// Client binding state.
20    Binding,
21    /// Outstanding expected operation.
22    ExpectedOperation,
23    /// Detach replay state.
24    DetachReplay,
25    /// Reconnect permit or attempt state.
26    Reconnect,
27    /// Pending tokenless abandonment.
28    Abandonment,
29}
30
31/// Failure while creating a canonical record from live client state.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum ClientResumeRecordEncodeError {
34    /// A nested request or terminal value cannot use the canonical wire codec.
35    NestedCodec {
36        /// Failing section.
37        section: ClientResumeRecordSection,
38        /// Exact wire codec error.
39        source: CodecError,
40    },
41    /// The own-codec payload cannot fit its u64 length field.
42    LengthOverflow,
43}
44
45/// Typed canonical client-record decode failure.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum ClientResumeRecordDecodeError {
48    /// Input ended before the requested number of bytes.
49    Truncated {
50        /// Bytes required at the failure point.
51        needed: usize,
52        /// Bytes remaining at the failure point.
53        remaining: usize,
54    },
55    /// The four-byte client-record magic was not `LPCR`.
56    InvalidMagic {
57        /// Presented magic bytes.
58        presented: [u8; 4],
59    },
60    /// The client-record envelope version is unsupported.
61    UnsupportedVersion {
62        /// Presented version.
63        presented: u16,
64    },
65    /// Declared payload length differs from the exact remaining bytes.
66    LengthMismatch {
67        /// Declared payload bytes.
68        declared: u64,
69        /// Actual payload bytes.
70        actual: usize,
71    },
72    /// A closed section tag was unknown.
73    InvalidTag {
74        /// Section containing the tag.
75        section: ClientResumeRecordSection,
76        /// Unknown tag.
77        tag: u8,
78    },
79    /// A nested canonical participant frame was invalid or had the wrong direction.
80    NestedCodec {
81        /// Failing section.
82        section: ClientResumeRecordSection,
83        /// Exact wire codec error when structural decode failed.
84        source: Option<CodecError>,
85    },
86    /// A serialized tokenless-after-crash abandonment carried an operation class
87    /// that has a wire attempt token and therefore cannot use that resolution.
88    InvalidAbandonmentRequest {
89        /// Rejected token-bearing request class.
90        request: crate::wire::ClientDiscriminant,
91    },
92    /// Extra bytes followed the four exact sections.
93    TrailingBytes {
94        /// Number of unexpected bytes.
95        remaining: usize,
96    },
97}
98
99/// Validated cold-restore invariant failure.
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub enum ClientResumeRestoreError {
102    /// A bound credential generation differs from its binding epoch.
103    BindingGenerationMismatch,
104    /// A continuous acknowledgement illegally occupied the write-ahead slot.
105    ContinuousAckOutstanding,
106    /// Replay terminal payload does not match its retained exact detach request.
107    ReplayTerminalMismatch,
108    /// Expected operation authorization is zero or exceeds its durable counter.
109    InvalidOperationAuthorization,
110    /// Expected operation is illegal for the restored binding state or identity.
111    ExpectedBindingMismatch,
112    /// Active detach replay is not coupled to its matching expected detach.
113    ActiveReplayExpectedDetachMismatch,
114    /// An expected detach has no active matching replay lifecycle.
115    ExpectedDetachActiveReplayMismatch,
116    /// Reconnect authorization is zero or exceeds its durable counter.
117    InvalidReconnectAuthorization,
118    /// A serialized lost-authority testimony does not match the destroyed
119    /// authority its slot and state imply (r2, 2026-07-18).
120    LostAuthorityTestimonyMismatch,
121    /// A pending tokenless abandonment coexists with a tokenless expected
122    /// operation, which the crate's admission gate never produces
123    /// (r2, 2026-07-18).
124    PendingAbandonmentConflict,
125    /// Private canonical bytes no longer decode; this is unreachable through public construction.
126    CorruptRecord(ClientResumeRecordDecodeError),
127}
128
129/// Private-field, inert, canonical client persistence record.
130///
131/// This type proves only that bytes are a canonical committed-record envelope;
132/// the storage owner remains responsible for admitting bytes from exactly one
133/// cold process epoch. Preventing two processes from restoring the same bytes
134/// is intentionally outside this `no_std` protocol crate, matching the server
135/// precedent in `lifecycle/storage.rs`, whose joint validation likewise does
136/// not prevent storage-owner double restore. Issuance flags are nevertheless
137/// preserved for token-bearing operations, so a record that testifies an
138/// authority was already issued never silently re-mints it. Tokenless
139/// `ObserverRecovery` is the deliberate exception and resolves to typed abandonment
140/// on every restore because replay cannot be made
141/// at-most-once without an outbound attempt token.
142#[derive(Debug, PartialEq, Eq)]
143pub struct ClientResumeRecord {
144    canonical: Vec<u8>,
145}
146
147impl ClientResumeRecord {
148    /// Encodes this already-validated record in canonical v1 form.
149    #[must_use]
150    pub fn encode_canonical(&self) -> Vec<u8> {
151        self.canonical.clone()
152    }
153
154    /// Decodes one exact canonical v1 client record without minting authority.
155    ///
156    /// # Errors
157    ///
158    /// Returns typed truncation, magic, version, tag, length, nested-codec, or
159    /// trailing-byte errors.
160    pub fn decode_canonical(input: &[u8]) -> Result<Self, ClientResumeRecordDecodeError> {
161        let _ = decode_facts(input)?;
162        Ok(Self {
163            canonical: input.to_vec(),
164        })
165    }
166
167    /// Validates cross-fact invariants and cold-restores executable client state.
168    ///
169    /// # Errors
170    ///
171    /// Returns a typed invariant error before any aggregate or reconnect
172    /// authority escapes.
173    pub fn restore(self) -> Result<ClientParticipantAggregate, ClientResumeRestoreError> {
174        let facts =
175            decode_facts(&self.canonical).map_err(ClientResumeRestoreError::CorruptRecord)?;
176        validate_facts(&facts)?;
177        let mut expected = facts.expected;
178        let tokenless = expected
179            .as_ref()
180            .is_some_and(|expected| matches!(expected.request, ClientRequest::ObserverRecovery(_)));
181        let restored_abandonment = if tokenless {
182            expected
183                .take()
184                .map(|expected| RestoredExpectedOperationAbandonment {
185                    request: expected.request,
186                    reason: RestoredExpectedOperationAbandonmentReason::TokenlessAfterCrash,
187                    was_issued: expected.issued,
188                })
189        } else {
190            facts.abandonment
191        };
192        if let Some(expected) = expected.as_mut()
193            && expected.issued
194            && expected.lost.is_none()
195        {
196            let kind = if matches!(expected.request, ClientRequest::Detach(_)) {
197                LostAuthorityKind::DetachTransportAttempt
198            } else {
199                LostAuthorityKind::IssuedOperationCorrelation
200            };
201            expected.lost = Some(LostAuthorityTestimony::mint(kind));
202        }
203        let mut reconnect_lost = facts.reconnect_lost;
204        if reconnect_lost.is_none() {
205            reconnect_lost = match facts.reconnect_state {
206                ReconnectMachineState::Permit { issued: true, .. } => Some(
207                    LostAuthorityTestimony::mint(LostAuthorityKind::ReconnectPermit),
208                ),
209                ReconnectMachineState::Attempt { .. } => Some(LostAuthorityTestimony::mint(
210                    LostAuthorityKind::ReconnectAttempt,
211                )),
212                ReconnectMachineState::Parked
213                | ReconnectMachineState::Permit { issued: false, .. }
214                | ReconnectMachineState::Online => None,
215            };
216        }
217        Ok(ClientParticipantAggregate {
218            binding: facts.binding,
219            expected,
220            next_operation_authorization: facts.next_operation_authorization,
221            detach_replay: SdkDetachReplayAggregate {
222                state: facts.replay,
223            },
224            reconnect: ReconnectAggregate {
225                state: facts.reconnect_state,
226                next_authorization: facts.next_authorization,
227                lost: reconnect_lost,
228            },
229            restored_abandonment,
230        })
231    }
232}
233
234impl ClientParticipantAggregate {
235    /// Captures every durable client fact in an inert resume record.
236    ///
237    /// # Errors
238    ///
239    /// Returns a typed nested-codec or length error if a live typed value cannot
240    /// be represented canonically.
241    pub fn resume_record(&self) -> Result<ClientResumeRecord, ClientResumeRecordEncodeError> {
242        Ok(ClientResumeRecord {
243            canonical: encode_aggregate(self)?,
244        })
245    }
246}
247
248impl super::ClientOperationCommit {
249    /// Captures the committed successor as a publicly cold-restorable record.
250    ///
251    /// The caller persists these `LPCR` bytes before releasing the aggregate and
252    /// operation with [`Self::into_parts`]. No pending record or speculative
253    /// promotion format exists.
254    ///
255    /// # Errors
256    ///
257    /// Returns a typed nested-codec or length error before authority release.
258    pub fn resume_record(&self) -> Result<ClientResumeRecord, ClientResumeRecordEncodeError> {
259        Ok(ClientResumeRecord {
260            canonical: encode_aggregate(&self.aggregate)?,
261        })
262    }
263}
264
265pub(super) struct DecodedFacts {
266    pub(super) binding: ClientBindingState,
267    pub(super) next_operation_authorization: u64,
268    pub(super) expected: Option<ExpectedOperationState>,
269    pub(super) replay: DetachReplayState,
270    pub(super) reconnect_state: ReconnectMachineState,
271    pub(super) next_authorization: u64,
272    pub(super) reconnect_lost: Option<LostAuthorityTestimony>,
273    pub(super) abandonment: Option<RestoredExpectedOperationAbandonment>,
274}
275
276fn validate_facts(facts: &DecodedFacts) -> Result<(), ClientResumeRestoreError> {
277    if let ClientBindingState::Bound {
278        generation,
279        binding_epoch,
280        ..
281    } = facts.binding
282        && generation != binding_epoch.capability_generation
283    {
284        return Err(ClientResumeRestoreError::BindingGenerationMismatch);
285    }
286    if matches!(
287        facts.expected,
288        Some(ExpectedOperationState {
289            request: ClientRequest::ParticipantAck(_),
290            ..
291        })
292    ) {
293        return Err(ClientResumeRestoreError::ContinuousAckOutstanding);
294    }
295    if facts.expected.as_ref().is_some_and(|expected| {
296        expected.authorization == 0 || expected.authorization > facts.next_operation_authorization
297    }) {
298        return Err(ClientResumeRestoreError::InvalidOperationAuthorization);
299    }
300    if facts
301        .expected
302        .as_ref()
303        .is_some_and(|expected| !facts.binding.accepts_request(&expected.request))
304    {
305        return Err(ClientResumeRestoreError::ExpectedBindingMismatch);
306    }
307    let active_replay = match &facts.replay {
308        DetachReplayState::Recorded { request, status }
309            if matches!(
310                status,
311                DetachReplayStatus::Parked | DetachReplayStatus::InFlight
312            ) =>
313        {
314            Some((request, status))
315        }
316        DetachReplayState::Empty | DetachReplayState::Recorded { .. } => None,
317    };
318    let expected_detach = facts.expected.as_ref().and_then(|expected| {
319        let ClientRequest::Detach(value) = &expected.request else {
320            return None;
321        };
322        Some((value, expected.issued))
323    });
324    match (active_replay, expected_detach) {
325        (Some((request, status)), Some((value, issued)))
326            if value.conversation_id == request.conversation_id
327                && value.participant_id == request.participant_id
328                && value.capability_generation == request.capability_generation
329                && value.detach_attempt_token == request.detach_attempt_token
330                && ((matches!(status, DetachReplayStatus::Parked) && !issued)
331                    || (matches!(status, DetachReplayStatus::InFlight) && issued)) => {}
332        (Some(_), _) => {
333            return Err(ClientResumeRestoreError::ActiveReplayExpectedDetachMismatch);
334        }
335        (None, Some(_)) => {
336            return Err(ClientResumeRestoreError::ExpectedDetachActiveReplayMismatch);
337        }
338        (None, None) => {}
339    }
340    if let DetachReplayState::Recorded {
341        request,
342        status: DetachReplayStatus::Terminal(terminal),
343    } = &facts.replay
344        && !terminal_matches(request, terminal)
345    {
346        return Err(ClientResumeRestoreError::ReplayTerminalMismatch);
347    }
348    let authorization = match facts.reconnect_state {
349        ReconnectMachineState::Permit { authorization, .. }
350        | ReconnectMachineState::Attempt { authorization, .. } => Some(authorization),
351        ReconnectMachineState::Parked | ReconnectMachineState::Online => None,
352    };
353    if authorization.is_some_and(|value| value == 0 || value > facts.next_authorization) {
354        return Err(ClientResumeRestoreError::InvalidReconnectAuthorization);
355    }
356    validate_testimony_coupling(facts)?;
357    Ok(())
358}
359
360/// Enforces both coupling directions for the serialized loss atoms: an atom
361/// whose slot or state does not imply the recorded destruction is refused, and
362/// a pending abandonment can never coexist with the tokenless expected
363/// operation that would mint a second one (r2, 2026-07-18).
364fn validate_testimony_coupling(facts: &DecodedFacts) -> Result<(), ClientResumeRestoreError> {
365    if let Some(expected) = facts.expected.as_ref()
366        && let Some(testimony) = expected.lost.as_ref()
367    {
368        let tokenless = matches!(expected.request, ClientRequest::ObserverRecovery(_));
369        let expected_kind = if matches!(expected.request, ClientRequest::Detach(_)) {
370            LostAuthorityKind::DetachTransportAttempt
371        } else {
372            LostAuthorityKind::IssuedOperationCorrelation
373        };
374        if !expected.issued || tokenless || testimony.kind() != expected_kind {
375            return Err(ClientResumeRestoreError::LostAuthorityTestimonyMismatch);
376        }
377    }
378    if let Some(testimony) = facts.reconnect_lost.as_ref() {
379        let state_kind = match facts.reconnect_state {
380            ReconnectMachineState::Permit { issued: true, .. } => {
381                Some(LostAuthorityKind::ReconnectPermit)
382            }
383            ReconnectMachineState::Attempt { .. } => Some(LostAuthorityKind::ReconnectAttempt),
384            ReconnectMachineState::Parked
385            | ReconnectMachineState::Permit { issued: false, .. }
386            | ReconnectMachineState::Online => None,
387        };
388        if state_kind != Some(testimony.kind()) {
389            return Err(ClientResumeRestoreError::LostAuthorityTestimonyMismatch);
390        }
391    }
392    if facts.abandonment.is_some()
393        && facts
394            .expected
395            .as_ref()
396            .is_some_and(|expected| matches!(expected.request, ClientRequest::ObserverRecovery(_)))
397    {
398        return Err(ClientResumeRestoreError::PendingAbandonmentConflict);
399    }
400    Ok(())
401}
402
403fn terminal_matches(
404    request: &crate::wire::DetachEnvelope,
405    terminal: &DetachReplayTerminal,
406) -> bool {
407    match terminal {
408        DetachReplayTerminal::DetachCommitted(value) => {
409            value.conversation_id() == request.conversation_id
410                && value.participant_id() == request.participant_id
411                && value.capability_generation() == request.capability_generation
412                && value.detach_attempt_token() == request.detach_attempt_token
413        }
414        DetachReplayTerminal::DetachInProgress(value) => {
415            let expected_generation = request.capability_generation;
416            let presented_generation = value.presented_generation;
417            let expected_token = request.detach_attempt_token;
418            let presented_token = value.presented_token;
419            value.conversation_id == request.conversation_id
420                && value.participant_id == request.participant_id
421                && presented_generation == expected_generation
422                && presented_token == expected_token
423        }
424        DetachReplayTerminal::TerminalizedDetachCell(value) => {
425            value.conversation_id() == request.conversation_id
426                && value.participant_id() == request.participant_id
427                && value.capability_generation() == request.capability_generation
428                && value.detach_attempt_token() == request.detach_attempt_token
429        }
430    }
431}