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