Skip to main content

liminal_protocol/lifecycle/
observer_recovery.rs

1use alloc::collections::{BTreeMap, BTreeSet};
2use alloc::vec::Vec;
3
4use crate::wire::{
5    ConversationId, DeliverySeq, InvalidObserverEpoch, InvalidObserverEpochList, ObserverEpoch,
6    ObserverProgressStatus, ObserverRecoveryAccepted, ObserverRecoveryHandshake,
7    ObserverRecoveryResponse,
8};
9
10/// Move-only exact observer-progress projection emitted by a committed source.
11///
12/// Participant acknowledgement and binding-ending transitions own the progress
13/// value. Consuming code can persist and publish this projection but cannot
14/// construct one from a guessed maximum or record-delivery observation.
15///
16/// ```compile_fail
17/// use liminal_protocol::lifecycle::ObserverProgressProjection;
18///
19/// fn require_clone<T: Clone>() {}
20/// require_clone::<ObserverProgressProjection>();
21/// ```
22#[derive(Debug, PartialEq, Eq)]
23pub struct ObserverProgressProjection {
24    conversation_id: ConversationId,
25    new_observer_progress: DeliverySeq,
26}
27
28impl ObserverProgressProjection {
29    pub(in crate::lifecycle) const fn new(
30        conversation_id: ConversationId,
31        new_observer_progress: DeliverySeq,
32    ) -> Self {
33        Self {
34            conversation_id,
35            new_observer_progress,
36        }
37    }
38
39    /// Builds a projection no transition produced, for a test that must
40    /// present a source a real transition never would.
41    ///
42    /// Absent from production builds (`test-support` / `cfg(test)` only).
43    #[cfg(any(test, feature = "test-support"))]
44    #[must_use]
45    pub const fn for_test(
46        conversation_id: ConversationId,
47        new_observer_progress: DeliverySeq,
48    ) -> Self {
49        Self::new(conversation_id, new_observer_progress)
50    }
51
52    /// Returns the conversation whose hard observer progress may advance.
53    #[must_use]
54    pub const fn conversation_id(&self) -> ConversationId {
55        self.conversation_id
56    }
57
58    /// Returns the exact protocol-produced hard observer progress.
59    #[must_use]
60    pub const fn new_observer_progress(&self) -> DeliverySeq {
61        self.new_observer_progress
62    }
63}
64
65/// Restore-time validation failure for the owned observer-recovery aggregate.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum ObserverRecoveryAggregateRestoreError {
68    /// One conversation appears twice in the durable progress rows.
69    DuplicateProgress {
70        /// Duplicated conversation.
71        conversation_id: ConversationId,
72    },
73    /// One conversation appears twice in the durable arm rows.
74    DuplicateArm {
75        /// Duplicated conversation.
76        conversation_id: ConversationId,
77    },
78    /// A durable arm names a conversation with no durable progress row.
79    ArmWithoutProgress {
80        /// Conversation named by the orphaned arm.
81        conversation_id: ConversationId,
82    },
83    /// A durable arm's epoch differs from its conversation's progress.
84    ///
85    /// An installed arm is always equal-epoch: it is installed at the exact
86    /// read progress and fired (removed) by the same mutation that advances
87    /// progress past it, so any durable disagreement is corruption.
88    ArmEpochMismatch {
89        /// Conversation whose arm disagrees.
90        conversation_id: ConversationId,
91        /// Epoch stored with the arm.
92        armed_epoch: ObserverEpoch,
93        /// Durable hard observer progress.
94        current_observer_progress: DeliverySeq,
95    },
96}
97
98/// Failure while registering a newly tracked conversation.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum ObserverProgressTrackError {
101    /// The conversation already has an authoritative progress row.
102    AlreadyTracked {
103        /// Conversation presented twice.
104        conversation_id: ConversationId,
105    },
106}
107
108/// Failure while advancing hard observer progress.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub enum ObserverProgressAdvanceError {
111    /// The conversation has no authoritative progress row.
112    ConversationUnknown {
113        /// Unknown conversation.
114        conversation_id: ConversationId,
115    },
116    /// The presented progress does not strictly advance the current value.
117    NotAdvancing {
118        /// Conversation whose progress was presented.
119        conversation_id: ConversationId,
120        /// Current hard observer progress.
121        current_observer_progress: DeliverySeq,
122        /// Non-advancing presented progress.
123        presented_progress: DeliverySeq,
124    },
125}
126
127/// Exclusively owned observer-recovery aggregate: per-conversation hard
128/// observer progress plus every installed equal-epoch arm, as ONE owned unit.
129///
130/// This is the A4 transactional surface
131/// (`docs/design/LP-GAP-CLOSURE-GOAL.md`): the equal-epoch progress read and
132/// the arm installation share one serialization boundary *by construction*,
133/// because [`Self::decide_recovery`] consumes the aggregate and only
134/// [`ObserverRecoveryTransaction::commit`] or
135/// [`ObserverRecoveryTransaction::abort`] returns it. No acknowledgement or
136/// binding fate can advance progress between the read and the installation,
137/// and a crash while the transaction is pending installs nothing.
138///
139/// The acknowledgement/binding-fate feed carries the same barrier:
140/// [`Self::decide_progress_advance`] consumes the aggregate and only
141/// [`ObserverProgressAdvanceTransaction::commit`] applies the progress write
142/// and surrenders the fired arm — after the caller's durable append is
143/// confirmed — while [`ObserverProgressAdvanceTransaction::abort`] returns
144/// the aggregate byte-for-byte unchanged, arm still installed. So does the
145/// registration feed: [`Self::decide_track`] consumes the aggregate and only
146/// [`ObserverProgressTrackTransaction::commit`] installs the new progress
147/// row, after the caller's durable append is confirmed, so a recovery batch
148/// can never plan an arm against a progress row that is not yet durable. No
149/// mutation in this module is exempt from the decide/commit/abort
150/// discipline, so live and durable state can never disagree about progress
151/// or an installed arm.
152///
153/// The aggregate is deliberately not `Clone`: at most one owner may read
154/// progress for arm selection.
155///
156/// ```compile_fail
157/// use liminal_protocol::lifecycle::ObserverRecoveryAggregate;
158///
159/// fn require_clone<T: Clone>() {}
160/// require_clone::<ObserverRecoveryAggregate>();
161/// ```
162#[derive(Debug, Default, PartialEq, Eq)]
163pub struct ObserverRecoveryAggregate {
164    progress: BTreeMap<ConversationId, DeliverySeq>,
165    armed: BTreeMap<ConversationId, ObserverEpoch>,
166}
167
168impl ObserverRecoveryAggregate {
169    /// Creates an empty aggregate tracking no conversations.
170    #[must_use]
171    pub const fn new() -> Self {
172        Self {
173            progress: BTreeMap::new(),
174            armed: BTreeMap::new(),
175        }
176    }
177
178    /// Rebuilds the aggregate from durable progress and arm rows.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`ObserverRecoveryAggregateRestoreError`] for duplicate rows,
183    /// an arm without its progress row, or an arm whose epoch is not the
184    /// exact current progress of its conversation.
185    pub fn restore(
186        progress_rows: &[(ConversationId, DeliverySeq)],
187        armed_rows: &[(ConversationId, ObserverEpoch)],
188    ) -> Result<Self, ObserverRecoveryAggregateRestoreError> {
189        let mut progress = BTreeMap::new();
190        for (conversation_id, observer_progress) in progress_rows {
191            if progress
192                .insert(*conversation_id, *observer_progress)
193                .is_some()
194            {
195                return Err(ObserverRecoveryAggregateRestoreError::DuplicateProgress {
196                    conversation_id: *conversation_id,
197                });
198            }
199        }
200        let mut armed = BTreeMap::new();
201        for (conversation_id, armed_epoch) in armed_rows {
202            let Some(current_observer_progress) = progress.get(conversation_id).copied() else {
203                return Err(ObserverRecoveryAggregateRestoreError::ArmWithoutProgress {
204                    conversation_id: *conversation_id,
205                });
206            };
207            if current_observer_progress != *armed_epoch {
208                return Err(ObserverRecoveryAggregateRestoreError::ArmEpochMismatch {
209                    conversation_id: *conversation_id,
210                    armed_epoch: *armed_epoch,
211                    current_observer_progress,
212                });
213            }
214            if armed.insert(*conversation_id, *armed_epoch).is_some() {
215                return Err(ObserverRecoveryAggregateRestoreError::DuplicateArm {
216                    conversation_id: *conversation_id,
217                });
218            }
219        }
220        Ok(Self { progress, armed })
221    }
222
223    /// Installs durable arm rows onto a restored aggregate, one arm at a time,
224    /// with the checks of [`Self::restore`]: each arm's conversation must be
225    /// tracked at exactly the arm's epoch, and an arm already installed at the
226    /// same epoch is the same arm. The cost is per arm, never a rebuild of the
227    /// whole aggregate, so a restore folds its tail in one pass.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`ObserverRecoveryAggregateRestoreError`] for an arm without its
232    /// progress row, an arm whose epoch is not its conversation's progress,
233    /// or a second arm at a different epoch for an armed conversation.
234    pub fn install_restored_arms(
235        mut self,
236        arms: &[(ConversationId, ObserverEpoch)],
237    ) -> Result<Self, ObserverRecoveryAggregateRestoreError> {
238        for (conversation_id, armed_epoch) in arms {
239            let Some(current_observer_progress) = self.progress.get(conversation_id).copied()
240            else {
241                return Err(ObserverRecoveryAggregateRestoreError::ArmWithoutProgress {
242                    conversation_id: *conversation_id,
243                });
244            };
245            if current_observer_progress != *armed_epoch {
246                return Err(ObserverRecoveryAggregateRestoreError::ArmEpochMismatch {
247                    conversation_id: *conversation_id,
248                    armed_epoch: *armed_epoch,
249                    current_observer_progress,
250                });
251            }
252            match self.armed.get(conversation_id) {
253                Some(installed) if installed == armed_epoch => {}
254                Some(_) => {
255                    return Err(ObserverRecoveryAggregateRestoreError::DuplicateArm {
256                        conversation_id: *conversation_id,
257                    });
258                }
259                None => {
260                    self.armed.insert(*conversation_id, *armed_epoch);
261                }
262            }
263        }
264        Ok(self)
265    }
266
267    /// Returns the current hard observer progress for one conversation.
268    #[must_use]
269    pub fn observer_progress(&self, conversation_id: ConversationId) -> Option<DeliverySeq> {
270        self.progress.get(&conversation_id).copied()
271    }
272
273    /// Returns the installed equal-epoch arm for one conversation, if any.
274    #[must_use]
275    pub fn armed_epoch(&self, conversation_id: ConversationId) -> Option<ObserverEpoch> {
276        self.armed.get(&conversation_id).copied()
277    }
278
279    /// Returns every durable progress row in conversation order.
280    #[must_use]
281    pub fn progress_rows(&self) -> Vec<(ConversationId, DeliverySeq)> {
282        self.progress
283            .iter()
284            .map(|(conversation_id, observer_progress)| (*conversation_id, *observer_progress))
285            .collect()
286    }
287
288    /// Returns every installed arm row in conversation order.
289    #[must_use]
290    pub fn armed_rows(&self) -> Vec<(ConversationId, ObserverEpoch)> {
291        self.armed
292            .iter()
293            .map(|(conversation_id, armed_epoch)| (*conversation_id, *armed_epoch))
294            .collect()
295    }
296
297    /// Consumes the aggregate into one registration-track transaction.
298    ///
299    /// This is the registration feed for newly tracked conversations, and it
300    /// carries the same barrier as the other two mutations. Validation
301    /// happens here — an already tracked conversation refuses with the
302    /// aggregate unchanged — and a validated registration returns a pending
303    /// [`ObserverProgressTrackTransaction`] carrying the new progress row for
304    /// the caller's durable append. Nothing mutates until
305    /// [`ObserverProgressTrackTransaction::commit`] confirms the append;
306    /// [`ObserverProgressTrackTransaction::abort`] returns the aggregate
307    /// byte-for-byte unchanged, conversation still untracked. A recovery
308    /// batch therefore can never read a progress row whose durable append
309    /// has not been confirmed, so a crash after an arm install can never
310    /// strand a durable arm row without its progress row
311    /// ([`ObserverRecoveryAggregateRestoreError::ArmWithoutProgress`]).
312    #[must_use]
313    pub fn decide_track(
314        self,
315        conversation_id: ConversationId,
316        observer_progress: DeliverySeq,
317    ) -> ObserverProgressTrackDecision {
318        if self.progress.contains_key(&conversation_id) {
319            return ObserverProgressTrackDecision::Refuse {
320                aggregate: self,
321                error: ObserverProgressTrackError::AlreadyTracked { conversation_id },
322            };
323        }
324        ObserverProgressTrackDecision::Commit(ObserverProgressTrackTransaction {
325            aggregate: self,
326            conversation_id,
327            observer_progress,
328        })
329    }
330
331    /// Consumes the aggregate into one progress-advance transaction.
332    ///
333    /// This is the acknowledgement/binding-fate feed. Validation happens
334    /// here — unknown conversation and non-advancing progress refuse with
335    /// the aggregate unchanged — and a validated advance returns a pending
336    /// [`ObserverProgressAdvanceTransaction`] carrying the new progress row
337    /// and the fired-arm plan for the caller's durable append. Nothing
338    /// mutates until [`ObserverProgressAdvanceTransaction::commit`] confirms
339    /// the append; [`ObserverProgressAdvanceTransaction::abort`] returns the
340    /// aggregate byte-for-byte unchanged. Every installed arm is equal-epoch
341    /// with its conversation's progress, so a strictly advancing write always
342    /// fires the installed arm: the fired arm is surrendered by `commit` so
343    /// the caller wakes the parked rows in the same durable transaction
344    /// (LAW-1: wake on the event, never poll).
345    #[must_use]
346    pub fn decide_progress_advance(
347        self,
348        conversation_id: ConversationId,
349        presented_progress: DeliverySeq,
350    ) -> ObserverProgressAdvanceDecision {
351        let Some(current) = self.progress.get(&conversation_id).copied() else {
352            return ObserverProgressAdvanceDecision::Refuse {
353                aggregate: self,
354                error: ObserverProgressAdvanceError::ConversationUnknown { conversation_id },
355            };
356        };
357        if presented_progress <= current {
358            return ObserverProgressAdvanceDecision::Refuse {
359                aggregate: self,
360                error: ObserverProgressAdvanceError::NotAdvancing {
361                    conversation_id,
362                    current_observer_progress: current,
363                    presented_progress,
364                },
365            };
366        }
367        let fired = self
368            .armed
369            .get(&conversation_id)
370            .copied()
371            .map(|refused_epoch| ObserverRecoveryArm {
372                conversation_id,
373                refused_epoch,
374            });
375        ObserverProgressAdvanceDecision::Commit(ObserverProgressAdvanceTransaction {
376            aggregate: self,
377            conversation_id,
378            presented_progress,
379            fired,
380        })
381    }
382
383    /// Consumes the aggregate into one observer-recovery transaction.
384    ///
385    /// This is the only public door to arm selection: it binds the
386    /// crate-internal selection's progress reads to the owned aggregate so
387    /// the read and the arm installation are one owned unit by construction.
388    /// A refused batch returns the aggregate unchanged alongside the exact
389    /// refusal response.
390    #[must_use]
391    pub fn decide_recovery(
392        self,
393        request: &ObserverRecoveryHandshake,
394        max_entries: u64,
395        connection_conversation_limit: u64,
396        tracked_conversations: &[ConversationId],
397    ) -> ObserverRecoveryTransactionDecision {
398        let decision = apply_observer_recovery(
399            request,
400            max_entries,
401            connection_conversation_limit,
402            tracked_conversations,
403            |conversation_id| self.progress.get(&conversation_id).copied(),
404        );
405        match decision {
406            ObserverRecoveryDecision::Respond(response) => {
407                ObserverRecoveryTransactionDecision::Respond {
408                    aggregate: self,
409                    response,
410                }
411            }
412            ObserverRecoveryDecision::Commit(commit) => {
413                ObserverRecoveryTransactionDecision::Commit(ObserverRecoveryTransaction {
414                    aggregate: self,
415                    commit,
416                })
417            }
418        }
419    }
420}
421
422/// Total transactional observer-recovery decision against the owned aggregate.
423#[derive(Debug, PartialEq, Eq)]
424pub enum ObserverRecoveryTransactionDecision {
425    /// The whole batch was refused; the aggregate is unchanged.
426    Respond {
427        /// Unchanged aggregate returned to its owner.
428        aggregate: ObserverRecoveryAggregate,
429        /// Exact refusal response.
430        response: ObserverRecoveryResponse,
431    },
432    /// Every entry validated; the arm plan may commit atomically.
433    Commit(ObserverRecoveryTransaction),
434}
435
436/// Ownership barrier between arm selection and atomic arm installation.
437///
438/// The aggregate is unreachable while the transaction is pending, so no
439/// progress can advance between the equal-epoch read and the installation.
440/// Consuming [`Self::commit`] installs every arm of the plan at once — a
441/// subset is unrepresentable — and consuming [`Self::abort`] installs none,
442/// so a crash at any point leaves either the complete arm plan or no arm at
443/// all, never a partially-armed request.
444#[derive(Debug, PartialEq, Eq)]
445pub struct ObserverRecoveryTransaction {
446    aggregate: ObserverRecoveryAggregate,
447    commit: ObserverRecoveryCommit,
448}
449
450impl ObserverRecoveryTransaction {
451    /// Borrows the complete equal-epoch arm plan for the durable append.
452    #[must_use]
453    pub fn arms(&self) -> &[ObserverRecoveryArm] {
454        self.commit.arms()
455    }
456
457    /// Borrows the exact request-ordered success response.
458    #[must_use]
459    pub const fn outcome(&self) -> &ObserverRecoveryAccepted {
460        self.commit.outcome()
461    }
462
463    /// Installs the whole arm plan after a confirmed durable append.
464    ///
465    /// Installation is idempotent: replaying a durable recovery against the
466    /// post-state reinstalls the identical equal-epoch arms, so at most one
467    /// arm per conversation ever exists and crash replay converges.
468    #[must_use]
469    pub fn commit(mut self) -> (ObserverRecoveryAggregate, ObserverRecoveryAccepted) {
470        let (arms, outcome) = self.commit.into_parts();
471        for arm in arms {
472            self.aggregate
473                .armed
474                .insert(arm.conversation_id(), arm.refused_epoch());
475        }
476        (self.aggregate, outcome)
477    }
478
479    /// Cancels a failed durable append; not one arm is installed.
480    #[must_use]
481    pub fn abort(self) -> ObserverRecoveryAggregate {
482        self.aggregate
483    }
484}
485
486/// Total transactional progress-advance decision against the owned aggregate.
487#[derive(Debug, PartialEq, Eq)]
488pub enum ObserverProgressAdvanceDecision {
489    /// The advance was refused; the aggregate is unchanged.
490    Refuse {
491        /// Unchanged aggregate returned to its owner.
492        aggregate: ObserverRecoveryAggregate,
493        /// Exact refusal.
494        error: ObserverProgressAdvanceError,
495    },
496    /// The advance validated; the progress write and arm fire may commit
497    /// atomically.
498    Commit(ObserverProgressAdvanceTransaction),
499}
500
501/// Ownership barrier between a validated progress advance and its durable
502/// append.
503///
504/// The aggregate is unreachable while the transaction is pending, so no
505/// recovery batch can read the not-yet-durable progress and no other advance
506/// can race the fired-arm plan. Consuming [`Self::commit`] applies the
507/// progress write and removes the fired arm as ONE mutation — after the
508/// caller has confirmed the durable append of the progress row, the arm-row
509/// deletion, and the wake — and consuming [`Self::abort`] returns the
510/// aggregate byte-for-byte unchanged, arm still installed, so a failed
511/// append never leaves live progress ahead of durable state or a
512/// phantom-fired arm.
513#[derive(Debug, PartialEq, Eq)]
514pub struct ObserverProgressAdvanceTransaction {
515    aggregate: ObserverRecoveryAggregate,
516    conversation_id: ConversationId,
517    presented_progress: DeliverySeq,
518    fired: Option<ObserverRecoveryArm>,
519}
520
521impl ObserverProgressAdvanceTransaction {
522    /// Returns the conversation whose progress row the durable append writes.
523    #[must_use]
524    pub const fn conversation_id(&self) -> ConversationId {
525        self.conversation_id
526    }
527
528    /// Returns the new hard observer progress for the durable append.
529    #[must_use]
530    pub const fn presented_progress(&self) -> DeliverySeq {
531        self.presented_progress
532    }
533
534    /// Returns the fired-arm plan for the durable append.
535    ///
536    /// `Some` names the installed arm whose durable row the same append must
537    /// delete and whose parked rows the same append must wake; `None` means
538    /// the conversation carried no arm.
539    #[must_use]
540    pub const fn fired_arm(&self) -> Option<ObserverRecoveryArm> {
541        self.fired
542    }
543
544    /// Applies the advance after a confirmed durable append.
545    ///
546    /// The progress write and the arm removal are one mutation: the returned
547    /// arm is exactly [`Self::fired_arm`], surrendered so the caller wakes
548    /// the parked rows it durably committed to waking.
549    #[must_use]
550    pub fn commit(mut self) -> (ObserverRecoveryAggregate, Option<ObserverRecoveryArm>) {
551        self.aggregate
552            .progress
553            .insert(self.conversation_id, self.presented_progress);
554        if self.fired.is_some() {
555            self.aggregate.armed.remove(&self.conversation_id);
556        }
557        (self.aggregate, self.fired)
558    }
559
560    /// Cancels a failed durable append; neither progress nor arm changes.
561    #[must_use]
562    pub fn abort(self) -> ObserverRecoveryAggregate {
563        self.aggregate
564    }
565}
566
567/// Total transactional registration decision against the owned aggregate.
568#[derive(Debug, PartialEq, Eq)]
569pub enum ObserverProgressTrackDecision {
570    /// The registration was refused; the aggregate is unchanged.
571    Refuse {
572        /// Unchanged aggregate returned to its owner.
573        aggregate: ObserverRecoveryAggregate,
574        /// Exact refusal.
575        error: ObserverProgressTrackError,
576    },
577    /// The registration validated; the progress row may commit atomically.
578    Commit(ObserverProgressTrackTransaction),
579}
580
581/// Ownership barrier between a validated registration and its durable append.
582///
583/// The aggregate is unreachable while the transaction is pending, so no
584/// recovery batch can plan an equal-epoch arm against a progress row whose
585/// durable append is not yet confirmed. Consuming [`Self::commit`] installs
586/// the new progress row — after the caller has confirmed the durable append —
587/// and consuming [`Self::abort`] returns the aggregate byte-for-byte
588/// unchanged, conversation still untracked, so a failed append never leaves
589/// a live progress row that durable state does not hold.
590#[derive(Debug, PartialEq, Eq)]
591pub struct ObserverProgressTrackTransaction {
592    aggregate: ObserverRecoveryAggregate,
593    conversation_id: ConversationId,
594    observer_progress: DeliverySeq,
595}
596
597impl ObserverProgressTrackTransaction {
598    /// Returns the conversation whose progress row the durable append writes.
599    #[must_use]
600    pub const fn conversation_id(&self) -> ConversationId {
601        self.conversation_id
602    }
603
604    /// Returns the registered hard observer progress for the durable append.
605    #[must_use]
606    pub const fn observer_progress(&self) -> DeliverySeq {
607        self.observer_progress
608    }
609
610    /// Installs the registered progress row after a confirmed durable append.
611    #[must_use]
612    pub fn commit(mut self) -> ObserverRecoveryAggregate {
613        self.aggregate
614            .progress
615            .insert(self.conversation_id, self.observer_progress);
616        self.aggregate
617    }
618
619    /// Cancels a failed durable append; the conversation stays untracked.
620    #[must_use]
621    pub fn abort(self) -> ObserverRecoveryAggregate {
622        self.aggregate
623    }
624}
625
626/// One equal observer epoch that must be armed by an accepted recovery batch.
627#[derive(Clone, Copy, Debug, PartialEq, Eq)]
628pub struct ObserverRecoveryArm {
629    conversation_id: ConversationId,
630    refused_epoch: ObserverEpoch,
631}
632
633impl ObserverRecoveryArm {
634    /// Returns the conversation whose progress event must wake the parked rows.
635    #[must_use]
636    pub const fn conversation_id(self) -> ConversationId {
637        self.conversation_id
638    }
639
640    /// Returns the exact refusal epoch being armed.
641    #[must_use]
642    pub const fn refused_epoch(self) -> ObserverEpoch {
643        self.refused_epoch
644    }
645}
646
647/// Whole-batch observer-recovery commit selected after exhaustive validation.
648///
649/// The arm list and response are produced together. A consumer must persist all
650/// arms atomically before sending [`Self::outcome`], so no validation failure or
651/// crash can expose a partially armed request.
652#[derive(Clone, Debug, PartialEq, Eq)]
653pub struct ObserverRecoveryCommit {
654    arms: Vec<ObserverRecoveryArm>,
655    outcome: ObserverRecoveryAccepted,
656}
657
658impl ObserverRecoveryCommit {
659    /// Borrows the complete equal-epoch arm plan in request order.
660    #[must_use]
661    pub fn arms(&self) -> &[ObserverRecoveryArm] {
662        &self.arms
663    }
664
665    /// Borrows the exact request-ordered success response.
666    #[must_use]
667    pub const fn outcome(&self) -> &ObserverRecoveryAccepted {
668        &self.outcome
669    }
670
671    /// Consumes the commit into the atomic arm plan and success response.
672    #[must_use]
673    pub fn into_parts(self) -> (Vec<ObserverRecoveryArm>, ObserverRecoveryAccepted) {
674        (self.arms, self.outcome)
675    }
676}
677
678/// Total observer-recovery decision.
679#[derive(Clone, Debug, PartialEq, Eq)]
680pub enum ObserverRecoveryDecision {
681    /// Validation or connection-capacity preflight refused the whole batch.
682    Respond(ObserverRecoveryResponse),
683    /// Every entry validated and the complete arm plan may commit atomically.
684    Commit(ObserverRecoveryCommit),
685}
686
687fn wire_count(value: usize) -> u64 {
688    u64::try_from(value).unwrap_or(u64::MAX)
689}
690
691/// Applies the observer-recovery list, capacity, and epoch precedence.
692///
693/// Validation order is the frozen R-D1 order: list length, duplicate
694/// conversation, request-index connection capacity, then request-index unknown
695/// or ahead epoch. Only after every entry passes is an arm plan produced.
696/// `observer_progress` must return the current hard observer progress for a
697/// known conversation and `None` for an unknown conversation.
698///
699/// This raw selection is deliberately crate-internal: its progress reads and
700/// the installation of every returned arm must share one serialization
701/// boundary (releasing it between evaluation and installation would violate
702/// the equal-epoch subscribe-then-snapshot rule), and that requirement is
703/// enforceable only by ownership.
704/// [`ObserverRecoveryAggregate::decide_recovery`] is the one public door to
705/// arm selection: it binds these progress reads to the owned aggregate, so
706/// no caller can interleave an acknowledgement or fate advance between
707/// evaluation and installation.
708#[must_use]
709pub(super) fn apply_observer_recovery<F>(
710    request: &ObserverRecoveryHandshake,
711    max_entries: u64,
712    connection_conversation_limit: u64,
713    tracked_conversations: &[ConversationId],
714    mut observer_progress: F,
715) -> ObserverRecoveryDecision
716where
717    F: FnMut(ConversationId) -> Option<DeliverySeq>,
718{
719    let presented_entries = wire_count(request.observer_refusals.len());
720    if presented_entries > max_entries {
721        return ObserverRecoveryDecision::Respond(
722            ObserverRecoveryResponse::invalid_observer_epoch_list(
723                InvalidObserverEpochList::TooManyEntries {
724                    presented_entries,
725                    max_entries,
726                },
727            ),
728        );
729    }
730
731    let mut first_indices = BTreeMap::new();
732    for (index, refusal) in request.observer_refusals.iter().enumerate() {
733        let request_index = wire_count(index);
734        if let Some(first_index) = first_indices.insert(refusal.conversation_id, request_index) {
735            return ObserverRecoveryDecision::Respond(
736                ObserverRecoveryResponse::invalid_observer_epoch_list(
737                    InvalidObserverEpochList::DuplicateConversation {
738                        conversation_id: refusal.conversation_id,
739                        first_index,
740                        duplicate_index: request_index,
741                    },
742                ),
743            );
744        }
745    }
746
747    let mut tracked: BTreeSet<_> = tracked_conversations.iter().copied().collect();
748    for refusal in &request.observer_refusals {
749        if tracked.contains(&refusal.conversation_id) {
750            continue;
751        }
752        let occupied = wire_count(tracked.len());
753        if occupied >= connection_conversation_limit {
754            return ObserverRecoveryDecision::Respond(
755                ObserverRecoveryResponse::connection_capacity_exceeded(
756                    refusal.conversation_id,
757                    connection_conversation_limit,
758                ),
759            );
760        }
761        tracked.insert(refusal.conversation_id);
762    }
763
764    let mut validated = Vec::with_capacity(request.observer_refusals.len());
765    for refusal in &request.observer_refusals {
766        let Some(current_observer_progress) = observer_progress(refusal.conversation_id) else {
767            return ObserverRecoveryDecision::Respond(
768                ObserverRecoveryResponse::invalid_observer_epoch(
769                    InvalidObserverEpoch::ConversationUnknown {
770                        conversation_id: refusal.conversation_id,
771                        presented_epoch: refusal.refused_epoch,
772                    },
773                ),
774            );
775        };
776        if refusal.refused_epoch > current_observer_progress {
777            return ObserverRecoveryDecision::Respond(
778                ObserverRecoveryResponse::invalid_observer_epoch(
779                    InvalidObserverEpoch::EpochAhead {
780                        conversation_id: refusal.conversation_id,
781                        presented_epoch: refusal.refused_epoch,
782                        current_observer_progress,
783                    },
784                ),
785            );
786        }
787        validated.push((refusal, current_observer_progress));
788    }
789
790    let mut arms = Vec::new();
791    let mut statuses = Vec::with_capacity(validated.len());
792    for (refusal, current_observer_progress) in validated {
793        let armed = refusal.refused_epoch == current_observer_progress;
794        if armed {
795            arms.push(ObserverRecoveryArm {
796                conversation_id: refusal.conversation_id,
797                refused_epoch: refusal.refused_epoch,
798            });
799        }
800        statuses.push(ObserverProgressStatus {
801            conversation_id: refusal.conversation_id,
802            refused_epoch: refusal.refused_epoch,
803            current_observer_progress,
804            armed,
805            progressed: !armed,
806        });
807    }
808
809    ObserverRecoveryDecision::Commit(ObserverRecoveryCommit {
810        arms,
811        outcome: ObserverRecoveryAccepted { statuses },
812    })
813}