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}