Skip to main content

liminal_protocol/lifecycle/operations/
nonzero_participant_ack.rs

1use alloc::boxed::Box;
2
3use crate::wire::{
4    AckCommitted, BindingEpoch, ConversationId, DeliverySeq, Generation, ParticipantAck,
5    ParticipantAckResponse, ParticipantId,
6};
7
8use super::super::{
9    BindingRequiredLookupResult, BindingState, CumulativeAckAuthorizationError,
10    CumulativeAckOutcome, LiveMember, NonzeroDebtCursorEpisode, ObserverProgressProjection,
11    ParticipantBindingRequest, PresentedIdentity, RecipientAckObligations,
12    RecipientAckObligationsContextError, SealedBindingFateToken, lookup_binding_required,
13    membership::{LiveMemberCursorUpdate, LiveMemberCursorUpdateError},
14};
15
16/// Durable episode position supplied while applying an aggregate ack commit.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum NonzeroAckEpisodePosition {
19    /// Exact episode from which the commit was selected.
20    Before,
21    /// Exact episode already produced by the commit.
22    Resulting,
23}
24
25/// Atomic nonzero-debt participant-ack commit.
26///
27/// The commit retains both exact episode prestates plus the crate-private
28/// membership cursor authority. [`Self::apply_to`] validates the aggregate as
29/// one pair before changing either value, so consuming storage can persist the
30/// resulting member and episode in one transaction and replay that transaction
31/// from either wholly-old or wholly-resulting durable state.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct NonzeroParticipantAckCommit {
34    outcome: AckCommitted,
35    from_cursor: DeliverySeq,
36    binding_epoch: BindingEpoch,
37    before_episode: NonzeroDebtCursorEpisode,
38    resulting_episode: NonzeroDebtCursorEpisode,
39    cursor_update: LiveMemberCursorUpdate,
40}
41
42impl NonzeroParticipantAckCommit {
43    /// Borrows the exact committed wire outcome.
44    #[must_use]
45    pub const fn outcome(&self) -> &AckCommitted {
46        &self.outcome
47    }
48
49    /// Projects the exact committed ack boundary into hard observer progress.
50    #[must_use]
51    pub const fn observer_progress_projection(&self) -> ObserverProgressProjection {
52        let request = self.outcome.request();
53        ObserverProgressProjection::new(request.conversation_id, request.through_seq)
54    }
55
56    /// Borrows the exact episode that must be persisted with the member cursor.
57    #[must_use]
58    pub const fn resulting_episode(&self) -> &NonzeroDebtCursorEpisode {
59        &self.resulting_episode
60    }
61
62    /// Replays this exact selected cursor transition into one sealed fate token.
63    ///
64    /// # Errors
65    ///
66    /// Returns the unchanged token when its conversation, participant, binding
67    /// epoch, or cursor prestate differs from this committed acknowledgement.
68    pub fn progress_binding_fate_token(
69        &self,
70        token: SealedBindingFateToken,
71    ) -> Result<SealedBindingFateToken, Box<SealedBindingFateToken>> {
72        let request = self.outcome.request();
73        token.participant_ack_progressed(
74            request.conversation_id,
75            request.participant_id,
76            self.binding_epoch,
77            self.from_cursor,
78            request.through_seq,
79        )
80    }
81
82    /// Applies this aggregate commit from an exact wholly-old or
83    /// wholly-resulting durable prestate.
84    ///
85    /// The old pair advances once. Replaying against the exact resulting pair
86    /// returns the identical [`AckCommitted`] without another change. A split
87    /// member/episode pair is rejected rather than silently repaired.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`NonzeroParticipantAckCommitError`] for a mismatched member,
92    /// unrelated episode, or split aggregate prestate. Neither argument is
93    /// changed on error.
94    pub fn apply_to<F>(
95        self,
96        member: &mut LiveMember<F>,
97        episode: &mut NonzeroDebtCursorEpisode,
98    ) -> Result<AckCommitted, NonzeroParticipantAckCommitError> {
99        let request = self.outcome.request();
100        if member.conversation_id() != request.conversation_id {
101            return Err(NonzeroParticipantAckCommitError::Conversation {
102                expected: request.conversation_id,
103                actual: member.conversation_id(),
104            });
105        }
106        if member.participant_id() != request.participant_id {
107            return Err(NonzeroParticipantAckCommitError::Participant {
108                expected: request.participant_id,
109                actual: member.participant_id(),
110            });
111        }
112        if member.generation() != request.capability_generation {
113            return Err(NonzeroParticipantAckCommitError::Generation {
114                expected: request.capability_generation,
115                actual: member.generation(),
116            });
117        }
118
119        let (position, expected_cursor) = if *episode == self.before_episode {
120            (NonzeroAckEpisodePosition::Before, self.from_cursor)
121        } else if *episode == self.resulting_episode {
122            (NonzeroAckEpisodePosition::Resulting, request.through_seq)
123        } else {
124            return Err(NonzeroParticipantAckCommitError::EpisodePrestate);
125        };
126        if member.cursor() != expected_cursor {
127            return Err(NonzeroParticipantAckCommitError::AggregateCursorPrestate {
128                episode_position: position,
129                expected_cursor,
130                actual_cursor: member.cursor(),
131            });
132        }
133
134        member
135            .apply_cursor_update(self.cursor_update)
136            .map_err(NonzeroParticipantAckCommitError::from_member_error)?;
137        if position == NonzeroAckEpisodePosition::Before {
138            *episode = self.resulting_episode;
139        }
140        Ok(self.outcome)
141    }
142}
143
144/// Failure while applying an already-selected nonzero-debt ack commit.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum NonzeroParticipantAckCommitError {
147    /// Commit belongs to another conversation.
148    Conversation {
149        /// Conversation captured by the commit.
150        expected: ConversationId,
151        /// Conversation carried by the supplied member.
152        actual: ConversationId,
153    },
154    /// Commit belongs to another participant.
155    Participant {
156        /// Participant captured by the commit.
157        expected: ParticipantId,
158        /// Participant carried by the supplied member.
159        actual: ParticipantId,
160    },
161    /// Commit belongs to another credential generation.
162    Generation {
163        /// Generation captured by the commit.
164        expected: Generation,
165        /// Generation carried by the supplied member.
166        actual: Generation,
167    },
168    /// Supplied episode is neither the exact old nor exact resulting state.
169    EpisodePrestate,
170    /// Member cursor disagrees with the supplied old/resulting episode state.
171    AggregateCursorPrestate {
172        /// Which exact episode state was supplied.
173        episode_position: NonzeroAckEpisodePosition,
174        /// Cursor required by that episode position.
175        expected_cursor: DeliverySeq,
176        /// Cursor carried by the supplied member.
177        actual_cursor: DeliverySeq,
178    },
179    /// A malformed internal update did not strictly advance its source cursor.
180    NonAdvancing {
181        /// Cursor from which the update was selected.
182        from_cursor: DeliverySeq,
183        /// Proposed resulting cursor.
184        resulting_cursor: DeliverySeq,
185    },
186    /// The opaque member update disagreed with the already-validated pair.
187    CursorPrestate {
188        /// Cursor from which the commit was selected.
189        expected_from_cursor: DeliverySeq,
190        /// Cursor produced by the commit.
191        resulting_cursor: DeliverySeq,
192        /// Cursor carried by the supplied member.
193        actual_cursor: DeliverySeq,
194    },
195}
196
197impl NonzeroParticipantAckCommitError {
198    const fn from_member_error(error: LiveMemberCursorUpdateError) -> Self {
199        match error {
200            LiveMemberCursorUpdateError::Conversation { expected, actual } => {
201                Self::Conversation { expected, actual }
202            }
203            LiveMemberCursorUpdateError::Participant { expected, actual } => {
204                Self::Participant { expected, actual }
205            }
206            LiveMemberCursorUpdateError::Generation { expected, actual } => {
207                Self::Generation { expected, actual }
208            }
209            LiveMemberCursorUpdateError::NonAdvancing {
210                from_cursor,
211                resulting_cursor,
212            } => Self::NonAdvancing {
213                from_cursor,
214                resulting_cursor,
215            },
216            LiveMemberCursorUpdateError::CursorPrestate {
217                expected_from_cursor,
218                resulting_cursor,
219                actual_cursor,
220            } => Self::CursorPrestate {
221                expected_from_cursor,
222                resulting_cursor,
223                actual_cursor,
224            },
225        }
226    }
227}
228
229/// Durable aggregate mismatch found after common request authority succeeded.
230#[derive(Clone, Copy, Debug, PartialEq, Eq)]
231pub enum NonzeroParticipantAckInvariantError {
232    /// Episode belongs to another conversation.
233    Conversation {
234        /// Conversation owned by the selected member.
235        member: ConversationId,
236        /// Conversation owned by the episode.
237        episode: ConversationId,
238    },
239    /// Episode has no entry for the selected permanent participant index.
240    ParticipantMissing {
241        /// Selected participant identifier/index.
242        participant_id: ParticipantId,
243    },
244    /// Episode entry carries another credential generation.
245    Generation {
246        /// Current member generation.
247        member: Generation,
248        /// Generation carried by the episode binding epoch.
249        episode: Generation,
250    },
251    /// Episode entry cursor differs from durable membership.
252    Cursor {
253        /// Durable member cursor.
254        member: DeliverySeq,
255        /// Episode entry cursor.
256        episode: DeliverySeq,
257    },
258    /// Episode entry carries another active binding epoch.
259    BindingEpoch {
260        /// Exact binding authorized by common lookup.
261        active: BindingEpoch,
262        /// Binding epoch carried by the episode entry.
263        episode: BindingEpoch,
264    },
265    /// Existing episode transition rejected an otherwise-validated aggregate.
266    EpisodeTransition(CumulativeAckAuthorizationError),
267}
268
269/// Total nonzero-debt participant-ack decision.
270#[derive(Clone, Debug, PartialEq, Eq)]
271pub enum NonzeroParticipantAckDecision {
272    /// Authority, regression, no-op, or gap response; aggregate is unchanged.
273    Respond(ParticipantAckResponse),
274    /// Durable aggregate state is internally inconsistent; nothing changed.
275    Invariant(NonzeroParticipantAckInvariantError),
276    /// Exact committed response paired with the sole aggregate mutation.
277    Commit(Box<NonzeroParticipantAckCommit>),
278}
279
280/// Applies the complete nonzero-debt normal-ack selector.
281///
282/// Shared participant lookup runs first. Only its authorized arm may inspect
283/// the episode. The selected member and episode must then agree, in order, on
284/// conversation, participant, generation, current cursor, and exact active
285/// binding epoch. The existing episode transition remains the sole owner of
286/// gap selection, participant-scoped cursor facts, and floor computation.
287/// Refusals and invariant errors borrow the prestate and cannot mutate it.
288#[must_use]
289pub fn apply_nonzero_participant_ack<EF, V, LF>(
290    presented_identity: PresentedIdentity<'_, EF, V, LF>,
291    binding: &BindingState,
292    receiving_binding_epoch: BindingEpoch,
293    request: &ParticipantAck,
294    contiguously_available_through: DeliverySeq,
295    episode: &NonzeroDebtCursorEpisode,
296) -> NonzeroParticipantAckDecision {
297    apply_nonzero_participant_ack_by_availability(
298        presented_identity,
299        binding,
300        receiving_binding_epoch,
301        request,
302        &AckAvailability::Contiguous(contiguously_available_through),
303        episode,
304    )
305}
306
307/// Applies the nonzero-debt selector with durable recipient obligations.
308///
309/// Shared lookup and aggregate validation retain their existing precedence. The
310/// episode then requires a forward endpoint to occur in the sealed recipient
311/// obligation index; internal conversation sequence gaps remain legal.
312#[must_use]
313pub fn apply_nonzero_participant_ack_with_obligations<EF, V, LF>(
314    presented_identity: PresentedIdentity<'_, EF, V, LF>,
315    binding: &BindingState,
316    receiving_binding_epoch: BindingEpoch,
317    request: &ParticipantAck,
318    obligations: &RecipientAckObligations,
319    episode: &NonzeroDebtCursorEpisode,
320) -> NonzeroParticipantAckDecision {
321    apply_nonzero_participant_ack_by_availability(
322        presented_identity,
323        binding,
324        receiving_binding_epoch,
325        request,
326        &AckAvailability::Obligations(obligations),
327        episode,
328    )
329}
330
331/// Returns the sealed scalar audit for an endpoint in this recipient index.
332///
333/// `None` is the typed sparse-gap boundary; callers must not select the scalar
334/// sibling as a fallback there.
335///
336/// # Errors
337///
338/// Returns [`RecipientAckObligationsContextError`] if the testimony belongs to
339/// another participant or acknowledged prestate.
340pub fn scalar_audit_for_recipient_endpoint(
341    obligations: &RecipientAckObligations,
342    participant_id: ParticipantId,
343    acknowledged_through: DeliverySeq,
344    endpoint: DeliverySeq,
345    scalar_audit: DeliverySeq,
346) -> Result<Option<DeliverySeq>, RecipientAckObligationsContextError> {
347    obligations
348        .contains_endpoint(participant_id, acknowledged_through, endpoint)
349        .map(|testified| testified.then_some(scalar_audit))
350}
351
352enum AckAvailability<'a> {
353    Contiguous(DeliverySeq),
354    Obligations(&'a RecipientAckObligations),
355}
356
357fn apply_nonzero_participant_ack_by_availability<EF, V, LF>(
358    presented_identity: PresentedIdentity<'_, EF, V, LF>,
359    binding: &BindingState,
360    receiving_binding_epoch: BindingEpoch,
361    request: &ParticipantAck,
362    availability: &AckAvailability<'_>,
363    episode: &NonzeroDebtCursorEpisode,
364) -> NonzeroParticipantAckDecision {
365    let lookup_request = ParticipantBindingRequest::ParticipantAck(request.clone());
366    let (member, active_binding) = match lookup_binding_required(
367        presented_identity,
368        binding,
369        Some(receiving_binding_epoch),
370        &lookup_request,
371    ) {
372        BindingRequiredLookupResult::Retired(outcome) => {
373            return NonzeroParticipantAckDecision::Respond(ParticipantAckResponse::from_retired(
374                outcome,
375            ));
376        }
377        BindingRequiredLookupResult::ParticipantUnknown(outcome) => {
378            return NonzeroParticipantAckDecision::Respond(
379                ParticipantAckResponse::from_participant_unknown(outcome),
380            );
381        }
382        BindingRequiredLookupResult::StaleAuthority(outcome) => {
383            return NonzeroParticipantAckDecision::Respond(
384                ParticipantAckResponse::from_stale_authority(outcome),
385            );
386        }
387        BindingRequiredLookupResult::NoBinding(outcome) => {
388            return NonzeroParticipantAckDecision::Respond(
389                ParticipantAckResponse::from_no_binding(outcome),
390            );
391        }
392        BindingRequiredLookupResult::Authorized { member, binding } => (member, binding),
393    };
394
395    if let Err(error) = validate_aggregate(member, active_binding.binding_epoch, episode) {
396        return NonzeroParticipantAckDecision::Invariant(error);
397    }
398
399    let mut resulting_episode = episode.clone();
400    let selected = match availability {
401        AckAvailability::Contiguous(available_through) => resulting_episode.acknowledge(
402            member.participant_id(),
403            active_binding.binding_epoch,
404            request,
405            *available_through,
406        ),
407        AckAvailability::Obligations(obligations) => resulting_episode
408            .acknowledge_with_obligations(
409                member.participant_id(),
410                active_binding.binding_epoch,
411                request,
412                obligations,
413            ),
414    };
415    let outcome = match selected {
416        Ok(outcome) => outcome,
417        Err(error) => {
418            return NonzeroParticipantAckDecision::Invariant(
419                NonzeroParticipantAckInvariantError::EpisodeTransition(error),
420            );
421        }
422    };
423    match outcome {
424        CumulativeAckOutcome::Committed(outcome) => {
425            NonzeroParticipantAckDecision::Commit(Box::new(NonzeroParticipantAckCommit {
426                cursor_update: LiveMemberCursorUpdate::new(
427                    request.conversation_id,
428                    request.participant_id,
429                    request.capability_generation,
430                    member.cursor(),
431                    request.through_seq,
432                ),
433                outcome,
434                binding_epoch: active_binding.binding_epoch,
435                from_cursor: member.cursor(),
436                before_episode: episode.clone(),
437                resulting_episode,
438            }))
439        }
440        CumulativeAckOutcome::NoOp(outcome) => {
441            NonzeroParticipantAckDecision::Respond(ParticipantAckResponse::from_ack_no_op(outcome))
442        }
443        CumulativeAckOutcome::Gap(outcome) => {
444            NonzeroParticipantAckDecision::Respond(ParticipantAckResponse::ack_gap(outcome))
445        }
446        CumulativeAckOutcome::Regression(outcome) => {
447            NonzeroParticipantAckDecision::Respond(ParticipantAckResponse::ack_regression(outcome))
448        }
449    }
450}
451
452fn validate_aggregate<F>(
453    member: &LiveMember<F>,
454    active_binding_epoch: BindingEpoch,
455    episode: &NonzeroDebtCursorEpisode,
456) -> Result<(), NonzeroParticipantAckInvariantError> {
457    if member.conversation_id() != episode.conversation_id() {
458        return Err(NonzeroParticipantAckInvariantError::Conversation {
459            member: member.conversation_id(),
460            episode: episode.conversation_id(),
461        });
462    }
463    let Some(episode_participant) = episode.participant(member.participant_id()) else {
464        return Err(NonzeroParticipantAckInvariantError::ParticipantMissing {
465            participant_id: member.participant_id(),
466        });
467    };
468    let episode_generation = episode_participant
469        .active_binding_epoch()
470        .capability_generation;
471    if episode_generation != member.generation() {
472        return Err(NonzeroParticipantAckInvariantError::Generation {
473            member: member.generation(),
474            episode: episode_generation,
475        });
476    }
477    if episode_participant.cursor() != member.cursor() {
478        return Err(NonzeroParticipantAckInvariantError::Cursor {
479            member: member.cursor(),
480            episode: episode_participant.cursor(),
481        });
482    }
483    if episode_participant.active_binding_epoch() != active_binding_epoch {
484        return Err(NonzeroParticipantAckInvariantError::BindingEpoch {
485            active: active_binding_epoch,
486            episode: episode_participant.active_binding_epoch(),
487        });
488    }
489    Ok(())
490}