Skip to main content

liminal_protocol/client/
replay.rs

1use super::{ClientParticipantAggregate, ClientResponseCorrelation};
2use crate::wire::{
3    AttachBound, DetachCommitted, DetachEnvelope, DetachInProgress, LeaveCommitted,
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}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub(super) enum DetachReplayState {
35    Empty,
36    Recorded {
37        request: DetachEnvelope,
38        status: DetachReplayStatus,
39    },
40}
41
42/// Non-cloneable owner of the exact detach replay envelope and lifecycle.
43#[derive(Debug, PartialEq, Eq)]
44pub struct SdkDetachReplayAggregate {
45    pub(super) state: DetachReplayState,
46}
47
48impl SdkDetachReplayAggregate {
49    pub(super) const fn new() -> Self {
50        Self {
51            state: DetachReplayState::Empty,
52        }
53    }
54
55    /// Borrows the exact retained detach request, absent only before first record.
56    #[must_use]
57    pub const fn request(&self) -> Option<&DetachEnvelope> {
58        match &self.state {
59            DetachReplayState::Empty => None,
60            DetachReplayState::Recorded { request, .. } => Some(request),
61        }
62    }
63
64    /// Borrows the lossless replay status, absent only before first record.
65    #[must_use]
66    pub const fn status(&self) -> Option<&DetachReplayStatus> {
67        match &self.state {
68            DetachReplayState::Empty => None,
69            DetachReplayState::Recorded { status, .. } => Some(status),
70        }
71    }
72
73    pub(super) const fn mark_initial_attempt_started(&mut self) {
74        if let DetachReplayState::Recorded { status, .. } = &mut self.state {
75            if matches!(status, DetachReplayStatus::Parked) {
76                *status = DetachReplayStatus::InFlight;
77            }
78        }
79    }
80
81    pub(super) fn can_replace_with(&self, request: &DetachEnvelope) -> bool {
82        match &self.state {
83            DetachReplayState::Recorded {
84                request: retained,
85                status,
86            } => {
87                retained.conversation_id == request.conversation_id
88                    && retained.participant_id == request.participant_id
89                    && request.capability_generation > retained.capability_generation
90                    && matches!(
91                        status,
92                        DetachReplayStatus::Superseded | DetachReplayStatus::Terminal(_)
93                    )
94            }
95            DetachReplayState::Empty => false,
96        }
97    }
98
99    pub(super) fn apply_attach(&mut self, attach: &AttachBound) -> bool {
100        let DetachReplayState::Recorded { request, status } = &mut self.state else {
101            return false;
102        };
103        if attach.conversation_id() == request.conversation_id
104            && attach.participant_id() == request.participant_id
105            && attach.request_generation() == request.capability_generation
106            && attach.capability_generation() > request.capability_generation
107        {
108            *status = DetachReplayStatus::Superseded;
109            true
110        } else {
111            false
112        }
113    }
114
115    pub(super) fn apply_leave(&mut self, leave: &LeaveCommitted) -> bool {
116        let DetachReplayState::Recorded { request, status } = &mut self.state else {
117            return false;
118        };
119        if leave.conversation_id() == request.conversation_id
120            && leave.participant_id() == request.participant_id
121            && leave.presented_generation() == request.capability_generation
122        {
123            *status = DetachReplayStatus::LeaveSuperseded;
124            true
125        } else {
126            false
127        }
128    }
129
130    pub(super) fn apply_retired(
131        &mut self,
132        conversation_id: u64,
133        participant_id: u64,
134        retired_generation: crate::wire::Generation,
135    ) -> bool {
136        let DetachReplayState::Recorded { request, status } = &mut self.state else {
137            return false;
138        };
139        if request.conversation_id == conversation_id
140            && request.participant_id == participant_id
141            && retired_generation >= request.capability_generation
142        {
143            *status = DetachReplayStatus::LeaveSuperseded;
144            true
145        } else {
146            false
147        }
148    }
149
150    pub(super) fn apply_detach_committed(&mut self, value: &DetachCommitted) -> bool {
151        let DetachReplayState::Recorded { request, status } = &mut self.state else {
152            return false;
153        };
154        if detach_committed_matches(request, value) {
155            *status =
156                DetachReplayStatus::Terminal(DetachReplayTerminal::DetachCommitted(value.clone()));
157            true
158        } else {
159            false
160        }
161    }
162
163    pub(super) fn apply_detach_in_progress(&mut self, value: &DetachInProgress) -> bool {
164        let DetachReplayState::Recorded { request, status } = &mut self.state else {
165            return false;
166        };
167        if detach_in_progress_matches(request, value) {
168            *status =
169                DetachReplayStatus::Terminal(DetachReplayTerminal::DetachInProgress(value.clone()));
170            true
171        } else {
172            false
173        }
174    }
175
176    pub(super) fn apply_terminalized_detach_cell(
177        &mut self,
178        value: &TerminalizedDetachCell,
179    ) -> bool {
180        let DetachReplayState::Recorded { request, status } = &mut self.state else {
181            return false;
182        };
183        if terminalized_matches(request, value) {
184            *status = DetachReplayStatus::Terminal(DetachReplayTerminal::TerminalizedDetachCell(
185                value.clone(),
186            ));
187            true
188        } else {
189            false
190        }
191    }
192}
193
194/// Reason a detach replay input was refused unchanged.
195#[derive(Clone, Copy, Debug, PartialEq, Eq)]
196pub enum DetachReplayRefusalReason {
197    /// Replay is already active and cannot be silently replaced.
198    AlreadyRecorded,
199    /// The requested transition is not legal in the current replay status.
200    InvalidStatus,
201    /// The typed input does not match the retained exact detach request.
202    ForeignInput,
203    /// A restore already testified the in-flight send authority destroyed;
204    /// only the pending testimony can resolve it (r2, 2026-07-18).
205    LostAuthorityPending,
206}
207
208/// Applied detach replay transition.
209#[derive(Debug, PartialEq, Eq)]
210pub struct DetachReplayApplied {
211    aggregate: ClientParticipantAggregate,
212}
213
214impl DetachReplayApplied {
215    /// Releases the resulting client aggregate.
216    #[must_use]
217    pub fn into_aggregate(self) -> ClientParticipantAggregate {
218        self.aggregate
219    }
220}
221
222/// Refused detach replay transition with unchanged aggregate and input.
223#[derive(Debug, PartialEq, Eq)]
224pub struct DetachReplayRefusal<T> {
225    aggregate: ClientParticipantAggregate,
226    input: T,
227    reason: DetachReplayRefusalReason,
228}
229
230impl<T> DetachReplayRefusal<T> {
231    /// Returns the closed refusal reason.
232    #[must_use]
233    pub const fn reason(&self) -> DetachReplayRefusalReason {
234        self.reason
235    }
236
237    /// Releases the unchanged aggregate and refused typed input.
238    #[must_use]
239    pub fn into_parts(self) -> (ClientParticipantAggregate, T) {
240        (self.aggregate, self.input)
241    }
242}
243
244/// Sealed effect authorizing one transport send of the exact detach.
245#[derive(Debug, PartialEq, Eq)]
246pub struct DetachTransportAttempt {
247    request: DetachEnvelope,
248    authorization: u64,
249}
250
251impl DetachTransportAttempt {
252    /// Borrows the exact detach to send.
253    #[must_use]
254    pub const fn request(&self) -> &DetachEnvelope {
255        &self.request
256    }
257
258    /// Consumes this one-use send effect into the exact wire envelope and its
259    /// lifecycle correlation. The correlation must be consumed by outcome,
260    /// transport fate, or typed abandonment before another attempt can start.
261    #[must_use]
262    pub const fn into_request(self) -> (DetachEnvelope, ClientResponseCorrelation) {
263        (
264            self.request,
265            ClientResponseCorrelation {
266                authorization: self.authorization,
267            },
268        )
269    }
270}
271
272/// Decision for starting a detach transport attempt.
273#[derive(Debug, PartialEq, Eq)]
274pub enum DetachTransportAttemptDecision {
275    /// Replay moved from parked to in-flight and released one send effect.
276    Started {
277        /// Resulting aggregate.
278        aggregate: ClientParticipantAggregate,
279        /// One-use exact send effect.
280        attempt: DetachTransportAttempt,
281    },
282    /// Replay stayed unchanged.
283    Refused(DetachReplayRefusal<()>),
284}
285
286/// Moves a parked detach to in-flight and releases its exact send effect.
287///
288/// If the matching committed expected detach was still unissued, this path
289/// atomically marks it issued. Consequently the inverse restore order
290/// (`transport_attempt_started` before `recover_expected_operation`) cannot
291/// release a second initial-send authority.
292#[must_use]
293pub fn transport_attempt_started(
294    mut aggregate: ClientParticipantAggregate,
295) -> DetachTransportAttemptDecision {
296    let request = match &aggregate.detach_replay.state {
297        DetachReplayState::Recorded {
298            request,
299            status: DetachReplayStatus::Parked,
300        } => request.clone(),
301        DetachReplayState::Empty | DetachReplayState::Recorded { .. } => {
302            return DetachTransportAttemptDecision::Refused(DetachReplayRefusal {
303                aggregate,
304                input: (),
305                reason: DetachReplayRefusalReason::InvalidStatus,
306            });
307        }
308    };
309    let expected_matches = aggregate.expected.as_ref().is_some_and(|expected| {
310        expected.authorization != 0
311            && matches!(&expected.request, crate::wire::ClientRequest::Detach(value)
312            if value.conversation_id == request.conversation_id
313                && value.participant_id == request.participant_id
314                && value.capability_generation == request.capability_generation
315                && value.detach_attempt_token == request.detach_attempt_token)
316    });
317    if !expected_matches {
318        return DetachTransportAttemptDecision::Refused(DetachReplayRefusal {
319            aggregate,
320            input: (),
321            reason: DetachReplayRefusalReason::InvalidStatus,
322        });
323    }
324    let authorization = aggregate
325        .expected
326        .as_ref()
327        .map_or(0, |expected| expected.authorization);
328    if let Some(expected) = aggregate.expected.as_mut() {
329        expected.issued = true;
330    }
331    if let DetachReplayState::Recorded { status, .. } = &mut aggregate.detach_replay.state {
332        *status = DetachReplayStatus::InFlight;
333    }
334    DetachTransportAttemptDecision::Started {
335        aggregate,
336        attempt: DetachTransportAttempt {
337            request,
338            authorization,
339        },
340    }
341}
342
343/// Typed transport fate for an outstanding detach send.
344#[derive(Clone, Copy, Debug, PartialEq, Eq)]
345pub enum DetachTransportFate {
346    /// The transport failed before a semantic response was obtained.
347    ResponseUnavailable,
348}
349
350/// Decision for returning an in-flight detach to parked replay.
351#[derive(Debug, PartialEq, Eq)]
352pub enum DetachTransportFateDecision {
353    /// Typed fate consumed the exact send authority and parked replay.
354    Parked(DetachReplayApplied),
355    /// No matching in-flight attempt existed; state, authority, and fate are retained.
356    Refused(DetachReplayRefusal<(ClientResponseCorrelation, DetachTransportFate)>),
357}
358
359/// Consumes the outstanding send authority with typed transport fate.
360///
361/// This is the live-process `InFlight -> Parked` path. It marks the matching
362/// expected detach unissued before allowing another attempt, so the consumed
363/// correlation and a replacement effect can never coexist.
364#[must_use]
365pub fn transport_fate(
366    mut aggregate: ClientParticipantAggregate,
367    correlation: ClientResponseCorrelation,
368    fate: DetachTransportFate,
369) -> DetachTransportFateDecision {
370    if aggregate.operation_loss_pending() {
371        return DetachTransportFateDecision::Refused(DetachReplayRefusal {
372            aggregate,
373            input: (correlation, fate),
374            reason: DetachReplayRefusalReason::LostAuthorityPending,
375        });
376    }
377    if detach_authority_matches(&aggregate, &correlation) {
378        if let DetachReplayState::Recorded { status, .. } = &mut aggregate.detach_replay.state {
379            *status = DetachReplayStatus::Parked;
380        }
381        if let Some(expected) = aggregate.expected.as_mut() {
382            expected.issued = false;
383        }
384        return DetachTransportFateDecision::Parked(DetachReplayApplied { aggregate });
385    }
386    DetachTransportFateDecision::Refused(DetachReplayRefusal {
387        aggregate,
388        input: (correlation, fate),
389        reason: DetachReplayRefusalReason::InvalidStatus,
390    })
391}
392
393/// Decision for applying a matching newer attach to replay.
394#[derive(Debug, PartialEq, Eq)]
395pub enum ApplyAttachDecision {
396    /// Matching attach superseded the old detach.
397    Superseded(DetachReplayApplied),
398    /// Non-matching attach was retained and replay state stayed exact.
399    Refused(DetachReplayRefusal<(AttachBound, ClientResponseCorrelation)>),
400}
401
402/// Applies attach supersession without treating attach as transport fate.
403#[must_use]
404pub fn apply_attach(
405    mut aggregate: ClientParticipantAggregate,
406    attach: AttachBound,
407    correlation: ClientResponseCorrelation,
408) -> ApplyAttachDecision {
409    if aggregate.operation_loss_pending() {
410        return ApplyAttachDecision::Refused(DetachReplayRefusal {
411            aggregate,
412            input: (attach, correlation),
413            reason: DetachReplayRefusalReason::LostAuthorityPending,
414        });
415    }
416    if detach_authority_matches(&aggregate, &correlation)
417        && aggregate.detach_replay.apply_attach(&attach)
418    {
419        aggregate.expected = None;
420        ApplyAttachDecision::Superseded(DetachReplayApplied { aggregate })
421    } else {
422        ApplyAttachDecision::Refused(DetachReplayRefusal {
423            aggregate,
424            input: (attach, correlation),
425            reason: DetachReplayRefusalReason::ForeignInput,
426        })
427    }
428}
429
430/// Decision for applying a durable Leave to replay.
431#[derive(Debug, PartialEq, Eq)]
432pub enum ApplyLeaveDecision {
433    /// Matching Leave superseded the old detach.
434    Superseded(DetachReplayApplied),
435    /// Non-matching Leave was retained with unchanged replay.
436    Refused(DetachReplayRefusal<(LeaveCommitted, ClientResponseCorrelation)>),
437}
438
439/// Applies durable Leave supersession.
440#[must_use]
441pub fn apply_leave_durable(
442    mut aggregate: ClientParticipantAggregate,
443    leave: LeaveCommitted,
444    correlation: ClientResponseCorrelation,
445) -> ApplyLeaveDecision {
446    if aggregate.operation_loss_pending() {
447        return ApplyLeaveDecision::Refused(DetachReplayRefusal {
448            aggregate,
449            input: (leave, correlation),
450            reason: DetachReplayRefusalReason::LostAuthorityPending,
451        });
452    }
453    if detach_authority_matches(&aggregate, &correlation)
454        && aggregate.detach_replay.apply_leave(&leave)
455    {
456        aggregate.expected = None;
457        ApplyLeaveDecision::Superseded(DetachReplayApplied { aggregate })
458    } else {
459        ApplyLeaveDecision::Refused(DetachReplayRefusal {
460            aggregate,
461            input: (leave, correlation),
462            reason: DetachReplayRefusalReason::ForeignInput,
463        })
464    }
465}
466
467/// Typed terminal detach outcome accepted by replay.
468#[derive(Debug, PartialEq, Eq)]
469pub enum DetachReplayOutcome {
470    /// Stable committed detach.
471    DetachCommitted(DetachCommitted),
472    /// Different token found a pending detach.
473    DetachInProgress(DetachInProgress),
474    /// Exact old token resolved to a terminalized cell.
475    TerminalizedDetachCell(TerminalizedDetachCell),
476}
477
478/// Decision for terminalizing detach replay.
479#[derive(Debug, PartialEq, Eq)]
480pub enum ApplyDetachOutcomeDecision {
481    /// Exact typed outcome terminalized replay.
482    Terminal(DetachReplayApplied),
483    /// Non-matching outcome was retained with unchanged replay.
484    Refused(DetachReplayRefusal<(DetachReplayOutcome, ClientResponseCorrelation)>),
485}
486
487/// Validates a typed detach outcome against the retained exact request.
488#[must_use]
489pub fn apply_detach_outcome(
490    mut aggregate: ClientParticipantAggregate,
491    outcome: DetachReplayOutcome,
492    correlation: ClientResponseCorrelation,
493) -> ApplyDetachOutcomeDecision {
494    if aggregate.operation_loss_pending() {
495        return ApplyDetachOutcomeDecision::Refused(DetachReplayRefusal {
496            aggregate,
497            input: (outcome, correlation),
498            reason: DetachReplayRefusalReason::LostAuthorityPending,
499        });
500    }
501    if !detach_authority_matches(&aggregate, &correlation) {
502        return ApplyDetachOutcomeDecision::Refused(DetachReplayRefusal {
503            aggregate,
504            input: (outcome, correlation),
505            reason: DetachReplayRefusalReason::InvalidStatus,
506        });
507    }
508    let applied = match &outcome {
509        DetachReplayOutcome::DetachCommitted(value) => {
510            aggregate.detach_replay.apply_detach_committed(value)
511        }
512        DetachReplayOutcome::DetachInProgress(value) => {
513            aggregate.detach_replay.apply_detach_in_progress(value)
514        }
515        DetachReplayOutcome::TerminalizedDetachCell(value) => aggregate
516            .detach_replay
517            .apply_terminalized_detach_cell(value),
518    };
519    if applied {
520        aggregate.expected = None;
521        ApplyDetachOutcomeDecision::Terminal(DetachReplayApplied { aggregate })
522    } else {
523        ApplyDetachOutcomeDecision::Refused(DetachReplayRefusal {
524            aggregate,
525            input: (outcome, correlation),
526            reason: DetachReplayRefusalReason::ForeignInput,
527        })
528    }
529}
530
531fn detach_authority_matches(
532    aggregate: &ClientParticipantAggregate,
533    correlation: &ClientResponseCorrelation,
534) -> bool {
535    let Some(expected) = aggregate.expected.as_ref() else {
536        return false;
537    };
538    if !expected.issued || expected.authorization != correlation.authorization {
539        return false;
540    }
541    let DetachReplayState::Recorded {
542        request,
543        status: DetachReplayStatus::InFlight,
544    } = &aggregate.detach_replay.state
545    else {
546        return false;
547    };
548    matches!(&expected.request, crate::wire::ClientRequest::Detach(value)
549        if value.conversation_id == request.conversation_id
550            && value.participant_id == request.participant_id
551            && value.capability_generation == request.capability_generation
552            && value.detach_attempt_token == request.detach_attempt_token)
553}
554
555fn detach_committed_matches(request: &DetachEnvelope, value: &DetachCommitted) -> bool {
556    value.conversation_id() == request.conversation_id
557        && value.participant_id() == request.participant_id
558        && value.capability_generation() == request.capability_generation
559        && value.detach_attempt_token() == request.detach_attempt_token
560}
561
562fn detach_in_progress_matches(request: &DetachEnvelope, value: &DetachInProgress) -> bool {
563    let expected_generation = request.capability_generation;
564    let presented_generation = value.presented_generation;
565    let expected_token = request.detach_attempt_token;
566    let presented_token = value.presented_token;
567    value.conversation_id == request.conversation_id
568        && value.participant_id == request.participant_id
569        && presented_generation == expected_generation
570        && presented_token == expected_token
571}
572
573fn terminalized_matches(request: &DetachEnvelope, value: &TerminalizedDetachCell) -> bool {
574    value.conversation_id() == request.conversation_id
575        && value.participant_id() == request.participant_id
576        && value.capability_generation() == request.capability_generation
577        && value.detach_attempt_token() == request.detach_attempt_token
578}