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