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;