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