Skip to main content

liminal_sdk/remote/websocket/
binding.rs

1//! R2.2 typed-fate wiring: socket facts enter the client unit; policy never
2//! exits it.
3//!
4//! The binding owns one [`ClientParticipantAggregate`] and mediates every real
5//! WebSocket socket open against the client unit's sealed reconnect machinery:
6//! a socket may open only after a one-use permit has become a
7//! [`ReconnectInProgressAttempt`]; open completion is passed as
8//! [`ReconnectAttemptFate::Connected`]/[`ReconnectAttemptFate::Failed`];
9//! established loss is passed as [`EstablishedConnectionTransportFate::Lost`];
10//! and a detach-send loss is passed to the typed detach replay fate. The
11//! aggregate — never this binding, never the adapter — decides whether an
12//! attempt, replay, park, refusal, or terminal result exists. A WebSocket
13//! close code, I/O failure, or protocol violation is diagnostic data only and
14//! never selects an aggregate transition.
15
16use liminal_protocol::client::{
17    ClientParticipantAggregate, ClientResponseCorrelation, DetachReplayRefusalReason,
18    DetachTransportFate, DetachTransportFateDecision, EstablishedConnectionTransportFate,
19    ExplicitReconnectAction, ReconnectAttemptDecision, ReconnectAttemptFate,
20    ReconnectAttemptFateDecision, ReconnectAttemptFateRefusalReason, ReconnectAttemptPermit,
21    ReconnectAttemptRefusalReason, ReconnectFreshEvent, ReconnectInProgressAttempt,
22    ReconnectPermitDecision, ReconnectPermitRefusalReason, record_attempt_fate,
23    record_explicit_reconnect, record_transport_fate, redeem_attempt, transport_fate,
24};
25use liminal_protocol::outcome::ReconnectState;
26
27use super::core::TransportTerminal;
28
29/// Decision for one open request.
30#[derive(Debug, PartialEq, Eq)]
31pub enum OpenRequestDecision {
32    /// The aggregate authorized exactly one real socket open.
33    Authorized {
34        /// The fresh event that authorized this attempt.
35        event: ReconnectFreshEvent,
36    },
37    /// The open was refused typed with unchanged authority state.
38    Refused(OpenRequestRefusal),
39}
40
41/// Closed refusal classes for an open request.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum OpenRequestRefusal {
44    /// A real open is already authorized and in progress.
45    OpenAlreadyInProgress,
46    /// The aggregate refused to mint a fresh permit.
47    Permit(ReconnectPermitRefusalReason),
48    /// The aggregate refused to redeem the held permit.
49    Redemption(ReconnectAttemptRefusalReason),
50}
51
52/// Outcome of recording an open-completion socket fate.
53#[derive(Debug, PartialEq, Eq)]
54pub enum AttemptFateOutcome {
55    /// The aggregate consumed the typed fate.
56    Recorded {
57        /// Resulting reconnect state (`Online` or `Parked`).
58        state: ReconnectState,
59    },
60    /// The fate was refused typed with unchanged authority state.
61    Refused(AttemptFateRefusal),
62}
63
64/// Closed refusal classes for an open-completion fate.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum AttemptFateRefusal {
67    /// No authorized open is in progress; the fate has no attempt to consume.
68    NoOpenInProgress,
69    /// The aggregate refused the fate for the held attempt.
70    Fate(ReconnectAttemptFateRefusalReason),
71}
72
73/// Outcome of recording an established-connection loss.
74#[derive(Debug, PartialEq, Eq)]
75pub enum LossRecordOutcome {
76    /// `Lost` was recorded; the minted one-use permit is retained for the
77    /// next open request. No timer, no automatic retry.
78    PermitRetained,
79    /// The loss report was refused typed with unchanged authority state.
80    Refused(LossRecordRefusal),
81}
82
83/// Closed refusal classes for an established-loss report.
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum LossRecordRefusal {
86    /// No established connection exists at the aggregate (not `Online`).
87    NotEstablished,
88    /// The aggregate refused to mint the loss permit.
89    Permit(ReconnectPermitRefusalReason),
90}
91
92/// Outcome of reporting a detach-send transport loss.
93#[derive(Debug, PartialEq, Eq)]
94pub enum DetachLossOutcome {
95    /// The typed fate consumed the send authority and parked replay.
96    Parked,
97    /// The report was refused typed with unchanged replay state.
98    Refused(DetachReplayRefusalReason),
99}
100
101/// Socket-fact conduit into one [`ClientParticipantAggregate`].
102///
103/// The binding retains at most one un-redeemed permit (minted by an
104/// established loss) and at most one in-progress attempt (minted by an open
105/// request); both are the client unit's sealed one-use authorities, so "one
106/// real open per fresh authorization" holds by construction.
107#[derive(Debug)]
108pub struct WebSocketAuthorityBinding {
109    aggregate: ClientParticipantAggregate,
110    permit: Option<ReconnectAttemptPermit>,
111    attempt: Option<ReconnectInProgressAttempt>,
112    last_loss_diagnostic: Option<TransportTerminal>,
113}
114
115impl WebSocketAuthorityBinding {
116    /// Creates a binding over a fresh, unbound client aggregate.
117    #[must_use]
118    pub const fn new() -> Self {
119        Self::with_aggregate(ClientParticipantAggregate::new())
120    }
121
122    /// Creates a binding over an existing client aggregate (for example one
123    /// restored from a committed resume record or prepared by the caller).
124    #[must_use]
125    pub const fn with_aggregate(aggregate: ClientParticipantAggregate) -> Self {
126        Self {
127            aggregate,
128            permit: None,
129            attempt: None,
130            last_loss_diagnostic: None,
131        }
132    }
133
134    /// Borrows the owned aggregate for inspection.
135    #[must_use]
136    pub const fn aggregate(&self) -> &ClientParticipantAggregate {
137        &self.aggregate
138    }
139
140    /// Reports the aggregate's reconnect state.
141    #[must_use]
142    pub const fn reconnect_state(&self) -> ReconnectState {
143        self.aggregate.reconnect().state()
144    }
145
146    /// The diagnostic terminal recorded with the most recent established loss.
147    ///
148    /// Diagnostics only: the terminal variant never selected the transition.
149    #[must_use]
150    pub const fn last_loss_diagnostic(&self) -> Option<&TransportTerminal> {
151        self.last_loss_diagnostic.as_ref()
152    }
153
154    /// Requests authority for exactly one real socket open.
155    ///
156    /// A permit retained from an established loss is redeemed first; otherwise
157    /// the request is recorded as the explicit caller action fresh event. On
158    /// `Authorized` the caller must perform exactly one real open and then
159    /// report its completion through [`connection_established`] or
160    /// [`open_failed`].
161    ///
162    /// [`connection_established`]: Self::connection_established
163    /// [`open_failed`]: Self::open_failed
164    pub fn request_open(&mut self) -> OpenRequestDecision {
165        if self.attempt.is_some() {
166            return OpenRequestDecision::Refused(OpenRequestRefusal::OpenAlreadyInProgress);
167        }
168        let aggregate = self.take_aggregate();
169        let (aggregate, permit) = match self.permit.take() {
170            Some(retained) => (aggregate, retained),
171            None => {
172                match record_explicit_reconnect(aggregate, ExplicitReconnectAction::ReconnectNow) {
173                    ReconnectPermitDecision::Permitted {
174                        aggregate, permit, ..
175                    } => (aggregate, permit),
176                    ReconnectPermitDecision::Refused(refusal) => {
177                        let reason = refusal.reason();
178                        let (aggregate, _event) = refusal.into_parts();
179                        self.aggregate = aggregate;
180                        return OpenRequestDecision::Refused(OpenRequestRefusal::Permit(reason));
181                    }
182                }
183            }
184        };
185        match redeem_attempt(aggregate, permit) {
186            ReconnectAttemptDecision::Started { aggregate, attempt } => {
187                let event = attempt.event();
188                self.aggregate = aggregate;
189                self.attempt = Some(attempt);
190                OpenRequestDecision::Authorized { event }
191            }
192            ReconnectAttemptDecision::Refused {
193                aggregate,
194                permit,
195                reason,
196            } => {
197                self.aggregate = aggregate;
198                self.permit = Some(permit);
199                OpenRequestDecision::Refused(OpenRequestRefusal::Redemption(reason))
200            }
201        }
202    }
203
204    /// Passes the socket's successful open as the typed `Connected` fate.
205    pub fn connection_established(&mut self) -> AttemptFateOutcome {
206        self.record_open_fate(ReconnectAttemptFate::Connected)
207    }
208
209    /// Passes the socket's failed open as the typed `Failed` fate, which
210    /// parks the aggregate without minting any retry authority.
211    pub fn open_failed(&mut self) -> AttemptFateOutcome {
212        self.record_open_fate(ReconnectAttemptFate::Failed)
213    }
214
215    fn record_open_fate(&mut self, fate: ReconnectAttemptFate) -> AttemptFateOutcome {
216        let Some(attempt) = self.attempt.take() else {
217            return AttemptFateOutcome::Refused(AttemptFateRefusal::NoOpenInProgress);
218        };
219        let aggregate = self.take_aggregate();
220        match record_attempt_fate(aggregate, attempt, fate) {
221            ReconnectAttemptFateDecision::Recorded(aggregate) => {
222                let state = aggregate.reconnect().state();
223                self.aggregate = aggregate;
224                AttemptFateOutcome::Recorded { state }
225            }
226            ReconnectAttemptFateDecision::Refused {
227                aggregate,
228                attempt,
229                reason,
230                ..
231            } => {
232                self.aggregate = aggregate;
233                self.attempt = Some(attempt);
234                AttemptFateOutcome::Refused(AttemptFateRefusal::Fate(reason))
235            }
236        }
237    }
238
239    /// Passes an established-connection terminal as the typed `Lost` fate.
240    ///
241    /// `terminal` is retained as diagnostics only; every variant reaches the
242    /// identical aggregate decision. On success the minted one-use permit is
243    /// retained for the next [`request_open`] — the binding never opens on its
244    /// own and never re-arms a timer.
245    ///
246    /// [`request_open`]: Self::request_open
247    pub fn established_terminal(&mut self, terminal: &TransportTerminal) -> LossRecordOutcome {
248        if self.reconnect_state() != ReconnectState::Online {
249            return LossRecordOutcome::Refused(LossRecordRefusal::NotEstablished);
250        }
251        let aggregate = self.take_aggregate();
252        match record_transport_fate(aggregate, EstablishedConnectionTransportFate::Lost) {
253            ReconnectPermitDecision::Permitted {
254                aggregate, permit, ..
255            } => {
256                self.aggregate = aggregate;
257                self.permit = Some(permit);
258                self.last_loss_diagnostic = Some(*terminal);
259                LossRecordOutcome::PermitRetained
260            }
261            ReconnectPermitDecision::Refused(refusal) => {
262                let reason = refusal.reason();
263                let (aggregate, _event) = refusal.into_parts();
264                self.aggregate = aggregate;
265                LossRecordOutcome::Refused(LossRecordRefusal::Permit(reason))
266            }
267        }
268    }
269
270    /// Passes a detach-send transport loss to the typed detach replay fate,
271    /// consuming the outstanding send authority and parking replay.
272    pub fn detach_send_lost(
273        &mut self,
274        correlation: ClientResponseCorrelation,
275    ) -> DetachLossOutcome {
276        let aggregate = self.take_aggregate();
277        match transport_fate(
278            aggregate,
279            correlation,
280            DetachTransportFate::ResponseUnavailable,
281        ) {
282            DetachTransportFateDecision::Parked(applied) => {
283                self.aggregate = applied.into_aggregate();
284                DetachLossOutcome::Parked
285            }
286            DetachTransportFateDecision::Refused(refusal) => {
287                let reason = refusal.reason();
288                let (aggregate, _input) = refusal.into_parts();
289                self.aggregate = aggregate;
290                DetachLossOutcome::Refused(reason)
291            }
292        }
293    }
294
295    /// Takes the owned aggregate for a consuming client-unit call; every
296    /// decision arm reinstalls the returned aggregate before returning.
297    const fn take_aggregate(&mut self) -> ClientParticipantAggregate {
298        core::mem::replace(&mut self.aggregate, ClientParticipantAggregate::new())
299    }
300}
301
302impl Default for WebSocketAuthorityBinding {
303    fn default() -> Self {
304        Self::new()
305    }
306}