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