Skip to main content

liminal_protocol/lifecycle/
cursor_facts.rs

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