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