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    // A write the server could not settle answers nothing: the request may or
284    // may not have committed. The expected slot stands under its authority,
285    // so a correlated read hands the correlation back, and its holder ends the
286    // expectation as a lost response and re-presents the same request with the
287    // same attempt token.
288    if matches!(value, ServerValue::WriteOutcomeUnknown(_)) {
289        return ClientInboundDecision::Applied(ClientInboundApplied { aggregate, value });
290    }
291
292    retire_expected_operation(&mut aggregate, &value);
293    ClientInboundDecision::Applied(ClientInboundApplied { aggregate, value })
294}
295
296/// Retires the expected slot and settles the detach replay in one statement.
297///
298/// The two are a coupled pair: an active replay without its exact expected
299/// detach is a state `resume_record` refuses to encode
300/// (`ClientResumeRecordEncodeError::DecoupledDetachReplay`) and no restore
301/// accepts. So the clear may not happen on its own.
302///
303/// This function used to be that bare clear, under a comment asserting every
304/// matching arm of `apply_correlated_value` supersedes or terminalizes the
305/// replay. The assertion was false. A correlated value reaches here only when
306/// its wire identity resolves to the expected request's key, and six values
307/// whose key is a detach refuse that detach's AUTHORITY without answering it --
308/// a rotated generation, a dropped binding, an unknown participant, a
309/// connection-capacity or observer-backpressure refusal, a retirement older
310/// than the replay. All six fell through `apply_correlated_value` untouched and
311/// left the replay active behind a cleared slot.
312///
313/// The mechanism is deliberately an OBSERVATION rather than a longer list of
314/// arms: whatever `apply_correlated_value` did or did not do, a replay left
315/// active is settled here into a terminal retaining the exact refusing value.
316/// A list is what was wrong before, and the list assembled while fixing it was
317/// itself wrong in both directions -- it named a value that cannot correlate to
318/// a detach (`ConversationOrderExhausted`, whose envelope has no detach
319/// variant) and missed one that can (`ObserverBackpressure::Detach`). A new
320/// correlatable refusal class therefore cannot reintroduce the decoupling,
321/// because nothing here enumerates the classes.
322fn retire_expected_operation(aggregate: &mut ClientParticipantAggregate, value: &ServerValue) {
323    let expected_detach = match aggregate
324        .expected
325        .as_ref()
326        .map(|expected| &expected.request)
327    {
328        Some(crate::wire::ClientRequest::Detach(request)) => Some(request.clone()),
329        _ => None,
330    };
331    aggregate.expected = None;
332    apply_correlated_value(aggregate, value);
333    if let Some(request) = expected_detach {
334        let envelope = crate::wire::DetachEnvelope {
335            conversation_id: request.conversation_id,
336            participant_id: request.participant_id,
337            capability_generation: request.capability_generation,
338            detach_attempt_token: request.detach_attempt_token,
339        };
340        aggregate
341            .detach_replay
342            .settle_refused_authority(&envelope, value);
343        debug_assert!(
344            !aggregate.detach_replay.is_active()
345                || aggregate.detach_replay.request() != Some(&envelope),
346            "retiring an expected detach must leave its own replay settled"
347        );
348    }
349    debug_assert!(
350        aggregate.expected.is_none(),
351        "the expected slot must be retired by this statement"
352    );
353}
354
355const fn inbound_refusal(
356    aggregate: ClientParticipantAggregate,
357    value: ServerValue,
358    reason: ClientInboundRefusalReason,
359) -> ClientInboundDecision {
360    ClientInboundDecision::Refused(ClientInboundRefusal {
361        aggregate,
362        value,
363        reason,
364    })
365}
366
367fn apply_correlated_value(aggregate: &mut ClientParticipantAggregate, value: &ServerValue) {
368    match value {
369        ServerValue::EnrollBound(value) => apply_enroll_bound(aggregate, value),
370        ServerValue::Bound(ReceiptReplay::Enrollment(value)) => {
371            apply_enroll_bound(aggregate, value);
372        }
373        ServerValue::AttachBound(value)
374        | ServerValue::Bound(ReceiptReplay::CredentialAttach(value)) => {
375            apply_attach_bound(aggregate, value);
376            aggregate.detach_replay.apply_attach(value);
377        }
378        ServerValue::UnboundReceipt(ReceiptReplay::CredentialAttach(value)) => {
379            apply_unbound_attach_receipt(aggregate, value);
380            aggregate.detach_replay.apply_attach(value);
381        }
382        ServerValue::DetachCommitted(value) => {
383            let attach_secret = match aggregate.binding {
384                ClientBindingState::Bound { attach_secret, .. }
385                | ClientBindingState::Detached { attach_secret, .. } => attach_secret,
386                ClientBindingState::Unbound | ClientBindingState::Left { .. } => return,
387            };
388            aggregate.binding = ClientBindingState::Detached {
389                conversation_id: value.conversation_id(),
390                participant_id: value.participant_id(),
391                generation: value.capability_generation(),
392                attach_secret,
393            };
394            aggregate.detach_replay.apply_detach_committed(value);
395        }
396        ServerValue::DetachInProgress(value) => {
397            aggregate.detach_replay.apply_detach_in_progress(value);
398        }
399        ServerValue::StaleAuthority(crate::wire::StaleAuthority::Detach(
400            crate::wire::DetachStaleAuthority::TerminalizedDetachCell(value),
401        )) => {
402            aggregate
403                .detach_replay
404                .apply_terminalized_detach_cell(value);
405        }
406        ServerValue::LeaveCommitted(value) => {
407            aggregate.binding = ClientBindingState::Left {
408                conversation_id: value.conversation_id(),
409                participant_id: value.participant_id(),
410                generation: value.retired_generation(),
411            };
412            aggregate.detach_replay.apply_leave(value);
413        }
414        ServerValue::Retired(value) => {
415            apply_retired(aggregate, value);
416        }
417        ServerValue::ParticipantTransportRejected(_)
418        | ServerValue::AttemptTokenBodyConflict(_)
419        | ServerValue::ConnectionConversationCapacityExceeded(_)
420        | ServerValue::ConnectionConversationBindingOccupied(_)
421        | ServerValue::ConversationOrderExhausted(_)
422        | ServerValue::ParticipantUnknown(_)
423        | ServerValue::NoBinding(_)
424        | ServerValue::StaleAuthority(_)
425        | ServerValue::MarkerClosureCapacityExceeded(_)
426        | ServerValue::EnrollmentKnown(_)
427        | ServerValue::ReceiptExpired(_)
428        | ServerValue::ReceiptCapacityExceeded(_)
429        | ServerValue::IdentityCapacityExceeded(_)
430        | ServerValue::ObserverBackpressure(_)
431        | ServerValue::ConversationSequenceExhausted(_)
432        | ServerValue::StaleOrUnknownReceipt(_)
433        | ServerValue::MarkerNotDelivered(_)
434        | ServerValue::MarkerMismatch(_)
435        // Only the ENROLLMENT half stays inert. Its attach twin is adopted
436        // above; enrolment's own torn-but-bound recovery is the same shape but
437        // a different failure, and closing it here would be closing a failure
438        // this lane did not measure.
439        | ServerValue::UnboundReceipt(ReceiptReplay::Enrollment(_))
440        | ServerValue::AckCommitted(_)
441        | ServerValue::AckNoOp(_)
442        | ServerValue::AckGap(_)
443        | ServerValue::AckRegression(_)
444        | ServerValue::MarkerAckCommitted(_)
445        | ServerValue::SettlementAnswer(_)
446        | ServerValue::RecordCommitted(_)
447        | ServerValue::RecordTooLarge(_)
448        // A terminal protocol-fault refusal commits nothing and consumes
449        // nothing: the server reinstalls the unchanged owner before answering,
450        // so no aggregate state moves here either.
451        | ServerValue::RecordAdmissionProtocolFault(_)
452        | ServerValue::ObserverRecoveryAccepted(_)
453        | ServerValue::InvalidObserverEpoch(_)
454        | ServerValue::InvalidObserverEpochList(_)
455        // Settlement backpressure commits nothing and restores what it
456        // consumed, so no aggregate state moves here. The waiting state its
457        // retry discipline persists is the SDK's, not the aggregate's
458        // (participant contract ยง0.16 condition 2).
459        | ServerValue::MarkerSettlementBackpressure(_)
460        | ServerValue::EnrollmentSettlementBackpressure(_)
461        // Never correlated to a retirement: it leaves the slot standing.
462        | ServerValue::WriteOutcomeUnknown(_) => {}
463    }
464}
465
466const fn apply_enroll_bound(
467    aggregate: &mut ClientParticipantAggregate,
468    value: &crate::wire::EnrollBound,
469) {
470    aggregate.binding = ClientBindingState::Bound {
471        conversation_id: value.conversation_id(),
472        participant_id: value.participant_id(),
473        generation: value.capability_generation(),
474        attach_secret: value.attach_secret(),
475        binding_epoch: value.origin_binding_epoch(),
476    };
477}
478
479/// Adopts the rotated credential from an attach receipt whose origin binding is
480/// no longer current.
481///
482/// The server replays this receipt to answer an attempt whose answer was lost,
483/// and it carries the two values the client cannot otherwise obtain: the
484/// successor capability generation and the newly minted attach secret. Without
485/// adopting them the client is stranded one generation behind forever โ€” it can
486/// form only an attach the server refuses as `StaleAuthority`, while the attach
487/// the server would accept is refused locally as `BindingMismatch` (p0-61
488/// residue, task #62).
489///
490/// The result is [`ClientBindingState::Detached`], never `Bound`. An
491/// `UnboundReceipt` states exactly that the receipt no longer names its origin
492/// binding โ€” the tear killed the connection that held it โ€” so claiming `Bound`
493/// would assert a live binding the server has already released. `Detached`
494/// carries the credential forward and is the state a fresh attach may be formed
495/// from.
496///
497/// No identity, liveness or monotonicity guard is written here, and each
498/// omission is load-bearing rather than an oversight. [`decide_correlated_inbound`]
499/// refuses any value whose expected request the current binding does not accept
500/// before application, so a foreign participant's receipt and a receipt arriving
501/// after a durable Leave never reach this function; and that same gate forces
502/// the retained generation to equal the expected attach's presented generation,
503/// which [`AttachBound::ordinary`] guarantees is the exact predecessor of the
504/// granted one. A backward adoption is therefore unreachable, not merely
505/// guarded. All three are pinned in `p0_62_stranded_handle_tests`.
506const fn apply_unbound_attach_receipt(
507    aggregate: &mut ClientParticipantAggregate,
508    value: &AttachBound,
509) {
510    aggregate.binding = ClientBindingState::Detached {
511        conversation_id: value.conversation_id(),
512        participant_id: value.participant_id(),
513        generation: value.capability_generation(),
514        attach_secret: value.attach_secret(),
515    };
516}
517
518const fn apply_attach_bound(aggregate: &mut ClientParticipantAggregate, value: &AttachBound) {
519    aggregate.binding = ClientBindingState::Bound {
520        conversation_id: value.conversation_id(),
521        participant_id: value.participant_id(),
522        generation: value.capability_generation(),
523        attach_secret: value.attach_secret(),
524        binding_epoch: value.origin_binding_epoch(),
525    };
526}
527
528fn apply_retired(aggregate: &mut ClientParticipantAggregate, value: &crate::wire::Retired) {
529    let (conversation_id, participant_id, generation) = match value {
530        crate::wire::Retired::Enrollment {
531            request,
532            participant_id,
533            retired_generation,
534        } => (
535            request.conversation_id,
536            *participant_id,
537            *retired_generation,
538        ),
539        crate::wire::Retired::Participant {
540            request,
541            retired_generation,
542        } => {
543            let (conversation_id, participant_id) = participant_reference_identity(request);
544            (conversation_id, participant_id, *retired_generation)
545        }
546    };
547    aggregate.binding = ClientBindingState::Left {
548        conversation_id,
549        participant_id,
550        generation,
551    };
552    aggregate
553        .detach_replay
554        .apply_retired(conversation_id, participant_id, generation);
555}
556
557const fn participant_reference_identity(
558    request: &crate::wire::ParticipantReferenceEnvelope,
559) -> (u64, u64) {
560    match request {
561        crate::wire::ParticipantReferenceEnvelope::CredentialAttach(value) => {
562            (value.conversation_id, value.participant_id)
563        }
564        crate::wire::ParticipantReferenceEnvelope::Detach(value) => {
565            (value.conversation_id, value.participant_id)
566        }
567        crate::wire::ParticipantReferenceEnvelope::ParticipantAck(value) => {
568            (value.conversation_id, value.participant_id)
569        }
570        crate::wire::ParticipantReferenceEnvelope::Leave(value) => {
571            (value.conversation_id, value.participant_id)
572        }
573        crate::wire::ParticipantReferenceEnvelope::MarkerAck(value) => {
574            (value.conversation_id, value.participant_id)
575        }
576        crate::wire::ParticipantReferenceEnvelope::RecordAdmission(value) => {
577            (value.conversation_id, value.participant_id)
578        }
579    }
580}