Skip to main content

liminal_sdk/remote/
participant.rs

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