Skip to main content

liminal_protocol/client/
barrier.rs

1//! Brief-mandated write-ahead operation barrier and sealed response authority.
2
3use super::{ClientParticipantAggregate, DetachReplayStatus, ExpectedOperationState, replay};
4use crate::wire::ClientRequest;
5
6/// Sealed, non-cloneable authority to execute exactly the committed operation.
7#[derive(Debug, PartialEq, Eq)]
8pub struct ExpectedParticipantOperation {
9    request: ClientRequest,
10    authorization: u64,
11}
12
13impl ExpectedParticipantOperation {
14    /// Borrows the exact request released by the durability barrier.
15    #[must_use]
16    pub const fn request(&self) -> &ClientRequest {
17        &self.request
18    }
19
20    /// Consumes the send authority into its exact request and a non-cloneable
21    /// response-correlation capability.
22    ///
23    /// Returning both values from the advertised send path prevents successful
24    /// request extraction from silently discarding the only typed handle that
25    /// can terminalize an issued operation after transport loss.
26    #[must_use]
27    pub fn into_request(self) -> (ClientRequest, ClientResponseCorrelation) {
28        (
29            self.request,
30            ClientResponseCorrelation {
31                authorization: self.authorization,
32            },
33        )
34    }
35}
36
37/// Non-cloneable local correlation for responses that omit request body identity.
38#[derive(Debug, PartialEq, Eq)]
39pub struct ClientResponseCorrelation {
40    pub(super) authorization: u64,
41}
42
43/// Reason an operation could not enter the write-ahead slot.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum ClientOperationRecordRefusalReason {
46    /// Another write-ahead operation remains outstanding.
47    OutstandingOperation,
48    /// A different detach replay request remains retained.
49    DetachReplayOutstanding,
50    /// The retained same-envelope replay status is not compatible with a fresh
51    /// first send; re-recording would revive expected-detach authority over an
52    /// inactive replay (r2, 2026-07-18).
53    DetachReplayIncompatible,
54    /// A pending tokenless abandonment must be taken before another tokenless
55    /// operation is recorded (r2, 2026-07-18).
56    AbandonmentPending,
57    /// The request is not legal for the retained binding identity or state.
58    BindingMismatch,
59    /// A durable Leave or retirement made the participant permanently dead.
60    AlreadyDead,
61    /// The wire response cannot distinguish this request from an older request.
62    AmbiguousCorrelation,
63    /// The monotonic local response-correlation counter is exhausted.
64    AuthorizationExhausted,
65}
66
67/// Unchanged aggregate and request refused before persistence.
68#[derive(Debug, PartialEq, Eq)]
69pub struct ClientOperationRecordRefusal {
70    aggregate: ClientParticipantAggregate,
71    request: ClientRequest,
72    reason: ClientOperationRecordRefusalReason,
73}
74
75impl ClientOperationRecordRefusal {
76    /// Returns the closed refusal reason.
77    #[must_use]
78    pub const fn reason(&self) -> ClientOperationRecordRefusalReason {
79        self.reason
80    }
81
82    /// Recovers the unchanged aggregate and refused request.
83    #[must_use]
84    pub fn into_parts(self) -> (ClientParticipantAggregate, ClientRequest) {
85        (self.aggregate, self.request)
86    }
87}
88
89/// Continuous acknowledgement that bypasses the write-ahead slot.
90#[derive(Debug, PartialEq, Eq)]
91pub struct ClientContinuousOperation {
92    aggregate: ClientParticipantAggregate,
93    operation: ExpectedParticipantOperation,
94}
95
96impl ClientContinuousOperation {
97    /// Releases the unchanged aggregate and executable acknowledgement.
98    #[must_use]
99    pub fn into_parts(self) -> (ClientParticipantAggregate, ExpectedParticipantOperation) {
100        (self.aggregate, self.operation)
101    }
102}
103
104/// Pending decision whose successor and executable parts remain unreachable.
105///
106/// Round-3's authorized order is commit-seal, persist the committed `LPCR`, then
107/// release. Pending has no persistence API: a crash here means the operation did
108/// not happen and the caller may record it again after restart.
109///
110/// ```compile_fail
111/// use liminal_protocol::client::ClientPendingOperationRecord;
112/// fn speculative_persistence_is_forbidden(pending: ClientPendingOperationRecord) {
113///     let _bytes = pending.encode_resume_record();
114/// }
115/// ```
116#[derive(Debug, PartialEq, Eq)]
117pub struct ClientPendingOperationRecord {
118    pub(super) successor: ClientParticipantAggregate,
119    operation: ExpectedParticipantOperation,
120    prior_replay: Option<replay::DetachReplayState>,
121}
122
123impl ClientPendingOperationRecord {
124    /// Seals the speculative decision as committed without yet releasing its
125    /// successor aggregate or executable authority.
126    #[must_use]
127    pub fn commit(self) -> ClientOperationCommit {
128        ClientOperationCommit {
129            aggregate: self.successor,
130            operation: self.operation,
131        }
132    }
133
134    /// Aborts the speculative successor and returns the unchanged aggregate and
135    /// refused request.
136    #[must_use]
137    pub fn abort(mut self) -> (ClientParticipantAggregate, ClientRequest) {
138        self.successor.expected = None;
139        self.successor.next_operation_authorization =
140            self.operation.authorization.saturating_sub(1);
141        if let Some(prior_replay) = self.prior_replay {
142            self.successor.detach_replay.state = prior_replay;
143        }
144        (self.successor, self.operation.request)
145    }
146}
147
148/// Committed operation decision that still seals aggregate and execution authority.
149///
150/// This implements the authorized round-3 mandate's governing order:
151/// commit-seal, persist the committed `LPCR` exposed by this value, then call
152/// [`Self::into_parts`] to release authority.
153#[derive(Debug, PartialEq, Eq)]
154pub struct ClientOperationCommit {
155    pub(super) aggregate: ClientParticipantAggregate,
156    operation: ExpectedParticipantOperation,
157}
158
159impl ClientOperationCommit {
160    /// Releases the committed aggregate and one-use expected operation.
161    #[must_use]
162    pub fn into_parts(mut self) -> (ClientParticipantAggregate, ExpectedParticipantOperation) {
163        if let Some(expected) = self.aggregate.expected.as_mut() {
164            expected.issued = true;
165        }
166        if matches!(self.operation.request, ClientRequest::Detach(_)) {
167            self.aggregate.detach_replay.mark_initial_attempt_started();
168        }
169        (self.aggregate, self.operation)
170    }
171}
172
173/// Decision for releasing a committed cold-restored operation exactly once.
174#[derive(Debug, PartialEq, Eq)]
175pub enum RecoveredExpectedOperationDecision {
176    /// The unissued committed operation was released and marked issued.
177    Recovered {
178        /// Resulting aggregate.
179        aggregate: ClientParticipantAggregate,
180        /// One-use exact operation authority.
181        operation: ExpectedParticipantOperation,
182    },
183    /// No unissued committed operation is available.
184    NotAvailable {
185        /// Unchanged aggregate.
186        aggregate: ClientParticipantAggregate,
187        /// Whether an expected operation is retained but already issued.
188        already_issued: bool,
189    },
190}
191
192/// Releases an unissued operation from a validated committed cold restore.
193///
194/// Detach recovery atomically marks its initial replay attempt in flight, so
195/// the generic expected-operation authority is the sole first-send authority.
196#[must_use]
197pub fn recover_expected_operation(
198    mut aggregate: ClientParticipantAggregate,
199) -> RecoveredExpectedOperationDecision {
200    let Some(expected) = aggregate.expected.as_mut() else {
201        return RecoveredExpectedOperationDecision::NotAvailable {
202            aggregate,
203            already_issued: false,
204        };
205    };
206    if expected.issued {
207        return RecoveredExpectedOperationDecision::NotAvailable {
208            aggregate,
209            already_issued: true,
210        };
211    }
212    expected.issued = true;
213    let request = expected.request.clone();
214    let authorization = expected.authorization;
215    if matches!(request, ClientRequest::Detach(_)) {
216        aggregate.detach_replay.mark_initial_attempt_started();
217    }
218    RecoveredExpectedOperationDecision::Recovered {
219        aggregate,
220        operation: ExpectedParticipantOperation {
221            request,
222            authorization,
223        },
224    }
225}
226
227/// Reason a lost-authority resolution was refused.
228#[derive(Clone, Copy, Debug, PartialEq, Eq)]
229pub enum LostAuthorityResolutionRefusalReason {
230    /// No serialized lost-authority testimony is pending in this domain.
231    NoPendingTestimony,
232}
233
234/// Complete decision for resolving a restored operation-authority loss.
235///
236/// This is the sole recovery path for the operation-domain testimony minted by
237/// validated cold restore (`LP-CLIENT-GOAL` piece 4, r2, 2026-07-18). It takes
238/// no fate parameter: the serialized testimony inside the aggregate is the
239/// consumed value, so a process-loss fate is not publicly constructible and
240/// cannot terminalize an operation whose live correlation still exists.
241#[derive(Debug, PartialEq, Eq)]
242pub enum LostOperationAuthorityDecision {
243    /// The consumed testimony terminalized the lost non-detach operation.
244    Recorded {
245        /// Resulting aggregate with an empty expected-operation slot.
246        aggregate: ClientParticipantAggregate,
247        /// Exact operation whose issued authority was destroyed.
248        request: ClientRequest,
249        /// Consumed take-once testimony.
250        testimony: super::LostAuthorityTestimony,
251    },
252    /// The consumed testimony parked the exact-token detach for replay.
253    DetachParked {
254        /// Resulting aggregate with no live send effect.
255        aggregate: ClientParticipantAggregate,
256        /// Exact detach retained for replay.
257        request: ClientRequest,
258        /// Consumed take-once testimony.
259        testimony: super::LostAuthorityTestimony,
260    },
261    /// No pending testimony existed; the aggregate is unchanged.
262    Refused {
263        /// Unchanged aggregate.
264        aggregate: ClientParticipantAggregate,
265        /// Closed refusal reason.
266        reason: LostAuthorityResolutionRefusalReason,
267    },
268}
269
270/// Consumes the pending operation-domain lost-authority testimony exactly once.
271///
272/// A second call after consumption returns a typed refusal without mutation,
273/// so the take-once atom can never resolve the same loss twice.
274#[must_use]
275pub fn resolve_lost_operation_authority(
276    mut aggregate: ClientParticipantAggregate,
277) -> LostOperationAuthorityDecision {
278    let Some(expected) = aggregate.expected.as_mut() else {
279        return LostOperationAuthorityDecision::Refused {
280            aggregate,
281            reason: LostAuthorityResolutionRefusalReason::NoPendingTestimony,
282        };
283    };
284    let Some(testimony) = expected.lost.take() else {
285        return LostOperationAuthorityDecision::Refused {
286            aggregate,
287            reason: LostAuthorityResolutionRefusalReason::NoPendingTestimony,
288        };
289    };
290    if matches!(expected.request, ClientRequest::Detach(_)) {
291        expected.issued = false;
292        let request = expected.request.clone();
293        if let replay::DetachReplayState::Recorded { status, .. } =
294            &mut aggregate.detach_replay.state
295        {
296            *status = DetachReplayStatus::Parked;
297        }
298        return LostOperationAuthorityDecision::DetachParked {
299            aggregate,
300            request,
301            testimony,
302        };
303    }
304    let request = expected.request.clone();
305    aggregate.expected = None;
306    LostOperationAuthorityDecision::Recorded {
307        aggregate,
308        request,
309        testimony,
310    }
311}
312
313/// Typed transport fate for an issued non-detach operation awaiting a response.
314#[derive(Clone, Copy, Debug, PartialEq, Eq)]
315pub enum ExpectedOperationTransportFate {
316    /// The established transport ended before a correlated response arrived.
317    ResponseUnavailable,
318}
319
320/// Reason an expected-operation transport fate was refused.
321#[derive(Clone, Copy, Debug, PartialEq, Eq)]
322pub enum ExpectedOperationFateRefusalReason {
323    /// No issued expected operation is retained.
324    NoIssuedOperation,
325    /// Correlation belongs to an older operation.
326    StaleCorrelation,
327    /// Detach must use its lossless replay-specific transport fate.
328    DetachUsesReplayFate,
329    /// A restore already testified this issued authority destroyed; only the
330    /// pending testimony can resolve it (r2, 2026-07-18).
331    LostAuthorityPending,
332}
333
334/// Complete typed fate decision for an issued non-detach operation.
335#[derive(Debug, PartialEq, Eq)]
336pub enum ExpectedOperationFateDecision {
337    /// The lost response terminalized the expectation without inventing a response.
338    Recorded {
339        /// Resulting aggregate with an empty expected-operation slot.
340        aggregate: ClientParticipantAggregate,
341        /// Exact request whose response became unavailable.
342        request: ClientRequest,
343        /// Consumed transport fate.
344        fate: ExpectedOperationTransportFate,
345    },
346    /// Aggregate, correlation, and fate are unchanged.
347    Refused {
348        /// Unchanged aggregate.
349        aggregate: ClientParticipantAggregate,
350        /// Retained local correlation.
351        correlation: ClientResponseCorrelation,
352        /// Retained typed fate.
353        fate: ExpectedOperationTransportFate,
354        /// Closed refusal reason.
355        reason: ExpectedOperationFateRefusalReason,
356    },
357}
358
359/// Records transport loss for the exact issued non-detach operation.
360///
361/// This is the typed lifecycle exit for an issued expectation when no inbound
362/// value can arrive. Detach is refused because its exact replay state owns the
363/// corresponding lossless fate transition.
364#[must_use]
365pub fn record_expected_operation_fate(
366    mut aggregate: ClientParticipantAggregate,
367    correlation: ClientResponseCorrelation,
368    fate: ExpectedOperationTransportFate,
369) -> ExpectedOperationFateDecision {
370    let Some(expected) = aggregate.expected.as_ref() else {
371        return ExpectedOperationFateDecision::Refused {
372            aggregate,
373            correlation,
374            fate,
375            reason: ExpectedOperationFateRefusalReason::NoIssuedOperation,
376        };
377    };
378    if !expected.issued {
379        return ExpectedOperationFateDecision::Refused {
380            aggregate,
381            correlation,
382            fate,
383            reason: ExpectedOperationFateRefusalReason::NoIssuedOperation,
384        };
385    }
386    if expected.lost.is_some() {
387        return ExpectedOperationFateDecision::Refused {
388            aggregate,
389            correlation,
390            fate,
391            reason: ExpectedOperationFateRefusalReason::LostAuthorityPending,
392        };
393    }
394    if expected.authorization != correlation.authorization {
395        return ExpectedOperationFateDecision::Refused {
396            aggregate,
397            correlation,
398            fate,
399            reason: ExpectedOperationFateRefusalReason::StaleCorrelation,
400        };
401    }
402    if matches!(expected.request, ClientRequest::Detach(_)) {
403        return ExpectedOperationFateDecision::Refused {
404            aggregate,
405            correlation,
406            fate,
407            reason: ExpectedOperationFateRefusalReason::DetachUsesReplayFate,
408        };
409    }
410    let expected = aggregate.expected.take();
411    match expected {
412        Some(expected) => ExpectedOperationFateDecision::Recorded {
413            aggregate,
414            request: expected.request,
415            fate,
416        },
417        None => ExpectedOperationFateDecision::Refused {
418            aggregate,
419            correlation,
420            fate,
421            reason: ExpectedOperationFateRefusalReason::NoIssuedOperation,
422        },
423    }
424}
425
426/// Complete write-ahead admission decision.
427#[derive(Debug, PartialEq, Eq)]
428pub enum ClientOperationRecordDecision {
429    /// Durability must precede commit and execution.
430    Pending(ClientPendingOperationRecord),
431    /// Continuous acknowledgements execute without occupying the slot.
432    Continuous(ClientContinuousOperation),
433    /// The one permitted slot is already occupied.
434    Refused(ClientOperationRecordRefusal),
435}
436
437/// Records one operation behind the client durability barrier.
438///
439/// Continuous acknowledgements bypass the write-ahead slot. Every other request
440/// is rejected while an expected operation exists; the crate never queues or
441/// silently replaces it.
442#[must_use]
443pub fn record_operation(
444    mut aggregate: ClientParticipantAggregate,
445    request: ClientRequest,
446) -> ClientOperationRecordDecision {
447    if !aggregate.binding.accepts_request(&request) {
448        let reason = if aggregate.binding.is_left() {
449            ClientOperationRecordRefusalReason::AlreadyDead
450        } else {
451            ClientOperationRecordRefusalReason::BindingMismatch
452        };
453        return ClientOperationRecordDecision::Refused(ClientOperationRecordRefusal {
454            aggregate,
455            request,
456            reason,
457        });
458    }
459    if matches!(request, ClientRequest::ParticipantAck(_)) {
460        return ClientOperationRecordDecision::Continuous(ClientContinuousOperation {
461            aggregate,
462            operation: ExpectedParticipantOperation {
463                request,
464                authorization: 0,
465            },
466        });
467    }
468    if aggregate.expected.is_some() {
469        return ClientOperationRecordDecision::Refused(ClientOperationRecordRefusal {
470            aggregate,
471            request,
472            reason: ClientOperationRecordRefusalReason::OutstandingOperation,
473        });
474    }
475    if aggregate.restored_abandonment.is_some()
476        && matches!(request, ClientRequest::ObserverRecovery(_))
477    {
478        return ClientOperationRecordDecision::Refused(ClientOperationRecordRefusal {
479            aggregate,
480            request,
481            reason: ClientOperationRecordRefusalReason::AbandonmentPending,
482        });
483    }
484    let Some(authorization) = aggregate.next_operation_authorization.checked_add(1) else {
485        return ClientOperationRecordDecision::Refused(ClientOperationRecordRefusal {
486            aggregate,
487            request,
488            reason: ClientOperationRecordRefusalReason::AuthorizationExhausted,
489        });
490    };
491    let prior_replay = match admit_detach_replay(&mut aggregate, &request) {
492        Ok(prior_replay) => prior_replay,
493        Err(reason) => {
494            return ClientOperationRecordDecision::Refused(ClientOperationRecordRefusal {
495                aggregate,
496                request,
497                reason,
498            });
499        }
500    };
501    aggregate.next_operation_authorization = authorization;
502    aggregate.expected = Some(ExpectedOperationState {
503        request: request.clone(),
504        issued: false,
505        authorization,
506        lost: None,
507    });
508    ClientOperationRecordDecision::Pending(ClientPendingOperationRecord {
509        successor: aggregate,
510        operation: ExpectedParticipantOperation {
511            request,
512            authorization,
513        },
514        prior_replay,
515    })
516}
517
518/// Atomically admits a detach into the replay lifecycle during recording.
519///
520/// Re-recording the retained same envelope is admitted only over a `Parked`
521/// replay: a superseded, Leave-superseded, terminal, or in-flight replay
522/// status is not compatible with a fresh first send and refuses with a typed
523/// reason instead of reviving expected-detach authority over an inactive
524/// replay (r2, 2026-07-18).
525fn admit_detach_replay(
526    aggregate: &mut ClientParticipantAggregate,
527    request: &ClientRequest,
528) -> Result<Option<replay::DetachReplayState>, ClientOperationRecordRefusalReason> {
529    let ClientRequest::Detach(value) = request else {
530        return Ok(None);
531    };
532    let envelope = crate::wire::DetachEnvelope {
533        conversation_id: value.conversation_id,
534        participant_id: value.participant_id,
535        capability_generation: value.capability_generation,
536        detach_attempt_token: value.detach_attempt_token,
537    };
538    match &aggregate.detach_replay.state {
539        replay::DetachReplayState::Empty => {
540            let prior = aggregate.detach_replay.state.clone();
541            aggregate.detach_replay.state = replay::DetachReplayState::Recorded {
542                request: envelope,
543                status: DetachReplayStatus::Parked,
544            };
545            Ok(Some(prior))
546        }
547        replay::DetachReplayState::Recorded {
548            request: retained,
549            status,
550        } if retained == &envelope => {
551            if matches!(status, DetachReplayStatus::Parked) {
552                Ok(Some(aggregate.detach_replay.state.clone()))
553            } else {
554                Err(ClientOperationRecordRefusalReason::DetachReplayIncompatible)
555            }
556        }
557        replay::DetachReplayState::Recorded { .. }
558            if aggregate.detach_replay.can_replace_with(&envelope) =>
559        {
560            let prior = aggregate.detach_replay.state.clone();
561            aggregate.detach_replay.state = replay::DetachReplayState::Recorded {
562                request: envelope,
563                status: DetachReplayStatus::Parked,
564            };
565            Ok(Some(prior))
566        }
567        replay::DetachReplayState::Recorded { .. } => {
568            Err(ClientOperationRecordRefusalReason::DetachReplayOutstanding)
569        }
570    }
571}