Skip to main content

liminal_protocol/lifecycle/
cursor_facts.rs

1use alloc::{collections::BTreeMap, vec::Vec};
2
3use crate::algebra::{FloorComputation, floor_transition};
4use crate::wire::{
5    AckCommitted, AckGap, AckNoOp, AckRegression, BindingEpoch, ConversationId, DeliverySeq,
6    ParticipantAck, ParticipantAckEnvelope, ParticipantId, ParticipantIndex,
7};
8
9use super::{ClaimFrontiers, FrontierBinding, edge::ClosureDebt};
10
11/// Participant-scoped cursor progress key mandated by extraction Fix 2.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
13pub struct CursorProgressKey {
14    /// Permanent participant index.
15    pub participant_index: ParticipantIndex,
16    /// Requested cumulative boundary.
17    pub boundary: DeliverySeq,
18}
19
20/// Durable cursor-progress fact state.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum CursorProgressFact {
23    /// Boundary remains independently fireable for this participant.
24    Pending,
25    /// Boundary was covered by this participant's committed cumulative ack.
26    Consumed,
27}
28
29/// Variable participant-scoped cursor facts; no fixed occurrence array exists.
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
31pub struct CursorProgressFacts {
32    facts: BTreeMap<CursorProgressKey, CursorProgressFact>,
33}
34
35/// Validated durable delivery obligations for one participant ack frontier.
36///
37/// The outbox supplies its sorted live-obligation index, while the protocol
38/// owns participant/frontier binding and endpoint membership. Internal
39/// conversation-sequence gaps are legal; a forward ack endpoint must occur in
40/// this exact recipient index.
41///
42/// This testimony is move-only and cannot be assembled from independent public
43/// fields:
44///
45/// ```compile_fail
46/// use liminal_protocol::lifecycle::RecipientAckObligations;
47///
48/// fn clone_testimony(value: &RecipientAckObligations) -> RecipientAckObligations {
49///     value.clone()
50/// }
51/// ```
52#[derive(Debug, PartialEq, Eq)]
53pub struct RecipientAckObligations {
54    participant_id: ParticipantId,
55    acknowledged_through: DeliverySeq,
56    delivery_sequences: Vec<DeliverySeq>,
57}
58
59/// Malformed durable recipient-obligation testimony.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum RecipientAckObligationsError {
62    /// An indexed obligation is already at or below the durable ack frontier.
63    NotLive {
64        /// Durable participant acknowledgement frontier.
65        acknowledged_through: DeliverySeq,
66        /// Invalid obligation endpoint.
67        delivery_seq: DeliverySeq,
68    },
69    /// Obligation endpoints are not strictly increasing and duplicate-free.
70    NotStrictlyIncreasing {
71        /// Previous obligation in the supplied index.
72        previous: DeliverySeq,
73        /// Current non-increasing obligation.
74        current: DeliverySeq,
75    },
76}
77
78/// The testimony belongs to another participant or durable ack prestate.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum RecipientAckObligationsContextError {
81    /// The testimony names another permanent participant.
82    Participant {
83        /// Participant required by the authorized request.
84        expected: ParticipantId,
85        /// Participant carried by the testimony.
86        actual: ParticipantId,
87    },
88    /// The testimony was sealed against another durable ack frontier.
89    AcknowledgedThrough {
90        /// Cursor required by the authorized request prestate.
91        expected: DeliverySeq,
92        /// Cursor carried by the testimony.
93        actual: DeliverySeq,
94    },
95}
96
97impl RecipientAckObligations {
98    /// Validates one recipient's complete current live-obligation index.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`RecipientAckObligationsError`] when an obligation is already
103    /// discharged or the index is not strictly increasing and duplicate-free.
104    pub fn try_new(
105        participant_id: ParticipantId,
106        acknowledged_through: DeliverySeq,
107        delivery_sequences: Vec<DeliverySeq>,
108    ) -> Result<Self, RecipientAckObligationsError> {
109        let mut previous = None;
110        for delivery_seq in &delivery_sequences {
111            if *delivery_seq <= acknowledged_through {
112                return Err(RecipientAckObligationsError::NotLive {
113                    acknowledged_through,
114                    delivery_seq: *delivery_seq,
115                });
116            }
117            if let Some(previous) = previous
118                && *delivery_seq <= previous
119            {
120                return Err(RecipientAckObligationsError::NotStrictlyIncreasing {
121                    previous,
122                    current: *delivery_seq,
123                });
124            }
125            previous = Some(*delivery_seq);
126        }
127        Ok(Self {
128            participant_id,
129            acknowledged_through,
130            delivery_sequences,
131        })
132    }
133
134    pub(in crate::lifecycle) fn contains_endpoint(
135        &self,
136        participant_id: ParticipantId,
137        acknowledged_through: DeliverySeq,
138        endpoint: DeliverySeq,
139    ) -> Result<bool, RecipientAckObligationsContextError> {
140        if self.participant_id != participant_id {
141            return Err(RecipientAckObligationsContextError::Participant {
142                expected: participant_id,
143                actual: self.participant_id,
144            });
145        }
146        if self.acknowledged_through != acknowledged_through {
147            return Err(RecipientAckObligationsContextError::AcknowledgedThrough {
148                expected: acknowledged_through,
149                actual: self.acknowledged_through,
150            });
151        }
152        Ok(self.delivery_sequences.binary_search(&endpoint).is_ok())
153    }
154}
155
156/// One currently bound participant's durable cumulative-cursor state.
157///
158/// All fields are private so cursor advancement can occur only through the
159/// monotonic cumulative-ack transition below.
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub struct BoundParticipantCursor {
162    participant_id: ParticipantId,
163    active_binding_epoch: BindingEpoch,
164    cursor: DeliverySeq,
165}
166
167impl BoundParticipantCursor {
168    /// Creates one bound participant cursor at its already-durable position.
169    #[must_use]
170    pub const fn new(
171        participant_id: ParticipantId,
172        active_binding_epoch: BindingEpoch,
173        cursor: DeliverySeq,
174    ) -> Self {
175        Self {
176            participant_id,
177            active_binding_epoch,
178            cursor,
179        }
180    }
181
182    /// Returns the participant's permanent index.
183    #[must_use]
184    pub const fn participant_index(self) -> ParticipantIndex {
185        self.participant_id
186    }
187
188    /// Returns the participant's permanent identifier.
189    #[must_use]
190    pub const fn participant_id(self) -> ParticipantId {
191        self.participant_id
192    }
193
194    /// Returns the binding epoch authorized to advance this cursor.
195    #[must_use]
196    pub const fn active_binding_epoch(self) -> BindingEpoch {
197        self.active_binding_epoch
198    }
199
200    /// Returns the durable cumulative cursor.
201    #[must_use]
202    pub const fn cursor(self) -> DeliverySeq {
203        self.cursor
204    }
205}
206
207/// Private binding-tagged participant state for one coupled debt episode.
208#[derive(Clone, Copy, Debug, PartialEq, Eq)]
209struct DebtParticipantCursor {
210    participant_id: ParticipantId,
211    binding: FrontierBinding,
212    cursor: DeliverySeq,
213}
214
215impl From<BoundParticipantCursor> for DebtParticipantCursor {
216    fn from(participant: BoundParticipantCursor) -> Self {
217        Self {
218            participant_id: participant.participant_id,
219            binding: FrontierBinding::Bound(participant.active_binding_epoch),
220            cursor: participant.cursor,
221        }
222    }
223}
224
225impl DebtParticipantCursor {
226    const fn advance_to(&mut self, boundary: DeliverySeq) {
227        if boundary > self.cursor {
228            self.cursor = boundary;
229        }
230    }
231
232    const fn binding_epoch(self) -> BindingEpoch {
233        match self.binding {
234            FrontierBinding::Bound(epoch) | FrontierBinding::Detached(epoch) => epoch,
235        }
236    }
237}
238
239/// Construction failure for a participant-scoped nonzero-debt episode.
240#[derive(Clone, Copy, Debug, PartialEq, Eq)]
241pub enum CursorEpisodeBuildError {
242    /// Two cursor states name the same permanent participant identifier/index.
243    DuplicateParticipant {
244        /// Repeated permanent participant identifier and index.
245        participant_id: ParticipantId,
246    },
247    /// Hard observer progress cannot be beyond the candidate watermark.
248    ObserverBeyondHighWatermark {
249        /// Durable observer progress.
250        observer_progress: DeliverySeq,
251        /// Candidate high watermark `H'`.
252        candidate_high_watermark: DeliverySeq,
253    },
254    /// A bound cursor cannot acknowledge beyond the candidate watermark.
255    CursorBeyondHighWatermark {
256        /// Permanent participant identifier/index.
257        participant_id: ParticipantId,
258        /// Durable participant cursor.
259        cursor: DeliverySeq,
260        /// Candidate high watermark `H'`.
261        candidate_high_watermark: DeliverySeq,
262    },
263    /// The supplied first-retained floor is beyond checked `H' + 1`.
264    FloorBeyondRetainedEnd {
265        /// Supplied current floor `F`.
266        current_floor: u128,
267        /// Checked one-past retained end `H' + 1`.
268        retained_end: u128,
269    },
270    /// The append-free ack envelope selected a floor below the actual base.
271    CapacityFloorBelowBase {
272        /// Supplied committing-class `cap_floor`.
273        cap_floor: u128,
274        /// `max(F, preferred_floor)` for the initial episode state.
275        base_floor: u128,
276    },
277    /// The supplied capacity floor would overtake hard observer retention.
278    CapacityFloorBeyondObserver {
279        /// Supplied committing-class `cap_floor`.
280        cap_floor: u128,
281        /// Greatest floor allowed by hard observer retention, `o + 1`.
282        observer_limit: u128,
283    },
284}
285
286/// Authority failure before a cumulative-ack transition.
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub enum CumulativeAckAuthorizationError {
289    /// The episode does not contain the selected permanent participant index.
290    ParticipantIndexUnknown,
291    /// The request names another conversation.
292    ConversationMismatch,
293    /// The request identifier does not match the selected participant index.
294    ParticipantMismatch,
295    /// The request generation does not match the active binding epoch.
296    GenerationMismatch,
297    /// The receiving connection does not own the participant's active epoch.
298    BindingEpochMismatch,
299    /// Durable recipient-obligation testimony belongs to another prestate.
300    ObligationContext {
301        /// Exact testimony mismatch.
302        error: RecipientAckObligationsContextError,
303    },
304    /// A fixed wire-outcome constructor rejected an already-proven cursor relation.
305    CursorRelationInvariant,
306}
307
308/// Exhaustive outcome of an authority-checked normal cumulative ack.
309#[derive(Clone, Debug, PartialEq, Eq)]
310pub enum CumulativeAckOutcome {
311    /// The cursor advanced and its participant-scoped fact was consumed.
312    Committed(AckCommitted),
313    /// The request exactly repeated the durable cursor.
314    NoOp(AckNoOp),
315    /// The forward endpoint lacks the availability testimony required by the selector.
316    Gap(AckGap),
317    /// The request boundary was below the durable cursor.
318    Regression(AckRegression),
319}
320
321/// Participant-scoped cursor accounting for one provably nonzero-debt episode.
322///
323/// The wrapper requires [`ClosureDebt`], whose constructor rejects componentwise
324/// zero. Unlike the frozen document's defective fixed occurrence array, its
325/// progress facts are variable and keyed by `(participant_index, boundary)` as
326/// mandated by `docs/design/LP-EXTRACTION-GOAL.md` Fix 2. It also owns the hard
327/// observer position, candidate watermark, retained-suffix range, and the
328/// append-free ack class's capacity floor. Every committed ack recomputes
329/// `F' = max(F, min(m, o) + 1, cap_floor)` from those durable values.
330#[derive(Clone, Debug, PartialEq, Eq)]
331pub struct NonzeroDebtCursorEpisode {
332    conversation_id: ConversationId,
333    debt: ClosureDebt,
334    observer_progress: DeliverySeq,
335    candidate_high_watermark: DeliverySeq,
336    cap_floor: u128,
337    floor: FloorComputation,
338    participants: BTreeMap<ParticipantIndex, DebtParticipantCursor>,
339    facts: CursorProgressFacts,
340}
341
342/// Deterministic cursor-fact serialization failure.
343#[derive(Clone, Copy, Debug, PartialEq, Eq)]
344pub enum CursorFactEncodeError {
345    /// Participant count cannot fit the variable format's `u32` count.
346    TooManyParticipants,
347    /// Fact count cannot fit the variable format's `u32` count.
348    TooManyFacts,
349    /// Encoded byte length overflowed the platform allocation domain.
350    LengthOverflow,
351}
352
353impl CursorProgressFacts {
354    /// Creates an empty participant-scoped fact map.
355    #[must_use]
356    pub const fn new() -> Self {
357        Self {
358            facts: BTreeMap::new(),
359        }
360    }
361
362    /// Inserts an independently fireable `(participant_index, boundary)` fact.
363    ///
364    /// Returns `true` only when the exact pair was not already present.
365    pub fn record(&mut self, key: CursorProgressKey) -> bool {
366        if self.facts.contains_key(&key) {
367            return false;
368        }
369        self.facts.insert(key, CursorProgressFact::Pending);
370        true
371    }
372
373    /// Marks every pending boundary at or below `through` consumed for one
374    /// participant, without touching another participant's identical boundary.
375    pub fn consume_through(
376        &mut self,
377        participant_index: ParticipantIndex,
378        through: DeliverySeq,
379    ) -> Vec<CursorProgressKey> {
380        let mut consumed = Vec::new();
381        for (key, fact) in &mut self.facts {
382            if key.participant_index == participant_index
383                && key.boundary <= through
384                && *fact == CursorProgressFact::Pending
385            {
386                *fact = CursorProgressFact::Consumed;
387                consumed.push(*key);
388            }
389        }
390        consumed
391    }
392
393    /// Returns one exact fact state.
394    #[must_use]
395    pub fn get(&self, key: CursorProgressKey) -> Option<CursorProgressFact> {
396        self.facts.get(&key).copied()
397    }
398
399    /// Number of distinct participant/boundary pairs.
400    #[must_use]
401    pub fn len(&self) -> usize {
402        self.facts.len()
403    }
404
405    /// Returns whether no cursor fact exists.
406    #[must_use]
407    pub fn is_empty(&self) -> bool {
408        self.facts.is_empty()
409    }
410
411    /// Deterministically serializes the variable map in key order.
412    ///
413    /// Format: `count:u32`, followed by `participant_index:u64`,
414    /// `boundary:u64`, and `state:u8` (`0` Pending, `1` Consumed) per fact.
415    /// This is lifecycle storage, not the participant network frame format.
416    ///
417    /// # Errors
418    ///
419    /// Returns [`CursorFactEncodeError`] if count or allocation length cannot be
420    /// represented.
421    pub fn encode(&self) -> Result<Vec<u8>, CursorFactEncodeError> {
422        let count =
423            u32::try_from(self.facts.len()).map_err(|_| CursorFactEncodeError::TooManyFacts)?;
424        let body_len = self
425            .facts
426            .len()
427            .checked_mul(17)
428            .ok_or(CursorFactEncodeError::LengthOverflow)?;
429        let capacity = 4_usize
430            .checked_add(body_len)
431            .ok_or(CursorFactEncodeError::LengthOverflow)?;
432        let mut bytes = Vec::with_capacity(capacity);
433        bytes.extend_from_slice(&count.to_be_bytes());
434        for (key, fact) in &self.facts {
435            bytes.extend_from_slice(&key.participant_index.to_be_bytes());
436            bytes.extend_from_slice(&key.boundary.to_be_bytes());
437            bytes.push(match fact {
438                CursorProgressFact::Pending => 0,
439                CursorProgressFact::Consumed => 1,
440            });
441        }
442        Ok(bytes)
443    }
444}
445
446impl NonzeroDebtCursorEpisode {
447    /// Creates one nonzero-debt episode with retained-suffix and floor state.
448    ///
449    /// `candidate_high_watermark` is `H'`; the retained suffix is the inclusive
450    /// sequence range from `current_floor` through `H'`, or empty when the floor
451    /// is checked `H' + 1`. `cap_floor` is the committing append-free ack
452    /// class's actual capacity floor at this initial state.
453    ///
454    /// # Errors
455    ///
456    /// Returns [`CursorEpisodeBuildError`] for a duplicate participant, a
457    /// cursor/observer beyond `H'`, an invalid retained range, or a capacity
458    /// floor outside the initial base and observer bounds.
459    #[allow(clippy::too_many_arguments)]
460    pub fn new(
461        conversation_id: ConversationId,
462        debt: ClosureDebt,
463        observer_progress: DeliverySeq,
464        candidate_high_watermark: DeliverySeq,
465        current_floor: u128,
466        cap_floor: u128,
467        participants: Vec<BoundParticipantCursor>,
468    ) -> Result<Self, CursorEpisodeBuildError> {
469        if observer_progress > candidate_high_watermark {
470            return Err(CursorEpisodeBuildError::ObserverBeyondHighWatermark {
471                observer_progress,
472                candidate_high_watermark,
473            });
474        }
475        let retained_end = u128::from(candidate_high_watermark) + 1;
476        if current_floor > retained_end {
477            return Err(CursorEpisodeBuildError::FloorBeyondRetainedEnd {
478                current_floor,
479                retained_end,
480            });
481        }
482
483        let mut indexed = BTreeMap::new();
484        for participant in participants {
485            if participant.cursor > candidate_high_watermark {
486                return Err(CursorEpisodeBuildError::CursorBeyondHighWatermark {
487                    participant_id: participant.participant_id,
488                    cursor: participant.cursor,
489                    candidate_high_watermark,
490                });
491            }
492            if indexed.contains_key(&participant.participant_id) {
493                return Err(CursorEpisodeBuildError::DuplicateParticipant {
494                    participant_id: participant.participant_id,
495                });
496            }
497            indexed.insert(
498                participant.participant_id,
499                DebtParticipantCursor::from(participant),
500            );
501        }
502
503        let minimum_member_cursor = indexed.values().map(|participant| participant.cursor).min();
504        let floor = floor_transition(
505            current_floor,
506            minimum_member_cursor,
507            candidate_high_watermark,
508            observer_progress,
509            cap_floor,
510        );
511        let base_floor = current_floor.max(floor.preferred_floor);
512        if cap_floor < base_floor {
513            return Err(CursorEpisodeBuildError::CapacityFloorBelowBase {
514                cap_floor,
515                base_floor,
516            });
517        }
518        let observer_limit = u128::from(observer_progress) + 1;
519        if cap_floor > observer_limit {
520            return Err(CursorEpisodeBuildError::CapacityFloorBeyondObserver {
521                cap_floor,
522                observer_limit,
523            });
524        }
525
526        Ok(Self {
527            conversation_id,
528            debt,
529            observer_progress,
530            candidate_high_watermark,
531            cap_floor,
532            floor,
533            participants: indexed,
534            facts: CursorProgressFacts::new(),
535        })
536    }
537
538    #[allow(clippy::too_many_arguments)]
539    pub(super) fn restore(
540        conversation_id: ConversationId,
541        debt: ClosureDebt,
542        observer_progress: DeliverySeq,
543        candidate_high_watermark: DeliverySeq,
544        current_floor: u128,
545        cap_floor: u128,
546        participants: Vec<BoundParticipantCursor>,
547        facts: Vec<(CursorProgressKey, CursorProgressFact)>,
548    ) -> Option<Self> {
549        let mut episode = Self::new(
550            conversation_id,
551            debt,
552            observer_progress,
553            candidate_high_watermark,
554            current_floor,
555            cap_floor,
556            participants,
557        )
558        .ok()?;
559        if episode.floor.resulting_floor != current_floor {
560            return None;
561        }
562        for (key, fact) in facts {
563            let participant = episode.participants.get(&key.participant_index)?;
564            if key.boundary > candidate_high_watermark
565                || (fact == CursorProgressFact::Consumed && key.boundary > participant.cursor)
566                || (fact == CursorProgressFact::Pending && key.boundary <= participant.cursor)
567                || episode.facts.facts.insert(key, fact).is_some()
568            {
569                return None;
570            }
571        }
572        Some(episode)
573    }
574
575    pub(super) fn from_claim_frontiers(
576        frontiers: &ClaimFrontiers,
577        debt: ClosureDebt,
578        observer_progress: DeliverySeq,
579    ) -> Result<Self, CursorEpisodeBuildError> {
580        let candidate_high_watermark = frontiers.sequence().ledger().high_watermark();
581        if observer_progress > candidate_high_watermark {
582            return Err(CursorEpisodeBuildError::ObserverBeyondHighWatermark {
583                observer_progress,
584                candidate_high_watermark,
585            });
586        }
587        let current_floor = frontiers.retained_floor();
588        let retained_end = u128::from(candidate_high_watermark) + 1;
589        if current_floor > retained_end {
590            return Err(CursorEpisodeBuildError::FloorBeyondRetainedEnd {
591                current_floor,
592                retained_end,
593            });
594        }
595        let participants = frontiers
596            .active_identities()
597            .participants()
598            .iter()
599            .map(|participant| {
600                (
601                    participant.participant_index(),
602                    DebtParticipantCursor {
603                        participant_id: participant.participant_index(),
604                        binding: participant.binding(),
605                        cursor: participant.cursor(),
606                    },
607                )
608            })
609            .collect::<BTreeMap<_, _>>();
610        let minimum_member_cursor = participants
611            .values()
612            .map(|participant| participant.cursor)
613            .min();
614        let preferred = floor_transition(
615            current_floor,
616            minimum_member_cursor,
617            candidate_high_watermark,
618            observer_progress,
619            current_floor,
620        );
621        let cap_floor = current_floor.max(preferred.preferred_floor);
622        let floor = floor_transition(
623            current_floor,
624            minimum_member_cursor,
625            candidate_high_watermark,
626            observer_progress,
627            cap_floor,
628        );
629        Ok(Self {
630            conversation_id: frontiers.conversation_id(),
631            debt,
632            observer_progress,
633            candidate_high_watermark,
634            cap_floor,
635            floor,
636            participants,
637            facts: CursorProgressFacts::new(),
638        })
639    }
640
641    pub(super) fn reconcile_claim_frontiers(
642        mut self,
643        frontiers: &ClaimFrontiers,
644        debt: ClosureDebt,
645        observer_progress: DeliverySeq,
646    ) -> Result<Self, CursorEpisodeBuildError> {
647        let mut next = Self::from_claim_frontiers(frontiers, debt, observer_progress)?;
648        for (key, fact) in self.facts.facts {
649            let Some(participant) = next.participants.get(&key.participant_index) else {
650                continue;
651            };
652            if key.boundary <= next.candidate_high_watermark {
653                let reconciled = if key.boundary <= participant.cursor {
654                    CursorProgressFact::Consumed
655                } else {
656                    fact
657                };
658                next.facts.facts.insert(key, reconciled);
659            }
660        }
661        for participant in next.participants.values() {
662            let _ = next
663                .facts
664                .consume_through(participant.participant_id, participant.cursor);
665        }
666        self.participants.clear();
667        Ok(next)
668    }
669
670    pub(super) fn participant_binding(
671        &self,
672        participant_index: ParticipantIndex,
673    ) -> Option<(FrontierBinding, DeliverySeq)> {
674        self.participants
675            .get(&participant_index)
676            .map(|participant| (participant.binding, participant.cursor))
677    }
678
679    /// Returns the conversation owning this aggregate for internal operation
680    /// prestate validation.
681    pub(super) const fn conversation_id(&self) -> ConversationId {
682        self.conversation_id
683    }
684
685    /// Returns the exact nonzero debt proving this episode is active.
686    #[must_use]
687    pub const fn debt(&self) -> ClosureDebt {
688        self.debt
689    }
690
691    /// Returns hard observer progress `o` used by every ack floor transition.
692    #[must_use]
693    pub const fn observer_progress(&self) -> DeliverySeq {
694        self.observer_progress
695    }
696
697    /// Returns the candidate high watermark `H'` and retained-suffix end.
698    #[must_use]
699    pub const fn candidate_high_watermark(&self) -> DeliverySeq {
700        self.candidate_high_watermark
701    }
702
703    /// Returns the append-free ack transaction class's current `cap_floor`.
704    #[must_use]
705    pub const fn cap_floor(&self) -> u128 {
706        self.cap_floor
707    }
708
709    /// Returns the latest reproducible document floor computation.
710    #[must_use]
711    pub const fn floor_computation(&self) -> FloorComputation {
712        self.floor
713    }
714
715    /// Returns the first retained sequence, or `None` for an empty suffix.
716    #[must_use]
717    pub fn retained_suffix_start(&self) -> Option<DeliverySeq> {
718        if self.floor.resulting_floor > u128::from(self.candidate_high_watermark) {
719            return None;
720        }
721        DeliverySeq::try_from(self.floor.resulting_floor).ok()
722    }
723
724    /// Returns whether one durable sequence remains in the retained suffix.
725    #[must_use]
726    pub fn retains(&self, delivery_seq: DeliverySeq) -> bool {
727        u128::from(delivery_seq) >= self.floor.resulting_floor
728            && delivery_seq <= self.candidate_high_watermark
729    }
730
731    /// Returns one indexed bound participant cursor.
732    #[must_use]
733    pub fn participant(
734        &self,
735        participant_index: ParticipantIndex,
736    ) -> Option<BoundParticipantCursor> {
737        let participant = self.participants.get(&participant_index).copied()?;
738        let FrontierBinding::Bound(epoch) = participant.binding else {
739            return None;
740        };
741        Some(BoundParticipantCursor::new(
742            participant.participant_id,
743            epoch,
744            participant.cursor,
745        ))
746    }
747
748    /// Returns the participant-scoped cursor fact map.
749    #[must_use]
750    pub const fn facts(&self) -> &CursorProgressFacts {
751        &self.facts
752    }
753
754    /// Applies one authority-checked cumulative normal acknowledgement.
755    ///
756    /// `receiving_binding_epoch` identifies the connection epoch on which the
757    /// request arrived. This scalar entry point preserves the Unit 1 contiguous
758    /// suffix selector; Unit 2 production uses
759    /// [`Self::acknowledge_with_obligations`] so restart-safe durable recipient
760    /// testimony, rather than volatile offered progress, decides endpoint
761    /// availability. A successful advance records and consumes the fact keyed by
762    /// the selected participant index and requested boundary; any lower pending
763    /// facts for that participant are consumed in the same transition.
764    /// It then recomputes the physical floor from the post-ack minimum cursor,
765    /// hard observer progress, current floor, and append-free ack `cap_floor`.
766    /// Another participant's equal boundary is never touched.
767    ///
768    /// # Errors
769    ///
770    /// Returns [`CumulativeAckAuthorizationError`] before any mutation when the
771    /// conversation, participant, generation, or active binding epoch differs.
772    pub fn acknowledge(
773        &mut self,
774        participant_index: ParticipantIndex,
775        receiving_binding_epoch: BindingEpoch,
776        request: &ParticipantAck,
777        contiguously_available_through: DeliverySeq,
778    ) -> Result<CumulativeAckOutcome, CumulativeAckAuthorizationError> {
779        let available_through = contiguously_available_through.min(self.candidate_high_watermark);
780        self.acknowledge_by_endpoint(
781            participant_index,
782            receiving_binding_epoch,
783            request,
784            move |_, _, endpoint| Ok(endpoint <= available_through),
785        )
786    }
787
788    /// Applies the durable per-recipient obligation endpoint rule.
789    ///
790    /// Conversation-global sequence gaps are skipped as non-obligations. A
791    /// forward request commits only when its endpoint exists in `obligations`;
792    /// ending on a sender-excluded or otherwise absent sequence returns
793    /// `AckGap`. The testimony must name the selected participant and current
794    /// durable cursor exactly.
795    ///
796    /// # Errors
797    ///
798    /// Returns [`CumulativeAckAuthorizationError`] before mutation when request
799    /// authority or testimony context differs from this episode.
800    pub fn acknowledge_with_obligations(
801        &mut self,
802        participant_index: ParticipantIndex,
803        receiving_binding_epoch: BindingEpoch,
804        request: &ParticipantAck,
805        obligations: &RecipientAckObligations,
806    ) -> Result<CumulativeAckOutcome, CumulativeAckAuthorizationError> {
807        self.acknowledge_by_endpoint(
808            participant_index,
809            receiving_binding_epoch,
810            request,
811            |participant_id, acknowledged_through, endpoint| {
812                obligations
813                    .contains_endpoint(participant_id, acknowledged_through, endpoint)
814                    .map_err(|error| CumulativeAckAuthorizationError::ObligationContext { error })
815            },
816        )
817    }
818
819    fn acknowledge_by_endpoint<F>(
820        &mut self,
821        participant_index: ParticipantIndex,
822        receiving_binding_epoch: BindingEpoch,
823        request: &ParticipantAck,
824        endpoint_is_available: F,
825    ) -> Result<CumulativeAckOutcome, CumulativeAckAuthorizationError>
826    where
827        F: FnOnce(
828            ParticipantId,
829            DeliverySeq,
830            DeliverySeq,
831        ) -> Result<bool, CumulativeAckAuthorizationError>,
832    {
833        let Some(participant) = self.participants.get(&participant_index).copied() else {
834            return Err(CumulativeAckAuthorizationError::ParticipantIndexUnknown);
835        };
836        if request.conversation_id != self.conversation_id {
837            return Err(CumulativeAckAuthorizationError::ConversationMismatch);
838        }
839        if request.participant_id != participant.participant_id {
840            return Err(CumulativeAckAuthorizationError::ParticipantMismatch);
841        }
842        let FrontierBinding::Bound(active_binding_epoch) = participant.binding else {
843            return Err(CumulativeAckAuthorizationError::BindingEpochMismatch);
844        };
845        if request.capability_generation != active_binding_epoch.capability_generation {
846            return Err(CumulativeAckAuthorizationError::GenerationMismatch);
847        }
848        if receiving_binding_epoch != active_binding_epoch {
849            return Err(CumulativeAckAuthorizationError::BindingEpochMismatch);
850        }
851
852        let current_cursor = participant.cursor;
853        let through_seq = request.through_seq;
854        let envelope = ParticipantAckEnvelope {
855            conversation_id: request.conversation_id,
856            participant_id: request.participant_id,
857            capability_generation: request.capability_generation,
858            through_seq,
859        };
860        let endpoint_available =
861            endpoint_is_available(participant.participant_id, current_cursor, through_seq)?;
862
863        if through_seq < current_cursor {
864            return AckRegression::new(envelope, current_cursor)
865                .map(CumulativeAckOutcome::Regression)
866                .ok_or(CumulativeAckAuthorizationError::CursorRelationInvariant);
867        }
868        if through_seq == current_cursor {
869            return Ok(CumulativeAckOutcome::NoOp(AckNoOp::participant_ack(
870                envelope,
871            )));
872        }
873        if through_seq > self.candidate_high_watermark || !endpoint_available {
874            return AckGap::new(envelope, current_cursor)
875                .map(CumulativeAckOutcome::Gap)
876                .ok_or(CumulativeAckAuthorizationError::CursorRelationInvariant);
877        }
878
879        let Some(stored) = self.participants.get_mut(&participant_index) else {
880            return Err(CumulativeAckAuthorizationError::ParticipantIndexUnknown);
881        };
882        stored.advance_to(through_seq);
883        let key = CursorProgressKey {
884            participant_index,
885            boundary: through_seq,
886        };
887        self.facts.record(key);
888        let _ = self.facts.consume_through(participant_index, through_seq);
889
890        let minimum_member_cursor = self
891            .participants
892            .values()
893            .map(|participant| participant.cursor)
894            .min();
895        self.floor = floor_transition(
896            self.floor.resulting_floor,
897            minimum_member_cursor,
898            self.candidate_high_watermark,
899            self.observer_progress,
900            self.cap_floor,
901        );
902        // `cap_floor` is defined from `f >= base_floor`, so for the next
903        // append-free ack its durable lower bound is the just-committed floor.
904        self.cap_floor = self.floor.resulting_floor;
905
906        Ok(CumulativeAckOutcome::Committed(AckCommitted::new(envelope)))
907    }
908
909    /// Deterministically serializes the active debt, binding-tagged cursors, and facts.
910    ///
911    /// Format: conversation id, debt entries/bytes as two u128 values, observer
912    /// progress and `H'` as two u64 values, current `F'` and `cap_floor` as two
913    /// u128 values, participant count as u32, participants in index order, then
914    /// fact count as u32 and facts in `(participant_index, boundary)` order. The
915    /// retained suffix is reproduced exactly by `[F', H']`. A participant is
916    /// one binding tag plus five u64 values: its canonical identifier/index,
917    /// server incarnation, connection ordinal, generation, and cursor. A fact retains the 17-byte
918    /// format documented by [`CursorProgressFacts::encode`]. This is lifecycle
919    /// storage, not the participant network frame format.
920    ///
921    /// # Errors
922    ///
923    /// Returns [`CursorFactEncodeError`] if either count or the exact allocation
924    /// length cannot be represented.
925    pub fn encode(&self) -> Result<Vec<u8>, CursorFactEncodeError> {
926        let participant_count = u32::try_from(self.participants.len())
927            .map_err(|_| CursorFactEncodeError::TooManyParticipants)?;
928        let fact_count =
929            u32::try_from(self.facts.len()).map_err(|_| CursorFactEncodeError::TooManyFacts)?;
930        let participant_bytes = self
931            .participants
932            .len()
933            .checked_mul(41)
934            .ok_or(CursorFactEncodeError::LengthOverflow)?;
935        let fact_bytes = self
936            .facts
937            .len()
938            .checked_mul(17)
939            .ok_or(CursorFactEncodeError::LengthOverflow)?;
940        let capacity = 96_usize
941            .checked_add(participant_bytes)
942            .and_then(|length| length.checked_add(fact_bytes))
943            .ok_or(CursorFactEncodeError::LengthOverflow)?;
944
945        let debt = self.debt.value();
946        let mut bytes = Vec::with_capacity(capacity);
947        bytes.extend_from_slice(&self.conversation_id.to_be_bytes());
948        bytes.extend_from_slice(&debt.entries.to_be_bytes());
949        bytes.extend_from_slice(&debt.bytes.to_be_bytes());
950        bytes.extend_from_slice(&self.observer_progress.to_be_bytes());
951        bytes.extend_from_slice(&self.candidate_high_watermark.to_be_bytes());
952        bytes.extend_from_slice(&self.floor.resulting_floor.to_be_bytes());
953        bytes.extend_from_slice(&self.cap_floor.to_be_bytes());
954        bytes.extend_from_slice(&participant_count.to_be_bytes());
955        for participant in self.participants.values() {
956            bytes.push(match participant.binding {
957                FrontierBinding::Bound(_) => 0,
958                FrontierBinding::Detached(_) => 1,
959            });
960            bytes.extend_from_slice(&participant.participant_id.to_be_bytes());
961            let binding_epoch = participant.binding_epoch();
962            bytes.extend_from_slice(
963                &binding_epoch
964                    .connection_incarnation
965                    .server_incarnation
966                    .to_be_bytes(),
967            );
968            bytes.extend_from_slice(
969                &binding_epoch
970                    .connection_incarnation
971                    .connection_ordinal
972                    .to_be_bytes(),
973            );
974            bytes.extend_from_slice(&binding_epoch.capability_generation.get().to_be_bytes());
975            bytes.extend_from_slice(&participant.cursor.to_be_bytes());
976        }
977        bytes.extend_from_slice(&fact_count.to_be_bytes());
978        for (key, fact) in &self.facts.facts {
979            bytes.extend_from_slice(&key.participant_index.to_be_bytes());
980            bytes.extend_from_slice(&key.boundary.to_be_bytes());
981            bytes.push(match fact {
982                CursorProgressFact::Pending => 0,
983                CursorProgressFact::Consumed => 1,
984            });
985        }
986        Ok(bytes)
987    }
988}