Skip to main content

liminal_protocol/client/
replay.rs

1use super::{ClientParticipantAggregate, ClientResponseCorrelation};
2use crate::wire::{
3    AttachBound, DetachCommitted, DetachEnvelope, DetachInProgress, LeaveCommitted, ServerValue,
4    TerminalizedDetachCell,
5};
6
7/// Closed, lossless detach replay status vocabulary.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum DetachReplayStatus {
10    /// The exact detach is durably parked for a transport attempt.
11    Parked,
12    /// A transport attempt is outstanding.
13    InFlight,
14    /// A matching newer attach permanently superseded the old detach.
15    Superseded,
16    /// A matching durable Leave permanently superseded the old detach.
17    LeaveSuperseded,
18    /// A typed server result terminalized replay.
19    Terminal(DetachReplayTerminal),
20}
21
22/// Typed terminal detach replay outcomes retained without projection.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum DetachReplayTerminal {
25    /// Exact committed detach result.
26    DetachCommitted(DetachCommitted),
27    /// Exact competing-pending result.
28    DetachInProgress(DetachInProgress),
29    /// Exact terminalized old-cell authority result.
30    TerminalizedDetachCell(TerminalizedDetachCell),
31    /// The server refused the replayed detach's authority without answering it.
32    AuthorityRefused(DetachAuthorityRefused),
33}
34
35/// The exact server value that refused a replayed detach's authority.
36///
37/// This is the terminal for a correlated response that named the retained
38/// detach on the wire and then declined to act on it -- a rotated generation, a
39/// dropped binding, an unknown participant, a capacity or backpressure refusal,
40/// a retirement older than the replay. None of them answers the detach, and
41/// none of them can be retried at the presented generation, so the replay is
42/// over; but the reason is not interchangeable, and a consumer choosing between
43/// re-attaching, re-enrolling, and backing off needs to tell them apart.
44///
45/// The refusing value is therefore retained WHOLE rather than classified. The
46/// canonical record already nests a wire frame for the other terminals, so
47/// losslessness here costs one tag and no new format.
48///
49/// Only the settlement path constructs this, and only from a value the crate
50/// has already correlated to the retained detach, so the pairing cannot be
51/// forged through the public API. Restore re-checks it anyway.
52/// The value is boxed so this terminal does not widen every `DetachReplayStatus`
53/// to the size of the largest server value.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct DetachAuthorityRefused {
56    pub(super) value: alloc::boxed::Box<ServerValue>,
57}
58
59impl DetachAuthorityRefused {
60    /// Borrows the exact refusing server value, retained without projection.
61    #[must_use]
62    pub const fn value(&self) -> &ServerValue {
63        &self.value
64    }
65}
66
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub(super) enum DetachReplayState {
69    Empty,
70    Recorded {
71        request: DetachEnvelope,
72        status: DetachReplayStatus,
73    },
74}
75
76/// Non-cloneable owner of the exact detach replay envelope and lifecycle.
77#[derive(Debug, PartialEq, Eq)]
78pub struct SdkDetachReplayAggregate {
79    pub(super) state: DetachReplayState,
80}
81
82impl SdkDetachReplayAggregate {
83    pub(super) const fn new() -> Self {
84        Self {
85            state: DetachReplayState::Empty,
86        }
87    }
88
89    /// Borrows the exact retained detach request, absent only before first record.
90    #[must_use]
91    pub const fn request(&self) -> Option<&DetachEnvelope> {
92        match &self.state {
93            DetachReplayState::Empty => None,
94            DetachReplayState::Recorded { request, .. } => Some(request),
95        }
96    }
97
98    /// Borrows the lossless replay status, absent only before first record.
99    #[must_use]
100    pub const fn status(&self) -> Option<&DetachReplayStatus> {
101        match &self.state {
102            DetachReplayState::Empty => None,
103            DetachReplayState::Recorded { status, .. } => Some(status),
104        }
105    }
106
107    pub(super) fn mark_initial_attempt_started(&mut self) {
108        if let DetachReplayState::Recorded { status, .. } = &mut self.state {
109            if matches!(status, DetachReplayStatus::Parked) {
110                *status = DetachReplayStatus::InFlight;
111            }
112        }
113    }
114
115    pub(super) fn can_replace_with(&self, request: &DetachEnvelope) -> bool {
116        match &self.state {
117            DetachReplayState::Recorded {
118                request: retained,
119                status,
120            } => {
121                retained.conversation_id == request.conversation_id
122                    && retained.participant_id == request.participant_id
123                    && request.capability_generation > retained.capability_generation
124                    && matches!(
125                        status,
126                        DetachReplayStatus::Superseded | DetachReplayStatus::Terminal(_)
127                    )
128            }
129            DetachReplayState::Empty => false,
130        }
131    }
132
133    pub(super) fn apply_attach(&mut self, attach: &AttachBound) -> bool {
134        let DetachReplayState::Recorded { request, status } = &mut self.state else {
135            return false;
136        };
137        // Generations are monotonic per participant, so any granted generation
138        // above the replayed detach's proves that capability is retired forever
139        // -- including a generation-SKIPPING attach whose request_generation
140        // never equaled the replay's (field 2026-08-07: a req-4-granted-5
141        // attach over a gen-3 replay must supersede, not strand it InFlight).
142        if attach.conversation_id() == request.conversation_id
143            && attach.participant_id() == request.participant_id
144            && attach.capability_generation() > request.capability_generation
145        {
146            *status = DetachReplayStatus::Superseded;
147            true
148        } else {
149            false
150        }
151    }
152
153    pub(super) fn apply_leave(&mut self, leave: &LeaveCommitted) -> bool {
154        let DetachReplayState::Recorded { request, status } = &mut self.state else {
155            return false;
156        };
157        if leave.conversation_id() == request.conversation_id
158            && leave.participant_id() == request.participant_id
159            && leave.presented_generation() == request.capability_generation
160        {
161            *status = DetachReplayStatus::LeaveSuperseded;
162            true
163        } else {
164            false
165        }
166    }
167
168    pub(super) fn apply_retired(
169        &mut self,
170        conversation_id: u64,
171        participant_id: u64,
172        retired_generation: crate::wire::Generation,
173    ) -> bool {
174        let DetachReplayState::Recorded { request, status } = &mut self.state else {
175            return false;
176        };
177        if request.conversation_id == conversation_id
178            && request.participant_id == participant_id
179            && retired_generation >= request.capability_generation
180        {
181            *status = DetachReplayStatus::LeaveSuperseded;
182            true
183        } else {
184            false
185        }
186    }
187
188    /// Settles a replay the correlated response left active, retaining the
189    /// exact value that refused it.
190    ///
191    /// `expected` names the detach whose slot is being retired in this same
192    /// statement. Only the replay holding that same detach may be settled: a
193    /// replay retaining some other detach was already decoupled before this
194    /// response arrived, and quietly terminalizing it here would launder
195    /// exactly the fault this path exists to prevent.
196    ///
197    /// Returns whether it settled anything, so the caller can assert the
198    /// post-condition rather than assume it.
199    pub(super) fn settle_refused_authority(
200        &mut self,
201        expected: &DetachEnvelope,
202        value: &ServerValue,
203    ) -> bool {
204        let DetachReplayState::Recorded { request, status } = &mut self.state else {
205            return false;
206        };
207        if !matches!(
208            status,
209            DetachReplayStatus::Parked | DetachReplayStatus::InFlight
210        ) {
211            return false;
212        }
213        if request.conversation_id != expected.conversation_id
214            || request.participant_id != expected.participant_id
215            || request.capability_generation != expected.capability_generation
216            || request.detach_attempt_token != expected.detach_attempt_token
217        {
218            return false;
219        }
220        *status = DetachReplayStatus::Terminal(DetachReplayTerminal::AuthorityRefused(
221            DetachAuthorityRefused {
222                value: alloc::boxed::Box::new(value.clone()),
223            },
224        ));
225        true
226    }
227
228    /// Whether the replay still expects a transport attempt or an answer.
229    ///
230    /// Used to assert the settlement's post-condition rather than assume it.
231    pub(super) const fn is_active(&self) -> bool {
232        matches!(
233            self.state,
234            DetachReplayState::Recorded {
235                status: DetachReplayStatus::Parked | DetachReplayStatus::InFlight,
236                ..
237            }
238        )
239    }
240
241    pub(super) fn apply_detach_committed(&mut self, value: &DetachCommitted) -> bool {
242        let DetachReplayState::Recorded { request, status } = &mut self.state else {
243            return false;
244        };
245        if detach_committed_matches(request, value) {
246            *status =
247                DetachReplayStatus::Terminal(DetachReplayTerminal::DetachCommitted(value.clone()));
248            true
249        } else {
250            false
251        }
252    }
253
254    pub(super) fn apply_detach_in_progress(&mut self, value: &DetachInProgress) -> bool {
255        let DetachReplayState::Recorded { request, status } = &mut self.state else {
256            return false;
257        };
258        if detach_in_progress_matches(request, value) {
259            *status =
260                DetachReplayStatus::Terminal(DetachReplayTerminal::DetachInProgress(value.clone()));
261            true
262        } else {
263            false
264        }
265    }
266
267    pub(super) fn apply_terminalized_detach_cell(
268        &mut self,
269        value: &TerminalizedDetachCell,
270    ) -> bool {
271        let DetachReplayState::Recorded { request, status } = &mut self.state else {
272            return false;
273        };
274        if terminalized_matches(request, value) {
275            *status = DetachReplayStatus::Terminal(DetachReplayTerminal::TerminalizedDetachCell(
276                value.clone(),
277            ));
278            true
279        } else {
280            false
281        }
282    }
283}
284
285/// Reason a detach replay input was refused unchanged.
286#[derive(Clone, Copy, Debug, PartialEq, Eq)]
287pub enum DetachReplayRefusalReason {
288    /// Replay is already active and cannot be silently replaced.
289    AlreadyRecorded,
290    /// The requested transition is not legal in the current replay status.
291    InvalidStatus,
292    /// The typed input does not match the retained exact detach request.
293    ForeignInput,
294    /// A restore already testified the in-flight send authority destroyed;
295    /// only the pending testimony can resolve it (r2, 2026-07-18).
296    LostAuthorityPending,
297}
298
299/// Applied detach replay transition.
300#[derive(Debug, PartialEq, Eq)]
301pub struct DetachReplayApplied {
302    aggregate: ClientParticipantAggregate,
303}
304
305impl DetachReplayApplied {
306    /// Releases the resulting client aggregate.
307    #[must_use]
308    pub fn into_aggregate(self) -> ClientParticipantAggregate {
309        self.aggregate
310    }
311}
312
313/// Refused detach replay transition with unchanged aggregate and input.
314#[derive(Debug, PartialEq, Eq)]
315pub struct DetachReplayRefusal<T> {
316    aggregate: ClientParticipantAggregate,
317    input: T,
318    reason: DetachReplayRefusalReason,
319}
320
321impl<T> DetachReplayRefusal<T> {
322    /// Returns the closed refusal reason.
323    #[must_use]
324    pub const fn reason(&self) -> DetachReplayRefusalReason {
325        self.reason
326    }
327
328    /// Releases the unchanged aggregate and refused typed input.
329    #[must_use]
330    pub fn into_parts(self) -> (ClientParticipantAggregate, T) {
331        (self.aggregate, self.input)
332    }
333}
334
335/// Sealed effect authorizing one transport send of the exact detach.
336#[derive(Debug, PartialEq, Eq)]
337pub struct DetachTransportAttempt {
338    request: DetachEnvelope,
339    authorization: u64,
340}
341
342impl DetachTransportAttempt {
343    /// Borrows the exact detach to send.
344    #[must_use]
345    pub const fn request(&self) -> &DetachEnvelope {
346        &self.request
347    }
348
349    /// Consumes this one-use send effect into the exact wire envelope and its
350    /// lifecycle correlation. The correlation must be consumed by outcome,
351    /// transport fate, or typed abandonment before another attempt can start.
352    #[must_use]
353    pub const fn into_request(self) -> (DetachEnvelope, ClientResponseCorrelation) {
354        (
355            self.request,
356            ClientResponseCorrelation {
357                authorization: self.authorization,
358            },
359        )
360    }
361}
362
363/// Decision for starting a detach transport attempt.
364#[derive(Debug, PartialEq, Eq)]
365pub enum DetachTransportAttemptDecision {
366    /// Replay moved from parked to in-flight and released one send effect.
367    Started {
368        /// Resulting aggregate.
369        aggregate: ClientParticipantAggregate,
370        /// One-use exact send effect.
371        attempt: DetachTransportAttempt,
372    },
373    /// Replay stayed unchanged.
374    Refused(DetachReplayRefusal<()>),
375}
376
377/// Moves a parked detach to in-flight and releases its exact send effect.
378///
379/// If the matching committed expected detach was still unissued, this path
380/// atomically marks it issued. Consequently the inverse restore order
381/// (`transport_attempt_started` before `recover_expected_operation`) cannot
382/// release a second initial-send authority.
383#[must_use]
384pub fn transport_attempt_started(
385    mut aggregate: ClientParticipantAggregate,
386) -> DetachTransportAttemptDecision {
387    let request = match &aggregate.detach_replay.state {
388        DetachReplayState::Recorded {
389            request,
390            status: DetachReplayStatus::Parked,
391        } => request.clone(),
392        DetachReplayState::Empty | DetachReplayState::Recorded { .. } => {
393            return DetachTransportAttemptDecision::Refused(DetachReplayRefusal {
394                aggregate,
395                input: (),
396                reason: DetachReplayRefusalReason::InvalidStatus,
397            });
398        }
399    };
400    let expected_matches = aggregate.expected.as_ref().is_some_and(|expected| {
401        expected.authorization != 0
402            && matches!(&expected.request, crate::wire::ClientRequest::Detach(value)
403            if value.conversation_id == request.conversation_id
404                && value.participant_id == request.participant_id
405                && value.capability_generation == request.capability_generation
406                && value.detach_attempt_token == request.detach_attempt_token)
407    });
408    if !expected_matches {
409        return DetachTransportAttemptDecision::Refused(DetachReplayRefusal {
410            aggregate,
411            input: (),
412            reason: DetachReplayRefusalReason::InvalidStatus,
413        });
414    }
415    let authorization = aggregate
416        .expected
417        .as_ref()
418        .map_or(0, |expected| expected.authorization);
419    if let Some(expected) = aggregate.expected.as_mut() {
420        expected.issued = true;
421    }
422    if let DetachReplayState::Recorded { status, .. } = &mut aggregate.detach_replay.state {
423        *status = DetachReplayStatus::InFlight;
424    }
425    DetachTransportAttemptDecision::Started {
426        aggregate,
427        attempt: DetachTransportAttempt {
428            request,
429            authorization,
430        },
431    }
432}
433
434/// Typed transport fate for an outstanding detach send.
435#[derive(Clone, Copy, Debug, PartialEq, Eq)]
436pub enum DetachTransportFate {
437    /// The transport failed before a semantic response was obtained.
438    ResponseUnavailable,
439}
440
441/// Decision for returning an in-flight detach to parked replay.
442#[derive(Debug, PartialEq, Eq)]
443pub enum DetachTransportFateDecision {
444    /// Typed fate consumed the exact send authority and parked replay.
445    Parked(DetachReplayApplied),
446    /// No matching in-flight attempt existed; state, authority, and fate are retained.
447    Refused(DetachReplayRefusal<(ClientResponseCorrelation, DetachTransportFate)>),
448}
449
450/// Consumes the outstanding send authority with typed transport fate.
451///
452/// This is the live-process `InFlight -> Parked` path. It marks the matching
453/// expected detach unissued before allowing another attempt, so the consumed
454/// correlation and a replacement effect can never coexist.
455#[must_use]
456pub fn transport_fate(
457    mut aggregate: ClientParticipantAggregate,
458    correlation: ClientResponseCorrelation,
459    fate: DetachTransportFate,
460) -> DetachTransportFateDecision {
461    if aggregate.operation_loss_pending() {
462        return DetachTransportFateDecision::Refused(DetachReplayRefusal {
463            aggregate,
464            input: (correlation, fate),
465            reason: DetachReplayRefusalReason::LostAuthorityPending,
466        });
467    }
468    if detach_authority_matches(&aggregate, &correlation) {
469        if let DetachReplayState::Recorded { status, .. } = &mut aggregate.detach_replay.state {
470            *status = DetachReplayStatus::Parked;
471        }
472        if let Some(expected) = aggregate.expected.as_mut() {
473            expected.issued = false;
474        }
475        return DetachTransportFateDecision::Parked(DetachReplayApplied { aggregate });
476    }
477    DetachTransportFateDecision::Refused(DetachReplayRefusal {
478        aggregate,
479        input: (correlation, fate),
480        reason: DetachReplayRefusalReason::InvalidStatus,
481    })
482}
483
484/// Decision for applying a matching newer attach to replay.
485#[derive(Debug, PartialEq, Eq)]
486pub enum ApplyAttachDecision {
487    /// Matching attach superseded the old detach.
488    Superseded(DetachReplayApplied),
489    /// Non-matching attach was retained and replay state stayed exact.
490    Refused(DetachReplayRefusal<(AttachBound, ClientResponseCorrelation)>),
491}
492
493/// Applies attach supersession without treating attach as transport fate.
494#[must_use]
495pub fn apply_attach(
496    mut aggregate: ClientParticipantAggregate,
497    attach: AttachBound,
498    correlation: ClientResponseCorrelation,
499) -> ApplyAttachDecision {
500    if aggregate.operation_loss_pending() {
501        return ApplyAttachDecision::Refused(DetachReplayRefusal {
502            aggregate,
503            input: (attach, correlation),
504            reason: DetachReplayRefusalReason::LostAuthorityPending,
505        });
506    }
507    if detach_authority_matches(&aggregate, &correlation)
508        && aggregate.detach_replay.apply_attach(&attach)
509    {
510        aggregate.expected = None;
511        ApplyAttachDecision::Superseded(DetachReplayApplied { aggregate })
512    } else {
513        ApplyAttachDecision::Refused(DetachReplayRefusal {
514            aggregate,
515            input: (attach, correlation),
516            reason: DetachReplayRefusalReason::ForeignInput,
517        })
518    }
519}
520
521/// Decision for applying a durable Leave to replay.
522#[derive(Debug, PartialEq, Eq)]
523pub enum ApplyLeaveDecision {
524    /// Matching Leave superseded the old detach.
525    Superseded(DetachReplayApplied),
526    /// Non-matching Leave was retained with unchanged replay.
527    Refused(DetachReplayRefusal<(LeaveCommitted, ClientResponseCorrelation)>),
528}
529
530/// Applies durable Leave supersession.
531#[must_use]
532pub fn apply_leave_durable(
533    mut aggregate: ClientParticipantAggregate,
534    leave: LeaveCommitted,
535    correlation: ClientResponseCorrelation,
536) -> ApplyLeaveDecision {
537    if aggregate.operation_loss_pending() {
538        return ApplyLeaveDecision::Refused(DetachReplayRefusal {
539            aggregate,
540            input: (leave, correlation),
541            reason: DetachReplayRefusalReason::LostAuthorityPending,
542        });
543    }
544    if detach_authority_matches(&aggregate, &correlation)
545        && aggregate.detach_replay.apply_leave(&leave)
546    {
547        aggregate.expected = None;
548        ApplyLeaveDecision::Superseded(DetachReplayApplied { aggregate })
549    } else {
550        ApplyLeaveDecision::Refused(DetachReplayRefusal {
551            aggregate,
552            input: (leave, correlation),
553            reason: DetachReplayRefusalReason::ForeignInput,
554        })
555    }
556}
557
558/// Typed terminal detach outcome accepted by replay.
559#[derive(Debug, PartialEq, Eq)]
560pub enum DetachReplayOutcome {
561    /// Stable committed detach.
562    DetachCommitted(DetachCommitted),
563    /// Different token found a pending detach.
564    DetachInProgress(DetachInProgress),
565    /// Exact old token resolved to a terminalized cell.
566    TerminalizedDetachCell(TerminalizedDetachCell),
567}
568
569/// Decision for terminalizing detach replay.
570#[derive(Debug, PartialEq, Eq)]
571pub enum ApplyDetachOutcomeDecision {
572    /// Exact typed outcome terminalized replay.
573    Terminal(DetachReplayApplied),
574    /// Non-matching outcome was retained with unchanged replay.
575    Refused(DetachReplayRefusal<(DetachReplayOutcome, ClientResponseCorrelation)>),
576}
577
578/// Validates a typed detach outcome against the retained exact request.
579#[must_use]
580pub fn apply_detach_outcome(
581    mut aggregate: ClientParticipantAggregate,
582    outcome: DetachReplayOutcome,
583    correlation: ClientResponseCorrelation,
584) -> ApplyDetachOutcomeDecision {
585    if aggregate.operation_loss_pending() {
586        return ApplyDetachOutcomeDecision::Refused(DetachReplayRefusal {
587            aggregate,
588            input: (outcome, correlation),
589            reason: DetachReplayRefusalReason::LostAuthorityPending,
590        });
591    }
592    if !detach_authority_matches(&aggregate, &correlation) {
593        return ApplyDetachOutcomeDecision::Refused(DetachReplayRefusal {
594            aggregate,
595            input: (outcome, correlation),
596            reason: DetachReplayRefusalReason::InvalidStatus,
597        });
598    }
599    let applied = match &outcome {
600        DetachReplayOutcome::DetachCommitted(value) => {
601            aggregate.detach_replay.apply_detach_committed(value)
602        }
603        DetachReplayOutcome::DetachInProgress(value) => {
604            aggregate.detach_replay.apply_detach_in_progress(value)
605        }
606        DetachReplayOutcome::TerminalizedDetachCell(value) => aggregate
607            .detach_replay
608            .apply_terminalized_detach_cell(value),
609    };
610    if applied {
611        aggregate.expected = None;
612        ApplyDetachOutcomeDecision::Terminal(DetachReplayApplied { aggregate })
613    } else {
614        ApplyDetachOutcomeDecision::Refused(DetachReplayRefusal {
615            aggregate,
616            input: (outcome, correlation),
617            reason: DetachReplayRefusalReason::ForeignInput,
618        })
619    }
620}
621
622fn detach_authority_matches(
623    aggregate: &ClientParticipantAggregate,
624    correlation: &ClientResponseCorrelation,
625) -> bool {
626    let Some(expected) = aggregate.expected.as_ref() else {
627        return false;
628    };
629    if !expected.issued || expected.authorization != correlation.authorization {
630        return false;
631    }
632    let DetachReplayState::Recorded {
633        request,
634        status: DetachReplayStatus::InFlight,
635    } = &aggregate.detach_replay.state
636    else {
637        return false;
638    };
639    matches!(&expected.request, crate::wire::ClientRequest::Detach(value)
640        if value.conversation_id == request.conversation_id
641            && value.participant_id == request.participant_id
642            && value.capability_generation == request.capability_generation
643            && value.detach_attempt_token == request.detach_attempt_token)
644}
645
646fn detach_committed_matches(request: &DetachEnvelope, value: &DetachCommitted) -> bool {
647    value.conversation_id() == request.conversation_id
648        && value.participant_id() == request.participant_id
649        && value.capability_generation() == request.capability_generation
650        && value.detach_attempt_token() == request.detach_attempt_token
651}
652
653fn detach_in_progress_matches(request: &DetachEnvelope, value: &DetachInProgress) -> bool {
654    let expected_generation = request.capability_generation;
655    let presented_generation = value.presented_generation;
656    let expected_token = request.detach_attempt_token;
657    let presented_token = value.presented_token;
658    value.conversation_id == request.conversation_id
659        && value.participant_id == request.participant_id
660        && presented_generation == expected_generation
661        && presented_token == expected_token
662}
663
664fn terminalized_matches(request: &DetachEnvelope, value: &TerminalizedDetachCell) -> bool {
665    value.conversation_id() == request.conversation_id
666        && value.participant_id() == request.participant_id
667        && value.capability_generation() == request.capability_generation
668        && value.detach_attempt_token() == request.detach_attempt_token
669}