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::UnboundReceipt(ReceiptReplay::CredentialAttach(value)) => {
323            apply_unbound_attach_receipt(aggregate, value);
324            aggregate.detach_replay.apply_attach(value);
325        }
326        ServerValue::DetachCommitted(value) => {
327            let attach_secret = match aggregate.binding {
328                ClientBindingState::Bound { attach_secret, .. }
329                | ClientBindingState::Detached { attach_secret, .. } => attach_secret,
330                ClientBindingState::Unbound | ClientBindingState::Left { .. } => return,
331            };
332            aggregate.binding = ClientBindingState::Detached {
333                conversation_id: value.conversation_id(),
334                participant_id: value.participant_id(),
335                generation: value.capability_generation(),
336                attach_secret,
337            };
338            aggregate.detach_replay.apply_detach_committed(value);
339        }
340        ServerValue::DetachInProgress(value) => {
341            aggregate.detach_replay.apply_detach_in_progress(value);
342        }
343        ServerValue::StaleAuthority(crate::wire::StaleAuthority::Detach(
344            crate::wire::DetachStaleAuthority::TerminalizedDetachCell(value),
345        )) => {
346            aggregate
347                .detach_replay
348                .apply_terminalized_detach_cell(value);
349        }
350        ServerValue::LeaveCommitted(value) => {
351            aggregate.binding = ClientBindingState::Left {
352                conversation_id: value.conversation_id(),
353                participant_id: value.participant_id(),
354                generation: value.retired_generation(),
355            };
356            aggregate.detach_replay.apply_leave(value);
357        }
358        ServerValue::Retired(value) => {
359            apply_retired(aggregate, value);
360        }
361        ServerValue::ParticipantTransportRejected(_)
362        | ServerValue::AttemptTokenBodyConflict(_)
363        | ServerValue::ConnectionConversationCapacityExceeded(_)
364        | ServerValue::ConnectionConversationBindingOccupied(_)
365        | ServerValue::ConversationOrderExhausted(_)
366        | ServerValue::ParticipantUnknown(_)
367        | ServerValue::NoBinding(_)
368        | ServerValue::StaleAuthority(_)
369        | ServerValue::MarkerClosureCapacityExceeded(_)
370        | ServerValue::EnrollmentKnown(_)
371        | ServerValue::ReceiptExpired(_)
372        | ServerValue::ReceiptCapacityExceeded(_)
373        | ServerValue::IdentityCapacityExceeded(_)
374        | ServerValue::ObserverBackpressure(_)
375        | ServerValue::ConversationSequenceExhausted(_)
376        | ServerValue::StaleOrUnknownReceipt(_)
377        | ServerValue::MarkerNotDelivered(_)
378        | ServerValue::MarkerMismatch(_)
379        // Only the ENROLLMENT half stays inert. Its attach twin is adopted
380        // above; enrolment's own torn-but-bound recovery is the same shape but
381        // a different failure, and closing it here would be closing a failure
382        // this lane did not measure.
383        | ServerValue::UnboundReceipt(ReceiptReplay::Enrollment(_))
384        | ServerValue::AckCommitted(_)
385        | ServerValue::AckNoOp(_)
386        | ServerValue::AckGap(_)
387        | ServerValue::AckRegression(_)
388        | ServerValue::MarkerAckCommitted(_)
389        | ServerValue::RecordCommitted(_)
390        | ServerValue::RecordTooLarge(_)
391        | ServerValue::ObserverRecoveryAccepted(_)
392        | ServerValue::InvalidObserverEpoch(_)
393        | ServerValue::InvalidObserverEpochList(_)
394        // Settlement backpressure commits nothing and restores what it
395        // consumed, so no aggregate state moves here. The waiting state its
396        // retry discipline persists is the SDK's, not the aggregate's
397        // (participant contract ยง0.16 condition 2).
398        | ServerValue::MarkerSettlementBackpressure(_)
399        | ServerValue::EnrollmentSettlementBackpressure(_) => {}
400    }
401}
402
403const fn apply_enroll_bound(
404    aggregate: &mut ClientParticipantAggregate,
405    value: &crate::wire::EnrollBound,
406) {
407    aggregate.binding = ClientBindingState::Bound {
408        conversation_id: value.conversation_id(),
409        participant_id: value.participant_id(),
410        generation: value.capability_generation(),
411        attach_secret: value.attach_secret(),
412        binding_epoch: value.origin_binding_epoch(),
413    };
414}
415
416/// Adopts the rotated credential from an attach receipt whose origin binding is
417/// no longer current.
418///
419/// The server replays this receipt to answer an attempt whose answer was lost,
420/// and it carries the two values the client cannot otherwise obtain: the
421/// successor capability generation and the newly minted attach secret. Without
422/// adopting them the client is stranded one generation behind forever โ€” it can
423/// form only an attach the server refuses as `StaleAuthority`, while the attach
424/// the server would accept is refused locally as `BindingMismatch` (p0-61
425/// residue, task #62).
426///
427/// The result is [`ClientBindingState::Detached`], never `Bound`. An
428/// `UnboundReceipt` states exactly that the receipt no longer names its origin
429/// binding โ€” the tear killed the connection that held it โ€” so claiming `Bound`
430/// would assert a live binding the server has already released. `Detached`
431/// carries the credential forward and is the state a fresh attach may be formed
432/// from.
433///
434/// No identity, liveness or monotonicity guard is written here, and each
435/// omission is load-bearing rather than an oversight. [`decide_correlated_inbound`]
436/// refuses any value whose expected request the current binding does not accept
437/// before application, so a foreign participant's receipt and a receipt arriving
438/// after a durable Leave never reach this function; and that same gate forces
439/// the retained generation to equal the expected attach's presented generation,
440/// which [`AttachBound::ordinary`] guarantees is the exact predecessor of the
441/// granted one. A backward adoption is therefore unreachable, not merely
442/// guarded. All three are pinned in `p0_62_stranded_handle_tests`.
443const fn apply_unbound_attach_receipt(
444    aggregate: &mut ClientParticipantAggregate,
445    value: &AttachBound,
446) {
447    aggregate.binding = ClientBindingState::Detached {
448        conversation_id: value.conversation_id(),
449        participant_id: value.participant_id(),
450        generation: value.capability_generation(),
451        attach_secret: value.attach_secret(),
452    };
453}
454
455const fn apply_attach_bound(aggregate: &mut ClientParticipantAggregate, value: &AttachBound) {
456    aggregate.binding = ClientBindingState::Bound {
457        conversation_id: value.conversation_id(),
458        participant_id: value.participant_id(),
459        generation: value.capability_generation(),
460        attach_secret: value.attach_secret(),
461        binding_epoch: value.origin_binding_epoch(),
462    };
463}
464
465fn apply_retired(aggregate: &mut ClientParticipantAggregate, value: &crate::wire::Retired) {
466    let (conversation_id, participant_id, generation) = match value {
467        crate::wire::Retired::Enrollment {
468            request,
469            participant_id,
470            retired_generation,
471        } => (
472            request.conversation_id,
473            *participant_id,
474            *retired_generation,
475        ),
476        crate::wire::Retired::Participant {
477            request,
478            retired_generation,
479        } => {
480            let (conversation_id, participant_id) = participant_reference_identity(request);
481            (conversation_id, participant_id, *retired_generation)
482        }
483    };
484    aggregate.binding = ClientBindingState::Left {
485        conversation_id,
486        participant_id,
487        generation,
488    };
489    aggregate
490        .detach_replay
491        .apply_retired(conversation_id, participant_id, generation);
492}
493
494const fn participant_reference_identity(
495    request: &crate::wire::ParticipantReferenceEnvelope,
496) -> (u64, u64) {
497    match request {
498        crate::wire::ParticipantReferenceEnvelope::CredentialAttach(value) => {
499            (value.conversation_id, value.participant_id)
500        }
501        crate::wire::ParticipantReferenceEnvelope::Detach(value) => {
502            (value.conversation_id, value.participant_id)
503        }
504        crate::wire::ParticipantReferenceEnvelope::ParticipantAck(value) => {
505            (value.conversation_id, value.participant_id)
506        }
507        crate::wire::ParticipantReferenceEnvelope::Leave(value) => {
508            (value.conversation_id, value.participant_id)
509        }
510        crate::wire::ParticipantReferenceEnvelope::MarkerAck(value) => {
511            (value.conversation_id, value.participant_id)
512        }
513        crate::wire::ParticipantReferenceEnvelope::RecordAdmission(value) => {
514            (value.conversation_id, value.participant_id)
515        }
516    }
517}