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. This initial descriptor requires complete WAL history; checkpoint
14/// transfer and nonvoting recovery are not implemented by this core.
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    pub(crate) fn after_abandon(
150        mut normal: NormalReplica,
151        previous: PromiseTicket,
152        requested_view: u64,
153    ) -> Result<Self, ViewChangeError> {
154        normal.restore_fenced(previous.generation(), previous.log());
155        let mut changing = normal.into_view_change(requested_view)?;
156        changing.promised = previous.scope().view;
157        changing.completed = Some(previous);
158        Ok(changing)
159    }
160
161    /// Restart an intact durable voter without granting same-view authority.
162    ///
163    /// Restart from an installed normal view requests its successor, fencing a
164    /// primary that sent prepares before its local write completed. An already
165    /// promised but never installed election resumes that same view: it could
166    /// not have assigned normal operations there. Repeated restarts cannot
167    /// ratchet that unfinished election. Quorum selection and durable selected
168    /// installation/activation remain mandatory before any normal authority.
169    ///
170    /// `generation` must be a fresh writer incarnation. Journal recovery and
171    /// validation run on the storage worker before calling this sans-I/O method.
172    /// Retained operations stay on disk; only the live pipeline is preallocated.
173    /// Canonical application recovery is deferred to selected-view installation.
174    pub fn recover_intact(
175        configuration: Configuration,
176        local: NodeId,
177        generation: JournalGeneration,
178        recovered: RecoveredState,
179        limits: PipelineLimits,
180    ) -> Result<Self, ViewChangeError> {
181        if configuration.policy() == crate::QuorumPolicy::Replicated {
182            return Err(ViewChangeError::VolatileRestart);
183        }
184        Self::recover_verified(configuration, local, generation, recovered, limits)
185    }
186
187    /// Restart a memory-voting broker from independently proven drained history.
188    ///
189    /// The adapter must verify a durable shutdown marker bound to the exact
190    /// configuration, store, selected manifest, and accepted prefix, or a fresh
191    /// completed group recovery. Before returning any driver it must replace
192    /// that marker durably with running state. Intact bytes alone are insufficient:
193    /// they cannot prove that an unpersisted RAM vote never existed.
194    pub fn recover_drained(
195        configuration: Configuration,
196        local: NodeId,
197        generation: JournalGeneration,
198        recovered: RecoveredState,
199        limits: PipelineLimits,
200    ) -> Result<Self, ViewChangeError> {
201        if configuration.policy() != crate::QuorumPolicy::Replicated {
202            return Err(ReplicationError::PolicyMismatch.into());
203        }
204        Self::recover_verified(configuration, local, generation, recovered, limits)
205    }
206
207    fn recover_verified(
208        configuration: Configuration,
209        local: NodeId,
210        generation: JournalGeneration,
211        recovered: RecoveredState,
212        limits: PipelineLimits,
213    ) -> Result<Self, ViewChangeError> {
214        if recovered.scope
215            != (Scope {
216                view: recovered.scope.view,
217                ..configuration.scope()
218            })
219        {
220            return Err(ReplicationError::ScopeMismatch.into());
221        }
222        if recovered.log.last_normal_view > recovered.scope.view {
223            return Err(ViewChangeError::InvalidReport);
224        }
225        let view = if recovered.scope.view > recovered.log.last_normal_view {
226            recovered.scope.view
227        } else {
228            recovered
229                .scope
230                .view
231                .checked_add(1)
232                .ok_or(ViewChangeError::ViewExhausted)?
233        };
234        validate_report(DoViewChange {
235            scope: Scope {
236                view,
237                ..recovered.scope
238            },
239            generation,
240            log: recovered.log,
241        })?;
242        let normal =
243            NormalReplica::recover_fenced(configuration, local, generation, recovered.log, limits)?;
244        let mut changing = normal.into_view_change(view)?;
245        changing.promised = recovered.scope.view;
246        if changing.promised == view {
247            changing.starts |= 1 << changing.local; // Independently recovered durable promise.
248            changing.completed = Some(PromiseTicket {
249                scope: changing.scope(),
250                generation,
251                log: recovered.log,
252            });
253        }
254        Ok(changing)
255    }
256
257    pub(crate) fn begin(
258        mut normal: NormalReplica,
259        configuration: Configuration,
260        local: usize,
261        view: u64,
262    ) -> Result<Self, ViewChangeError> {
263        normal.fence();
264        if normal.snapshot().status == Status::Faulted {
265            return Err(ViewChangeError::Faulted);
266        }
267        if view <= configuration.scope().view {
268            return Err(ViewChangeError::ViewNotHigher);
269        }
270        Ok(Self {
271            normal,
272            configuration,
273            local,
274            view,
275            promised: configuration.scope().view,
276            pending: None,
277            completed: None,
278            starts: 0,
279            reports: [None; 3],
280            selected: None,
281        })
282    }
283
284    /// Read-only old-role journal/application evidence for storage scheduling.
285    pub fn normal_snapshot(&self) -> ReplicaSnapshot {
286        self.normal.snapshot()
287    }
288
289    /// Requested view; it may not yet have a completed durable promise.
290    pub fn scope(&self) -> Scope {
291        Scope {
292            view: self.view,
293            ..self.configuration.scope()
294        }
295    }
296
297    /// Highest view whose promise completion this core has observed.
298    pub const fn promised_view(&self) -> u64 {
299        self.promised
300    }
301
302    /// Move toward a later view without discarding an outstanding disk action.
303    ///
304    /// Old reports/selection immediately lose authority. A pending earlier
305    /// promise must settle before the next can be issued. Its late completion
306    /// never authorizes messages for either the old or the unpersisted new view.
307    pub fn advance_view(&mut self, view: u64) -> Result<(), ViewChangeError> {
308        self.require_active()?;
309        if view <= self.view {
310            return Err(ViewChangeError::ViewNotHigher);
311        }
312        self.view = view;
313        self.starts = 0;
314        self.reports = [None; 3];
315        self.selected = None;
316        Ok(())
317    }
318
319    /// Settle a previously admitted complete write without reviving old votes.
320    pub fn complete_write(&mut self, ticket: WriteTicket) -> Result<(), ViewChangeError> {
321        Ok(self.normal.complete_write(ticket)?)
322    }
323
324    /// Capture the written prefix for a real barrier on the storage worker.
325    pub fn begin_sync(&self) -> Result<SyncTicket, ViewChangeError> {
326        Ok(self.normal.begin_sync()?)
327    }
328
329    /// Settle a real disk barrier. It cannot advance old-view commit.
330    pub fn complete_sync(&mut self, ticket: SyncTicket) -> Result<(), ViewChangeError> {
331        Ok(self.normal.complete_sync(ticket)?)
332    }
333
334    /// Report uncertain journal I/O and permanently fault this transition.
335    pub fn fail_io(&mut self, generation: JournalGeneration) -> Result<(), ViewChangeError> {
336        Ok(self.normal.fail_io(generation)?)
337    }
338
339    /// Issue one metadata promise after every accepted operation is synchronized.
340    ///
341    /// Finishing all old writes makes the report immutable, including operations
342    /// another replica may already have acknowledged before local I/O finished.
343    pub fn begin_promise(&mut self) -> Result<PromiseTicket, ViewChangeError> {
344        self.require_active()?;
345        if self.pending.is_some() {
346            return Err(ViewChangeError::PromisePending);
347        }
348        if self.promised == self.view {
349            return Err(ViewChangeError::AlreadyPromised);
350        }
351        let snapshot = self.normal.snapshot();
352        if snapshot.journal.durable != snapshot.accepted.op {
353            return Err(ViewChangeError::StoragePending);
354        }
355        let ticket = PromiseTicket {
356            scope: self.scope(),
357            generation: snapshot.journal.generation,
358            log: FrozenLog {
359                last_normal_view: snapshot.scope.view,
360                accepted: snapshot.accepted,
361                committed: snapshot.committed,
362            },
363        };
364        self.pending = Some(ticket);
365        Ok(ticket)
366    }
367
368    /// Observe successful durable publication of the exact issued promise.
369    pub fn complete_promise(&mut self, ticket: PromiseTicket) -> Result<(), ViewChangeError> {
370        self.require_active()?;
371        if self.completed == Some(ticket) {
372            return Ok(());
373        }
374        self.require_ticket(ticket)?;
375        self.pending = None;
376        self.completed = Some(ticket);
377        self.promised = ticket.scope.view;
378        if self.promised == self.view {
379            self.starts |= 1 << self.local;
380        }
381        Ok(())
382    }
383
384    /// An ambiguous promise failure fences all election messages and selection.
385    pub fn fail_promise(&mut self, ticket: PromiseTicket) -> Result<(), ViewChangeError> {
386        self.require_active()?;
387        self.require_ticket(ticket)?;
388        self.fail_io(ticket.generation)
389    }
390
391    /// Broadcast only after the requested view promise is durably complete.
392    pub fn start_message(&self) -> Result<StartViewChange, ViewChangeError> {
393        self.require_promised()?;
394        Ok(StartViewChange {
395            scope: self.scope(),
396        })
397    }
398
399    /// Collect an authenticated other voter's first-phase message.
400    /// Duplicate messages cannot create extra voters or replace local persistence.
401    pub fn receive_start(
402        &mut self,
403        from: NodeId,
404        message: StartViewChange,
405    ) -> Result<(), ViewChangeError> {
406        self.require_scope(message.scope)?;
407        let voter = self.configuration.voter_index(from)?;
408        if voter == self.local {
409            return Err(ReplicationError::WrongRole.into());
410        }
411        self.starts |= 1 << voter;
412        Ok(())
413    }
414
415    /// Freeze the local second-phase report after a first-phase majority.
416    /// Send it to the configured primary; the primary retains its own report too.
417    pub fn report(&mut self) -> Result<DoViewChange, ViewChangeError> {
418        self.require_promised()?;
419        if self.starts.count_ones() < 2 {
420            return Err(ViewChangeError::StartQuorumMissing);
421        }
422        let promise = self.completed.expect("requested view promised");
423        let report = DoViewChange {
424            scope: self.scope(),
425            generation: promise.generation,
426            log: promise.log,
427        };
428        self.reports[self.local] = Some(report);
429        Ok(report)
430    }
431
432    /// Primary collects immutable, authenticated second-phase reports.
433    ///
434    /// Later views are explicit `advance_view` events. A new report cannot alter
435    /// an already selected quorum; changed frozen logs from one voter are faults.
436    /// Intact restart may repeat the identical log with a fresh writer generation.
437    /// Keep the first source descriptor unchanged, including after selection:
438    /// this is one vote, not permission to mix or redirect history chunks.
439    pub fn receive_report(
440        &mut self,
441        from: NodeId,
442        report: DoViewChange,
443    ) -> Result<(), ViewChangeError> {
444        self.require_scope(report.scope)?;
445        self.require_primary()?;
446        let voter = self.configuration.voter_index(from)?;
447        if voter == self.local {
448            return Err(ReplicationError::WrongRole.into());
449        }
450        validate_report(report)?;
451        if let Some(previous) = self.reports[voter] {
452            if previous.log != report.log {
453                return Err(self.conflict());
454            }
455            // An unfinished durable promise survives restart, but its process's
456            // writer incarnation does not. Equivalent history is not a fault.
457            // A vanished first source may require another view; never replace a
458            // pinned or selected source merely because a different ID arrived.
459            return Ok(());
460        }
461        if self.selected.is_some() {
462            return Err(ViewChangeError::SelectionFrozen);
463        }
464        self.reports[voter] = Some(report);
465        Ok(())
466    }
467
468    /// Select the highest last-normal-view, then longest accepted lineage.
469    ///
470    /// `lookup` must be a nonblocking lookup into already verified, pinned
471    /// canonical history anchored by the supplied source's exact tail digest.
472    /// An isolated matching hash or a chunk from another generation is not such
473    /// evidence. Missing bytes return `None`; fetch/verify them outside the core.
474    /// At most six protected-prefix lookups are needed for three voters.
475    ///
476    /// Selection includes this primary's report and another distinct voter. It
477    /// protects every reported commit and same-normal-view accepted prefix.
478    /// Higher-normal-view histories may discard only older uncertain suffixes.
479    pub fn select(
480        &mut self,
481        mut lookup: impl FnMut(LogSource, OpNumber) -> Option<Digest>,
482    ) -> Result<SelectedView, ViewChangeError> {
483        self.require_promised()?;
484        self.require_primary()?;
485        if let Some(selected) = self.selected {
486            return Ok(selected);
487        }
488        let reports = self.reports;
489        if reports[self.local].is_none() || reports.iter().flatten().count() < 2 {
490            return Err(ViewChangeError::ReportQuorumMissing);
491        }
492        let mut chosen = self.local;
493        let mut voters = 0;
494        for (index, report) in reports.iter().enumerate() {
495            let Some(report) = report else {
496                continue;
497            };
498            voters |= 1 << index;
499            let current = reports[chosen].expect("selected report");
500            let rank = (report.log.last_normal_view, report.log.accepted.op);
501            let best = (current.log.last_normal_view, current.log.accepted.op);
502            if rank == best && report.log.accepted != current.log.accepted {
503                return Err(self.conflict());
504            }
505            if rank > best {
506                chosen = index;
507            }
508        }
509        let report = reports[chosen].expect("selected report");
510        let source = LogSource {
511            voter: self.configuration.voters()[chosen],
512            generation: report.generation,
513            accepted: report.log.accepted,
514        };
515        let mut committed = Prefix::GENESIS;
516        for candidate in reports.iter().flatten() {
517            if candidate.log.committed.op > committed.op {
518                committed = candidate.log.committed;
519            }
520            self.verify_prefix(source, candidate.log.committed, &mut lookup)?;
521            if candidate.log.last_normal_view == report.log.last_normal_view {
522                self.verify_prefix(source, candidate.log.accepted, &mut lookup)?;
523            }
524        }
525        let selected = SelectedView {
526            scope: self.scope(),
527            source,
528            committed,
529            voters,
530        };
531        self.selected = Some(selected);
532        Ok(selected)
533    }
534
535    pub(crate) fn verify_prefix(
536        &mut self,
537        source: LogSource,
538        prefix: Prefix,
539        lookup: &mut impl FnMut(LogSource, OpNumber) -> Option<Digest>,
540    ) -> Result<(), ViewChangeError> {
541        if prefix.op > source.accepted.op {
542            return Err(self.conflict());
543        }
544        let digest = if prefix.op == OpNumber(0) {
545            Digest::ZERO
546        } else if prefix.op == source.accepted.op {
547            source.accepted.digest
548        } else {
549            lookup(source, prefix.op).ok_or(ViewChangeError::HistoryMissing)?
550        };
551        if digest != prefix.digest {
552            return Err(self.conflict());
553        }
554        Ok(())
555    }
556
557    fn require_active(&self) -> Result<(), ViewChangeError> {
558        if self.normal.snapshot().status == Status::Faulted {
559            return Err(ViewChangeError::Faulted);
560        }
561        Ok(())
562    }
563
564    pub(crate) fn require_scope(&self, scope: Scope) -> Result<(), ViewChangeError> {
565        self.require_active()?;
566        if scope != self.scope() {
567            return Err(ReplicationError::ScopeMismatch.into());
568        }
569        Ok(())
570    }
571
572    pub(crate) fn require_primary(&self) -> Result<(), ViewChangeError> {
573        if self.configuration.primary(self.view) != self.configuration.voters()[self.local] {
574            return Err(ReplicationError::WrongRole.into());
575        }
576        Ok(())
577    }
578
579    pub(crate) fn require_promised(&self) -> Result<(), ViewChangeError> {
580        self.require_active()?;
581        if self.promised != self.view {
582            return Err(ViewChangeError::PromiseRequired);
583        }
584        Ok(())
585    }
586
587    fn require_ticket(&self, ticket: PromiseTicket) -> Result<(), ViewChangeError> {
588        if self.pending != Some(ticket) {
589            return Err(ViewChangeError::StalePromise);
590        }
591        Ok(())
592    }
593
594    fn conflict(&mut self) -> ViewChangeError {
595        self.normal
596            .fail_io(self.normal.snapshot().journal.generation)
597            .expect("current generation");
598        ViewChangeError::ConflictingHistory
599    }
600}
601
602pub(crate) fn valid_prefix(prefix: Prefix) -> bool {
603    prefix.op.0 != u64::MAX && ((prefix.op.0 == 0) == (prefix.digest == Digest::ZERO))
604}
605
606fn validate_report(report: DoViewChange) -> Result<(), ViewChangeError> {
607    let log = report.log;
608    if log.last_normal_view >= report.scope.view
609        || !valid_prefix(log.accepted)
610        || !valid_prefix(log.committed)
611        || log.committed.op > log.accepted.op
612        || (log.committed.op == log.accepted.op && log.committed != log.accepted)
613    {
614        return Err(ViewChangeError::InvalidReport);
615    }
616    Ok(())
617}
618
619/// Rejected election event. Missing history/quorums wait; conflicts fault.
620#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
621pub enum ViewChangeError {
622    /// A disk prefix cannot prove that all earlier retained-memory votes survived.
623    #[error("retained-memory voter restart requires fresh recovery or drained-state evidence")]
624    VolatileRestart,
625    /// Existing journal/normal scope, role, or membership check failed.
626    #[error(transparent)]
627    Replication(#[from] ReplicationError),
628    /// Timers/messages cannot regress or reuse the target view.
629    #[error("view must increase")]
630    ViewNotHigher,
631    /// A restart cannot safely wrap its highest durable view back to zero.
632    #[error("durable view number exhausted")]
633    ViewExhausted,
634    /// All accepted old-view operations must finish synchronization first.
635    #[error("old-view journal work has not settled")]
636    StoragePending,
637    /// Only one metadata promise action can be in flight.
638    #[error("a view promise is already pending")]
639    PromisePending,
640    /// No new metadata write is necessary for this requested view.
641    #[error("requested view already promised")]
642    AlreadyPromised,
643    /// Completion is not the exact outstanding metadata action.
644    #[error("stale or foreign view-promise completion")]
645    StalePromise,
646    /// No election message before successful durable promise completion.
647    #[error("requested view promise has not completed")]
648    PromiseRequired,
649    /// Need own durable promise plus another distinct first-phase voter.
650    #[error("start-view-change quorum missing")]
651    StartQuorumMissing,
652    /// Selection needs the primary's own report and another distinct voter.
653    #[error("do-view-change quorum missing")]
654    ReportQuorumMissing,
655    /// Fetch and validate the frozen selected history before selecting it.
656    #[error("selected lineage evidence missing")]
657    HistoryMissing,
658    /// Malformed positions or last-normal-view cannot become evidence.
659    #[error("invalid frozen view-change report")]
660    InvalidReport,
661    /// A protected prefix or immutable report conflicts with this lineage.
662    #[error("conflicting view-change history")]
663    ConflictingHistory,
664    /// Do not change the selected quorum while installation may be in flight.
665    #[error("view selection already frozen")]
666    SelectionFrozen,
667    /// Primary cannot install without its completed quorum selection.
668    #[error("view has not been selected")]
669    SelectionRequired,
670    /// A replacement writer needs a fresh, globally unique incarnation.
671    #[error("installation reused the old writer generation")]
672    ReusedGeneration,
673    /// Completion does not identify this exact pending installation.
674    #[error("stale or foreign installation completion")]
675    StaleInstallation,
676    /// Successful completion has already transferred ownership to a new role.
677    #[error("installation already completed")]
678    InstallationCompleted,
679    /// Committed canonical state must be rebuilt before role activation.
680    #[error("installed application state is not at the required committed prefix")]
681    ApplicationPending,
682    /// Storage or lineage fault requires recovery, not another local election.
683    #[error("view change faulted")]
684    Faulted,
685}