Skip to main content

liminal_protocol/client/
inbound.rs

1use super::{
2    ClientBindingState, ClientParticipantAggregate, ClientResponseCorrelation, correlation,
3};
4use crate::wire::{AttachBound, ReceiptReplay, ServerValue};
5
6/// Closed refusal classes for inbound semantic values.
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum ClientInboundRefusalReason {
9    /// A durable Leave already terminalized the local participant.
10    AlreadyDead,
11    /// The value names another operation or participant identity.
12    ForeignResponse,
13    /// The value is absent an expectation or belongs to an older request.
14    DelayedResponse,
15    /// Wire identity is insufficient to assign this value to one expected operation.
16    AmbiguousResponse,
17    /// An expected-operation response was presented without its one-use send correlation.
18    MissingResponseAuthority,
19    /// A restore already testified the issued send authority destroyed; only
20    /// the pending testimony can resolve the operation (r2, 2026-07-18).
21    LostAuthorityPending,
22}
23
24/// Applied inbound value and resulting aggregate.
25#[derive(Debug, PartialEq, Eq)]
26pub struct ClientInboundApplied {
27    aggregate: ClientParticipantAggregate,
28    value: ServerValue,
29}
30
31impl ClientInboundApplied {
32    /// Releases the resulting aggregate and exact applied value.
33    #[must_use]
34    pub fn into_parts(self) -> (ClientParticipantAggregate, ServerValue) {
35        (self.aggregate, self.value)
36    }
37}
38
39/// Refused inbound value paired with the unchanged aggregate.
40#[derive(Debug, PartialEq, Eq)]
41pub struct ClientInboundRefusal {
42    aggregate: ClientParticipantAggregate,
43    value: ServerValue,
44    reason: ClientInboundRefusalReason,
45}
46
47impl ClientInboundRefusal {
48    /// Returns the closed refusal reason.
49    #[must_use]
50    pub const fn reason(&self) -> ClientInboundRefusalReason {
51        self.reason
52    }
53
54    /// Releases the unchanged aggregate and exact refused value.
55    #[must_use]
56    pub fn into_parts(self) -> (ClientParticipantAggregate, ServerValue) {
57        (self.aggregate, self.value)
58    }
59}
60
61/// Exhaustive inbound correlation decision.
62#[derive(Debug, PartialEq, Eq)]
63pub enum ClientInboundDecision {
64    /// The crate correlated and applied the typed value.
65    Applied(ClientInboundApplied),
66    /// The crate retained both authority and value unchanged.
67    Refused(ClientInboundRefusal),
68}
69
70/// Refused body-omitting response with the exact local correlation retained.
71#[derive(Debug, PartialEq, Eq)]
72pub struct ClientCorrelatedInboundRefusal {
73    aggregate: ClientParticipantAggregate,
74    value: ServerValue,
75    correlation: ClientResponseCorrelation,
76    reason: ClientInboundRefusalReason,
77}
78
79impl ClientCorrelatedInboundRefusal {
80    /// Returns the closed refusal reason.
81    #[must_use]
82    pub const fn reason(&self) -> ClientInboundRefusalReason {
83        self.reason
84    }
85
86    /// Releases every unchanged input, including the non-cloneable correlation.
87    #[must_use]
88    pub fn into_parts(
89        self,
90    ) -> (
91        ClientParticipantAggregate,
92        ServerValue,
93        ClientResponseCorrelation,
94    ) {
95        (self.aggregate, self.value, self.correlation)
96    }
97}
98
99/// Inbound decision for response classes whose wire envelopes omit request identity.
100#[derive(Debug, PartialEq, Eq)]
101pub enum ClientCorrelatedInboundDecision {
102    /// The exact local operation authorization and wire envelope both matched.
103    Applied(ClientInboundApplied),
104    /// Aggregate, value, and correlation were retained unchanged.
105    Refused(ClientCorrelatedInboundRefusal),
106}
107
108/// Correlates and applies one server value inside the client aggregate.
109#[must_use]
110pub fn decide_inbound(
111    aggregate: ClientParticipantAggregate,
112    value: ServerValue,
113) -> ClientInboundDecision {
114    decide_inbound_inner(aggregate, value, false)
115}
116
117/// Attempts correlation while retaining the process-local handle on refusal.
118#[must_use]
119pub fn decide_correlated_inbound(
120    aggregate: ClientParticipantAggregate,
121    value: ServerValue,
122    correlation: ClientResponseCorrelation,
123) -> ClientCorrelatedInboundDecision {
124    let current_authority = aggregate.expected.as_ref().is_some_and(|expected| {
125        expected.issued && expected.authorization == correlation.authorization
126    });
127    if !current_authority {
128        return ClientCorrelatedInboundDecision::Refused(ClientCorrelatedInboundRefusal {
129            aggregate,
130            value,
131            correlation,
132            reason: ClientInboundRefusalReason::DelayedResponse,
133        });
134    }
135    match decide_inbound_inner(aggregate, value, true) {
136        ClientInboundDecision::Applied(applied) => {
137            ClientCorrelatedInboundDecision::Applied(applied)
138        }
139        ClientInboundDecision::Refused(refusal) => {
140            let reason = refusal.reason();
141            let (aggregate, value) = refusal.into_parts();
142            ClientCorrelatedInboundDecision::Refused(ClientCorrelatedInboundRefusal {
143                aggregate,
144                value,
145                correlation,
146                reason,
147            })
148        }
149    }
150}
151
152fn decide_inbound_inner(
153    mut aggregate: ClientParticipantAggregate,
154    value: ServerValue,
155    has_response_authority: bool,
156) -> ClientInboundDecision {
157    if aggregate.binding.is_left() {
158        return inbound_refusal(aggregate, value, ClientInboundRefusalReason::AlreadyDead);
159    }
160
161    if let Some(request) = correlation::participant_ack_request(&value) {
162        if aggregate.binding.matches_ack(request) {
163            return ClientInboundDecision::Applied(ClientInboundApplied { aggregate, value });
164        }
165        return inbound_refusal(
166            aggregate,
167            value,
168            ClientInboundRefusalReason::ForeignResponse,
169        );
170    }
171
172    if matches!(value, ServerValue::ParticipantTransportRejected(_)) {
173        return ClientInboundDecision::Applied(ClientInboundApplied { aggregate, value });
174    }
175
176    let Some(expected) = aggregate.expected.as_ref() else {
177        return inbound_refusal(
178            aggregate,
179            value,
180            ClientInboundRefusalReason::DelayedResponse,
181        );
182    };
183
184    if expected.lost.is_some() {
185        return inbound_refusal(
186            aggregate,
187            value,
188            ClientInboundRefusalReason::LostAuthorityPending,
189        );
190    }
191
192    if !has_response_authority {
193        return inbound_refusal(
194            aggregate,
195            value,
196            ClientInboundRefusalReason::MissingResponseAuthority,
197        );
198    }
199
200    if !aggregate.binding.accepts_request(&expected.request) {
201        return inbound_refusal(
202            aggregate,
203            value,
204            ClientInboundRefusalReason::ForeignResponse,
205        );
206    }
207
208    if !correlation::matches_request(&value, &expected.request) {
209        let same_request_class = value.originating_request()
210            == Some(expected.request.discriminant())
211            || matches!(
212                (&value, &expected.request),
213                (
214                    ServerValue::ObserverRecoveryAccepted(_)
215                        | ServerValue::InvalidObserverEpoch(_)
216                        | ServerValue::InvalidObserverEpochList(_),
217                    crate::wire::ClientRequest::ObserverRecovery(_)
218                )
219            );
220        let same_identity = correlation::same_identity(&value, &expected.request);
221        let reason = if same_request_class && same_identity {
222            if matches!(
223                expected.request,
224                crate::wire::ClientRequest::RecordAdmission(_)
225            ) {
226                ClientInboundRefusalReason::AmbiguousResponse
227            } else {
228                ClientInboundRefusalReason::DelayedResponse
229            }
230        } else {
231            ClientInboundRefusalReason::ForeignResponse
232        };
233        return inbound_refusal(aggregate, value, reason);
234    }
235
236    retire_expected_operation(&mut aggregate, &value);
237    ClientInboundDecision::Applied(ClientInboundApplied { aggregate, value })
238}
239
240/// Retires the expected slot and settles the detach replay in one statement.
241///
242/// The two are a coupled pair: an active replay without its exact expected
243/// detach is a state `resume_record` refuses to encode
244/// (`ClientResumeRecordEncodeError::DecoupledDetachReplay`) and no restore
245/// accepts. So the clear may not happen on its own.
246///
247/// This function used to be that bare clear, under a comment asserting every
248/// matching arm of `apply_correlated_value` supersedes or terminalizes the
249/// replay. The assertion was false. A correlated value reaches here only when
250/// its wire identity resolves to the expected request's key, and six values
251/// whose key is a detach refuse that detach's AUTHORITY without answering it --
252/// a rotated generation, a dropped binding, an unknown participant, a
253/// connection-capacity or observer-backpressure refusal, a retirement older
254/// than the replay. All six fell through `apply_correlated_value` untouched and
255/// left the replay active behind a cleared slot.
256///
257/// The mechanism is deliberately an OBSERVATION rather than a longer list of
258/// arms: whatever `apply_correlated_value` did or did not do, a replay left
259/// active is settled here into a terminal retaining the exact refusing value.
260/// A list is what was wrong before, and the list assembled while fixing it was
261/// itself wrong in both directions -- it named a value that cannot correlate to
262/// a detach (`ConversationOrderExhausted`, whose envelope has no detach
263/// variant) and missed one that can (`ObserverBackpressure::Detach`). A new
264/// correlatable refusal class therefore cannot reintroduce the decoupling,
265/// because nothing here enumerates the classes.
266fn retire_expected_operation(aggregate: &mut ClientParticipantAggregate, value: &ServerValue) {
267    let expected_detach = match aggregate
268        .expected
269        .as_ref()
270        .map(|expected| &expected.request)
271    {
272        Some(crate::wire::ClientRequest::Detach(request)) => Some(request.clone()),
273        _ => None,
274    };
275    aggregate.expected = None;
276    apply_correlated_value(aggregate, value);
277    if let Some(request) = expected_detach {
278        let envelope = crate::wire::DetachEnvelope {
279            conversation_id: request.conversation_id,
280            participant_id: request.participant_id,
281            capability_generation: request.capability_generation,
282            detach_attempt_token: request.detach_attempt_token,
283        };
284        aggregate
285            .detach_replay
286            .settle_refused_authority(&envelope, value);
287        debug_assert!(
288            !aggregate.detach_replay.is_active()
289                || aggregate.detach_replay.request() != Some(&envelope),
290            "retiring an expected detach must leave its own replay settled"
291        );
292    }
293    debug_assert!(
294        aggregate.expected.is_none(),
295        "the expected slot must be retired by this statement"
296    );
297}
298
299const fn inbound_refusal(
300    aggregate: ClientParticipantAggregate,
301    value: ServerValue,
302    reason: ClientInboundRefusalReason,
303) -> ClientInboundDecision {
304    ClientInboundDecision::Refused(ClientInboundRefusal {
305        aggregate,
306        value,
307        reason,
308    })
309}
310
311fn apply_correlated_value(aggregate: &mut ClientParticipantAggregate, value: &ServerValue) {
312    match value {
313        ServerValue::EnrollBound(value) => apply_enroll_bound(aggregate, value),
314        ServerValue::Bound(ReceiptReplay::Enrollment(value)) => {
315            apply_enroll_bound(aggregate, value);
316        }
317        ServerValue::AttachBound(value)
318        | ServerValue::Bound(ReceiptReplay::CredentialAttach(value)) => {
319            apply_attach_bound(aggregate, value);
320            aggregate.detach_replay.apply_attach(value);
321        }
322        ServerValue::DetachCommitted(value) => {
323            let attach_secret = match aggregate.binding {
324                ClientBindingState::Bound { attach_secret, .. }
325                | ClientBindingState::Detached { attach_secret, .. } => attach_secret,
326                ClientBindingState::Unbound | ClientBindingState::Left { .. } => return,
327            };
328            aggregate.binding = ClientBindingState::Detached {
329                conversation_id: value.conversation_id(),
330                participant_id: value.participant_id(),
331                generation: value.capability_generation(),
332                attach_secret,
333            };
334            aggregate.detach_replay.apply_detach_committed(value);
335        }
336        ServerValue::DetachInProgress(value) => {
337            aggregate.detach_replay.apply_detach_in_progress(value);
338        }
339        ServerValue::StaleAuthority(crate::wire::StaleAuthority::Detach(
340            crate::wire::DetachStaleAuthority::TerminalizedDetachCell(value),
341        )) => {
342            aggregate
343                .detach_replay
344                .apply_terminalized_detach_cell(value);
345        }
346        ServerValue::LeaveCommitted(value) => {
347            aggregate.binding = ClientBindingState::Left {
348                conversation_id: value.conversation_id(),
349                participant_id: value.participant_id(),
350                generation: value.retired_generation(),
351            };
352            aggregate.detach_replay.apply_leave(value);
353        }
354        ServerValue::Retired(value) => {
355            apply_retired(aggregate, value);
356        }
357        ServerValue::ParticipantTransportRejected(_)
358        | ServerValue::AttemptTokenBodyConflict(_)
359        | ServerValue::ConnectionConversationCapacityExceeded(_)
360        | ServerValue::ConnectionConversationBindingOccupied(_)
361        | ServerValue::ConversationOrderExhausted(_)
362        | ServerValue::ParticipantUnknown(_)
363        | ServerValue::NoBinding(_)
364        | ServerValue::StaleAuthority(_)
365        | ServerValue::MarkerClosureCapacityExceeded(_)
366        | ServerValue::EnrollmentKnown(_)
367        | ServerValue::ReceiptExpired(_)
368        | ServerValue::ReceiptCapacityExceeded(_)
369        | ServerValue::IdentityCapacityExceeded(_)
370        | ServerValue::ObserverBackpressure(_)
371        | ServerValue::ConversationSequenceExhausted(_)
372        | ServerValue::StaleOrUnknownReceipt(_)
373        | ServerValue::MarkerNotDelivered(_)
374        | ServerValue::MarkerMismatch(_)
375        | ServerValue::UnboundReceipt(_)
376        | ServerValue::AckCommitted(_)
377        | ServerValue::AckNoOp(_)
378        | ServerValue::AckGap(_)
379        | ServerValue::AckRegression(_)
380        | ServerValue::MarkerAckCommitted(_)
381        | ServerValue::RecordCommitted(_)
382        | ServerValue::RecordTooLarge(_)
383        | ServerValue::ObserverRecoveryAccepted(_)
384        | ServerValue::InvalidObserverEpoch(_)
385        | ServerValue::InvalidObserverEpochList(_) => {}
386    }
387}
388
389const fn apply_enroll_bound(
390    aggregate: &mut ClientParticipantAggregate,
391    value: &crate::wire::EnrollBound,
392) {
393    aggregate.binding = ClientBindingState::Bound {
394        conversation_id: value.conversation_id(),
395        participant_id: value.participant_id(),
396        generation: value.capability_generation(),
397        attach_secret: value.attach_secret(),
398        binding_epoch: value.origin_binding_epoch(),
399    };
400}
401
402const fn apply_attach_bound(aggregate: &mut ClientParticipantAggregate, value: &AttachBound) {
403    aggregate.binding = ClientBindingState::Bound {
404        conversation_id: value.conversation_id(),
405        participant_id: value.participant_id(),
406        generation: value.capability_generation(),
407        attach_secret: value.attach_secret(),
408        binding_epoch: value.origin_binding_epoch(),
409    };
410}
411
412fn apply_retired(aggregate: &mut ClientParticipantAggregate, value: &crate::wire::Retired) {
413    let (conversation_id, participant_id, generation) = match value {
414        crate::wire::Retired::Enrollment {
415            request,
416            participant_id,
417            retired_generation,
418        } => (
419            request.conversation_id,
420            *participant_id,
421            *retired_generation,
422        ),
423        crate::wire::Retired::Participant {
424            request,
425            retired_generation,
426        } => {
427            let (conversation_id, participant_id) = participant_reference_identity(request);
428            (conversation_id, participant_id, *retired_generation)
429        }
430    };
431    aggregate.binding = ClientBindingState::Left {
432        conversation_id,
433        participant_id,
434        generation,
435    };
436    aggregate
437        .detach_replay
438        .apply_retired(conversation_id, participant_id, generation);
439}
440
441const fn participant_reference_identity(
442    request: &crate::wire::ParticipantReferenceEnvelope,
443) -> (u64, u64) {
444    match request {
445        crate::wire::ParticipantReferenceEnvelope::CredentialAttach(value) => {
446            (value.conversation_id, value.participant_id)
447        }
448        crate::wire::ParticipantReferenceEnvelope::Detach(value) => {
449            (value.conversation_id, value.participant_id)
450        }
451        crate::wire::ParticipantReferenceEnvelope::ParticipantAck(value) => {
452            (value.conversation_id, value.participant_id)
453        }
454        crate::wire::ParticipantReferenceEnvelope::Leave(value) => {
455            (value.conversation_id, value.participant_id)
456        }
457        crate::wire::ParticipantReferenceEnvelope::MarkerAck(value) => {
458            (value.conversation_id, value.participant_id)
459        }
460        crate::wire::ParticipantReferenceEnvelope::RecordAdmission(value) => {
461            (value.conversation_id, value.participant_id)
462        }
463    }
464}