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