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