Skip to main content

ozzy_replication/
view_change.rs

1//! Durable view-change initiation and selection. Installation is a separate gate.
2
3use ozzy_proto::NodeId;
4
5use crate::{
6    Configuration, Digest, JournalGeneration, NormalReplica, OpNumber, PipelineLimits, Prefix,
7    ReplicaSnapshot, ReplicationError, Scope, Status, SyncTicket, WriteTicket,
8};
9
10/// One immutable, synchronized log description for a view-change report.
11///
12/// The adapter must pin the corresponding canonical history until the report is
13/// invalidated. Retired predecessors require independently verified overlapping
14/// lineage or checkpoint evidence from the adapter; the core performs no I/O.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct FrozenLog {
17    /// Last durably installed normal view, not the highest entry's original view.
18    pub last_normal_view: u64,
19    /// Complete synchronized accepted prefix, including uncertain operations.
20    pub accepted: Prefix,
21    /// Known committed prefix that any selected history must preserve.
22    pub committed: Prefix,
23}
24
25/// Intact durable state independently validated and synchronized on startup.
26///
27/// This descriptor is an adapter assertion, not proof of disk integrity. The
28/// caller must verify immutable voter/configuration identity, the complete
29/// selected canonical lineage and commit floor, and durable hard state. Empty,
30/// rolled-back, lost, or ambiguously corrupt stores must not use this path.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct RecoveredState {
33    /// Exact durable configuration and highest promised view, not last normal view.
34    pub scope: Scope,
35    /// Recovered accepted bytes and known commit floor. No ACK sets are restored.
36    pub log: FrozenLog,
37}
38
39/// Storage action that persists a view promise without installing a normal view.
40///
41/// Fields are private so completions can identify only actions this core issued.
42/// The adapter atomically persists this exact log and promised view, retaining
43/// the prior last-normal-view. Submission or an ordinary file flush is not a
44/// successful completion. Storage failure must call `ViewChange::fail_promise`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct PromiseTicket {
47    scope: Scope,
48    generation: JournalGeneration,
49    log: FrozenLog,
50}
51
52impl PromiseTicket {
53    /// Exact group/configuration and new promised view to persist.
54    pub const fn scope(self) -> Scope {
55        self.scope
56    }
57    /// Unique writer incarnation whose frozen history the promise covers.
58    pub const fn generation(self) -> JournalGeneration {
59        self.generation
60    }
61    /// Log positions and prior normal view that must survive publication.
62    pub const fn log(self) -> FrozenLog {
63        self.log
64    }
65}
66
67/// First view-change phase. Sender identity comes from the authenticated peer.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct StartViewChange {
70    /// Exact proposed group/configuration/view.
71    pub scope: Scope,
72}
73
74/// Second view-change phase. A bounded descriptor, never an unbounded log frame.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct DoViewChange {
77    /// Exact proposed group/configuration/view.
78    pub scope: Scope,
79    /// Frozen source incarnation; transferred chunks must not mix generations.
80    pub generation: JournalGeneration,
81    /// Immutable synchronized history and protected commit floor.
82    pub log: FrozenLog,
83}
84
85/// Exact source against which transferred history must be verified.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct LogSource {
88    /// Configured voter supplying the selected bytes.
89    pub voter: NodeId,
90    /// Frozen source writer incarnation from its report.
91    pub generation: JournalGeneration,
92    /// Tail hash anchoring the complete selected canonical lineage.
93    pub accepted: Prefix,
94}
95
96/// Verified view-change selection, not installed leadership authority.
97///
98/// A future installation adapter must durably install these bytes and hard state
99/// before `START_VIEW` or normal voting. This value cannot construct a normal core.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct SelectedView {
102    scope: Scope,
103    source: LogSource,
104    committed: Prefix,
105    voters: u8,
106}
107
108impl SelectedView {
109    /// New view whose quorum selected this lineage.
110    pub const fn scope(self) -> Scope {
111        self.scope
112    }
113    /// Frozen history to install. Uncertain accepted operations remain included.
114    pub const fn source(self) -> LogSource {
115        self.source
116    }
117    /// Largest compatible known commit floor in the participating reports.
118    pub const fn committed(self) -> Prefix {
119        self.committed
120    }
121    /// Bitset of participating voters in configured order, including the primary.
122    pub const fn voter_mask(self) -> u8 {
123        self.voters
124    }
125}
126
127/// Fixed-three-voter durable view-change initiation and log selection.
128///
129/// Owns the fenced former normal core. No normal-message entry point is exposed.
130/// Retains at most three reports and one pending metadata action. It has no I/O,
131/// timers, or allocations on live election transitions. Intact restart allocates
132/// only the bounded live pipeline and stays above its last installed normal view. Installation
133/// consumes this role and uses a separate completion/activation gate.
134#[derive(Debug)]
135pub struct ViewChange {
136    pub(crate) normal: NormalReplica,
137    pub(crate) configuration: Configuration,
138    pub(crate) local: usize,
139    view: u64,
140    promised: u64,
141    pending: Option<PromiseTicket>,
142    pub(crate) completed: Option<PromiseTicket>,
143    starts: u8,
144    reports: [Option<DoViewChange>; 3],
145    pub(crate) selected: Option<SelectedView>,
146}
147
148impl ViewChange {
149    /// Frozen reported sources available for bounded, externally verified history repair.
150    /// A report alone supplies no ancestry or installation authority.
151    pub fn history_sources(&self) -> [Option<LogSource>; 3] {
152        std::array::from_fn(|index| {
153            self.reports[index].map(|report| LogSource {
154                voter: self.configuration.voters()[index],
155                generation: report.generation,
156                accepted: report.log.accepted,
157            })
158        })
159    }
160
161    pub(crate) fn after_abandon(
162        mut normal: NormalReplica,
163        previous: PromiseTicket,
164        requested_view: u64,
165    ) -> Result<Self, ViewChangeError> {
166        normal.restore_fenced(previous.generation(), previous.log());
167        let mut changing = normal.into_view_change(requested_view)?;
168        changing.promised = previous.scope().view;
169        changing.completed = Some(previous);
170        Ok(changing)
171    }
172
173    /// Restart an intact durable voter without granting same-view authority.
174    ///
175    /// Restart from an installed normal view requests its successor, fencing a
176    /// primary that sent prepares before its local write completed. An already
177    /// promised but never installed election resumes that same view: it could
178    /// not have assigned normal operations there. Repeated restarts cannot
179    /// ratchet that unfinished election. Quorum selection and durable selected
180    /// installation/activation remain mandatory before any normal authority.
181    ///
182    /// `generation` must be a fresh writer incarnation. Journal recovery and
183    /// validation run on the storage worker before calling this sans-I/O method.
184    /// Retained operations stay on disk; only the live pipeline is preallocated.
185    /// Canonical application recovery is deferred to selected-view installation.
186    pub fn recover_intact(
187        configuration: Configuration,
188        local: NodeId,
189        generation: JournalGeneration,
190        recovered: RecoveredState,
191        limits: PipelineLimits,
192    ) -> Result<Self, ViewChangeError> {
193        if configuration.policy() == crate::QuorumPolicy::Replicated {
194            return Err(ViewChangeError::VolatileRestart);
195        }
196        Self::recover_verified(configuration, local, generation, recovered, limits)
197    }
198
199    /// Restart a memory-voting broker from independently proven drained history.
200    ///
201    /// The adapter must verify a durable shutdown marker bound to the exact
202    /// configuration, store, selected manifest, and accepted prefix, or a fresh
203    /// completed group recovery. Before returning any driver it must replace
204    /// that marker durably with running state. Intact bytes alone are insufficient:
205    /// they cannot prove that an unpersisted RAM vote never existed.
206    pub fn recover_drained(
207        configuration: Configuration,
208        local: NodeId,
209        generation: JournalGeneration,
210        recovered: RecoveredState,
211        limits: PipelineLimits,
212    ) -> Result<Self, ViewChangeError> {
213        if configuration.policy() != crate::QuorumPolicy::Replicated {
214            return Err(ReplicationError::PolicyMismatch.into());
215        }
216        Self::recover_verified(configuration, local, generation, recovered, limits)
217    }
218
219    fn recover_verified(
220        configuration: Configuration,
221        local: NodeId,
222        generation: JournalGeneration,
223        recovered: RecoveredState,
224        limits: PipelineLimits,
225    ) -> Result<Self, ViewChangeError> {
226        if recovered.scope
227            != (Scope {
228                view: recovered.scope.view,
229                ..configuration.scope()
230            })
231        {
232            return Err(ReplicationError::ScopeMismatch.into());
233        }
234        if recovered.log.last_normal_view > recovered.scope.view {
235            return Err(ViewChangeError::InvalidReport);
236        }
237        let view = if recovered.scope.view > recovered.log.last_normal_view {
238            recovered.scope.view
239        } else {
240            recovered
241                .scope
242                .view
243                .checked_add(1)
244                .ok_or(ViewChangeError::ViewExhausted)?
245        };
246        validate_report(DoViewChange {
247            scope: Scope {
248                view,
249                ..recovered.scope
250            },
251            generation,
252            log: recovered.log,
253        })?;
254        let normal =
255            NormalReplica::recover_fenced(configuration, local, generation, recovered.log, limits)?;
256        let mut changing = normal.into_view_change(view)?;
257        changing.promised = recovered.scope.view;
258        if changing.promised == view {
259            changing.starts |= 1 << changing.local; // Independently recovered durable promise.
260            changing.completed = Some(PromiseTicket {
261                scope: changing.scope(),
262                generation,
263                log: recovered.log,
264            });
265        }
266        Ok(changing)
267    }
268
269    pub(crate) fn begin(
270        mut normal: NormalReplica,
271        configuration: Configuration,
272        local: usize,
273        view: u64,
274    ) -> Result<Self, ViewChangeError> {
275        normal.fence();
276        if normal.snapshot().status == Status::Faulted {
277            return Err(ViewChangeError::Faulted);
278        }
279        if view <= configuration.scope().view {
280            return Err(ViewChangeError::ViewNotHigher);
281        }
282        Ok(Self {
283            normal,
284            configuration,
285            local,
286            view,
287            promised: configuration.scope().view,
288            pending: None,
289            completed: None,
290            starts: 0,
291            reports: [None; 3],
292            selected: None,
293        })
294    }
295
296    /// Read-only old-role journal/application evidence for storage scheduling.
297    pub fn normal_snapshot(&self) -> ReplicaSnapshot {
298        self.normal.snapshot()
299    }
300
301    /// Requested view; it may not yet have a completed durable promise.
302    pub fn scope(&self) -> Scope {
303        Scope {
304            view: self.view,
305            ..self.configuration.scope()
306        }
307    }
308
309    /// Highest view whose promise completion this core has observed.
310    pub const fn promised_view(&self) -> u64 {
311        self.promised
312    }
313
314    /// Move toward a later view without discarding an outstanding disk action.
315    ///
316    /// Old reports/selection immediately lose authority. A pending earlier
317    /// promise must settle before the next can be issued. Its late completion
318    /// never authorizes messages for either the old or the unpersisted new view.
319    pub fn advance_view(&mut self, view: u64) -> Result<(), ViewChangeError> {
320        self.require_active()?;
321        if view <= self.view {
322            return Err(ViewChangeError::ViewNotHigher);
323        }
324        self.view = view;
325        self.starts = 0;
326        self.reports = [None; 3];
327        self.selected = None;
328        Ok(())
329    }
330
331    /// Settle a previously admitted complete write without reviving old votes.
332    pub fn complete_write(&mut self, ticket: WriteTicket) -> Result<(), ViewChangeError> {
333        Ok(self.normal.complete_write(ticket)?)
334    }
335
336    /// Capture the written prefix for a real barrier on the storage worker.
337    pub fn begin_sync(&self) -> Result<SyncTicket, ViewChangeError> {
338        Ok(self.normal.begin_sync()?)
339    }
340
341    /// Settle a real disk barrier. It cannot advance old-view commit.
342    pub fn complete_sync(&mut self, ticket: SyncTicket) -> Result<(), ViewChangeError> {
343        Ok(self.normal.complete_sync(ticket)?)
344    }
345
346    /// Report uncertain journal I/O and permanently fault this transition.
347    pub fn fail_io(&mut self, generation: JournalGeneration) -> Result<(), ViewChangeError> {
348        Ok(self.normal.fail_io(generation)?)
349    }
350
351    /// Issue one metadata promise after every accepted operation is synchronized.
352    ///
353    /// Finishing all old writes makes the report immutable, including operations
354    /// another replica may already have acknowledged before local I/O finished.
355    pub fn begin_promise(&mut self) -> Result<PromiseTicket, ViewChangeError> {
356        self.require_active()?;
357        if self.pending.is_some() {
358            return Err(ViewChangeError::PromisePending);
359        }
360        if self.promised == self.view {
361            return Err(ViewChangeError::AlreadyPromised);
362        }
363        let snapshot = self.normal.snapshot();
364        if snapshot.journal.durable != snapshot.accepted.op {
365            return Err(ViewChangeError::StoragePending);
366        }
367        let ticket = PromiseTicket {
368            scope: self.scope(),
369            generation: snapshot.journal.generation,
370            log: FrozenLog {
371                last_normal_view: snapshot.scope.view,
372                accepted: snapshot.accepted,
373                committed: snapshot.committed,
374            },
375        };
376        self.pending = Some(ticket);
377        Ok(ticket)
378    }
379
380    /// Observe successful durable publication of the exact issued promise.
381    pub fn complete_promise(&mut self, ticket: PromiseTicket) -> Result<(), ViewChangeError> {
382        self.require_active()?;
383        if self.completed == Some(ticket) {
384            return Ok(());
385        }
386        self.require_ticket(ticket)?;
387        self.pending = None;
388        self.completed = Some(ticket);
389        self.promised = ticket.scope.view;
390        if self.promised == self.view {
391            self.starts |= 1 << self.local;
392        }
393        Ok(())
394    }
395
396    /// An ambiguous promise failure fences all election messages and selection.
397    pub fn fail_promise(&mut self, ticket: PromiseTicket) -> Result<(), ViewChangeError> {
398        self.require_active()?;
399        self.require_ticket(ticket)?;
400        self.fail_io(ticket.generation)
401    }
402
403    /// Broadcast only after the requested view promise is durably complete.
404    pub fn start_message(&self) -> Result<StartViewChange, ViewChangeError> {
405        self.require_promised()?;
406        Ok(StartViewChange {
407            scope: self.scope(),
408        })
409    }
410
411    /// Collect an authenticated other voter's first-phase message.
412    /// Duplicate messages cannot create extra voters or replace local persistence.
413    pub fn receive_start(
414        &mut self,
415        from: NodeId,
416        message: StartViewChange,
417    ) -> Result<(), ViewChangeError> {
418        self.require_scope(message.scope)?;
419        let voter = self.configuration.voter_index(from)?;
420        if voter == self.local {
421            return Err(ReplicationError::WrongRole.into());
422        }
423        self.starts |= 1 << voter;
424        Ok(())
425    }
426
427    /// Freeze the local second-phase report after a first-phase majority.
428    /// Send it to the configured primary; the primary retains its own report too.
429    pub fn report(&mut self) -> Result<DoViewChange, ViewChangeError> {
430        self.require_promised()?;
431        if self.starts.count_ones() < 2 {
432            return Err(ViewChangeError::StartQuorumMissing);
433        }
434        let promise = self.completed.expect("requested view promised");
435        let report = DoViewChange {
436            scope: self.scope(),
437            generation: promise.generation,
438            log: promise.log,
439        };
440        self.reports[self.local] = Some(report);
441        Ok(report)
442    }
443
444    /// Primary collects immutable, authenticated second-phase reports.
445    ///
446    /// Later views are explicit `advance_view` events. A new report cannot alter
447    /// an already selected quorum; changed frozen logs from one voter are faults.
448    /// Intact restart may repeat the identical log with a fresh writer generation.
449    /// Keep the first source descriptor unchanged, including after selection:
450    /// this is one vote, not permission to mix or redirect history chunks.
451    pub fn receive_report(
452        &mut self,
453        from: NodeId,
454        report: DoViewChange,
455    ) -> Result<(), ViewChangeError> {
456        self.require_scope(report.scope)?;
457        self.require_primary()?;
458        let voter = self.configuration.voter_index(from)?;
459        if voter == self.local {
460            return Err(ReplicationError::WrongRole.into());
461        }
462        validate_report(report)?;
463        if let Some(previous) = self.reports[voter] {
464            if previous.log != report.log {
465                return Err(self.conflict());
466            }
467            // An unfinished durable promise survives restart, but its process's
468            // writer incarnation does not. Equivalent history is not a fault.
469            // A vanished first source may require another view; never replace a
470            // pinned or selected source merely because a different ID arrived.
471            return Ok(());
472        }
473        if self.selected.is_some() {
474            return Err(ViewChangeError::SelectionFrozen);
475        }
476        self.reports[voter] = Some(report);
477        Ok(())
478    }
479
480    /// Select the highest last-normal-view, then longest accepted lineage.
481    ///
482    /// `lookup` must be a nonblocking lookup into already verified, pinned
483    /// canonical history anchored by the supplied source's exact tail digest.
484    /// An isolated matching hash or a chunk from another generation is not such
485    /// evidence. Missing bytes return `None`; fetch/verify them outside the core.
486    /// At most six protected-prefix lookups are needed for three voters.
487    ///
488    /// Selection includes this primary's report and another distinct voter. It
489    /// protects every reported commit and same-normal-view accepted prefix.
490    /// Higher-normal-view histories may discard only older uncertain suffixes.
491    pub fn select(
492        &mut self,
493        mut lookup: impl FnMut(LogSource, OpNumber) -> Option<Digest>,
494    ) -> Result<SelectedView, ViewChangeError> {
495        self.require_promised()?;
496        self.require_primary()?;
497        if let Some(selected) = self.selected {
498            return Ok(selected);
499        }
500        let reports = self.reports;
501        if reports[self.local].is_none() || reports.iter().flatten().count() < 2 {
502            return Err(ViewChangeError::ReportQuorumMissing);
503        }
504        let mut chosen = self.local;
505        let mut voters = 0;
506        for (index, report) in reports.iter().enumerate() {
507            let Some(report) = report else {
508                continue;
509            };
510            voters |= 1 << index;
511            let current = reports[chosen].expect("selected report");
512            let rank = (report.log.last_normal_view, report.log.accepted.op);
513            let best = (current.log.last_normal_view, current.log.accepted.op);
514            if rank == best && report.log.accepted != current.log.accepted {
515                return Err(self.conflict());
516            }
517            if rank > best {
518                chosen = index;
519            }
520        }
521        let report = reports[chosen].expect("selected report");
522        let source = LogSource {
523            voter: self.configuration.voters()[chosen],
524            generation: report.generation,
525            accepted: report.log.accepted,
526        };
527        let mut committed = Prefix::GENESIS;
528        for candidate in reports.iter().flatten() {
529            if candidate.log.committed.op > committed.op {
530                committed = candidate.log.committed;
531            }
532            self.verify_prefix(source, candidate.log.committed, &mut lookup)?;
533            if candidate.log.last_normal_view == report.log.last_normal_view {
534                self.verify_prefix(source, candidate.log.accepted, &mut lookup)?;
535            }
536        }
537        let selected = SelectedView {
538            scope: self.scope(),
539            source,
540            committed,
541            voters,
542        };
543        self.selected = Some(selected);
544        Ok(selected)
545    }
546
547    pub(crate) fn verify_prefix(
548        &mut self,
549        source: LogSource,
550        prefix: Prefix,
551        lookup: &mut impl FnMut(LogSource, OpNumber) -> Option<Digest>,
552    ) -> Result<(), ViewChangeError> {
553        if prefix.op > source.accepted.op {
554            return Err(self.conflict());
555        }
556        let digest = if prefix.op == OpNumber(0) {
557            Digest::ZERO
558        } else if prefix.op == source.accepted.op {
559            source.accepted.digest
560        } else {
561            lookup(source, prefix.op).ok_or(ViewChangeError::HistoryMissing)?
562        };
563        if digest != prefix.digest {
564            return Err(self.conflict());
565        }
566        Ok(())
567    }
568
569    fn require_active(&self) -> Result<(), ViewChangeError> {
570        if self.normal.snapshot().status == Status::Faulted {
571            return Err(ViewChangeError::Faulted);
572        }
573        Ok(())
574    }
575
576    pub(crate) fn require_scope(&self, scope: Scope) -> Result<(), ViewChangeError> {
577        self.require_active()?;
578        if scope != self.scope() {
579            return Err(ReplicationError::ScopeMismatch.into());
580        }
581        Ok(())
582    }
583
584    pub(crate) fn require_primary(&self) -> Result<(), ViewChangeError> {
585        if self.configuration.primary(self.view) != self.configuration.voters()[self.local] {
586            return Err(ReplicationError::WrongRole.into());
587        }
588        Ok(())
589    }
590
591    pub(crate) fn require_promised(&self) -> Result<(), ViewChangeError> {
592        self.require_active()?;
593        if self.promised != self.view {
594            return Err(ViewChangeError::PromiseRequired);
595        }
596        Ok(())
597    }
598
599    fn require_ticket(&self, ticket: PromiseTicket) -> Result<(), ViewChangeError> {
600        if self.pending != Some(ticket) {
601            return Err(ViewChangeError::StalePromise);
602        }
603        Ok(())
604    }
605
606    fn conflict(&mut self) -> ViewChangeError {
607        self.normal
608            .fail_io(self.normal.snapshot().journal.generation)
609            .expect("current generation");
610        ViewChangeError::ConflictingHistory
611    }
612}
613
614pub(crate) fn valid_prefix(prefix: Prefix) -> bool {
615    prefix.op.0 != u64::MAX && ((prefix.op.0 == 0) == (prefix.digest == Digest::ZERO))
616}
617
618fn validate_report(report: DoViewChange) -> Result<(), ViewChangeError> {
619    let log = report.log;
620    if log.last_normal_view >= report.scope.view
621        || !valid_prefix(log.accepted)
622        || !valid_prefix(log.committed)
623        || log.committed.op > log.accepted.op
624        || (log.committed.op == log.accepted.op && log.committed != log.accepted)
625    {
626        return Err(ViewChangeError::InvalidReport);
627    }
628    Ok(())
629}
630
631/// Rejected election event. Missing history/quorums wait; conflicts fault.
632#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
633pub enum ViewChangeError {
634    /// A disk prefix cannot prove that all earlier retained-memory votes survived.
635    #[error("retained-memory voter restart requires fresh recovery or drained-state evidence")]
636    VolatileRestart,
637    /// Existing journal/normal scope, role, or membership check failed.
638    #[error(transparent)]
639    Replication(#[from] ReplicationError),
640    /// Timers/messages cannot regress or reuse the target view.
641    #[error("view must increase")]
642    ViewNotHigher,
643    /// A restart cannot safely wrap its highest durable view back to zero.
644    #[error("durable view number exhausted")]
645    ViewExhausted,
646    /// All accepted old-view operations must finish synchronization first.
647    #[error("old-view journal work has not settled")]
648    StoragePending,
649    /// Only one metadata promise action can be in flight.
650    #[error("a view promise is already pending")]
651    PromisePending,
652    /// No new metadata write is necessary for this requested view.
653    #[error("requested view already promised")]
654    AlreadyPromised,
655    /// Completion is not the exact outstanding metadata action.
656    #[error("stale or foreign view-promise completion")]
657    StalePromise,
658    /// No election message before successful durable promise completion.
659    #[error("requested view promise has not completed")]
660    PromiseRequired,
661    /// Need own durable promise plus another distinct first-phase voter.
662    #[error("start-view-change quorum missing")]
663    StartQuorumMissing,
664    /// Selection needs the primary's own report and another distinct voter.
665    #[error("do-view-change quorum missing")]
666    ReportQuorumMissing,
667    /// Fetch and validate the frozen selected history before selecting it.
668    #[error("selected lineage evidence missing")]
669    HistoryMissing,
670    /// Malformed positions or last-normal-view cannot become evidence.
671    #[error("invalid frozen view-change report")]
672    InvalidReport,
673    /// A protected prefix or immutable report conflicts with this lineage.
674    #[error("conflicting view-change history")]
675    ConflictingHistory,
676    /// Do not change the selected quorum while installation may be in flight.
677    #[error("view selection already frozen")]
678    SelectionFrozen,
679    /// Primary cannot install without its completed quorum selection.
680    #[error("view has not been selected")]
681    SelectionRequired,
682    /// A replacement writer needs a fresh, globally unique incarnation.
683    #[error("installation reused the old writer generation")]
684    ReusedGeneration,
685    /// Completion does not identify this exact pending installation.
686    #[error("stale or foreign installation completion")]
687    StaleInstallation,
688    /// Successful completion has already transferred ownership to a new role.
689    #[error("installation already completed")]
690    InstallationCompleted,
691    /// Committed canonical state must be rebuilt before role activation.
692    #[error("installed application state is not at the required committed prefix")]
693    ApplicationPending,
694    /// Storage or lineage fault requires recovery, not another local election.
695    #[error("view change faulted")]
696    Faulted,
697}