Skip to main content

liminal_protocol/client/
reconnect.rs

1use super::{
2    ClientParticipantAggregate, LostAuthorityTestimony,
3    barrier::LostAuthorityResolutionRefusalReason,
4};
5use crate::outcome::{ReconnectDelayResult, ReconnectRequiredEvent, ReconnectState};
6
7/// Established-connection transport fate authorizing one reconnect attempt.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum EstablishedConnectionTransportFate {
10    /// The established transport was lost.
11    Lost,
12}
13
14/// Proved online transition authorizing one reconnect attempt.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum ProvedOnlineTransition {
17    /// Product state proved a fresh online transition.
18    ProvedOnline,
19}
20
21/// Explicit caller action authorizing one reconnect attempt.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum ExplicitReconnectAction {
24    /// The caller explicitly requested a real connection attempt.
25    ReconnectNow,
26}
27
28/// Closed fresh-event classes that may mint one reconnect permit.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum ReconnectFreshEvent {
31    /// Established connection received typed transport fate.
32    TransportFate(EstablishedConnectionTransportFate),
33    /// Product state proved an online transition.
34    OnlineTransition(ProvedOnlineTransition),
35    /// Caller explicitly requested a reconnect.
36    ExplicitCallerAction(ExplicitReconnectAction),
37}
38
39impl ReconnectFreshEvent {
40    const fn required_event(self) -> ReconnectRequiredEvent {
41        match self {
42            Self::TransportFate(_) => ReconnectRequiredEvent::TransportFate,
43            Self::OnlineTransition(_) => ReconnectRequiredEvent::OnlineTransition,
44            Self::ExplicitCallerAction(_) => ReconnectRequiredEvent::ExplicitCallerAction,
45        }
46    }
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub(super) enum ReconnectMachineState {
51    Parked,
52    Permit {
53        authorization: u64,
54        event: ReconnectFreshEvent,
55        issued: bool,
56    },
57    Attempt {
58        authorization: u64,
59        event: ReconnectFreshEvent,
60    },
61    Online,
62}
63
64/// Non-cloneable reconnect producer and single-attempt state shell.
65///
66/// The brief keeps this state inside [`ClientParticipantAggregate`], rather
67/// than exposing a fresh public constructor, so persisted authorization and the
68/// root participant facts cannot be independently recombined.
69#[derive(Debug, PartialEq, Eq)]
70pub struct ReconnectAggregate {
71    pub(super) state: ReconnectMachineState,
72    pub(super) next_authorization: u64,
73    pub(super) lost: Option<LostAuthorityTestimony>,
74}
75
76impl ReconnectAggregate {
77    pub(super) const fn new() -> Self {
78        Self {
79            state: ReconnectMachineState::Parked,
80            next_authorization: 0,
81            lost: None,
82        }
83    }
84
85    /// Reports reconnect state without exposing permit identity.
86    #[must_use]
87    pub const fn state(&self) -> ReconnectState {
88        match self.state {
89            ReconnectMachineState::Parked => ReconnectState::Parked,
90            ReconnectMachineState::Permit { .. } => ReconnectState::PermitOutstanding,
91            ReconnectMachineState::Attempt { .. } => ReconnectState::AttemptInProgress,
92            ReconnectMachineState::Online => ReconnectState::Online,
93        }
94    }
95}
96
97/// Sealed, non-cloneable authority for one real connection attempt.
98///
99/// ```compile_fail
100/// use liminal_protocol::client::ReconnectAttemptPermit;
101/// fn clone_is_forbidden(permit: ReconnectAttemptPermit) {
102///     let _duplicate = permit.clone();
103/// }
104/// ```
105///
106/// ```compile_fail
107/// use liminal_protocol::client::{
108///     ClientParticipantAggregate, ReconnectAttemptPermit, redeem_attempt,
109/// };
110/// fn moved_reuse_is_forbidden(
111///     aggregate: ClientParticipantAggregate,
112///     permit: ReconnectAttemptPermit,
113/// ) {
114///     let _started = redeem_attempt(aggregate, permit);
115///     let _reuse = permit;
116/// }
117/// ```
118#[derive(Debug, PartialEq, Eq)]
119pub struct ReconnectAttemptPermit {
120    authorization: u64,
121    event: ReconnectFreshEvent,
122}
123
124impl ReconnectAttemptPermit {
125    /// Returns the fresh event that authorized this attempt.
126    #[must_use]
127    pub const fn event(&self) -> ReconnectFreshEvent {
128        self.event
129    }
130}
131
132/// Reason a fresh event did not mint another permit.
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum ReconnectPermitRefusalReason {
135    /// A permit or attempt is already outstanding.
136    AuthorizationOutstanding,
137    /// The monotonic authorization counter is exhausted.
138    AuthorizationExhausted,
139}
140
141/// Fresh-event refusal with unchanged aggregate and retained event.
142#[derive(Debug, PartialEq, Eq)]
143pub struct ReconnectPermitRefusal {
144    aggregate: ClientParticipantAggregate,
145    event: ReconnectFreshEvent,
146    reason: ReconnectPermitRefusalReason,
147    result: ReconnectDelayResult,
148}
149
150impl ReconnectPermitRefusal {
151    /// Returns the closed refusal reason.
152    #[must_use]
153    pub const fn reason(&self) -> ReconnectPermitRefusalReason {
154        self.reason
155    }
156
157    /// Returns the total legacy-named reconnect result with no delay arm.
158    #[must_use]
159    pub const fn result(&self) -> ReconnectDelayResult {
160        self.result
161    }
162
163    /// Releases the unchanged aggregate and retained fresh event.
164    #[must_use]
165    pub fn into_parts(self) -> (ClientParticipantAggregate, ReconnectFreshEvent) {
166        (self.aggregate, self.event)
167    }
168}
169
170/// Complete fresh-event permit decision.
171#[derive(Debug, PartialEq, Eq)]
172pub enum ReconnectPermitDecision {
173    /// One fresh event minted one one-use permit.
174    Permitted {
175        /// Aggregate retaining the matching authorization.
176        aggregate: ClientParticipantAggregate,
177        /// One-use attempt permit.
178        permit: ReconnectAttemptPermit,
179        /// Total legacy-named result, now event-based and timer-free.
180        result: ReconnectDelayResult,
181    },
182    /// Existing authority was retained without mutation.
183    Refused(ReconnectPermitRefusal),
184}
185
186/// Records established-connection transport fate and mints at most one permit.
187#[must_use]
188pub const fn record_transport_fate(
189    aggregate: ClientParticipantAggregate,
190    fate: EstablishedConnectionTransportFate,
191) -> ReconnectPermitDecision {
192    record_fresh_event(aggregate, ReconnectFreshEvent::TransportFate(fate))
193}
194
195/// Records a proved online transition and mints at most one permit.
196#[must_use]
197pub const fn record_online_transition(
198    aggregate: ClientParticipantAggregate,
199    transition: ProvedOnlineTransition,
200) -> ReconnectPermitDecision {
201    record_fresh_event(aggregate, ReconnectFreshEvent::OnlineTransition(transition))
202}
203
204/// Records explicit caller action and mints at most one permit.
205#[must_use]
206pub const fn record_explicit_reconnect(
207    aggregate: ClientParticipantAggregate,
208    action: ExplicitReconnectAction,
209) -> ReconnectPermitDecision {
210    record_fresh_event(aggregate, ReconnectFreshEvent::ExplicitCallerAction(action))
211}
212
213const fn record_fresh_event(
214    mut aggregate: ClientParticipantAggregate,
215    event: ReconnectFreshEvent,
216) -> ReconnectPermitDecision {
217    if !matches!(
218        aggregate.reconnect.state,
219        ReconnectMachineState::Parked | ReconnectMachineState::Online
220    ) {
221        let state = aggregate.reconnect.state();
222        return ReconnectPermitDecision::Refused(ReconnectPermitRefusal {
223            aggregate,
224            event,
225            reason: ReconnectPermitRefusalReason::AuthorizationOutstanding,
226            result: ReconnectDelayResult::ReconnectNotArmed {
227                state,
228                required_event: event.required_event(),
229            },
230        });
231    }
232    let Some(authorization) = aggregate.reconnect.next_authorization.checked_add(1) else {
233        let state = aggregate.reconnect.state();
234        return ReconnectPermitDecision::Refused(ReconnectPermitRefusal {
235            aggregate,
236            event,
237            reason: ReconnectPermitRefusalReason::AuthorizationExhausted,
238            result: ReconnectDelayResult::ReconnectNotArmed {
239                state,
240                required_event: event.required_event(),
241            },
242        });
243    };
244    aggregate.reconnect.next_authorization = authorization;
245    aggregate.reconnect.state = ReconnectMachineState::Permit {
246        authorization,
247        event,
248        issued: true,
249    };
250    ReconnectPermitDecision::Permitted {
251        aggregate,
252        permit: ReconnectAttemptPermit {
253            authorization,
254            event,
255        },
256        result: ReconnectDelayResult::ReconnectArmed {
257            event: event.required_event(),
258        },
259    }
260}
261
262/// Decision for releasing a validated cold-restored permit exactly once.
263#[derive(Debug, PartialEq, Eq)]
264pub enum RecoveredReconnectPermitDecision {
265    /// Restore authority released one permit and marked it issued.
266    Recovered {
267        /// Resulting aggregate.
268        aggregate: ClientParticipantAggregate,
269        /// One-use restored permit.
270        permit: ReconnectAttemptPermit,
271    },
272    /// No unissued restored permit exists.
273    NotAvailable {
274        /// Unchanged aggregate.
275        aggregate: ClientParticipantAggregate,
276        /// Current reconnect state.
277        state: ReconnectState,
278    },
279}
280
281/// Releases an unissued permit created only by a committed cold record.
282///
283/// A record whose own issuance bit is true is never re-minted: restore
284/// testifies that loss instead, and only [`resolve_lost_reconnect_authority`]
285/// consumes the serialized testimony to abandon the lost process-local
286/// capability.
287#[must_use]
288pub const fn recover_reconnect_permit(
289    mut aggregate: ClientParticipantAggregate,
290) -> RecoveredReconnectPermitDecision {
291    match aggregate.reconnect.state {
292        ReconnectMachineState::Permit {
293            authorization,
294            event,
295            issued: false,
296        } => {
297            aggregate.reconnect.state = ReconnectMachineState::Permit {
298                authorization,
299                event,
300                issued: true,
301            };
302            RecoveredReconnectPermitDecision::Recovered {
303                aggregate,
304                permit: ReconnectAttemptPermit {
305                    authorization,
306                    event,
307                },
308            }
309        }
310        ReconnectMachineState::Parked
311        | ReconnectMachineState::Permit { .. }
312        | ReconnectMachineState::Attempt { .. }
313        | ReconnectMachineState::Online => {
314            let state = aggregate.reconnect.state();
315            RecoveredReconnectPermitDecision::NotAvailable { aggregate, state }
316        }
317    }
318}
319
320/// Complete decision for resolving restored reconnect-authority loss.
321///
322/// This is the sole recovery path for the reconnect-domain testimony minted by
323/// validated cold restore (`LP-CLIENT-GOAL` pieces 3 and 4, r2, 2026-07-18).
324/// It takes no fate parameter: reconnect process-fate values are not publicly
325/// constructible, so a caller can never terminalize an issued permit or
326/// in-progress attempt whose live authority still exists.
327#[derive(Debug, PartialEq, Eq)]
328pub enum LostReconnectAuthorityDecision {
329    /// The consumed testimony parked the producer without minting a
330    /// replacement; only a later fresh event authorizes a new real attempt.
331    Recorded {
332        /// Resulting aggregate parked without timer or replacement authority.
333        aggregate: ClientParticipantAggregate,
334        /// Consumed take-once testimony.
335        testimony: LostAuthorityTestimony,
336    },
337    /// No pending testimony existed; the aggregate is unchanged.
338    Refused {
339        /// Unchanged aggregate.
340        aggregate: ClientParticipantAggregate,
341        /// Closed refusal reason.
342        reason: LostAuthorityResolutionRefusalReason,
343    },
344}
345
346/// Consumes the pending reconnect-domain lost-authority testimony exactly once.
347///
348/// A second call after consumption returns a typed refusal without mutation.
349#[must_use]
350pub const fn resolve_lost_reconnect_authority(
351    mut aggregate: ClientParticipantAggregate,
352) -> LostReconnectAuthorityDecision {
353    match aggregate.reconnect.lost.take() {
354        Some(testimony) => {
355            aggregate.reconnect.state = ReconnectMachineState::Parked;
356            LostReconnectAuthorityDecision::Recorded {
357                aggregate,
358                testimony,
359            }
360        }
361        None => LostReconnectAuthorityDecision::Refused {
362            aggregate,
363            reason: LostAuthorityResolutionRefusalReason::NoPendingTestimony,
364        },
365    }
366}
367
368/// Sealed in-progress authority held while the binding opens a real connection.
369#[derive(Debug, PartialEq, Eq)]
370pub struct ReconnectInProgressAttempt {
371    authorization: u64,
372    event: ReconnectFreshEvent,
373}
374
375impl ReconnectInProgressAttempt {
376    /// Returns the fresh event authorizing the real connection attempt.
377    #[must_use]
378    pub const fn event(&self) -> ReconnectFreshEvent {
379        self.event
380    }
381}
382
383/// Reason a permit redemption was refused unchanged.
384#[derive(Clone, Copy, Debug, PartialEq, Eq)]
385pub enum ReconnectAttemptRefusalReason {
386    /// Aggregate has no matching outstanding permit.
387    NoPermit,
388    /// Permit belongs to an older or different authorization.
389    StalePermit,
390    /// A restore already testified the issued authority destroyed; only the
391    /// pending testimony can resolve it (r2, 2026-07-18).
392    LostAuthorityPending,
393}
394
395/// Complete permit redemption decision.
396#[derive(Debug, PartialEq, Eq)]
397pub enum ReconnectAttemptDecision {
398    /// Permit became a sealed in-progress attempt before transport open.
399    Started {
400        /// Resulting aggregate.
401        aggregate: ClientParticipantAggregate,
402        /// One-use in-progress attempt authority.
403        attempt: ReconnectInProgressAttempt,
404    },
405    /// Aggregate and permit were returned unchanged.
406    Refused {
407        /// Unchanged aggregate.
408        aggregate: ClientParticipantAggregate,
409        /// Retained stale or unmatched permit.
410        permit: ReconnectAttemptPermit,
411        /// Closed refusal reason.
412        reason: ReconnectAttemptRefusalReason,
413    },
414}
415
416/// Redeems one fresh permit into one in-progress real connection attempt.
417#[must_use]
418pub fn redeem_attempt(
419    mut aggregate: ClientParticipantAggregate,
420    permit: ReconnectAttemptPermit,
421) -> ReconnectAttemptDecision {
422    if aggregate.reconnect.lost.is_some() {
423        return ReconnectAttemptDecision::Refused {
424            aggregate,
425            permit,
426            reason: ReconnectAttemptRefusalReason::LostAuthorityPending,
427        };
428    }
429    match aggregate.reconnect.state {
430        ReconnectMachineState::Permit {
431            authorization,
432            event,
433            ..
434        } if authorization == permit.authorization && event == permit.event => {
435            aggregate.reconnect.state = ReconnectMachineState::Attempt {
436                authorization,
437                event,
438            };
439            ReconnectAttemptDecision::Started {
440                aggregate,
441                attempt: ReconnectInProgressAttempt {
442                    authorization,
443                    event,
444                },
445            }
446        }
447        ReconnectMachineState::Permit { .. } => ReconnectAttemptDecision::Refused {
448            aggregate,
449            permit,
450            reason: ReconnectAttemptRefusalReason::StalePermit,
451        },
452        ReconnectMachineState::Parked
453        | ReconnectMachineState::Attempt { .. }
454        | ReconnectMachineState::Online => ReconnectAttemptDecision::Refused {
455            aggregate,
456            permit,
457            reason: ReconnectAttemptRefusalReason::NoPermit,
458        },
459    }
460}
461
462/// Typed fate of one real connection attempt.
463#[derive(Clone, Copy, Debug, PartialEq, Eq)]
464pub enum ReconnectAttemptFate {
465    /// The binding proved the connection online.
466    Connected,
467    /// The real connection attempt failed and parks without timer authority.
468    Failed,
469}
470
471/// Reason an attempt fate was refused.
472#[derive(Clone, Copy, Debug, PartialEq, Eq)]
473pub enum ReconnectAttemptFateRefusalReason {
474    /// Aggregate has no matching in-progress attempt.
475    NoAttempt,
476    /// Attempt belongs to an older authorization.
477    StaleAttempt,
478    /// A restore already testified the in-progress authority destroyed; only
479    /// the pending testimony can resolve it (r2, 2026-07-18).
480    LostAuthorityPending,
481}
482
483/// Complete attempt-fate decision.
484#[derive(Debug, PartialEq, Eq)]
485pub enum ReconnectAttemptFateDecision {
486    /// Typed fate returned the aggregate to online or parked state.
487    Recorded(ClientParticipantAggregate),
488    /// Aggregate, attempt, and fate were retained unchanged.
489    Refused {
490        /// Unchanged aggregate.
491        aggregate: ClientParticipantAggregate,
492        /// Retained in-progress authority.
493        attempt: ReconnectInProgressAttempt,
494        /// Retained typed fate.
495        fate: ReconnectAttemptFate,
496        /// Closed refusal reason.
497        reason: ReconnectAttemptFateRefusalReason,
498    },
499}
500
501/// Records typed success or failure for one in-progress real attempt.
502#[must_use]
503pub fn record_attempt_fate(
504    mut aggregate: ClientParticipantAggregate,
505    attempt: ReconnectInProgressAttempt,
506    fate: ReconnectAttemptFate,
507) -> ReconnectAttemptFateDecision {
508    if aggregate.reconnect.lost.is_some() {
509        return ReconnectAttemptFateDecision::Refused {
510            aggregate,
511            attempt,
512            fate,
513            reason: ReconnectAttemptFateRefusalReason::LostAuthorityPending,
514        };
515    }
516    match aggregate.reconnect.state {
517        ReconnectMachineState::Attempt {
518            authorization,
519            event,
520        } if authorization == attempt.authorization && event == attempt.event => {
521            aggregate.reconnect.state = match fate {
522                ReconnectAttemptFate::Connected => ReconnectMachineState::Online,
523                ReconnectAttemptFate::Failed => ReconnectMachineState::Parked,
524            };
525            ReconnectAttemptFateDecision::Recorded(aggregate)
526        }
527        ReconnectMachineState::Attempt { .. } => ReconnectAttemptFateDecision::Refused {
528            aggregate,
529            attempt,
530            fate,
531            reason: ReconnectAttemptFateRefusalReason::StaleAttempt,
532        },
533        ReconnectMachineState::Parked
534        | ReconnectMachineState::Permit { .. }
535        | ReconnectMachineState::Online => ReconnectAttemptFateDecision::Refused {
536            aggregate,
537            attempt,
538            fate,
539            reason: ReconnectAttemptFateRefusalReason::NoAttempt,
540        },
541    }
542}