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