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