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/// Applied inbound value that did not answer the outstanding operation.
100///
101/// The value applied on its own authority -- a continuous acknowledgement
102/// answer or a pre-semantic transport rejection -- and left the expected slot
103/// standing under the same issued authorization. The actual answer is still
104/// owed, so the process-local correlation comes back with the aggregate
105/// instead of being spent.
106#[derive(Debug, PartialEq, Eq)]
107pub struct ClientCorrelatedInboundRetained {
108    aggregate: ClientParticipantAggregate,
109    value: ServerValue,
110    correlation: ClientResponseCorrelation,
111}
112
113impl ClientCorrelatedInboundRetained {
114    /// Releases the resulting aggregate, the applied value, and the still-current correlation.
115    #[must_use]
116    pub fn into_parts(
117        self,
118    ) -> (
119        ClientParticipantAggregate,
120        ServerValue,
121        ClientResponseCorrelation,
122    ) {
123        (self.aggregate, self.value, self.correlation)
124    }
125}
126
127/// Inbound decision for response classes whose wire envelopes omit request identity.
128#[derive(Debug, PartialEq, Eq)]
129pub enum ClientCorrelatedInboundDecision {
130    /// The exact local operation authorization and wire envelope both matched;
131    /// the correlation is spent.
132    Applied(ClientInboundApplied),
133    /// The value applied without answering the outstanding operation; the
134    /// correlation is handed back because the answer is still owed.
135    AppliedRetaining(ClientCorrelatedInboundRetained),
136    /// Aggregate, value, and correlation were retained unchanged.
137    Refused(ClientCorrelatedInboundRefusal),
138}
139
140/// Correlates and applies one server value inside the client aggregate.
141#[must_use]
142pub fn decide_inbound(
143    aggregate: ClientParticipantAggregate,
144    value: ServerValue,
145) -> ClientInboundDecision {
146    decide_inbound_inner(aggregate, value, false)
147}
148
149/// Attempts correlation while retaining the process-local handle on refusal.
150#[must_use]
151pub fn decide_correlated_inbound(
152    aggregate: ClientParticipantAggregate,
153    value: ServerValue,
154    correlation: ClientResponseCorrelation,
155) -> ClientCorrelatedInboundDecision {
156    let current_authority = aggregate.expected.as_ref().is_some_and(|expected| {
157        expected.issued && expected.authorization == correlation.authorization
158    });
159    if !current_authority {
160        return ClientCorrelatedInboundDecision::Refused(ClientCorrelatedInboundRefusal {
161            aggregate,
162            value,
163            correlation,
164            reason: ClientInboundRefusalReason::DelayedResponse,
165        });
166    }
167    match decide_inbound_inner(aggregate, value, true) {
168        ClientInboundDecision::Applied(applied) => {
169            // An OBSERVATION of the applied aggregate, not a list of value
170            // classes: whatever the value was, if the expected slot still
171            // stands under this correlation's issued authorization the answer
172            // is still owed and the correlation goes back with the aggregate.
173            let still_owed = applied.aggregate.expected.as_ref().is_some_and(|expected| {
174                expected.issued && expected.authorization == correlation.authorization
175            });
176            if still_owed {
177                ClientCorrelatedInboundDecision::AppliedRetaining(ClientCorrelatedInboundRetained {
178                    aggregate: applied.aggregate,
179                    value: applied.value,
180                    correlation,
181                })
182            } else {
183                ClientCorrelatedInboundDecision::Applied(applied)
184            }
185        }
186        ClientInboundDecision::Refused(refusal) => {
187            let reason = refusal.reason();
188            let (aggregate, value) = refusal.into_parts();
189            ClientCorrelatedInboundDecision::Refused(ClientCorrelatedInboundRefusal {
190                aggregate,
191                value,
192                correlation,
193                reason,
194            })
195        }
196    }
197}
198
199fn decide_inbound_inner(
200    mut aggregate: ClientParticipantAggregate,
201    value: ServerValue,
202    has_response_authority: bool,
203) -> ClientInboundDecision {
204    if aggregate.binding.is_left() {
205        return inbound_refusal(aggregate, value, ClientInboundRefusalReason::AlreadyDead);
206    }
207
208    if let Some(request) = correlation::participant_ack_request(&value) {
209        if aggregate.binding.matches_ack(request) {
210            return ClientInboundDecision::Applied(ClientInboundApplied { aggregate, value });
211        }
212        return inbound_refusal(
213            aggregate,
214            value,
215            ClientInboundRefusalReason::ForeignResponse,
216        );
217    }
218
219    if matches!(value, ServerValue::ParticipantTransportRejected(_)) {
220        return ClientInboundDecision::Applied(ClientInboundApplied { aggregate, value });
221    }
222
223    let Some(expected) = aggregate.expected.as_ref() else {
224        return inbound_refusal(
225            aggregate,
226            value,
227            ClientInboundRefusalReason::DelayedResponse,
228        );
229    };
230
231    if expected.lost.is_some() {
232        return inbound_refusal(
233            aggregate,
234            value,
235            ClientInboundRefusalReason::LostAuthorityPending,
236        );
237    }
238
239    if !has_response_authority {
240        return inbound_refusal(
241            aggregate,
242            value,
243            ClientInboundRefusalReason::MissingResponseAuthority,
244        );
245    }
246
247    if !aggregate.binding.accepts_request(&expected.request) {
248        return inbound_refusal(
249            aggregate,
250            value,
251            ClientInboundRefusalReason::ForeignResponse,
252        );
253    }
254
255    if !correlation::matches_request(&value, &expected.request) {
256        let same_request_class = value.originating_request()
257            == Some(expected.request.discriminant())
258            || matches!(
259                (&value, &expected.request),
260                (
261                    ServerValue::ObserverRecoveryAccepted(_)
262                        | ServerValue::InvalidObserverEpoch(_)
263                        | ServerValue::InvalidObserverEpochList(_),
264                    crate::wire::ClientRequest::ObserverRecovery(_)
265                )
266            );
267        let same_identity = correlation::same_identity(&value, &expected.request);
268        let reason = if same_request_class && same_identity {
269            if matches!(
270                expected.request,
271                crate::wire::ClientRequest::RecordAdmission(_)
272            ) {
273                ClientInboundRefusalReason::AmbiguousResponse
274            } else {
275                ClientInboundRefusalReason::DelayedResponse
276            }
277        } else {
278            ClientInboundRefusalReason::ForeignResponse
279        };
280        return inbound_refusal(aggregate, value, reason);
281    }
282
283    retire_expected_operation(&mut aggregate, &value);
284    ClientInboundDecision::Applied(ClientInboundApplied { aggregate, value })
285}
286
287/// Retires the expected slot and settles the detach replay in one statement.
288///
289/// The two are a coupled pair: an active replay without its exact expected
290/// detach is a state `resume_record` refuses to encode
291/// (`ClientResumeRecordEncodeError::DecoupledDetachReplay`) and no restore
292/// accepts. So the clear may not happen on its own.
293///
294/// This function used to be that bare clear, under a comment asserting every
295/// matching arm of `apply_correlated_value` supersedes or terminalizes the
296/// replay. The assertion was false. A correlated value reaches here only when
297/// its wire identity resolves to the expected request's key, and six values
298/// whose key is a detach refuse that detach's AUTHORITY without answering it --
299/// a rotated generation, a dropped binding, an unknown participant, a
300/// connection-capacity or observer-backpressure refusal, a retirement older
301/// than the replay. All six fell through `apply_correlated_value` untouched and
302/// left the replay active behind a cleared slot.
303///
304/// The mechanism is deliberately an OBSERVATION rather than a longer list of
305/// arms: whatever `apply_correlated_value` did or did not do, a replay left
306/// active is settled here into a terminal retaining the exact refusing value.
307/// A list is what was wrong before, and the list assembled while fixing it was
308/// itself wrong in both directions -- it named a value that cannot correlate to
309/// a detach (`ConversationOrderExhausted`, whose envelope has no detach
310/// variant) and missed one that can (`ObserverBackpressure::Detach`). A new
311/// correlatable refusal class therefore cannot reintroduce the decoupling,
312/// because nothing here enumerates the classes.
313fn retire_expected_operation(aggregate: &mut ClientParticipantAggregate, value: &ServerValue) {
314    let expected_detach = match aggregate
315        .expected
316        .as_ref()
317        .map(|expected| &expected.request)
318    {
319        Some(crate::wire::ClientRequest::Detach(request)) => Some(request.clone()),
320        _ => None,
321    };
322    aggregate.expected = None;
323    apply_correlated_value(aggregate, value);
324    if let Some(request) = expected_detach {
325        let envelope = crate::wire::DetachEnvelope {
326            conversation_id: request.conversation_id,
327            participant_id: request.participant_id,
328            capability_generation: request.capability_generation,
329            detach_attempt_token: request.detach_attempt_token,
330        };
331        aggregate
332            .detach_replay
333            .settle_refused_authority(&envelope, value);
334        debug_assert!(
335            !aggregate.detach_replay.is_active()
336                || aggregate.detach_replay.request() != Some(&envelope),
337            "retiring an expected detach must leave its own replay settled"
338        );
339    }
340    debug_assert!(
341        aggregate.expected.is_none(),
342        "the expected slot must be retired by this statement"
343    );
344}
345
346const fn inbound_refusal(
347    aggregate: ClientParticipantAggregate,
348    value: ServerValue,
349    reason: ClientInboundRefusalReason,
350) -> ClientInboundDecision {
351    ClientInboundDecision::Refused(ClientInboundRefusal {
352        aggregate,
353        value,
354        reason,
355    })
356}
357
358fn apply_correlated_value(aggregate: &mut ClientParticipantAggregate, value: &ServerValue) {
359    match value {
360        ServerValue::EnrollBound(value) => apply_enroll_bound(aggregate, value),
361        ServerValue::Bound(ReceiptReplay::Enrollment(value)) => {
362            apply_enroll_bound(aggregate, value);
363        }
364        ServerValue::AttachBound(value)
365        | ServerValue::Bound(ReceiptReplay::CredentialAttach(value)) => {
366            apply_attach_bound(aggregate, value);
367            aggregate.detach_replay.apply_attach(value);
368        }
369        ServerValue::UnboundReceipt(ReceiptReplay::CredentialAttach(value)) => {
370            apply_unbound_attach_receipt(aggregate, value);
371            aggregate.detach_replay.apply_attach(value);
372        }
373        ServerValue::DetachCommitted(value) => {
374            let attach_secret = match aggregate.binding {
375                ClientBindingState::Bound { attach_secret, .. }
376                | ClientBindingState::Detached { attach_secret, .. } => attach_secret,
377                ClientBindingState::Unbound | ClientBindingState::Left { .. } => return,
378            };
379            aggregate.binding = ClientBindingState::Detached {
380                conversation_id: value.conversation_id(),
381                participant_id: value.participant_id(),
382                generation: value.capability_generation(),
383                attach_secret,
384            };
385            aggregate.detach_replay.apply_detach_committed(value);
386        }
387        ServerValue::DetachInProgress(value) => {
388            aggregate.detach_replay.apply_detach_in_progress(value);
389        }
390        ServerValue::StaleAuthority(crate::wire::StaleAuthority::Detach(
391            crate::wire::DetachStaleAuthority::TerminalizedDetachCell(value),
392        )) => {
393            aggregate
394                .detach_replay
395                .apply_terminalized_detach_cell(value);
396        }
397        ServerValue::LeaveCommitted(value) => {
398            aggregate.binding = ClientBindingState::Left {
399                conversation_id: value.conversation_id(),
400                participant_id: value.participant_id(),
401                generation: value.retired_generation(),
402            };
403            aggregate.detach_replay.apply_leave(value);
404        }
405        ServerValue::Retired(value) => {
406            apply_retired(aggregate, value);
407        }
408        ServerValue::ParticipantTransportRejected(_)
409        | ServerValue::AttemptTokenBodyConflict(_)
410        | ServerValue::ConnectionConversationCapacityExceeded(_)
411        | ServerValue::ConnectionConversationBindingOccupied(_)
412        | ServerValue::ConversationOrderExhausted(_)
413        | ServerValue::ParticipantUnknown(_)
414        | ServerValue::NoBinding(_)
415        | ServerValue::StaleAuthority(_)
416        | ServerValue::MarkerClosureCapacityExceeded(_)
417        | ServerValue::EnrollmentKnown(_)
418        | ServerValue::ReceiptExpired(_)
419        | ServerValue::ReceiptCapacityExceeded(_)
420        | ServerValue::IdentityCapacityExceeded(_)
421        | ServerValue::ObserverBackpressure(_)
422        | ServerValue::ConversationSequenceExhausted(_)
423        | ServerValue::StaleOrUnknownReceipt(_)
424        | ServerValue::MarkerNotDelivered(_)
425        | ServerValue::MarkerMismatch(_)
426        // Only the ENROLLMENT half stays inert. Its attach twin is adopted
427        // above; enrolment's own torn-but-bound recovery is the same shape but
428        // a different failure, and closing it here would be closing a failure
429        // this lane did not measure.
430        | ServerValue::UnboundReceipt(ReceiptReplay::Enrollment(_))
431        | ServerValue::AckCommitted(_)
432        | ServerValue::AckNoOp(_)
433        | ServerValue::AckGap(_)
434        | ServerValue::AckRegression(_)
435        | ServerValue::MarkerAckCommitted(_)
436        | ServerValue::RecordCommitted(_)
437        | ServerValue::RecordTooLarge(_)
438        // A terminal protocol-fault refusal commits nothing and consumes
439        // nothing: the server reinstalls the unchanged owner before answering,
440        // so no aggregate state moves here either.
441        | ServerValue::RecordAdmissionProtocolFault(_)
442        | ServerValue::ObserverRecoveryAccepted(_)
443        | ServerValue::InvalidObserverEpoch(_)
444        | ServerValue::InvalidObserverEpochList(_)
445        // Settlement backpressure commits nothing and restores what it
446        // consumed, so no aggregate state moves here. The waiting state its
447        // retry discipline persists is the SDK's, not the aggregate's
448        // (participant contract ยง0.16 condition 2).
449        | ServerValue::MarkerSettlementBackpressure(_)
450        | ServerValue::EnrollmentSettlementBackpressure(_) => {}
451    }
452}
453
454const fn apply_enroll_bound(
455    aggregate: &mut ClientParticipantAggregate,
456    value: &crate::wire::EnrollBound,
457) {
458    aggregate.binding = ClientBindingState::Bound {
459        conversation_id: value.conversation_id(),
460        participant_id: value.participant_id(),
461        generation: value.capability_generation(),
462        attach_secret: value.attach_secret(),
463        binding_epoch: value.origin_binding_epoch(),
464    };
465}
466
467/// Adopts the rotated credential from an attach receipt whose origin binding is
468/// no longer current.
469///
470/// The server replays this receipt to answer an attempt whose answer was lost,
471/// and it carries the two values the client cannot otherwise obtain: the
472/// successor capability generation and the newly minted attach secret. Without
473/// adopting them the client is stranded one generation behind forever โ€” it can
474/// form only an attach the server refuses as `StaleAuthority`, while the attach
475/// the server would accept is refused locally as `BindingMismatch` (p0-61
476/// residue, task #62).
477///
478/// The result is [`ClientBindingState::Detached`], never `Bound`. An
479/// `UnboundReceipt` states exactly that the receipt no longer names its origin
480/// binding โ€” the tear killed the connection that held it โ€” so claiming `Bound`
481/// would assert a live binding the server has already released. `Detached`
482/// carries the credential forward and is the state a fresh attach may be formed
483/// from.
484///
485/// No identity, liveness or monotonicity guard is written here, and each
486/// omission is load-bearing rather than an oversight. [`decide_correlated_inbound`]
487/// refuses any value whose expected request the current binding does not accept
488/// before application, so a foreign participant's receipt and a receipt arriving
489/// after a durable Leave never reach this function; and that same gate forces
490/// the retained generation to equal the expected attach's presented generation,
491/// which [`AttachBound::ordinary`] guarantees is the exact predecessor of the
492/// granted one. A backward adoption is therefore unreachable, not merely
493/// guarded. All three are pinned in `p0_62_stranded_handle_tests`.
494const fn apply_unbound_attach_receipt(
495    aggregate: &mut ClientParticipantAggregate,
496    value: &AttachBound,
497) {
498    aggregate.binding = ClientBindingState::Detached {
499        conversation_id: value.conversation_id(),
500        participant_id: value.participant_id(),
501        generation: value.capability_generation(),
502        attach_secret: value.attach_secret(),
503    };
504}
505
506const fn apply_attach_bound(aggregate: &mut ClientParticipantAggregate, value: &AttachBound) {
507    aggregate.binding = ClientBindingState::Bound {
508        conversation_id: value.conversation_id(),
509        participant_id: value.participant_id(),
510        generation: value.capability_generation(),
511        attach_secret: value.attach_secret(),
512        binding_epoch: value.origin_binding_epoch(),
513    };
514}
515
516fn apply_retired(aggregate: &mut ClientParticipantAggregate, value: &crate::wire::Retired) {
517    let (conversation_id, participant_id, generation) = match value {
518        crate::wire::Retired::Enrollment {
519            request,
520            participant_id,
521            retired_generation,
522        } => (
523            request.conversation_id,
524            *participant_id,
525            *retired_generation,
526        ),
527        crate::wire::Retired::Participant {
528            request,
529            retired_generation,
530        } => {
531            let (conversation_id, participant_id) = participant_reference_identity(request);
532            (conversation_id, participant_id, *retired_generation)
533        }
534    };
535    aggregate.binding = ClientBindingState::Left {
536        conversation_id,
537        participant_id,
538        generation,
539    };
540    aggregate
541        .detach_replay
542        .apply_retired(conversation_id, participant_id, generation);
543}
544
545const fn participant_reference_identity(
546    request: &crate::wire::ParticipantReferenceEnvelope,
547) -> (u64, u64) {
548    match request {
549        crate::wire::ParticipantReferenceEnvelope::CredentialAttach(value) => {
550            (value.conversation_id, value.participant_id)
551        }
552        crate::wire::ParticipantReferenceEnvelope::Detach(value) => {
553            (value.conversation_id, value.participant_id)
554        }
555        crate::wire::ParticipantReferenceEnvelope::ParticipantAck(value) => {
556            (value.conversation_id, value.participant_id)
557        }
558        crate::wire::ParticipantReferenceEnvelope::Leave(value) => {
559            (value.conversation_id, value.participant_id)
560        }
561        crate::wire::ParticipantReferenceEnvelope::MarkerAck(value) => {
562            (value.conversation_id, value.participant_id)
563        }
564        crate::wire::ParticipantReferenceEnvelope::RecordAdmission(value) => {
565            (value.conversation_id, value.participant_id)
566        }
567    }
568}