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