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#[path = "observer_recovery_errors.rs"]
11mod errors;
12pub use errors::{
13    ObserverProgressAdvanceError, ObserverProgressTrackError, ObserverRecoveryAggregateRestoreError,
14};
15
16#[path = "observer_progress_projection.rs"]
17mod projection;
18pub use projection::ObserverProgressProjection;
19
20#[path = "observer_recovery_retire.rs"]
21mod retire;
22pub use retire::{
23    ObserverProgressRetireDecision, ObserverProgressRetireError, ObserverProgressRetireTransaction,
24};
25
26#[path = "observer_recovery_transactions.rs"]
27mod transactions;
28pub use transactions::{
29    ObserverProgressAdvanceDecision, ObserverProgressAdvanceTransaction,
30    ObserverProgressTrackDecision, ObserverProgressTrackTransaction, ObserverRecoveryArm,
31    ObserverRecoveryCommit, ObserverRecoveryTransaction, ObserverRecoveryTransactionDecision,
32};
33
34/// Exclusively owned observer-recovery aggregate: per-conversation hard
35/// observer progress plus every installed equal-epoch arm, as ONE owned unit.
36///
37/// This is the A4 transactional surface
38/// (`docs/design/LP-GAP-CLOSURE-GOAL.md`): the equal-epoch progress read and
39/// the arm installation share one serialization boundary *by construction*,
40/// because [`Self::decide_recovery`] consumes the aggregate and only
41/// [`ObserverRecoveryTransaction::commit`] or
42/// [`ObserverRecoveryTransaction::abort`] returns it. No acknowledgement or
43/// binding fate can advance progress between the read and the installation,
44/// and a crash while the transaction is pending installs nothing.
45///
46/// The acknowledgement/binding-fate feed carries the same barrier:
47/// [`Self::decide_progress_advance`] consumes the aggregate and only
48/// [`ObserverProgressAdvanceTransaction::commit`] applies the progress write
49/// and surrenders the fired arm — after the caller's durable append is
50/// confirmed — while [`ObserverProgressAdvanceTransaction::abort`] returns
51/// the aggregate byte-for-byte unchanged, arm still installed. So does the
52/// registration feed: [`Self::decide_track`] consumes the aggregate and only
53/// [`ObserverProgressTrackTransaction::commit`] installs the new progress
54/// row, after the caller's durable append is confirmed, so a recovery batch
55/// can never plan an arm against a progress row that is not yet durable. No
56/// mutation in this module is exempt from the decide/commit/abort
57/// discipline, so live and durable state can never disagree about progress
58/// or an installed arm.
59///
60/// The aggregate is deliberately not `Clone`: at most one owner may read
61/// progress for arm selection.
62///
63/// ```compile_fail
64/// use liminal_protocol::lifecycle::ObserverRecoveryAggregate;
65///
66/// fn require_clone<T: Clone>() {}
67/// require_clone::<ObserverRecoveryAggregate>();
68/// ```
69#[derive(Debug, Default, PartialEq, Eq)]
70pub struct ObserverRecoveryAggregate {
71    progress: BTreeMap<ConversationId, DeliverySeq>,
72    armed: BTreeMap<ConversationId, ObserverEpoch>,
73}
74
75impl ObserverRecoveryAggregate {
76    /// Creates an empty aggregate tracking no conversations.
77    #[must_use]
78    pub const fn new() -> Self {
79        Self {
80            progress: BTreeMap::new(),
81            armed: BTreeMap::new(),
82        }
83    }
84
85    /// Rebuilds the aggregate from durable progress and arm rows.
86    ///
87    /// # Errors
88    ///
89    /// Returns [`ObserverRecoveryAggregateRestoreError`] for duplicate rows,
90    /// an arm without its progress row, or an arm whose epoch is not the
91    /// exact current progress of its conversation.
92    pub fn restore(
93        progress_rows: &[(ConversationId, DeliverySeq)],
94        armed_rows: &[(ConversationId, ObserverEpoch)],
95    ) -> Result<Self, ObserverRecoveryAggregateRestoreError> {
96        let mut progress = BTreeMap::new();
97        for (conversation_id, observer_progress) in progress_rows {
98            if progress
99                .insert(*conversation_id, *observer_progress)
100                .is_some()
101            {
102                return Err(ObserverRecoveryAggregateRestoreError::DuplicateProgress {
103                    conversation_id: *conversation_id,
104                });
105            }
106        }
107        let mut armed = BTreeMap::new();
108        for (conversation_id, armed_epoch) in armed_rows {
109            let Some(current_observer_progress) = progress.get(conversation_id).copied() else {
110                return Err(ObserverRecoveryAggregateRestoreError::ArmWithoutProgress {
111                    conversation_id: *conversation_id,
112                });
113            };
114            if current_observer_progress != *armed_epoch {
115                return Err(ObserverRecoveryAggregateRestoreError::ArmEpochMismatch {
116                    conversation_id: *conversation_id,
117                    armed_epoch: *armed_epoch,
118                    current_observer_progress,
119                });
120            }
121            if armed.insert(*conversation_id, *armed_epoch).is_some() {
122                return Err(ObserverRecoveryAggregateRestoreError::DuplicateArm {
123                    conversation_id: *conversation_id,
124                });
125            }
126        }
127        Ok(Self { progress, armed })
128    }
129
130    /// Installs durable arm rows onto a restored aggregate, one arm at a time,
131    /// with the checks of [`Self::restore`]: each arm's conversation must be
132    /// tracked at exactly the arm's epoch, and an arm already installed at the
133    /// same epoch is the same arm. The cost is per arm, never a rebuild of the
134    /// whole aggregate, so a restore folds its tail in one pass.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`ObserverRecoveryAggregateRestoreError`] for an arm without its
139    /// progress row, an arm whose epoch is not its conversation's progress,
140    /// or a second arm at a different epoch for an armed conversation.
141    pub fn install_restored_arms(
142        mut self,
143        arms: &[(ConversationId, ObserverEpoch)],
144    ) -> Result<Self, ObserverRecoveryAggregateRestoreError> {
145        for (conversation_id, armed_epoch) in arms {
146            let Some(current_observer_progress) = self.progress.get(conversation_id).copied()
147            else {
148                return Err(ObserverRecoveryAggregateRestoreError::ArmWithoutProgress {
149                    conversation_id: *conversation_id,
150                });
151            };
152            if current_observer_progress != *armed_epoch {
153                return Err(ObserverRecoveryAggregateRestoreError::ArmEpochMismatch {
154                    conversation_id: *conversation_id,
155                    armed_epoch: *armed_epoch,
156                    current_observer_progress,
157                });
158            }
159            match self.armed.get(conversation_id) {
160                Some(installed) if installed == armed_epoch => {}
161                Some(_) => {
162                    return Err(ObserverRecoveryAggregateRestoreError::DuplicateArm {
163                        conversation_id: *conversation_id,
164                    });
165                }
166                None => {
167                    self.armed.insert(*conversation_id, *armed_epoch);
168                }
169            }
170        }
171        Ok(self)
172    }
173
174    /// Returns the current hard observer progress for one conversation.
175    #[must_use]
176    pub fn observer_progress(&self, conversation_id: ConversationId) -> Option<DeliverySeq> {
177        self.progress.get(&conversation_id).copied()
178    }
179
180    /// Returns the installed equal-epoch arm for one conversation, if any.
181    #[must_use]
182    pub fn armed_epoch(&self, conversation_id: ConversationId) -> Option<ObserverEpoch> {
183        self.armed.get(&conversation_id).copied()
184    }
185
186    /// Returns every durable progress row in conversation order.
187    #[must_use]
188    pub fn progress_rows(&self) -> Vec<(ConversationId, DeliverySeq)> {
189        self.progress
190            .iter()
191            .map(|(conversation_id, observer_progress)| (*conversation_id, *observer_progress))
192            .collect()
193    }
194
195    /// Returns every installed arm row in conversation order.
196    #[must_use]
197    pub fn armed_rows(&self) -> Vec<(ConversationId, ObserverEpoch)> {
198        self.armed
199            .iter()
200            .map(|(conversation_id, armed_epoch)| (*conversation_id, *armed_epoch))
201            .collect()
202    }
203
204    /// Consumes the aggregate into one registration-track transaction.
205    ///
206    /// This is the registration feed for newly tracked conversations, and it
207    /// carries the same barrier as the other two mutations. Validation
208    /// happens here — an already tracked conversation refuses with the
209    /// aggregate unchanged — and a validated registration returns a pending
210    /// [`ObserverProgressTrackTransaction`] carrying the new progress row for
211    /// the caller's durable append. Nothing mutates until
212    /// [`ObserverProgressTrackTransaction::commit`] confirms the append;
213    /// [`ObserverProgressTrackTransaction::abort`] returns the aggregate
214    /// byte-for-byte unchanged, conversation still untracked. A recovery
215    /// batch therefore can never read a progress row whose durable append
216    /// has not been confirmed, so a crash after an arm install can never
217    /// strand a durable arm row without its progress row
218    /// ([`ObserverRecoveryAggregateRestoreError::ArmWithoutProgress`]).
219    #[must_use]
220    pub fn decide_track(
221        self,
222        conversation_id: ConversationId,
223        observer_progress: DeliverySeq,
224    ) -> ObserverProgressTrackDecision {
225        if self.progress.contains_key(&conversation_id) {
226            return ObserverProgressTrackDecision::Refuse {
227                aggregate: self,
228                error: ObserverProgressTrackError::AlreadyTracked { conversation_id },
229            };
230        }
231        ObserverProgressTrackDecision::Commit(ObserverProgressTrackTransaction {
232            aggregate: self,
233            conversation_id,
234            observer_progress,
235        })
236    }
237
238    /// Consumes the aggregate into one progress-advance transaction.
239    ///
240    /// This is the acknowledgement/binding-fate feed. Validation happens
241    /// here — unknown conversation and non-advancing progress refuse with
242    /// the aggregate unchanged — and a validated advance returns a pending
243    /// [`ObserverProgressAdvanceTransaction`] carrying the new progress row
244    /// and the fired-arm plan for the caller's durable append. Nothing
245    /// mutates until [`ObserverProgressAdvanceTransaction::commit`] confirms
246    /// the append; [`ObserverProgressAdvanceTransaction::abort`] returns the
247    /// aggregate byte-for-byte unchanged. Every installed arm is equal-epoch
248    /// with its conversation's progress, so a strictly advancing write always
249    /// fires the installed arm: the fired arm is surrendered by `commit` so
250    /// the caller wakes the parked rows in the same durable transaction
251    /// (LAW-1: wake on the event, never poll).
252    #[must_use]
253    pub fn decide_progress_advance(
254        self,
255        conversation_id: ConversationId,
256        presented_progress: DeliverySeq,
257    ) -> ObserverProgressAdvanceDecision {
258        let Some(current) = self.progress.get(&conversation_id).copied() else {
259            return ObserverProgressAdvanceDecision::Refuse {
260                aggregate: self,
261                error: ObserverProgressAdvanceError::ConversationUnknown { conversation_id },
262            };
263        };
264        if presented_progress <= current {
265            return ObserverProgressAdvanceDecision::Refuse {
266                aggregate: self,
267                error: ObserverProgressAdvanceError::NotAdvancing {
268                    conversation_id,
269                    current_observer_progress: current,
270                    presented_progress,
271                },
272            };
273        }
274        let fired = self
275            .armed
276            .get(&conversation_id)
277            .copied()
278            .map(|refused_epoch| ObserverRecoveryArm {
279                conversation_id,
280                refused_epoch,
281            });
282        ObserverProgressAdvanceDecision::Commit(ObserverProgressAdvanceTransaction {
283            aggregate: self,
284            conversation_id,
285            presented_progress,
286            fired,
287        })
288    }
289
290    /// Consumes the aggregate into one observer-recovery transaction.
291    ///
292    /// This is the only public door to arm selection: it binds the
293    /// crate-internal selection's progress reads to the owned aggregate so
294    /// the read and the arm installation are one owned unit by construction.
295    /// A refused batch returns the aggregate unchanged alongside the exact
296    /// refusal response.
297    #[must_use]
298    pub fn decide_recovery(
299        self,
300        request: &ObserverRecoveryHandshake,
301        max_entries: u64,
302        connection_conversation_limit: u64,
303        tracked_conversations: &[ConversationId],
304    ) -> ObserverRecoveryTransactionDecision {
305        let decision = apply_observer_recovery(
306            request,
307            max_entries,
308            connection_conversation_limit,
309            tracked_conversations,
310            |conversation_id| self.progress.get(&conversation_id).copied(),
311        );
312        match decision {
313            ObserverRecoveryDecision::Respond(response) => {
314                ObserverRecoveryTransactionDecision::Respond {
315                    aggregate: self,
316                    response,
317                }
318            }
319            ObserverRecoveryDecision::Commit(commit) => {
320                ObserverRecoveryTransactionDecision::Commit(ObserverRecoveryTransaction {
321                    aggregate: self,
322                    commit,
323                })
324            }
325        }
326    }
327}
328
329/// Total observer-recovery decision.
330#[derive(Clone, Debug, PartialEq, Eq)]
331pub enum ObserverRecoveryDecision {
332    /// Validation or connection-capacity preflight refused the whole batch.
333    Respond(ObserverRecoveryResponse),
334    /// Every entry validated and the complete arm plan may commit atomically.
335    Commit(ObserverRecoveryCommit),
336}
337
338fn wire_count(value: usize) -> u64 {
339    u64::try_from(value).unwrap_or(u64::MAX)
340}
341
342/// Applies the observer-recovery list, capacity, and epoch precedence.
343///
344/// Validation order is the frozen R-D1 order: list length, duplicate
345/// conversation, request-index connection capacity, then request-index unknown
346/// or ahead epoch. Only after every entry passes is an arm plan produced.
347/// `observer_progress` must return the current hard observer progress for a
348/// known conversation and `None` for an unknown conversation.
349///
350/// This raw selection is deliberately crate-internal: its progress reads and
351/// the installation of every returned arm must share one serialization
352/// boundary (releasing it between evaluation and installation would violate
353/// the equal-epoch subscribe-then-snapshot rule), and that requirement is
354/// enforceable only by ownership.
355/// [`ObserverRecoveryAggregate::decide_recovery`] is the one public door to
356/// arm selection: it binds these progress reads to the owned aggregate, so
357/// no caller can interleave an acknowledgement or fate advance between
358/// evaluation and installation.
359#[must_use]
360pub(super) fn apply_observer_recovery<F>(
361    request: &ObserverRecoveryHandshake,
362    max_entries: u64,
363    connection_conversation_limit: u64,
364    tracked_conversations: &[ConversationId],
365    mut observer_progress: F,
366) -> ObserverRecoveryDecision
367where
368    F: FnMut(ConversationId) -> Option<DeliverySeq>,
369{
370    let presented_entries = wire_count(request.observer_refusals.len());
371    if presented_entries > max_entries {
372        return ObserverRecoveryDecision::Respond(
373            ObserverRecoveryResponse::invalid_observer_epoch_list(
374                InvalidObserverEpochList::TooManyEntries {
375                    presented_entries,
376                    max_entries,
377                },
378            ),
379        );
380    }
381
382    let mut first_indices = BTreeMap::new();
383    for (index, refusal) in request.observer_refusals.iter().enumerate() {
384        let request_index = wire_count(index);
385        if let Some(first_index) = first_indices.insert(refusal.conversation_id, request_index) {
386            return ObserverRecoveryDecision::Respond(
387                ObserverRecoveryResponse::invalid_observer_epoch_list(
388                    InvalidObserverEpochList::DuplicateConversation {
389                        conversation_id: refusal.conversation_id,
390                        first_index,
391                        duplicate_index: request_index,
392                    },
393                ),
394            );
395        }
396    }
397
398    let mut tracked: BTreeSet<_> = tracked_conversations.iter().copied().collect();
399    for refusal in &request.observer_refusals {
400        if tracked.contains(&refusal.conversation_id) {
401            continue;
402        }
403        let occupied = wire_count(tracked.len());
404        if occupied >= connection_conversation_limit {
405            return ObserverRecoveryDecision::Respond(
406                ObserverRecoveryResponse::connection_capacity_exceeded(
407                    refusal.conversation_id,
408                    connection_conversation_limit,
409                ),
410            );
411        }
412        tracked.insert(refusal.conversation_id);
413    }
414
415    let mut validated = Vec::with_capacity(request.observer_refusals.len());
416    for refusal in &request.observer_refusals {
417        let Some(current_observer_progress) = observer_progress(refusal.conversation_id) else {
418            return ObserverRecoveryDecision::Respond(
419                ObserverRecoveryResponse::invalid_observer_epoch(
420                    InvalidObserverEpoch::ConversationUnknown {
421                        conversation_id: refusal.conversation_id,
422                        presented_epoch: refusal.refused_epoch,
423                    },
424                ),
425            );
426        };
427        if refusal.refused_epoch > current_observer_progress {
428            return ObserverRecoveryDecision::Respond(
429                ObserverRecoveryResponse::invalid_observer_epoch(
430                    InvalidObserverEpoch::EpochAhead {
431                        conversation_id: refusal.conversation_id,
432                        presented_epoch: refusal.refused_epoch,
433                        current_observer_progress,
434                    },
435                ),
436            );
437        }
438        validated.push((refusal, current_observer_progress));
439    }
440
441    let mut arms = Vec::new();
442    let mut statuses = Vec::with_capacity(validated.len());
443    for (refusal, current_observer_progress) in validated {
444        let armed = refusal.refused_epoch == current_observer_progress;
445        if armed {
446            arms.push(ObserverRecoveryArm {
447                conversation_id: refusal.conversation_id,
448                refused_epoch: refusal.refused_epoch,
449            });
450        }
451        statuses.push(ObserverProgressStatus {
452            conversation_id: refusal.conversation_id,
453            refused_epoch: refusal.refused_epoch,
454            current_observer_progress,
455            armed,
456            progressed: !armed,
457        });
458    }
459
460    ObserverRecoveryDecision::Commit(ObserverRecoveryCommit {
461        arms,
462        outcome: ObserverRecoveryAccepted { statuses },
463    })
464}