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