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