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}