Skip to main content

liminal_sdk/remote/
participant.rs

1//! Remote participant state, durable client records, and typed transport outcomes.
2//!
3//! This module owns process mechanics only. Every participant lifecycle and
4//! correlation decision is delegated to `liminal-protocol`; the SDK stores the
5//! crate aggregate and its sealed one-use authorities without mirroring their
6//! rules.
7
8mod recovery;
9mod replay_apply;
10
11pub use recovery::{
12    RemoteDetachReplayOutcome, RemoteExpectedOperationRecovery, RemoteLostOperationResolution,
13    RemoteLostReconnectResolution, RemoteReconnectAttemptOutcome, RemoteReconnectPermitRecovery,
14    RemoteReplayApplyOutcome, RemoteTransportLossOutcome,
15};
16
17use alloc::sync::Arc;
18use core::fmt;
19use core::time::Duration;
20
21use liminal_protocol::client::{
22    ClientCorrelatedInboundDecision, ClientInboundDecision, ClientInboundRefusalReason,
23    ClientOperationRecordDecision, ClientOperationRecordRefusalReason, ClientParticipantAggregate,
24    ClientResponseCorrelation, ClientResumeRecord, ClientResumeRecordDecodeError,
25    ClientResumeRecordEncodeError, ClientResumeRestoreError, ExpectedOperationFateRefusalReason,
26    ExpectedOperationTransportFate, ExpectedParticipantOperation, ReconnectPermitDecision,
27    decide_correlated_inbound, decide_inbound, record_expected_operation_fate,
28    record_transport_fate,
29};
30use liminal_protocol::outcome::ReconnectDelayResult;
31use liminal_protocol::wire::{
32    ClientRequest, DeliverySeq, ParticipantFrame, ServerPush, ServerValue,
33};
34use spin::Mutex;
35
36use crate::SdkError;
37
38use super::protocol::{ParticipantTransportFrame, RemoteTransport};
39use super::{RemoteConfig, ServerAddress};
40
41/// Storage boundary for canonical `LPCR` client resume bytes.
42///
43/// Implementations must replace the previously committed bytes durably before
44/// returning `Ok(())`. The SDK calls this boundary after the protocol crate's
45/// commit seal and before releasing executable operation authority.
46pub trait ParticipantResumeStore: Send {
47    /// Durably replaces the stored canonical client resume record.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`SdkError::Store`] when the bytes were not durably committed.
52    fn persist(&mut self, canonical_lpcr: &[u8]) -> Result<(), SdkError>;
53}
54
55/// Transport-layer testimony identifying the connection attempt that delivered a frame.
56///
57/// This is the sealed transport context anticipated by rationale 15 in
58/// `LP-CLIENT-GOAL`. It does not alter the wire format or relax the protocol
59/// crate's conservative `RecordAdmission` ambiguity.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub struct ParticipantResponseProvenance {
62    connection_id: u64,
63    attempt_id: u64,
64}
65
66impl ParticipantResponseProvenance {
67    #[cfg(feature = "std")]
68    pub(super) const fn new(connection_id: u64, attempt_id: u64) -> Self {
69        Self {
70            connection_id,
71            attempt_id,
72        }
73    }
74
75    /// Returns the local identity of the established socket.
76    #[must_use]
77    pub const fn connection_id(self) -> u64 {
78        self.connection_id
79    }
80
81    /// Returns the local identity of the real connection attempt.
82    #[must_use]
83    pub const fn attempt_id(self) -> u64 {
84        self.attempt_id
85    }
86}
87
88/// Failure at the SDK participant state, codec, storage, or transport boundary.
89#[derive(Debug, thiserror::Error)]
90pub enum RemoteParticipantError {
91    /// A prior commit could not be persisted, so no aggregate authority remains reachable.
92    #[error("participant state is unavailable after an unreleased durability failure")]
93    StateUnavailable,
94    /// The protocol crate could not encode the current aggregate as canonical LPCR.
95    #[error("client resume record encode failed: {0:?}")]
96    ResumeEncode(ClientResumeRecordEncodeError),
97    /// Persisted bytes were not a canonical LPCR record.
98    #[error("client resume record decode failed: {0:?}")]
99    ResumeDecode(ClientResumeRecordDecodeError),
100    /// Canonical facts violated a protocol restore invariant.
101    #[error("client resume record restore failed: {0:?}")]
102    ResumeRestore(ClientResumeRestoreError),
103    /// The caller-owned durable store rejected a canonical record.
104    #[error("client resume record persistence failed: {0}")]
105    Storage(SdkError),
106    /// The real transport failed outside a typed fate-reporting operation.
107    #[error("participant transport failed: {0}")]
108    Transport(SdkError),
109    /// A client-to-server request appeared on the SDK receive side.
110    #[error("participant transport decoded a request in the client receive direction")]
111    InvalidInboundDirection,
112    /// No live response correlation exists for a replay-specific input.
113    #[error("no live participant response authority is held")]
114    ResponseAuthorityUnavailable,
115}
116
117/// Opaque one-use operation released only after the SDK persisted sealed LPCR bytes.
118#[derive(Debug)]
119pub struct RemoteParticipantOperation {
120    operation: ExpectedParticipantOperation,
121    durability: OperationDurability,
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125enum OperationDurability {
126    WriteAhead,
127    Continuous,
128}
129
130/// Result of admitting an outbound request through the crate write-ahead barrier.
131#[derive(Debug)]
132pub enum RemoteOperationRecordOutcome {
133    /// Canonical LPCR bytes were persisted and one operation may now be sent.
134    Recorded(RemoteParticipantOperation),
135    /// A continuous acknowledgement bypassed the write-ahead slot by crate rule.
136    Continuous(RemoteParticipantOperation),
137    /// The crate refused the exact request without changing aggregate state.
138    Refused {
139        /// Exact refused request.
140        request: ClientRequest,
141        /// Closed protocol refusal reason.
142        reason: ClientOperationRecordRefusalReason,
143    },
144}
145
146/// Typed operation-domain consequence of an established transport loss.
147#[derive(Debug, PartialEq, Eq)]
148pub enum RemoteOperationTransportFate {
149    /// A non-detach operation's response became unavailable.
150    Recorded {
151        /// Exact terminalized request.
152        request: ClientRequest,
153    },
154    /// The exact detach was returned to parked replay.
155    DetachParked,
156    /// The crate retained the live correlation unchanged.
157    Refused {
158        /// Closed refusal reason from the generic operation-fate gate.
159        reason: ExpectedOperationFateRefusalReason,
160    },
161    /// No response authority was outstanding.
162    NotOutstanding,
163}
164
165/// Typed reconnect permit returned by a crate-authorized fresh event.
166#[derive(Debug)]
167pub struct RemoteReconnectPermit {
168    pub(super) permit: liminal_protocol::client::ReconnectAttemptPermit,
169}
170
171/// Event-driven reconnect permit decision; there is no delay or timer arm.
172#[derive(Debug)]
173pub enum RemoteReconnectPermitOutcome {
174    /// The crate minted one one-use permit.
175    Permitted {
176        /// Opaque permit for one real connection attempt.
177        permit: RemoteReconnectPermit,
178        /// Legacy-named crate result whose value is event-only.
179        result: ReconnectDelayResult,
180    },
181    /// Existing authority was retained.
182    Refused {
183        /// Closed crate refusal reason.
184        reason: liminal_protocol::client::ReconnectPermitRefusalReason,
185        /// Event-only crate result.
186        result: ReconnectDelayResult,
187    },
188}
189
190/// Result of sending an operation on the participant transport.
191#[derive(Debug)]
192pub enum RemoteParticipantSendOutcome {
193    /// The request bytes were written on this connection attempt.
194    Sent {
195        /// Sealed transport context for a later response.
196        provenance: ParticipantResponseProvenance,
197    },
198    /// The write failed and both operation and reconnect fates were delegated.
199    TransportLost {
200        /// Concrete socket failure.
201        error: SdkError,
202        /// Crate-owned operation-fate result.
203        operation_fate: RemoteOperationTransportFate,
204        /// Crate-owned reconnect permit result.
205        reconnect: RemoteReconnectPermitOutcome,
206    },
207}
208
209/// Default quiet window for [`RemoteParticipantHandle::try_receive`].
210///
211/// Deliberately ONE steady-state transport receive window rather than a new
212/// number of its own: that is the grain both transports already poll on, and it
213/// is the shape consumers' drain loops were written against before a total
214/// response deadline was layered above it. A pump that reports quiet after one
215/// closed window is the behaviour they expect; the deadline above it is the
216/// reply-owed protection they were never asking for.
217pub const PARTICIPANT_PUMP_WINDOW: Duration = super::framing::IO_TIMEOUT;
218
219/// Typed result of one decoded participant frame on the real receive path.
220#[derive(Debug)]
221pub enum RemoteParticipantInbound {
222    /// The protocol crate correlated and applied a semantic response.
223    Applied {
224        /// Exact applied server value.
225        value: ServerValue,
226        /// Connection/attempt that delivered it.
227        provenance: ParticipantResponseProvenance,
228    },
229    /// The protocol crate retained the response and aggregate unchanged.
230    Refused {
231        /// Exact refused server value.
232        value: ServerValue,
233        /// Closed crate refusal reason, including conservative ambiguity.
234        reason: ClientInboundRefusalReason,
235        /// Connection/attempt that delivered it.
236        provenance: ParticipantResponseProvenance,
237    },
238    /// Server push decoded in the client direction; no correlation rule applies.
239    Push {
240        /// Exact pushed value.
241        value: ServerPush,
242        /// Connection/attempt that delivered it.
243        provenance: ParticipantResponseProvenance,
244    },
245}
246
247impl RemoteParticipantInbound {
248    /// The record sequence the server assigned an admitted record, when this
249    /// inbound is an APPLIED [`ServerValue::RecordCommitted`].
250    ///
251    /// This is the answer to a `RecordAdmission`, read off the exact wire value
252    /// the protocol crate applied. It saves every caller destructuring the wire
253    /// enum to reach the one number a record admission is asked for, without
254    /// removing that value: [`Applied`](Self::Applied) still carries the whole
255    /// `ServerValue`, so this is purely additive.
256    ///
257    /// `None` for everything else, and that includes a `RecordCommitted` the
258    /// crate REFUSED. A refused commit carries a sequence on the wire while
259    /// leaving the aggregate and its correlation untouched -- returning it here
260    /// would report a commitment the crate deliberately declined to make. It is
261    /// also `None` for a [`Push`](Self::Push), which is a delivery rather than
262    /// a correlated response.
263    #[must_use]
264    pub const fn committed_delivery_seq(&self) -> Option<DeliverySeq> {
265        match self {
266            Self::Applied {
267                value: ServerValue::RecordCommitted(committed),
268                ..
269            } => Some(committed.delivery_seq()),
270            Self::Applied { .. } | Self::Refused { .. } | Self::Push { .. } => None,
271        }
272    }
273}
274
275pub(super) struct RemoteParticipantState<S> {
276    pub(super) aggregate: Option<ClientParticipantAggregate>,
277    pub(super) correlation: Option<ClientResponseCorrelation>,
278    pub(super) reconnect_attempt: Option<liminal_protocol::client::ReconnectInProgressAttempt>,
279    pub(super) store: S,
280}
281
282/// Remote participant entrypoint backed by protocol-crate state and canonical LPCR storage.
283///
284/// Records are deliberately not promised as generally successful: the reduced-B1
285/// server surface fails fully authorized `RecordAdmission` and `Leave` closed until
286/// live claim-frontier acquisition lands (`docs/design/LP-GAP-CLOSURE-GOAL.md:145`).
287pub struct RemoteParticipantHandle<S> {
288    pub(super) server_address: ServerAddress,
289    pub(super) transport: Arc<dyn RemoteTransport>,
290    pub(super) state: Mutex<RemoteParticipantState<S>>,
291}
292
293impl<S> fmt::Debug for RemoteParticipantHandle<S> {
294    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
295        formatter
296            .debug_struct("RemoteParticipantHandle")
297            .field("server_address", &self.server_address)
298            .finish_non_exhaustive()
299    }
300}
301
302impl<S: ParticipantResumeStore> RemoteParticipantHandle<S> {
303    /// Creates and durably checkpoints a fresh unbound participant.
304    ///
305    /// # Errors
306    ///
307    /// Returns a typed encode or storage error before the handle is exposed.
308    pub fn new(config: &RemoteConfig, store: S) -> Result<Self, RemoteParticipantError> {
309        Self::from_aggregate(config, store, ClientParticipantAggregate::new())
310    }
311
312    /// Decodes, validates, restores, and durably records crash testimony before exposure.
313    ///
314    /// # Errors
315    ///
316    /// Returns typed LPCR decode/restore, encode, or storage errors.
317    pub fn restore(
318        config: &RemoteConfig,
319        store: S,
320        canonical_lpcr: &[u8],
321    ) -> Result<Self, RemoteParticipantError> {
322        let record = ClientResumeRecord::decode_canonical(canonical_lpcr)
323            .map_err(RemoteParticipantError::ResumeDecode)?;
324        let aggregate = record
325            .restore()
326            .map_err(RemoteParticipantError::ResumeRestore)?;
327        Self::from_aggregate(config, store, aggregate)
328    }
329
330    fn from_aggregate(
331        config: &RemoteConfig,
332        mut store: S,
333        aggregate: ClientParticipantAggregate,
334    ) -> Result<Self, RemoteParticipantError> {
335        persist(&mut store, &aggregate)?;
336        Ok(Self {
337            server_address: config.server_address.clone(),
338            transport: Arc::clone(&config.transport),
339            state: Mutex::new(RemoteParticipantState {
340                aggregate: Some(aggregate),
341                correlation: None,
342                reconnect_attempt: None,
343                store,
344            }),
345        })
346    }
347
348    /// Runs `record_operation -> commit -> LPCR persist -> into_parts` exactly.
349    ///
350    /// # Errors
351    ///
352    /// Returns typed resume encoding or storage failures. A failed post-commit
353    /// persistence leaves the handle unavailable and releases no authority.
354    pub fn record_operation(
355        &self,
356        request: ClientRequest,
357    ) -> Result<RemoteOperationRecordOutcome, RemoteParticipantError> {
358        let mut state = self.state.lock();
359        let aggregate = take_aggregate(&mut state)?;
360        match liminal_protocol::client::record_operation(aggregate, request) {
361            ClientOperationRecordDecision::Pending(pending) => {
362                let commit = pending.commit();
363                let record = commit
364                    .resume_record()
365                    .map_err(RemoteParticipantError::ResumeEncode)?;
366                state
367                    .store
368                    .persist(&record.encode_canonical())
369                    .map_err(RemoteParticipantError::Storage)?;
370                let (aggregate, operation) = commit.into_parts();
371                state.aggregate = Some(aggregate);
372                Ok(RemoteOperationRecordOutcome::Recorded(
373                    RemoteParticipantOperation {
374                        operation,
375                        durability: OperationDurability::WriteAhead,
376                    },
377                ))
378            }
379            ClientOperationRecordDecision::Continuous(continuous) => {
380                let (aggregate, operation) = continuous.into_parts();
381                state.aggregate = Some(aggregate);
382                Ok(RemoteOperationRecordOutcome::Continuous(
383                    RemoteParticipantOperation {
384                        operation,
385                        durability: OperationDurability::Continuous,
386                    },
387                ))
388            }
389            ClientOperationRecordDecision::Refused(refusal) => {
390                let reason = refusal.reason();
391                let (aggregate, request) = refusal.into_parts();
392                state.aggregate = Some(aggregate);
393                Ok(RemoteOperationRecordOutcome::Refused { request, reason })
394            }
395        }
396    }
397
398    /// Persists issued state, writes the exact operation, and retains correlation.
399    ///
400    /// # Errors
401    ///
402    /// Returns typed state, LPCR, or storage failures. Transport failures are
403    /// returned as a typed outcome after crate fate delegation.
404    pub fn send_operation(
405        &self,
406        operation: RemoteParticipantOperation,
407    ) -> Result<RemoteParticipantSendOutcome, RemoteParticipantError> {
408        let mut state = self.state.lock();
409        let mut aggregate = take_aggregate(&mut state)?;
410        if operation.durability == OperationDurability::WriteAhead {
411            aggregate = persist_retaining(&mut state, aggregate)?;
412        }
413        let (request, correlation) = operation.operation.into_request();
414        match self
415            .transport
416            .send_participant(&self.server_address, &request)
417        {
418            Ok(provenance) => {
419                if operation.durability == OperationDurability::WriteAhead {
420                    state.correlation = Some(correlation);
421                }
422                state.aggregate = Some(aggregate);
423                Ok(RemoteParticipantSendOutcome::Sent { provenance })
424            }
425            Err(error) => {
426                let operation_fate = if operation.durability == OperationDurability::WriteAhead {
427                    record_operation_transport_fate(&mut state, aggregate, correlation)
428                } else {
429                    state.aggregate = Some(aggregate);
430                    RemoteOperationTransportFate::NotOutstanding
431                };
432                let reconnect = record_connection_fate(&mut state)?;
433                Ok(RemoteParticipantSendOutcome::TransportLost {
434                    error,
435                    operation_fate,
436                    reconnect,
437                })
438            }
439        }
440    }
441
442    /// Receives one real participant frame and delegates every `ServerValue` to the crate.
443    ///
444    /// # The contract, and why it is this one
445    ///
446    /// THIS IS THE REPLY-OWED DOOR. It blocks for up to the transport's full
447    /// response deadline (60 s), because the caller it is written for has just
448    /// sent a request and is waiting for the correlated answer — and there, a
449    /// quiet connection means a slow server, not a dead one. Ending that wait
450    /// early is the 2026-08-10 outage's client-side mechanism, so this method
451    /// keeps the deadline unchanged and unconditionally.
452    ///
453    /// A consumer PUMPING an idle connection — looping to collect whatever the
454    /// server pushes next, where silence is a normal state rather than a fault
455    /// — must use [`receive_within`](Self::receive_within) or
456    /// [`try_receive`](Self::try_receive) instead. That is not a preference: a
457    /// drain loop built on this method waits out the full deadline on every
458    /// quiet read, which is how a 30 s boot gate blows on a healthy server.
459    ///
460    /// The split is by CALLER INTENT and cannot be anything else. At a clean
461    /// frame boundary with an empty buffer, a pump read and an outage-shaped
462    /// reply-owed read are byte-for-byte identical states; only the caller
463    /// knows which one it is making, so only the caller's choice of method can
464    /// carry it. Inferring it from buffered bytes, or from whether an operation
465    /// is outstanding, would silently shorten the deadline for some class of
466    /// genuinely reply-owed read and re-open the outage for it.
467    ///
468    /// Pushed deliveries are at-least-once: the same
469    /// `(conversation_id, delivery_seq)` may arrive more than once on one
470    /// healthy connection, byte-identical each time — deduplicate on the pair
471    /// (participant contract R-C3, amendment A3).
472    ///
473    /// # Errors
474    ///
475    /// Returns transport, direction, LPCR encoding, or storage failures.
476    pub fn receive(&self) -> Result<RemoteParticipantInbound, RemoteParticipantError> {
477        let frame = self
478            .transport
479            .receive_participant(&self.server_address)
480            .map_err(RemoteParticipantError::Transport)?;
481        self.classify_inbound(frame)
482    }
483
484    /// Receives one participant frame if one arrives within `budget`, reporting
485    /// a quiet connection as `Ok(None)` instead of an error.
486    ///
487    /// THE PUMP DOOR — the lawful read for a consumer that is owed nothing.
488    /// `Ok(None)` means "no frame within this window", which is a normal state
489    /// on a healthy connection and never a fault; a real transport failure
490    /// still returns `Err`, and a quiet window never surfaces a raw errno.
491    ///
492    /// `budget` is the CALLER'S bound and is spent across as many transport
493    /// read windows as it takes. It never shortens anything else: a caller that
494    /// uses this method to await a correlated answer simply names its own
495    /// deadline, and passing one at or above the transport's 60 s response
496    /// deadline reproduces [`receive`](Self::receive)'s patience with a typed
497    /// silence at the end instead of an error.
498    ///
499    /// `Duration::ZERO` polls only what has already decoded, without arming a
500    /// read. Bytes of a partly-arrived frame stay buffered across an
501    /// `Ok(None)`, so a frame that was mid-flight when the budget expired is
502    /// never lost — the next call resumes on it.
503    ///
504    /// # Errors
505    ///
506    /// Returns transport, direction, LPCR encoding, or storage failures. A
507    /// quiet window is NOT one of them.
508    pub fn receive_within(
509        &self,
510        budget: Duration,
511    ) -> Result<Option<RemoteParticipantInbound>, RemoteParticipantError> {
512        let Some(frame) = self
513            .transport
514            .receive_participant_within(&self.server_address, budget)
515            .map_err(RemoteParticipantError::Transport)?
516        else {
517            return Ok(None);
518        };
519        self.classify_inbound(frame).map(Some)
520    }
521
522    /// One [`PARTICIPANT_PUMP_WINDOW`] of patience, then `Ok(None)`.
523    ///
524    /// The drain-loop convenience over [`receive_within`](Self::receive_within):
525    /// a consumer that wants "give me the next frame, or tell me the connection
526    /// is quiet" without choosing a number. Loop it until it answers `Ok(None)`
527    /// and the backlog is drained.
528    ///
529    /// # Errors
530    ///
531    /// As [`receive_within`](Self::receive_within).
532    pub fn try_receive(&self) -> Result<Option<RemoteParticipantInbound>, RemoteParticipantError> {
533        self.receive_within(PARTICIPANT_PUMP_WINDOW)
534    }
535
536    /// Routes one decoded transport frame into the crate's inbound decisions.
537    ///
538    /// Shared by [`receive`](Self::receive) and
539    /// [`receive_within`](Self::receive_within) so the two doors differ ONLY in
540    /// how long they wait for a frame. Every correlation, application, and
541    /// refusal rule below is reached identically by both — a pump read that
542    /// does find a frame applies it exactly as a reply-owed read would.
543    fn classify_inbound(
544        &self,
545        frame: ParticipantTransportFrame,
546    ) -> Result<RemoteParticipantInbound, RemoteParticipantError> {
547        let ParticipantTransportFrame { frame, provenance } = frame;
548        match frame {
549            ParticipantFrame::ServerPush(value) => {
550                Ok(RemoteParticipantInbound::Push { value, provenance })
551            }
552            ParticipantFrame::ClientRequest(_) => {
553                Err(RemoteParticipantError::InvalidInboundDirection)
554            }
555            ParticipantFrame::ServerValue(value) => self.apply_inbound(value, provenance),
556        }
557    }
558
559    fn apply_inbound(
560        &self,
561        value: ServerValue,
562        provenance: ParticipantResponseProvenance,
563    ) -> Result<RemoteParticipantInbound, RemoteParticipantError> {
564        let mut state = self.state.lock();
565        let aggregate = take_aggregate(&mut state)?;
566        if let Some(correlation) = state.correlation.take() {
567            match decide_correlated_inbound(aggregate, value, correlation) {
568                ClientCorrelatedInboundDecision::Applied(applied) => {
569                    let (aggregate, value) = applied.into_parts();
570                    let aggregate = persist_retaining(&mut state, aggregate)?;
571                    state.aggregate = Some(aggregate);
572                    Ok(RemoteParticipantInbound::Applied { value, provenance })
573                }
574                ClientCorrelatedInboundDecision::Refused(refusal) => {
575                    let reason = refusal.reason();
576                    let (aggregate, value, correlation) = refusal.into_parts();
577                    state.aggregate = Some(aggregate);
578                    state.correlation = Some(correlation);
579                    Ok(RemoteParticipantInbound::Refused {
580                        value,
581                        reason,
582                        provenance,
583                    })
584                }
585            }
586        } else {
587            match decide_inbound(aggregate, value) {
588                ClientInboundDecision::Applied(applied) => {
589                    let (aggregate, value) = applied.into_parts();
590                    let aggregate = persist_retaining(&mut state, aggregate)?;
591                    state.aggregate = Some(aggregate);
592                    Ok(RemoteParticipantInbound::Applied { value, provenance })
593                }
594                ClientInboundDecision::Refused(refusal) => {
595                    let reason = refusal.reason();
596                    let (aggregate, value) = refusal.into_parts();
597                    state.aggregate = Some(aggregate);
598                    Ok(RemoteParticipantInbound::Refused {
599                        value,
600                        reason,
601                        provenance,
602                    })
603                }
604            }
605        }
606    }
607}
608
609pub(super) fn take_aggregate<S>(
610    state: &mut RemoteParticipantState<S>,
611) -> Result<ClientParticipantAggregate, RemoteParticipantError> {
612    state
613        .aggregate
614        .take()
615        .ok_or(RemoteParticipantError::StateUnavailable)
616}
617
618pub(super) fn persist<S: ParticipantResumeStore>(
619    store: &mut S,
620    aggregate: &ClientParticipantAggregate,
621) -> Result<(), RemoteParticipantError> {
622    let record = aggregate
623        .resume_record()
624        .map_err(RemoteParticipantError::ResumeEncode)?;
625    store
626        .persist(&record.encode_canonical())
627        .map_err(RemoteParticipantError::Storage)
628}
629
630/// Persists `aggregate`, returning it to the caller on success and RE-SEATING it
631/// in `state` when encoding refused.
632///
633/// The two failure modes are not alike, and the difference is durability
634/// ambiguity:
635///
636/// * [`RemoteParticipantError::Storage`] means the store was asked to commit
637///   bytes and did not say it succeeded. Whether those bytes landed is unknown,
638///   so no further authority may be released from this aggregate. The handle
639///   is deliberately left bricked and the next call reports
640///   [`StateUnavailable`](RemoteParticipantError::StateUnavailable) -- the
641///   contract documented on that variant.
642/// * [`RemoteParticipantError::ResumeEncode`] means `resume_record`, a pure
643///   function of the aggregate, refused. Nothing was written and no authority
644///   was released, so there is nothing ambiguous to protect. Dropping the
645///   aggregate here converts a typed, catchable refusal into a permanently dead
646///   participant.
647///
648/// The re-seat lives in this function rather than at each seam on purpose: the
649/// SDK has ten `take_aggregate` -> persist -> re-seat sites, the `?` skips the
650/// re-seat at every one of them, and a new seam would inherit the same bug.
651/// Here it cannot be forgotten.
652///
653/// A caller that still needs the aggregate takes it back from the `Ok`; a
654/// caller that does not re-seats it itself. On the `ResumeEncode` path the
655/// aggregate is already seated, so callers must not seat it again.
656pub(super) fn persist_retaining<S: ParticipantResumeStore>(
657    state: &mut RemoteParticipantState<S>,
658    aggregate: ClientParticipantAggregate,
659) -> Result<ClientParticipantAggregate, RemoteParticipantError> {
660    match aggregate.resume_record() {
661        Ok(record) => state
662            .store
663            .persist(&record.encode_canonical())
664            .map(|()| aggregate)
665            .map_err(RemoteParticipantError::Storage),
666        Err(error) => {
667            state.aggregate = Some(aggregate);
668            Err(RemoteParticipantError::ResumeEncode(error))
669        }
670    }
671}
672
673fn record_operation_transport_fate<S: ParticipantResumeStore>(
674    state: &mut RemoteParticipantState<S>,
675    aggregate: ClientParticipantAggregate,
676    correlation: ClientResponseCorrelation,
677) -> RemoteOperationTransportFate {
678    match record_expected_operation_fate(
679        aggregate,
680        correlation,
681        ExpectedOperationTransportFate::ResponseUnavailable,
682    ) {
683        liminal_protocol::client::ExpectedOperationFateDecision::Recorded {
684            aggregate,
685            request,
686            ..
687        } => {
688            state.aggregate = Some(aggregate);
689            RemoteOperationTransportFate::Recorded { request }
690        }
691        liminal_protocol::client::ExpectedOperationFateDecision::Refused {
692            aggregate,
693            correlation,
694            reason: ExpectedOperationFateRefusalReason::DetachUsesReplayFate,
695            ..
696        } => match liminal_protocol::client::transport_fate(
697            aggregate,
698            correlation,
699            liminal_protocol::client::DetachTransportFate::ResponseUnavailable,
700        ) {
701            liminal_protocol::client::DetachTransportFateDecision::Parked(applied) => {
702                state.aggregate = Some(applied.into_aggregate());
703                RemoteOperationTransportFate::DetachParked
704            }
705            liminal_protocol::client::DetachTransportFateDecision::Refused(refusal) => {
706                let (aggregate, (correlation, _)) = refusal.into_parts();
707                state.aggregate = Some(aggregate);
708                state.correlation = Some(correlation);
709                RemoteOperationTransportFate::Refused {
710                    reason: ExpectedOperationFateRefusalReason::DetachUsesReplayFate,
711                }
712            }
713        },
714        liminal_protocol::client::ExpectedOperationFateDecision::Refused {
715            aggregate,
716            correlation,
717            reason,
718            ..
719        } => {
720            state.aggregate = Some(aggregate);
721            state.correlation = Some(correlation);
722            RemoteOperationTransportFate::Refused { reason }
723        }
724    }
725}
726
727pub(super) fn record_connection_fate<S: ParticipantResumeStore>(
728    state: &mut RemoteParticipantState<S>,
729) -> Result<RemoteReconnectPermitOutcome, RemoteParticipantError> {
730    let aggregate = take_aggregate(state)?;
731    let (aggregate, outcome) = match record_transport_fate(
732        aggregate,
733        liminal_protocol::client::EstablishedConnectionTransportFate::Lost,
734    ) {
735        ReconnectPermitDecision::Permitted {
736            aggregate,
737            permit,
738            result,
739        } => (
740            aggregate,
741            RemoteReconnectPermitOutcome::Permitted {
742                permit: RemoteReconnectPermit { permit },
743                result,
744            },
745        ),
746        ReconnectPermitDecision::Refused(refusal) => {
747            let reason = refusal.reason();
748            let result = refusal.result();
749            let (aggregate, _) = refusal.into_parts();
750            (
751                aggregate,
752                RemoteReconnectPermitOutcome::Refused { reason, result },
753            )
754        }
755    };
756    let aggregate = persist_retaining(state, aggregate)?;
757    state.aggregate = Some(aggregate);
758    Ok(outcome)
759}
760
761#[cfg(test)]
762mod tests;