liminal_protocol/client.rs
1//! Transport-agnostic client participant state and sealed effects.
2//!
3//! The client aggregate owns correlation, detach replay, and the durability
4//! barrier for one outstanding operation. The authorized round-3 order is
5//! commit-seal, persist the committed resume record, then release executable
6//! authority; pending has no durable form.
7//!
8//! # `LP-CLIENT-GOAL` API-shape rationale
9//!
10//! The Phase 1 brief requires authority-safe shapes rather than caller-owned
11//! state parts. The resulting public shape is deliberate:
12//!
13//! 1. Replay and reconnect transitions consume and return the root aggregate so
14//! their facts are persisted atomically; standalone state-part recomposition
15//! is not representable.
16//! 2. [`ReconnectAggregate`] has no public constructor because fresh detached
17//! reconnect state would separate permit identity from participant facts.
18//! 3. [`recover_reconnect_permit`] exists because an unissued committed cold
19//! record must release its permit once without making permits cloneable.
20//! 4. Only the commit seal exposes a durable record; pending has no encoder. The
21//! authorized round-3 order is commit-seal, persist committed `LPCR`, release.
22//! 5. The retained name [`crate::outcome::ReconnectDelayResult`] carries an
23//! event, never a delay: the brief explicitly supersedes timer scheduling.
24//! 6. A different retained detach is refused while live, but terminal or
25//! attach-superseded replay yields only to an exact newer-generation detach.
26//! 7. Terminalized-detach fixture construction remains `cfg(test)` only; wire
27//! authority construction is not weakened in production.
28//! 8. Detach recording is atomic with [`record_operation`], so no public raw
29//! envelope-to-replay mint or second caller-owned persistence step exists.
30//! 9. [`DetachTransportFate`] is closed to response unavailability because this
31//! protocol crate owns no socket, runtime, or transport handle.
32//! 10. [`DetachReplayOutcome`] is exhaustive so a generic server value cannot be
33//! relabeled by a caller as one of the three terminal detach outcomes.
34//! 11. Reconnect fresh-event producers are separate typed functions rather than
35//! a generic event-injection seam, limiting minting to the brief's classes.
36//! 12. [`recover_expected_operation`] is the one-use post-restore counterpart to
37//! the pending-to-commit barrier; detach recovery also marks replay in flight.
38//! 13. Replay inspection returns `None` only for its named Empty state; terminal
39//! payloads remain lossless and distinct.
40//! 14. There is no speculative persistence format. A pending-window crash means
41//! the operation did not happen and restart may record it again.
42//! 15. `RecordAdmission` responses are always ambiguous because wire identity is
43//! insufficient; `ObserverRecovery` compares its echoed list. As the M7
44//! companion rule, both tokenless classes resolve as typed abandoned on every
45//! restore and are never re-released. A later sealed-transport-context SDK leg
46//! may add outbound attempt tokens and lift this restriction.
47//! 16. A crash or restore that destroys live authority mints a serialized,
48//! take-once [`LostAuthorityTestimony`] with no public constructor
49//! (`LP-CLIENT-GOAL` piece 4, r2, 2026-07-18). Every public fate or
50//! recovery entry point consumes either a live one-use correlation, a
51//! one-use sealed attempt authority, or the pending serialized testimony;
52//! no publicly constructible process-fate value exists, so a fate that
53//! consumes neither is unrepresentable at the API surface.
54//! 17. Detached bindings retain the attach secret because the complete client
55//! record must remain capable of a later credential attach after restart.
56//! 18. Expected recovery and replay-start atomically share one detach issuance
57//! bit, guaranteeing one first-send authority in either call order.
58//! 19. Both pending atoms use the retain-in-bytes encode shape (`LP-CLIENT-GOAL`
59//! piece 2, r2, 2026-07-18): encoding an aggregate that holds a pending
60//! testimony or abandonment emits bytes that carry the atom, so
61//! encode-without-take loses nothing and no checkpoint window is refused.
62//! The refuse-while-pending shape was rejected because piece 4 requires the
63//! testimony to round-trip losslessly through the aggregate encoding.
64//! 20. Re-recording the retained detach envelope requires replay-status
65//! compatibility with a fresh first send: only `Parked` admits; superseded,
66//! Leave-superseded, terminal, and in-flight statuses refuse with typed
67//! [`ClientOperationRecordRefusalReason::DetachReplayIncompatible`]
68//! (r2, 2026-07-18), closing the round-4 door that revived expected-detach
69//! authority over an inactive replay.
70//! 21. [`ClientParticipantAggregate::lost_credential_attach`] is a pure READ of
71//! pending testimony, added because [`resolve_lost_operation_authority`] is
72//! destructive by design (#195, 2026-08-16). A recovery driver that owns one
73//! operation class had no way to ask whether the pending testimony was its
74//! class without already having spent the take-once atom on a path that
75//! still needed it — detach replay, or a tokenless abandonment. It mints
76//! nothing, consumes nothing, and moves no state, so it adds no row to the
77//! audit below and cannot widen any existing one; it only lets a caller
78//! decline a case without destroying it.
79//!
80//! # Exhaustive constructible-state audit
81//!
82//! Every state accepted by restore or reachable through a public apply path is
83//! listed here. The exhaustive conservation property test mechanically covers
84//! the **live authority** and **typed consumption** columns across 610 applied
85//! paths from a 4-operation × 11-action alphabet through depth 7, attempting
86//! every action at every step: 1041 attempts end in the crate's typed refusal
87//! and 1748 are unrepresentable because the consumed one-use value does not
88//! exist at the type level. Every row below is either reachable-and-tested or
89//! refused-by-construction with the refusing type named.
90//!
91//! | Owned state | Restore/apply acceptance | Live authority | Typed consumption / exit |
92//! |---|---|---:|---|
93//! | Binding `Unbound` | new, restore | 0 | enrollment/attach → `Bound`; identity-bound requests refuse |
94//! | Binding `Bound` | enrollment/attach, restore | 0 | detach result → `Detached`; Leave/Retired → `Left` |
95//! | Binding `Detached` | detach result, restore | 0 | exact-secret attach → `Bound`; Leave/Retired → `Left` |
96//! | Binding `Left` | correlated Leave/Retired, restore | 0 | permanent; inbound/outbound return `AlreadyDead` |
97//! | Expected `None` | new, abort, consumed outcome/fate, restore | 0 | one [`record_operation`] admission |
98//! | Tokenless expected, live process only | record/commit/release | 0 or 1 | exact response remains conservative; fate consumes; every restore converts it into the durable `TokenlessAfterCrash` abandonment atom below |
99//! | Token-bearing non-detach, unissued | committed `LPCR`, restore | 0 | [`recover_expected_operation`] issues once |
100//! | Token-bearing non-detach, issued | release/recovery, restore | 1 live, or serialized testimony after restore | correlated outcome/fate consumes the live correlation; restore mints the take-once testimony consumed only by [`resolve_lost_operation_authority`] |
101//! | Expected detach + replay `Parked` | commit, live fate, testimony resolution, restore only when exact and unissued | 0 | recovery or replay start issues exactly one effect |
102//! | Expected detach + replay `InFlight` | release/start, restore only when exact and issued | 1 live, or serialized testimony after restore | correlated outcome/fate consumes the live correlation; restore mints the take-once testimony whose [`resolve_lost_operation_authority`] resolution parks exact-token replay |
103//! | Expected detach + replay `Empty`, `Superseded`, `LeaveSuperseded`, or terminal | never accepted, unreachable by construction | 0 | refused at restore by `ClientResumeRestoreError::ExpectedDetachActiveReplayMismatch` and in live code by [`ClientOperationRecordRefusalReason::DetachReplayIncompatible`], which closes the only door that could re-couple an expected detach to an inactive replay |
104//! | Active replay without exact expected detach | never accepted | 0 | typed `ActiveReplayExpectedDetachMismatch` restore refusal; the write side refuses to encode the same decoupling with `ClientResumeRecordEncodeError::DecoupledDetachReplay` (field 2026-08-07: a generation-skipping attach once stranded a replay in exactly this shape) |
105//! | Replay `Empty` | new, abort, restore without expected detach | 0 | admitted detach records `Parked` atomically |
106//! | Replay `Superseded` | authority-consuming matching attach, restore | 0 | old generation terminal; exact newer generation may replace; same-envelope re-record refused by construction (`DetachReplayIncompatible`) |
107//! | Replay `LeaveSuperseded` | authority-consuming matching durable Leave, restore | 0 | proves only that matching Leave/retirement superseded replay; public [`apply_leave_durable`] does **not** change binding to `Left`; same-envelope re-record refused by construction (`DetachReplayIncompatible`) |
108//! | Replay terminal (three exact payload arms) | authority-consuming exact outcome, restore | 0 | lossless terminal; exact newer-generation detach may replace; same-envelope re-record refused by construction (`DetachReplayIncompatible`) |
109//! | Restored-loss testimony, operation slot | minted only by validated restore of an issued token-bearing operation; carried losslessly by encode/decode | counts as the destroyed authority | consumed exactly once by [`resolve_lost_operation_authority`]; coupling refusals in both directions: a serialized atom without its issued state (or with the wrong kind) is refused by construction by `ClientResumeRestoreError::LostAuthorityTestimonyMismatch`, and every live consumption of the testified state is refused reachable-and-tested by the typed `LostAuthorityPending` reasons on [`decide_correlated_inbound`], [`record_expected_operation_fate`], [`transport_fate`], [`apply_attach`], [`apply_leave_durable`], and [`apply_detach_outcome`] |
110//! | Restored-loss testimony, reconnect slot | minted only by validated restore of an issued permit or in-progress attempt; carried losslessly by encode/decode | counts as the destroyed authority | consumed exactly once by [`resolve_lost_reconnect_authority`]; coupling refusals in both directions: a serialized atom without its matching machine state is refused by construction by `ClientResumeRestoreError::LostAuthorityTestimonyMismatch`, and prior-process handles are refused reachable-and-tested by `ReconnectAttemptRefusalReason::LostAuthorityPending` and `ReconnectAttemptFateRefusalReason::LostAuthorityPending` |
111//! | Pending `TokenlessAfterCrash` abandonment | minted by restore of a tokenless expected operation; carried losslessly by encode/decode with its issued flag | `was_issued` marks a destroyed authority | taken exactly once by [`ClientParticipantAggregate::take_restored_operation_abandonment`]; a second take observes nothing; coupling refusals in both directions: a serialized abandonment beside a tokenless expected operation is refused by construction by `ClientResumeRestoreError::PendingAbandonmentConflict`, and tokenless admission while pending is refused reachable-and-tested by [`ClientOperationRecordRefusalReason::AbandonmentPending`] |
112//! | Reconnect `Parked` | new/failure/loss resolution, restore | 0 | typed fresh event → permit |
113//! | Reconnect permit unissued | committed restore testimony | 0 | one [`recover_reconnect_permit`] issue |
114//! | Reconnect permit issued | event/recovery, restore | 1 live, or serialized testimony after restore | held permit → attempt; restore mints the take-once testimony consumed only by [`resolve_lost_reconnect_authority`] |
115//! | Reconnect attempt | permit redemption, restore | 1 live, or serialized testimony after restore | held fate → `Online`/`Parked`; restore mints the take-once testimony consumed only by [`resolve_lost_reconnect_authority`] |
116//! | Reconnect `Online` | successful fate, restore | 0 | later typed fresh event → permit |
117
118use crate::wire::{ClientRequest, Generation, ParticipantAckEnvelope};
119
120/// Coarse client binding state without exposing credential-bearing state parts.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum ClientBindingStatus {
123 /// No participant binding has been established.
124 Unbound,
125 /// A live binding and attach credential are retained.
126 Bound,
127 /// The most recently correlated detach completed.
128 Detached,
129 /// A correlated durable Leave permanently retired the participant.
130 Left,
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub(super) enum ClientBindingState {
135 Unbound,
136 Bound {
137 conversation_id: u64,
138 participant_id: u64,
139 generation: Generation,
140 attach_secret: crate::wire::AttachSecret,
141 binding_epoch: crate::wire::BindingEpoch,
142 },
143 Detached {
144 conversation_id: u64,
145 participant_id: u64,
146 generation: Generation,
147 attach_secret: crate::wire::AttachSecret,
148 },
149 Left {
150 conversation_id: u64,
151 participant_id: u64,
152 generation: Generation,
153 },
154}
155
156impl ClientBindingState {
157 const fn status(&self) -> ClientBindingStatus {
158 match self {
159 Self::Unbound => ClientBindingStatus::Unbound,
160 Self::Bound { .. } => ClientBindingStatus::Bound,
161 Self::Detached { .. } => ClientBindingStatus::Detached,
162 Self::Left { .. } => ClientBindingStatus::Left,
163 }
164 }
165
166 const fn is_left(&self) -> bool {
167 matches!(self, Self::Left { .. })
168 }
169
170 fn matches_ack(&self, request: &ParticipantAckEnvelope) -> bool {
171 match self {
172 Self::Bound {
173 conversation_id,
174 participant_id,
175 generation,
176 ..
177 } => {
178 *conversation_id == request.conversation_id
179 && *participant_id == request.participant_id
180 && *generation == request.capability_generation
181 }
182 Self::Unbound | Self::Detached { .. } | Self::Left { .. } => false,
183 }
184 }
185
186 fn accepts_request(&self, request: &ClientRequest) -> bool {
187 if self.is_left() {
188 return false;
189 }
190 match request {
191 ClientRequest::Enrollment(_) => matches!(self, Self::Unbound),
192 ClientRequest::CredentialAttach(value) => {
193 matches!(self, Self::Unbound)
194 || self.matches_credential(
195 value.conversation_id,
196 value.participant_id,
197 value.capability_generation,
198 value.attach_secret,
199 true,
200 )
201 }
202 ClientRequest::Detach(value) => self.matches_identity(
203 value.conversation_id,
204 value.participant_id,
205 value.capability_generation,
206 false,
207 ),
208 ClientRequest::ParticipantAck(value) => self.matches_identity(
209 value.conversation_id,
210 value.participant_id,
211 value.capability_generation,
212 false,
213 ),
214 ClientRequest::Leave(value) => self.matches_credential(
215 value.conversation_id,
216 value.participant_id,
217 value.capability_generation,
218 value.attach_secret,
219 true,
220 ),
221 ClientRequest::MarkerAck(value) => self.matches_identity(
222 value.conversation_id,
223 value.participant_id,
224 value.capability_generation,
225 false,
226 ),
227 ClientRequest::RecordAdmission(value) => self.matches_identity(
228 value.conversation_id,
229 value.participant_id,
230 value.capability_generation,
231 false,
232 ),
233 ClientRequest::SettlementQuery(_) => false,
234 ClientRequest::ObserverRecovery(_) => true,
235 }
236 }
237
238 fn matches_identity(
239 &self,
240 conversation: u64,
241 participant: u64,
242 generation_value: Generation,
243 allow_detached: bool,
244 ) -> bool {
245 match self {
246 Self::Bound {
247 conversation_id,
248 participant_id,
249 generation,
250 ..
251 } => {
252 (*conversation_id, *participant_id, *generation)
253 == (conversation, participant, generation_value)
254 }
255 Self::Detached {
256 conversation_id,
257 participant_id,
258 generation,
259 ..
260 } if allow_detached => {
261 (*conversation_id, *participant_id, *generation)
262 == (conversation, participant, generation_value)
263 }
264 Self::Unbound | Self::Detached { .. } | Self::Left { .. } => false,
265 }
266 }
267
268 fn matches_credential(
269 &self,
270 conversation: u64,
271 participant: u64,
272 generation_value: Generation,
273 presented_secret: crate::wire::AttachSecret,
274 allow_detached: bool,
275 ) -> bool {
276 match self {
277 Self::Bound { attach_secret, .. } => {
278 *attach_secret == presented_secret
279 && self.matches_identity(
280 conversation,
281 participant,
282 generation_value,
283 allow_detached,
284 )
285 }
286 Self::Detached { attach_secret, .. } if allow_detached => {
287 *attach_secret == presented_secret
288 && self.matches_identity(conversation, participant, generation_value, true)
289 }
290 Self::Unbound | Self::Detached { .. } | Self::Left { .. } => false,
291 }
292 }
293}
294
295/// Closed description of which live process-local authority was destroyed.
296///
297/// The kind is descriptive only: no caller-suppliable kind value gates any
298/// authority transition (`LP-CLIENT-GOAL` piece 4, r2, 2026-07-18).
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300pub enum LostAuthorityKind {
301 /// An issued operation's one-use response correlation did not survive the
302 /// process.
303 IssuedOperationCorrelation,
304 /// An in-flight detach transport attempt did not survive the process.
305 DetachTransportAttempt,
306 /// An issued reconnect attempt permit did not survive the process.
307 ReconnectPermit,
308 /// An in-progress reconnect attempt did not survive the process.
309 ReconnectAttempt,
310}
311
312/// Serialized, take-once testimony that live process-local authority was
313/// destroyed by crash or restore (`LP-CLIENT-GOAL` piece 4, r2, 2026-07-18).
314///
315/// The crate mints this atom exactly when a validated cold restore accepts a
316/// state whose live authority did not survive the process. It is persisted in
317/// the aggregate encoding, so encode/decode round-trips it losslessly, and it
318/// is consumed exactly once by the one recovery path that resolves the loss:
319/// [`resolve_lost_operation_authority`] for the operation domain and
320/// [`resolve_lost_reconnect_authority`] for the reconnect domain. It has no
321/// public constructor, so a fate value that consumes neither a live one-use
322/// correlation nor this testimony is unrepresentable at the API surface.
323///
324/// ```compile_fail
325/// use liminal_protocol::client::{LostAuthorityKind, LostAuthorityTestimony};
326/// let _forged = LostAuthorityTestimony {
327/// kind: LostAuthorityKind::ReconnectPermit,
328/// };
329/// ```
330#[derive(Debug, PartialEq, Eq)]
331pub struct LostAuthorityTestimony {
332 kind: LostAuthorityKind,
333}
334
335impl LostAuthorityTestimony {
336 pub(super) const fn mint(kind: LostAuthorityKind) -> Self {
337 Self { kind }
338 }
339
340 /// Reports which destroyed authority this testimony records.
341 #[must_use]
342 pub const fn kind(&self) -> LostAuthorityKind {
343 self.kind
344 }
345}
346
347#[derive(Debug, PartialEq, Eq)]
348pub(super) struct ExpectedOperationState {
349 pub(super) request: ClientRequest,
350 pub(super) issued: bool,
351 pub(super) authorization: u64,
352 pub(super) lost: Option<LostAuthorityTestimony>,
353}
354
355/// Why a persisted expected operation was deliberately not re-released.
356#[derive(Clone, Copy, Debug, PartialEq, Eq)]
357pub enum RestoredExpectedOperationAbandonmentReason {
358 /// The operation class has no outbound attempt token and cannot be proven
359 /// unsent after a crash.
360 TokenlessAfterCrash,
361}
362
363/// Typed restore resolution for an operation that cannot safely be re-issued.
364///
365/// The abandonment is durable (`LP-CLIENT-GOAL` piece 4, r2, 2026-07-18): it
366/// is serialized in the aggregate encoding, survives encode-without-take, and
367/// is consumed exactly once by
368/// [`ClientParticipantAggregate::take_restored_operation_abandonment`].
369#[derive(Debug, PartialEq, Eq)]
370pub struct RestoredExpectedOperationAbandonment {
371 pub(super) request: ClientRequest,
372 pub(super) reason: RestoredExpectedOperationAbandonmentReason,
373 pub(super) was_issued: bool,
374}
375
376impl RestoredExpectedOperationAbandonment {
377 /// Borrows the exact operation the restore boundary abandoned.
378 #[must_use]
379 pub const fn request(&self) -> &ClientRequest {
380 &self.request
381 }
382
383 /// Reports the closed abandonment reason.
384 #[must_use]
385 pub const fn reason(&self) -> RestoredExpectedOperationAbandonmentReason {
386 self.reason
387 }
388
389 /// Reports whether the abandoned operation had been issued before the
390 /// crash, meaning a live send authority was destroyed with the process.
391 #[must_use]
392 pub const fn was_issued(&self) -> bool {
393 self.was_issued
394 }
395
396 /// Consumes the resolution into the request callers may explicitly re-record.
397 #[must_use]
398 pub fn into_request(self) -> ClientRequest {
399 self.request
400 }
401}
402
403/// Non-cloneable client participant state shell.
404///
405/// Its expected operation, credential-bearing binding, replay request, and
406/// reconnect state are private so callers must delegate every decision. This
407/// brief-required root ownership prevents callers from recombining independently
408/// persisted authorities into a state the crate never validated.
409#[derive(Debug, PartialEq, Eq)]
410pub struct ClientParticipantAggregate {
411 pub(super) binding: ClientBindingState,
412 pub(super) expected: Option<ExpectedOperationState>,
413 pub(super) next_operation_authorization: u64,
414 pub(super) detach_replay: SdkDetachReplayAggregate,
415 pub(super) reconnect: ReconnectAggregate,
416 pub(super) restored_abandonment: Option<RestoredExpectedOperationAbandonment>,
417}
418
419impl ClientParticipantAggregate {
420 /// Creates a fresh unbound client aggregate.
421 #[must_use]
422 pub const fn new() -> Self {
423 Self {
424 binding: ClientBindingState::Unbound,
425 expected: None,
426 next_operation_authorization: 0,
427 detach_replay: SdkDetachReplayAggregate::new(),
428 reconnect: ReconnectAggregate::new(),
429 restored_abandonment: None,
430 }
431 }
432
433 /// Reports binding status without exposing credential-bearing state.
434 #[must_use]
435 pub const fn binding_status(&self) -> ClientBindingStatus {
436 self.binding.status()
437 }
438
439 /// Reports whether one write-ahead operation is outstanding.
440 #[must_use]
441 pub const fn has_expected_operation(&self) -> bool {
442 self.expected.is_some()
443 }
444
445 /// Borrows the detach replay aggregate for status inspection.
446 #[must_use]
447 pub const fn detach_replay(&self) -> &SdkDetachReplayAggregate {
448 &self.detach_replay
449 }
450
451 /// Borrows the reconnect aggregate for status inspection.
452 #[must_use]
453 pub const fn reconnect(&self) -> &ReconnectAggregate {
454 &self.reconnect
455 }
456
457 /// Takes the typed tokenless-operation resolution produced by cold restore.
458 ///
459 /// The expected slot is already empty when this value exists; taking the
460 /// event cannot mint or release executable authority. The abandonment is
461 /// durable until taken: encode-without-take retains it, and a second take
462 /// observes nothing (`LP-CLIENT-GOAL` piece 4, r2, 2026-07-18).
463 #[must_use]
464 pub const fn take_restored_operation_abandonment(
465 &mut self,
466 ) -> Option<RestoredExpectedOperationAbandonment> {
467 self.restored_abandonment.take()
468 }
469
470 /// Borrows the pending tokenless abandonment without consuming it.
471 #[must_use]
472 pub const fn restored_operation_abandonment(
473 &self,
474 ) -> Option<&RestoredExpectedOperationAbandonment> {
475 self.restored_abandonment.as_ref()
476 }
477
478 /// Borrows the pending operation-domain lost-authority testimony, if any.
479 ///
480 /// While this testimony is pending, every correlation-consuming operation
481 /// path refuses with a typed lost-authority reason; only
482 /// [`resolve_lost_operation_authority`] consumes it.
483 #[must_use]
484 pub const fn lost_operation_testimony(&self) -> Option<&LostAuthorityTestimony> {
485 match &self.expected {
486 Some(expected) => expected.lost.as_ref(),
487 None => None,
488 }
489 }
490
491 /// Borrows the pending reconnect-domain lost-authority testimony, if any.
492 ///
493 /// While this testimony is pending, permit redemption and attempt fates
494 /// refuse with a typed lost-authority reason; only
495 /// [`resolve_lost_reconnect_authority`] consumes it.
496 #[must_use]
497 pub const fn lost_reconnect_testimony(&self) -> Option<&LostAuthorityTestimony> {
498 self.reconnect.lost.as_ref()
499 }
500
501 /// Borrows the exact retained credential-attach envelope whose issued send
502 /// authority a restore testified destroyed.
503 ///
504 /// This is a DRIVER-SUPPORT READ and mints nothing: it releases no
505 /// authority, consumes no testimony, and moves no state. It exists because
506 /// [`resolve_lost_operation_authority`] is destructive — it consumes the
507 /// take-once testimony to hand back the request — so a recovery driver that
508 /// owns only ONE operation class has no way to discover whether the pending
509 /// testimony is its class without already having spent it on an operation
510 /// belonging to a different path. Detach's replay machinery and the
511 /// tokenless abandonment atom are exactly such paths, and a driver that
512 /// consumed their testimony to find out it should not have would destroy
513 /// the resolution they depend on (#195, 2026-08-16).
514 ///
515 /// `Some` requires all three facts together: the retained operation is a
516 /// [`ClientRequest::CredentialAttach`], it was ISSUED, and a lost-authority
517 /// testimony is pending against it. That triple is precisely the
518 /// killed-mid-attach orphan — the client's attach reached the server, the
519 /// answer that carries the rotated credential did not reach the client, and
520 /// the process died holding the retained envelope that can still be
521 /// re-presented inside the server's receipt window.
522 #[must_use]
523 pub const fn lost_credential_attach(&self) -> Option<&crate::wire::CredentialAttachRequest> {
524 match &self.expected {
525 Some(ExpectedOperationState {
526 request: ClientRequest::CredentialAttach(request),
527 issued: true,
528 lost: Some(_),
529 ..
530 }) => Some(request),
531 _ => None,
532 }
533 }
534
535 pub(super) const fn operation_loss_pending(&self) -> bool {
536 self.lost_operation_testimony().is_some()
537 }
538}
539
540impl Default for ClientParticipantAggregate {
541 fn default() -> Self {
542 Self::new()
543 }
544}
545
546mod barrier;
547mod correlation;
548mod inbound;
549mod reconnect;
550mod replay;
551mod resume;
552mod resume_decode;
553mod resume_encode;
554
555pub use barrier::*;
556pub use inbound::{
557 ClientCorrelatedInboundDecision, ClientCorrelatedInboundRefusal,
558 ClientCorrelatedInboundRetained, ClientInboundApplied, ClientInboundDecision,
559 ClientInboundRefusal, ClientInboundRefusalReason, decide_correlated_inbound, decide_inbound,
560};
561pub use reconnect::{
562 EstablishedConnectionTransportFate, ExplicitReconnectAction, LostReconnectAuthorityDecision,
563 ProvedOnlineTransition, ReconnectAggregate, ReconnectAttemptDecision, ReconnectAttemptFate,
564 ReconnectAttemptFateDecision, ReconnectAttemptFateRefusalReason, ReconnectAttemptPermit,
565 ReconnectAttemptRefusalReason, ReconnectFreshEvent, ReconnectInProgressAttempt,
566 ReconnectPermitDecision, ReconnectPermitRefusal, ReconnectPermitRefusalReason,
567 RecoveredReconnectPermitDecision, record_attempt_fate, record_explicit_reconnect,
568 record_online_transition, record_transport_fate, recover_reconnect_permit, redeem_attempt,
569 resolve_lost_reconnect_authority,
570};
571pub use replay::{
572 ApplyAttachDecision, ApplyDetachOutcomeDecision, ApplyLeaveDecision, DetachAuthorityRefused,
573 DetachReplayApplied, DetachReplayOutcome, DetachReplayRefusal, DetachReplayRefusalReason,
574 DetachReplayStatus, DetachReplayTerminal, DetachTransportAttempt,
575 DetachTransportAttemptDecision, DetachTransportFate, DetachTransportFateDecision,
576 SdkDetachReplayAggregate, apply_attach, apply_detach_outcome, apply_leave_durable,
577 transport_attempt_started, transport_fate,
578};
579pub use resume::{
580 ClientResumeRecord, ClientResumeRecordDecodeError, ClientResumeRecordEncodeError,
581 ClientResumeRecordSection, ClientResumeRestoreError,
582};
583
584#[cfg(test)]
585mod a5_settlement_correlation_tests;
586#[cfg(test)]
587mod authority_property_tests;
588#[cfg(test)]
589mod d1_flip_tests;
590#[cfg(test)]
591mod detach_authority_settlement_tests;
592#[cfg(test)]
593mod gen_skip_supersession_tests;
594#[cfg(test)]
595mod p0_62_stranded_handle_tests;
596#[cfg(test)]
597mod r2_tests;
598#[cfg(test)]
599mod resume_tests;
600#[cfg(test)]
601mod review_tests;
602#[cfg(test)]
603mod rider_tests;
604#[cfg(test)]
605mod round3_tests;
606#[cfg(test)]
607mod round4_tests;
608#[cfg(test)]
609mod tests;