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