Skip to main content

laminar_core/cluster/control/
controller.rs

1//! Facade over `ClusterKv` + `BarrierCoordinator` + membership watch.
2//! `None` on `CheckpointCoordinator` means single-instance mode.
3
4use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
5use std::sync::Arc;
6use std::time::Duration;
7
8use futures::StreamExt as _;
9use sha2::{Digest as _, Sha256};
10use tokio::sync::watch;
11use uuid::Uuid;
12
13use super::barrier::{
14    BarrierAck, BarrierAnnouncement, BarrierCoordinator, ClusterKv, QuorumOutcome,
15};
16use super::leader::leader_of;
17use super::snapshot::AssignmentSnapshotStore;
18use crate::checkpoint::{
19    AssignmentDrainId, AssignmentDrainTransition, CheckpointAssignmentAdoption,
20    CheckpointAssignmentFence, CheckpointParticipant, LeaderProof, PreparedCheckpointWitness,
21    MAX_CHECKPOINT_PARTICIPANTS, MAX_PREPARED_CHECKPOINT_WITNESSES,
22};
23use crate::cluster::discovery::{assignable_node_ids, NodeId, NodeInfo, NodeState};
24#[cfg(test)]
25use crate::state::CheckpointAttempt;
26use crate::state::Locality;
27
28const RECOVERY_INCARNATION_KEY: &str = "control:recovery-incarnation";
29const DRAIN_ACK_KEY: &str = "control:drain-ack";
30const DRAIN_ACK_PROTOCOL_VERSION: u16 = 1;
31const RELEASE_READY_ACK_KEY: &str = "control:recovery-release-ready";
32const RELEASE_READY_PROTOCOL_VERSION: u16 = 2;
33const RECOVERY_STOPPED_REPORT_KEY: &str = "control:recovery-stopped";
34/// Current wire version for a recovery stopped report.
35const RECOVERY_STOPPED_REPORT_PROTOCOL_VERSION: u16 = 3;
36/// Maximum encoded size of one recovery stopped report.
37const MAX_RECOVERY_STOPPED_REPORT_BYTES: usize = 32 * 1_024;
38/// Shared ceiling for mutable recovery intents and immutable release terminals.
39pub(super) const MAX_RECOVERY_ANNOUNCEMENT_BYTES: usize = 256 * 1_024;
40const RECOVERY_CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(5);
41const MAX_DRAIN_ACK_BYTES: usize = 1_024;
42const MAX_RELEASE_READY_ACK_BYTES: usize = 1_024;
43const CONTROL_ROSTER_IO_CONCURRENCY: usize = 32;
44const PENDING_RELEASE_FAULT_AUDIT_INTERVAL: Duration = Duration::from_secs(1);
45
46#[cfg(feature = "cluster")]
47struct LeaderLeaseGate {
48    lease: watch::Receiver<Option<super::LeaderLease>>,
49    owner: super::LeaderLeaseOwner,
50    deadline: Arc<super::LeaseDeadline>,
51}
52
53/// One authority-validated clustered `Prepare` and its local assignment disposition.
54#[cfg(feature = "cluster")]
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum CheckpointPrepareObservation {
57    /// The announced assignment exactly matches the locally installed certified assignment.
58    AssignmentReady(BarrierAnnouncement),
59    /// Authority is valid, but the assignment must be rejected before barrier injection.
60    AssignmentRejected {
61        /// Leader announcement to identify the negative acknowledgement.
62        announcement: BarrierAnnouncement,
63        /// Local certification failure sent back to the leader.
64        error: String,
65    },
66}
67
68/// Immutable identity of one coordinated recovery attempt.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
70pub struct RecoveryRoundId {
71    /// Monotonic recovery generation.
72    pub generation: u64,
73    /// Random attempt identity. This prevents a restarted driver from reusing a generation.
74    pub nonce: Uuid,
75    /// Node that owns and drives this round.
76    pub driver: NodeId,
77}
78
79/// Frozen recovery round, assignment certificate, and durable driver term.
80#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct RecoveryRound {
83    /// Unique round identity.
84    pub id: RecoveryRoundId,
85    /// Exact durable leader term that created and may advance this round.
86    pub leader_proof: LeaderProof,
87    /// Exact owner-complete assignment cut from which the quorum was frozen.
88    pub assignment_fence: CheckpointAssignmentFence,
89    /// Exact non-owner processes whose fault evidence must be inventoried before recovery starts.
90    ///
91    /// These participants stop and report Prepared checkpoint evidence, but do not join the
92    /// assignment-owner restore or release quorum.
93    pub evidence_participants: Vec<CheckpointParticipant>,
94    /// Exact shared-authority fault inventory revision frozen for this round.
95    fault_revision: u64,
96    /// Canonical nonzero fault reports covered by this round's terminal `Release`.
97    pub faults: Vec<RecoveryFault>,
98}
99
100/// One durable fault report covered by a coordinated recovery round.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct RecoveryFault {
104    /// Stable node slot that published the report.
105    pub reporter: NodeId,
106    /// Nonzero globally monotonic authority sequence observed by the recovery driver.
107    pub sequence: u64,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
111#[serde(deny_unknown_fields)]
112pub(crate) struct RecoveryFaultPublisher {
113    pub(crate) participant: CheckpointParticipant,
114    pub(crate) process_term: u64,
115}
116
117/// Opaque process-local identity for one recoverable failure notification.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct RecoveryFaultRequest {
120    sequence: std::num::NonZeroU64,
121}
122
123/// Durable disposition of one process-local recovery-fault request.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum RecoveryFaultReportOutcome {
126    /// The exact request is the active fault for this stable node.
127    Active,
128    /// The exact request was already atomically covered by a committed recovery Release.
129    AlreadyCleared,
130    /// A newer request from the same process already superseded this request.
131    CoveredByNewerRequest,
132}
133
134impl RecoveryFaultRequest {
135    /// Process-local ordinal used to retain the request across bounded retries.
136    #[must_use]
137    pub const fn sequence(self) -> u64 {
138        self.sequence.get()
139    }
140}
141
142impl RecoveryFaultPublisher {
143    pub(crate) fn validate(&self) -> Result<(), String> {
144        if self.participant.node_id == 0
145            || self.participant.boot_incarnation.is_nil()
146            || self.process_term == 0
147        {
148            return Err("recovery fault publisher identity is not canonical".into());
149        }
150        Ok(())
151    }
152}
153
154/// One atomically observed shared-authority fault inventory.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct RecoveryFaultInventory {
157    pub(crate) revision: u64,
158    pub(crate) faults: Vec<RecoveryFault>,
159}
160
161impl RecoveryFaultInventory {
162    /// Authority sequence that admitted this exact active set.
163    #[must_use]
164    pub const fn revision(&self) -> u64 {
165        self.revision
166    }
167
168    /// Canonical active fault reports in stable-node order.
169    #[must_use]
170    pub fn faults(&self) -> &[RecoveryFault] {
171        &self.faults
172    }
173}
174
175/// One coherent recovery-admission view from the shared leader authority.
176/// This is evidence, not authority to open intake; callers must revalidate it with the audited
177/// leader proof immediately before activation.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct RecoveryAdmissionSnapshot {
180    pub(crate) committed_release: Option<RecoveryAnnouncement>,
181    pub(crate) fault_inventory: RecoveryFaultInventory,
182    pub(crate) authority_sequence: u64,
183    pub(crate) release_head: Option<super::leader_lease::RecoveryReleaseLink>,
184}
185
186impl RecoveryAdmissionSnapshot {
187    /// Latest committed recovery release in this authority view.
188    #[must_use]
189    pub fn committed_release(&self) -> Option<&RecoveryAnnouncement> {
190        self.committed_release.as_ref()
191    }
192
193    /// Active recovery faults from the same authority view.
194    #[must_use]
195    pub const fn fault_inventory(&self) -> &RecoveryFaultInventory {
196        &self.fault_inventory
197    }
198}
199
200impl RecoveryRound {
201    /// Construct a fresh uniquely identified recovery round.
202    ///
203    /// # Errors
204    /// Returns an error when the generation, leader proof, or assignment certificate is invalid.
205    pub fn new(
206        generation: u64,
207        leader_proof: LeaderProof,
208        assignment_fence: CheckpointAssignmentFence,
209        evidence_participants: Vec<CheckpointParticipant>,
210        fault_revision: u64,
211        faults: Vec<RecoveryFault>,
212    ) -> Result<Self, String> {
213        let driver = NodeId(leader_proof.owner.node_id);
214        let round = Self {
215            id: RecoveryRoundId {
216                generation,
217                nonce: Uuid::new_v4(),
218                driver,
219            },
220            leader_proof,
221            assignment_fence,
222            evidence_participants,
223            fault_revision,
224            faults,
225        };
226        round.validate()?;
227        Ok(round)
228    }
229
230    /// Whether `node` belongs to the immutable assignment-owner quorum.
231    #[must_use]
232    pub fn contains_owner(&self, node: NodeId) -> bool {
233        self.assignment_fence.contains(node.0)
234    }
235
236    /// Frozen assignment owners as runtime node identifiers.
237    #[must_use]
238    pub fn owners(&self) -> Vec<NodeId> {
239        self.assignment_fence
240            .participants
241            .iter()
242            .map(|participant| NodeId(participant.node_id))
243            .collect()
244    }
245
246    /// Frozen assignment-owner boot identity for `node`.
247    #[must_use]
248    pub(crate) fn owner_incarnation(&self, node: NodeId) -> Option<Uuid> {
249        self.assignment_fence.participant_incarnation(node.0)
250    }
251
252    /// Whether `node` must stop and report evidence for this round.
253    #[must_use]
254    pub fn contains_stopped_participant(&self, node: NodeId) -> bool {
255        self.contains_owner(node)
256            || self
257                .evidence_participants
258                .binary_search_by_key(&node.0, |participant| participant.node_id)
259                .is_ok()
260    }
261
262    /// Frozen stopped roster as sorted runtime node identifiers.
263    #[must_use]
264    pub fn stopped_participants(&self) -> Vec<NodeId> {
265        self.stopped_roster()
266            .into_iter()
267            .map(|participant| NodeId(participant.node_id))
268            .collect()
269    }
270
271    /// Frozen stopped-roster boot identity for `node`.
272    #[must_use]
273    pub(crate) fn stopped_participant_incarnation(&self, node: NodeId) -> Option<Uuid> {
274        self.assignment_fence
275            .participant_incarnation(node.0)
276            .or_else(|| {
277                self.evidence_participants
278                    .binary_search_by_key(&node.0, |participant| participant.node_id)
279                    .ok()
280                    .map(|index| self.evidence_participants[index].boot_incarnation)
281            })
282    }
283
284    fn stopped_roster(&self) -> Vec<CheckpointParticipant> {
285        let mut roster = Vec::with_capacity(
286            self.assignment_fence.participants.len() + self.evidence_participants.len(),
287        );
288        roster.extend(self.assignment_fence.participants.iter().copied());
289        roster.extend(self.evidence_participants.iter().copied());
290        roster.sort_unstable_by_key(|participant| participant.node_id);
291        roster
292    }
293
294    /// Exact fault sequence this round covers for `node`.
295    #[must_use]
296    pub fn fault_sequence(&self, node: NodeId) -> Option<u64> {
297        self.faults
298            .binary_search_by_key(&node, |fault| fault.reporter)
299            .ok()
300            .map(|index| self.faults[index].sequence)
301    }
302
303    /// Exact shared-authority fault inventory revision frozen into this round.
304    #[must_use]
305    pub const fn fault_revision(&self) -> u64 {
306        self.fault_revision
307    }
308
309    fn validate(&self) -> Result<(), String> {
310        if self.id.generation == 0 {
311            return Err("recovery generation must be nonzero".into());
312        }
313        if self.id.nonce.is_nil() {
314            return Err("recovery nonce must be non-nil".into());
315        }
316        if self.id.driver.is_unassigned() {
317            return Err("recovery driver must be assigned".into());
318        }
319        if !self.leader_proof.is_canonical() || self.id.driver.0 != self.leader_proof.owner.node_id
320        {
321            return Err("recovery driver must match a canonical leader proof".into());
322        }
323        if !self.assignment_fence.is_canonical() {
324            return Err("recovery assignment certificate is not canonical".into());
325        }
326        if !self.contains_owner(self.id.driver) {
327            return Err("recovery driver is absent from the frozen quorum".into());
328        }
329        if self.owner_incarnation(self.id.driver) != Some(self.leader_proof.owner.boot_id) {
330            return Err("recovery leader proof is not bound to the frozen driver process".into());
331        }
332        if self.fault_revision == 0 {
333            return Err("recovery fault revision must be nonzero".into());
334        }
335        if self.faults.is_empty()
336            || self.faults.iter().any(|fault| {
337                fault.reporter.is_unassigned()
338                    || fault.sequence == 0
339                    || fault.sequence > self.fault_revision
340            })
341            || self
342                .faults
343                .windows(2)
344                .any(|pair| pair[0].reporter >= pair[1].reporter)
345        {
346            return Err("recovery fault set is not canonical".into());
347        }
348        if self
349            .evidence_participants
350            .iter()
351            .any(|participant| participant.node_id == 0 || participant.boot_incarnation.is_nil())
352            || self
353                .evidence_participants
354                .windows(2)
355                .any(|pair| pair[0].node_id >= pair[1].node_id)
356        {
357            return Err("recovery evidence participant roster is not canonical".into());
358        }
359        if self.assignment_fence.participants.len() + self.evidence_participants.len()
360            > MAX_CHECKPOINT_PARTICIPANTS
361        {
362            return Err(format!(
363                "recovery stopped roster has {} participants; maximum is {MAX_CHECKPOINT_PARTICIPANTS}",
364                self.assignment_fence.participants.len() + self.evidence_participants.len()
365            ));
366        }
367        if self.evidence_participants.iter().any(|participant| {
368            self.assignment_fence.contains(participant.node_id)
369                || self
370                    .faults
371                    .binary_search_by_key(&NodeId(participant.node_id), |fault| fault.reporter)
372                    .is_err()
373        }) {
374            return Err("recovery evidence participants must be non-owner fault reporters".into());
375        }
376        let largest_terminal = RecoveryAnnouncement {
377            round: self.clone(),
378            phase: RecoverPhase::ReleaseCommitted { epoch: u64::MAX },
379        };
380        let encoded = serde_json::to_vec(&largest_terminal)
381            .map_err(|error| format!("could not encode recovery round: {error}"))?;
382        validate_recovery_announcement_size(encoded.len())?;
383        Ok(())
384    }
385}
386
387/// Phase of a coordinated recovery round, carried in the `control:recover` slot.
388#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
389pub enum RecoverPhase {
390    /// Stop the pipeline and ack; the rewind target is announced after the cluster quiesces.
391    Prepare,
392    /// Rewind to `epoch` and restart.
393    Start {
394        /// Rewind target; `0` means no committed cut exists — restart fresh.
395        epoch: u64,
396    },
397    /// Every restored owner must prepare its local transport authority and publish readiness while
398    /// source gates remain closed.
399    Release {
400        /// The identical rewind target carried by `Start`.
401        epoch: u64,
402    },
403    /// Leader-fenced terminal decision that every exact owner was ready for the release intent.
404    ReleaseCommitted {
405        /// The identical rewind target carried by `Start` and `Release`.
406        epoch: u64,
407    },
408}
409
410/// Durable announcement for one exact recovery round.
411#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
412#[serde(deny_unknown_fields)]
413pub struct RecoveryAnnouncement {
414    /// Frozen round identity and quorum.
415    pub round: RecoveryRound,
416    /// Current phase; `Start` is valid only after the identical `Prepare`.
417    pub phase: RecoverPhase,
418}
419
420impl RecoveryAnnouncement {
421    pub(crate) fn validate(&self) -> Result<(), String> {
422        self.round.validate()?;
423        let encoded = serde_json::to_vec(self)
424            .map_err(|error| format!("could not encode recovery announcement: {error}"))?;
425        validate_recovery_announcement_size(encoded.len())
426    }
427}
428
429/// Boot-bound phase-one acknowledgement and unresolved prepared-checkpoint inventory.
430///
431/// The report is recovery evidence, not a checkpoint outcome. Its exact round and publisher bind
432/// it to one frozen process quorum; every prepared witness still requires an immutable outcome.
433#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
434#[serde(deny_unknown_fields)]
435pub struct RecoveryStoppedReport {
436    /// Wire format version. Only [`RECOVERY_STOPPED_REPORT_PROTOCOL_VERSION`] is accepted.
437    protocol_version: u16,
438    /// Compact identity of the frozen recovery round for which the publisher stopped.
439    round_id: RecoveryRoundId,
440    /// Canonical SHA-256 digest of the exact full recovery round.
441    round_sha256: String,
442    /// Stable node slot and boot incarnation that published this report.
443    publisher: CheckpointParticipant,
444    /// Canonically ordered unresolved prepared-checkpoint evidence visible to the publisher.
445    prepared_witnesses: Vec<PreparedCheckpointWitness>,
446}
447
448impl RecoveryStoppedReport {
449    /// Construct a canonical stopped report.
450    ///
451    /// # Errors
452    /// Returns an error when the round, publisher, inventory ordering, count, or encoded size is
453    /// invalid.
454    pub fn new(
455        round: &RecoveryRound,
456        publisher: CheckpointParticipant,
457        prepared_witnesses: Vec<PreparedCheckpointWitness>,
458    ) -> Result<Self, String> {
459        let report = Self {
460            protocol_version: RECOVERY_STOPPED_REPORT_PROTOCOL_VERSION,
461            round_id: round.id,
462            round_sha256: recovery_round_sha256(round)?,
463            publisher,
464            prepared_witnesses,
465        };
466        report.validate(round)?;
467        Ok(report)
468    }
469
470    /// Exact recovery-round identity bound into this report.
471    #[must_use]
472    pub const fn round_id(&self) -> RecoveryRoundId {
473        self.round_id
474    }
475
476    /// Exact process that published this report.
477    #[must_use]
478    pub const fn publisher(&self) -> CheckpointParticipant {
479        self.publisher
480    }
481
482    /// Canonically ordered unresolved prepared-checkpoint evidence.
483    #[must_use]
484    pub fn prepared_witnesses(&self) -> &[PreparedCheckpointWitness] {
485        &self.prepared_witnesses
486    }
487
488    /// Validate all semantic and encoded-size invariants of this report.
489    ///
490    /// # Errors
491    /// Returns an error describing the first non-canonical invariant.
492    pub fn validate(&self, round: &RecoveryRound) -> Result<(), String> {
493        self.validate_semantics(round)?;
494        let encoded = serde_json::to_vec(self)
495            .map_err(|error| format!("could not encode recovery stopped report: {error}"))?;
496        validate_stopped_report_size(encoded.len())
497    }
498
499    fn validate_shape(&self) -> Result<(), String> {
500        if self.protocol_version != RECOVERY_STOPPED_REPORT_PROTOCOL_VERSION {
501            return Err(format!(
502                "unsupported recovery stopped report version {}; expected {RECOVERY_STOPPED_REPORT_PROTOCOL_VERSION}",
503                self.protocol_version
504            ));
505        }
506        if self.round_id.generation == 0
507            || self.round_id.nonce.is_nil()
508            || self.round_id.driver.is_unassigned()
509        {
510            return Err("recovery stopped report round identity is not canonical".into());
511        }
512        if !is_sha256_hex(&self.round_sha256) {
513            return Err("recovery stopped report round digest is not canonical".into());
514        }
515        if self.publisher.node_id == 0 || self.publisher.boot_incarnation.is_nil() {
516            return Err("recovery stopped report publisher is not canonical".into());
517        }
518        if self.prepared_witnesses.len() > MAX_PREPARED_CHECKPOINT_WITNESSES {
519            return Err(format!(
520                "recovery stopped report has {} prepared witnesses; maximum is {MAX_PREPARED_CHECKPOINT_WITNESSES}",
521                self.prepared_witnesses.len()
522            ));
523        }
524        for witness in &self.prepared_witnesses {
525            witness.validate()?;
526            if witness.participant_id != self.publisher.node_id {
527                return Err(format!(
528                    "prepared checkpoint witness participant {} does not match report publisher {}",
529                    witness.participant_id, self.publisher.node_id
530                ));
531            }
532        }
533        if self
534            .prepared_witnesses
535            .windows(2)
536            .any(|pair| pair[0].ordering_key() >= pair[1].ordering_key())
537        {
538            return Err(
539                "recovery stopped report witnesses must be uniquely sorted by epoch, checkpoint ID, and participant"
540                    .into(),
541            );
542        }
543        Ok(())
544    }
545
546    fn validate_semantics(&self, round: &RecoveryRound) -> Result<(), String> {
547        self.validate_shape()?;
548        round.validate()?;
549        if self.round_id != round.id || self.round_sha256 != recovery_round_sha256(round)? {
550            return Err("recovery stopped report does not match the exact frozen round".into());
551        }
552        if round.stopped_participant_incarnation(NodeId(self.publisher.node_id))
553            != Some(self.publisher.boot_incarnation)
554        {
555            return Err("recovery stopped report publisher does not match the frozen round".into());
556        }
557        Ok(())
558    }
559}
560
561#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
562#[serde(deny_unknown_fields)]
563struct RecoveryAnnouncementAck {
564    announcement: RecoveryAnnouncement,
565    incarnation: Uuid,
566}
567
568#[derive(serde::Serialize)]
569struct RecoveryStoppedRoundDigestInput<'a> {
570    protocol: &'static str,
571    round: &'a RecoveryRound,
572}
573
574#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
575#[serde(deny_unknown_fields)]
576pub(crate) struct RecoveryReleaseId {
577    protocol_version: u16,
578    generation: u64,
579    nonce: Uuid,
580    driver: NodeId,
581    epoch: u64,
582    round_sha256: String,
583}
584
585#[derive(serde::Serialize)]
586struct RecoveryReleaseDigestInput<'a> {
587    protocol: &'static str,
588    round: &'a RecoveryRound,
589    epoch: u64,
590}
591
592impl RecoveryReleaseId {
593    pub(crate) fn for_pending(release: &RecoveryAnnouncement) -> Result<Self, String> {
594        let RecoverPhase::Release { epoch } = release.phase else {
595            return Err("release readiness must bind a pending Release target".into());
596        };
597        release.validate()?;
598        let encoded = serde_json::to_vec(&RecoveryReleaseDigestInput {
599            protocol: "laminardb-recovery-release-v2",
600            round: &release.round,
601            epoch,
602        })
603        .map_err(|error| format!("could not encode recovery release identity: {error}"))?;
604        Ok(Self {
605            protocol_version: RELEASE_READY_PROTOCOL_VERSION,
606            generation: release.round.id.generation,
607            nonce: release.round.id.nonce,
608            driver: release.round.id.driver,
609            epoch,
610            round_sha256: sha256_hex(&encoded),
611        })
612    }
613
614    pub(crate) fn is_canonical(&self) -> bool {
615        self.protocol_version == RELEASE_READY_PROTOCOL_VERSION
616            && self.generation != 0
617            && !self.nonce.is_nil()
618            && !self.driver.is_unassigned()
619            && is_sha256_hex(&self.round_sha256)
620    }
621
622    pub(crate) fn generation(&self) -> u64 {
623        self.generation
624    }
625}
626
627#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
628#[serde(deny_unknown_fields)]
629struct RecoveryReleaseReadyAck {
630    release: RecoveryReleaseId,
631    participant: CheckpointParticipant,
632}
633
634impl RecoveryReleaseReadyAck {
635    fn is_canonical(&self) -> bool {
636        self.release.is_canonical()
637            && self.participant.node_id != 0
638            && !self.participant.boot_incarnation.is_nil()
639    }
640}
641
642/// Classified recovery-control failure. Only [`Self::Uncertain`] is retryable.
643#[derive(Debug, thiserror::Error, PartialEq, Eq)]
644pub enum RecoveryControlError {
645    /// A bounded durable operation has an unknown outcome.
646    #[error("recovery control outcome is uncertain: {0}")]
647    Uncertain(String),
648    /// Durable state is malformed or contradicts the exact protocol identity.
649    #[error("recovery control conflict: {0}")]
650    Conflict(String),
651    /// A newer process, round, fault set, or leader term replaced this operation.
652    #[error("recovery round was superseded: {0}")]
653    Superseded(String),
654}
655
656impl RecoveryControlError {
657    #[cfg(feature = "cluster")]
658    fn from_authority(error: super::ClusterCheckpointAuthorityError) -> Self {
659        match error {
660            super::ClusterCheckpointAuthorityError::Authority(super::LeaseError::Io(reason)) => {
661                Self::Uncertain(reason)
662            }
663            super::ClusterCheckpointAuthorityError::Fenced => {
664                Self::Superseded("durable leader authority changed".into())
665            }
666            error => Self::Conflict(error.to_string()),
667        }
668    }
669
670    #[cfg(feature = "cluster")]
671    fn from_process_authority(error: super::ProcessLeaseError) -> Self {
672        match error {
673            super::ProcessLeaseError::Io(reason) | super::ProcessLeaseError::Deadline(reason) => {
674                Self::Uncertain(reason)
675            }
676            super::ProcessLeaseError::Invalid(reason) => Self::Conflict(reason),
677            super::ProcessLeaseError::Json(error) => Self::Conflict(error.to_string()),
678        }
679    }
680}
681
682/// Exact leader-side observation of the compact release-readiness roster.
683#[derive(Debug, Clone, PartialEq, Eq)]
684enum ReleaseReadyStatus {
685    /// At least one frozen owner has not published readiness for this release intent.
686    Pending {
687        /// Exact owner slots still missing a matching record.
688        missing: Vec<NodeId>,
689    },
690    /// Every frozen owner published the exact release identity.
691    Complete,
692}
693
694/// One bounded leader attempt to commit a pending recovery Release.
695#[derive(Debug, Clone, PartialEq, Eq)]
696pub enum ReleaseCommitStatus {
697    /// At least one frozen owner has not prepared this exact release intent.
698    Pending {
699        /// Exact owner slots still missing a matching readiness record.
700        missing: Vec<NodeId>,
701    },
702    /// The leader durably admitted the immutable terminal into shared authority.
703    Committed {
704        /// Exact terminal record followers resolve from shared authority across takeover.
705        terminal: RecoveryAnnouncement,
706    },
707}
708
709/// Held after this process validates its atomically settled recovery fault and until its local
710/// source gate transition is complete. A concurrent fault report cannot cross that transition.
711#[must_use = "keep the release guard until local source intake is opened or definitively fenced"]
712pub struct RecoveryReleaseGuard<'a> {
713    _write_guard: tokio::sync::MutexGuard<'a, ()>,
714}
715
716impl std::fmt::Debug for RecoveryReleaseGuard<'_> {
717    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
718        formatter
719            .debug_struct("RecoveryReleaseGuard")
720            .finish_non_exhaustive()
721    }
722}
723
724fn sha256_hex(encoded: &[u8]) -> String {
725    use std::fmt::Write as _;
726
727    let digest = Sha256::digest(encoded);
728    let mut hex = String::with_capacity(64);
729    for byte in digest {
730        write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail");
731    }
732    hex
733}
734
735fn recovery_round_sha256(round: &RecoveryRound) -> Result<String, String> {
736    let encoded = serde_json::to_vec(&RecoveryStoppedRoundDigestInput {
737        protocol: "laminardb-recovery-stopped-round-v3",
738        round,
739    })
740    .map_err(|error| format!("could not encode recovery stopped round identity: {error}"))?;
741    Ok(sha256_hex(&encoded))
742}
743
744fn is_sha256_hex(value: &str) -> bool {
745    value.len() == 64
746        && value
747            .bytes()
748            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
749}
750
751/// Bounded durable proof that one exact predecessor process reached the global source frontier
752/// for an assignment handoff. The round binds the version, vnode-owner map, and complete
753/// boot-incarnation roster; a version alone is not an assignment identity.
754#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
755#[serde(deny_unknown_fields)]
756struct DrainAck {
757    protocol_version: u16,
758    participant: CheckpointParticipant,
759    round: AssignmentDrainId,
760}
761
762impl DrainAck {
763    fn for_transition(
764        participant: CheckpointParticipant,
765        transition: &AssignmentDrainTransition,
766    ) -> Self {
767        Self {
768            protocol_version: DRAIN_ACK_PROTOCOL_VERSION,
769            participant,
770            round: transition.id(),
771        }
772    }
773
774    fn is_canonical(&self) -> bool {
775        self.protocol_version == DRAIN_ACK_PROTOCOL_VERSION
776            && self.participant.node_id != 0
777            && !self.participant.boot_incarnation.is_nil()
778            && self.round.is_canonical()
779    }
780
781    fn matches_transition(&self, transition: &AssignmentDrainTransition) -> bool {
782        self.is_canonical()
783            && self.round == transition.id()
784            && transition
785                .predecessor
786                .participant_incarnation(self.participant.node_id)
787                == Some(self.participant.boot_incarnation)
788    }
789}
790
791fn encode_drain_ack(ack: &DrainAck) -> Result<String, String> {
792    if !ack.is_canonical() {
793        return Err("drain acknowledgement is not canonical".into());
794    }
795    let encoded = serde_json::to_string(ack)
796        .map_err(|error| format!("could not encode drain acknowledgement: {error}"))?;
797    if encoded.len() > MAX_DRAIN_ACK_BYTES {
798        return Err(format!(
799            "drain acknowledgement is {} bytes; maximum is {MAX_DRAIN_ACK_BYTES}",
800            encoded.len()
801        ));
802    }
803    Ok(encoded)
804}
805
806fn parse_drain_ack(raw: &str, publisher: NodeId) -> Result<DrainAck, String> {
807    if raw.len() > MAX_DRAIN_ACK_BYTES {
808        return Err(format!(
809            "drain acknowledgement from {publisher} is {} bytes; maximum is {MAX_DRAIN_ACK_BYTES}",
810            raw.len()
811        ));
812    }
813    let ack: DrainAck = serde_json::from_str(raw)
814        .map_err(|error| format!("invalid drain acknowledgement from {publisher}: {error}"))?;
815    if !ack.is_canonical() || ack.participant.node_id != publisher.0 {
816        return Err(format!(
817            "drain acknowledgement from {publisher} has a non-canonical publisher"
818        ));
819    }
820    if encode_drain_ack(&ack)? != raw {
821        return Err(format!(
822            "drain acknowledgement from {publisher} is not canonically encoded"
823        ));
824    }
825    Ok(ack)
826}
827
828fn parse_recovery_announcement(raw: &str) -> Result<Option<RecoveryAnnouncement>, String> {
829    if raw.is_empty() {
830        return Ok(None);
831    }
832    validate_recovery_announcement_size(raw.len())?;
833    let announcement: RecoveryAnnouncement = serde_json::from_str(raw)
834        .map_err(|error| format!("invalid recovery announcement: {error}"))?;
835    announcement.validate()?;
836    if encode_recovery_announcement(&announcement)? != raw {
837        return Err("recovery announcement is not canonically encoded".into());
838    }
839    Ok(Some(announcement))
840}
841
842fn validate_recovery_announcement_size(encoded_len: usize) -> Result<(), String> {
843    if encoded_len == 0 || encoded_len > MAX_RECOVERY_ANNOUNCEMENT_BYTES {
844        return Err(format!(
845            "recovery announcement is {encoded_len} bytes; maximum is {MAX_RECOVERY_ANNOUNCEMENT_BYTES}"
846        ));
847    }
848    Ok(())
849}
850
851fn encode_recovery_announcement(announcement: &RecoveryAnnouncement) -> Result<String, String> {
852    announcement.round.validate()?;
853    let encoded = serde_json::to_string(announcement)
854        .map_err(|error| format!("could not encode recovery announcement: {error}"))?;
855    validate_recovery_announcement_size(encoded.len())?;
856    Ok(encoded)
857}
858
859fn validate_stopped_report_size(encoded_len: usize) -> Result<(), String> {
860    if encoded_len > MAX_RECOVERY_STOPPED_REPORT_BYTES {
861        return Err(format!(
862            "recovery stopped report is {encoded_len} bytes; maximum is {MAX_RECOVERY_STOPPED_REPORT_BYTES}"
863        ));
864    }
865    Ok(())
866}
867
868fn encode_recovery_stopped_report(
869    report: &RecoveryStoppedReport,
870    round: &RecoveryRound,
871) -> Result<String, String> {
872    report.validate_semantics(round)?;
873    encode_recovery_stopped_report_shape(report)
874}
875
876fn encode_recovery_stopped_report_shape(report: &RecoveryStoppedReport) -> Result<String, String> {
877    report.validate_shape()?;
878    let encoded = serde_json::to_string(report)
879        .map_err(|error| format!("could not encode recovery stopped report: {error}"))?;
880    validate_stopped_report_size(encoded.len())?;
881    Ok(encoded)
882}
883
884#[cfg(test)]
885fn parse_recovery_stopped_report(
886    raw: &str,
887    publisher: NodeId,
888    round: &RecoveryRound,
889) -> Result<RecoveryStoppedReport, String> {
890    let report = parse_recovery_stopped_report_shape(raw, publisher)?;
891    report.validate_semantics(round)?;
892    Ok(report)
893}
894
895fn parse_recovery_stopped_report_shape(
896    raw: &str,
897    publisher: NodeId,
898) -> Result<RecoveryStoppedReport, String> {
899    validate_stopped_report_size(raw.len())?;
900    let report: RecoveryStoppedReport = serde_json::from_str(raw)
901        .map_err(|error| format!("invalid recovery stopped report from {publisher}: {error}"))?;
902    report.validate_shape()?;
903    if report.publisher.node_id != publisher.0 {
904        return Err(format!(
905            "recovery stopped report from {publisher} names publisher {}",
906            report.publisher.node_id
907        ));
908    }
909    if encode_recovery_stopped_report_shape(&report)? != raw {
910        return Err(format!(
911            "recovery stopped report from {publisher} is not canonically encoded"
912        ));
913    }
914    Ok(report)
915}
916
917fn parse_recovery_announcement_ack(
918    raw: &str,
919    publisher: NodeId,
920) -> Result<RecoveryAnnouncement, String> {
921    let ack: RecoveryAnnouncementAck = serde_json::from_str(raw)
922        .map_err(|error| format!("invalid recovery phase acknowledgement: {error}"))?;
923    ack.announcement.validate()?;
924    if ack.announcement.round.owner_incarnation(publisher) != Some(ack.incarnation) {
925        return Err(format!(
926            "recovery phase acknowledgement from {publisher} has a stale process incarnation"
927        ));
928    }
929    Ok(ack.announcement)
930}
931
932fn encode_release_ready_ack(ack: &RecoveryReleaseReadyAck) -> Result<String, String> {
933    if !ack.is_canonical() {
934        return Err("release readiness acknowledgement is not canonical".into());
935    }
936    let encoded = serde_json::to_string(ack)
937        .map_err(|error| format!("could not encode release readiness: {error}"))?;
938    if encoded.len() > MAX_RELEASE_READY_ACK_BYTES {
939        return Err(format!(
940            "release readiness is {} bytes; maximum is {MAX_RELEASE_READY_ACK_BYTES}",
941            encoded.len()
942        ));
943    }
944    Ok(encoded)
945}
946
947fn parse_release_ready_ack(
948    raw: &str,
949    publisher: NodeId,
950) -> Result<RecoveryReleaseReadyAck, String> {
951    if raw.len() > MAX_RELEASE_READY_ACK_BYTES {
952        return Err(format!(
953            "release readiness from {publisher} is {} bytes; maximum is {MAX_RELEASE_READY_ACK_BYTES}",
954            raw.len()
955        ));
956    }
957    let ack: RecoveryReleaseReadyAck = serde_json::from_str(raw)
958        .map_err(|error| format!("invalid release readiness from {publisher}: {error}"))?;
959    if !ack.is_canonical() || ack.participant.node_id != publisher.0 {
960        return Err(format!(
961            "release readiness from {publisher} has a non-canonical publisher"
962        ));
963    }
964    if encode_release_ready_ack(&ack)? != raw {
965        return Err(format!(
966            "release readiness from {publisher} is not canonically encoded"
967        ));
968    }
969    Ok(ack)
970}
971
972/// Facade composing the cluster-control primitives.
973pub struct ClusterController {
974    instance_id: NodeId,
975    /// Fast, process-local/discovery KV used for barriers, routing, and assignment liveness.
976    kv: Arc<dyn ClusterKv>,
977    /// Durable authority for coordinated recovery. This is deliberately separate from gossip:
978    /// terminal recovery state and generations must survive every process in the cluster.
979    recovery_kv: Arc<dyn ClusterKv>,
980    barrier: BarrierCoordinator,
981    snapshot: Option<Arc<AssignmentSnapshotStore>>,
982    members_rx: watch::Receiver<Vec<NodeInfo>>,
983    /// Locally computed version-bound checkpoint fence; admission/recovery borrows it instead of
984    /// performing a gossip scan on the critical path.
985    checkpoint_assignment_fence: watch::Sender<Option<CheckpointAssignmentFence>>,
986    /// Locally audited drain transition. While present, the predecessor fence remains usable only
987    /// for a checkpoint carrying the transition's exact leader term.
988    checkpoint_drain_transition: watch::Sender<Option<AssignmentDrainTransition>>,
989    /// Serialises every local authority grant with terminal process fencing.
990    process_authority_transition: parking_lot::Mutex<()>,
991    /// Process-unique recovery identity, published before this node becomes Active.
992    recovery_incarnation: Uuid,
993    /// Stable-node process term bound to `recovery_incarnation`.
994    recovery_process_term: AtomicU64,
995    /// Per-process request identity; shared authority assigns the cluster-visible fault sequence.
996    recovery_fault_request_sequence: AtomicU64,
997    /// Recovery-safe cluster watermark installed after an immutable Commit outcome or from a
998    /// validated recovery capsule. `i64::MIN` = uninitialised.
999    cluster_min_watermark: Arc<AtomicI64>,
1000    /// While draining, the node excludes itself from [`Self::assignable_instances`] so the
1001    /// next rotation sheds its vnodes before it exits.
1002    draining: Arc<AtomicBool>,
1003    /// Held while a coordinated restart is in flight; the checkpoint gate consults it so
1004    /// no checkpoint is injected mid-recovery.
1005    recovering: Arc<AtomicBool>,
1006    /// Whether this node has announced itself as Active.
1007    active: Arc<AtomicBool>,
1008    /// False permanently after the durable stable-node lease is lost.
1009    process_lease_live: Arc<AtomicBool>,
1010    /// Monotonic deadline consulted directly by hot compute/control paths.
1011    process_lease_deadline: std::sync::OnceLock<Arc<super::LeaseDeadline>>,
1012    /// Shared durable authority used to prove an unresponsive process term was revoked.
1013    process_lease_authority: std::sync::OnceLock<Arc<super::process_lease::ProcessLeaseAuthority>>,
1014    /// Whether this node may be elected leader. A certified leader may retain coordination while
1015    /// draining so it can checkpoint its own predecessor cut.
1016    leader_eligible: Arc<AtomicBool>,
1017    /// Coalescing-safe local candidacy generation. Every observed loss advances the generation
1018    /// synchronously, so a rapid loss/reacquire cannot reuse a prior leader token.
1019    leader_candidacy: watch::Sender<super::LeaderCandidacy>,
1020    /// Last certified assignment roster. Retained across transient certificate suspension so an
1021    /// ownerless worker cannot displace an available data owner. If every certified owner is
1022    /// unavailable, a durably lease-fenced idle worker may lead placement repair only.
1023    leadership_participants: parking_lot::RwLock<Option<(u64, Vec<u64>)>>,
1024    /// Serialises this node's recovery and fault-slot conditional writes.
1025    recovery_writes: tokio::sync::Mutex<()>,
1026    /// Coalesces non-authorizing fault audits while a Release still lacks readiness. The complete
1027    /// path never consults this cache.
1028    pending_release_fault_audit:
1029        tokio::sync::Mutex<Option<(RecoveryReleaseId, tokio::time::Instant)>>,
1030    /// Exact process incarnations that missed a capture quorum, keyed by stable node id. Entries
1031    /// remain quarantined until that process acknowledges or a different lease-bound boot appears;
1032    /// elapsed time is not evidence that a stalled owner became safe.
1033    unresponsive: Arc<parking_lot::Mutex<rustc_hash::FxHashMap<u64, Option<Uuid>>>>,
1034    /// This node's own failure-domain locality; peers carry theirs in `members_rx`.
1035    self_locality: parking_lot::RwLock<Locality>,
1036    /// When wired, leadership is lease-fenced: [`Self::is_leader`] also requires the durable
1037    /// lease. Absent in embedded / static-discovery deployments (gossip-only leadership).
1038    #[cfg(feature = "cluster")]
1039    leader_lease: std::sync::OnceLock<LeaderLeaseGate>,
1040}
1041
1042impl std::fmt::Debug for ClusterController {
1043    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1044        f.debug_struct("ClusterController")
1045            .field("instance_id", &self.instance_id)
1046            .finish_non_exhaustive()
1047    }
1048}
1049
1050impl ClusterController {
1051    /// Wrap the given primitives.
1052    #[must_use]
1053    pub fn new(
1054        instance_id: NodeId,
1055        kv: Arc<dyn ClusterKv>,
1056        snapshot: Option<Arc<AssignmentSnapshotStore>>,
1057        members_rx: watch::Receiver<Vec<NodeInfo>>,
1058    ) -> Self {
1059        Self::new_with_recovery_kv(instance_id, Arc::clone(&kv), kv, snapshot, members_rx)
1060    }
1061
1062    /// Wrap the given primitives with a separate durable recovery authority.
1063    ///
1064    /// The barrier/discovery KV remains the low-latency path. `recovery_kv` owns recovery
1065    /// incarnations, generations, phase announcements, and acknowledgements.
1066    #[must_use]
1067    pub fn new_with_recovery_kv(
1068        instance_id: NodeId,
1069        kv: Arc<dyn ClusterKv>,
1070        recovery_kv: Arc<dyn ClusterKv>,
1071        snapshot: Option<Arc<AssignmentSnapshotStore>>,
1072        members_rx: watch::Receiver<Vec<NodeInfo>>,
1073    ) -> Self {
1074        Self::new_with_recovery_incarnation(
1075            instance_id,
1076            kv,
1077            recovery_kv,
1078            snapshot,
1079            members_rx,
1080            Uuid::new_v4(),
1081        )
1082    }
1083
1084    /// Wrap the control primitives with a caller-generated boot identity.
1085    #[must_use]
1086    pub fn new_with_recovery_incarnation(
1087        instance_id: NodeId,
1088        kv: Arc<dyn ClusterKv>,
1089        recovery_kv: Arc<dyn ClusterKv>,
1090        snapshot: Option<Arc<AssignmentSnapshotStore>>,
1091        members_rx: watch::Receiver<Vec<NodeInfo>>,
1092        recovery_incarnation: Uuid,
1093    ) -> Self {
1094        let leader_eligible = Arc::new(AtomicBool::new(true));
1095        let mut barrier = BarrierCoordinator::new(Arc::clone(&kv));
1096        #[cfg(feature = "cluster")]
1097        barrier.set_leader_election(
1098            instance_id,
1099            members_rx.clone(),
1100            Arc::clone(&leader_eligible),
1101        );
1102        let controller = Self {
1103            instance_id,
1104            barrier,
1105            kv,
1106            recovery_kv,
1107            snapshot,
1108            members_rx,
1109            // A new leader must not checkpoint until it proves exact assignment convergence.
1110            checkpoint_assignment_fence: watch::channel(None).0,
1111            checkpoint_drain_transition: watch::channel(None).0,
1112            process_authority_transition: parking_lot::Mutex::new(()),
1113            recovery_incarnation,
1114            recovery_process_term: AtomicU64::new(0),
1115            recovery_fault_request_sequence: AtomicU64::new(1),
1116            cluster_min_watermark: Arc::new(AtomicI64::new(i64::MIN)),
1117            draining: Arc::new(AtomicBool::new(false)),
1118            recovering: Arc::new(AtomicBool::new(false)),
1119            active: Arc::new(AtomicBool::new(true)),
1120            process_lease_live: Arc::new(AtomicBool::new(true)),
1121            process_lease_deadline: std::sync::OnceLock::new(),
1122            process_lease_authority: std::sync::OnceLock::new(),
1123            leader_eligible,
1124            leader_candidacy: watch::channel(super::LeaderCandidacy::initial(false)).0,
1125            leadership_participants: parking_lot::RwLock::new(None),
1126            recovery_writes: tokio::sync::Mutex::new(()),
1127            pending_release_fault_audit: tokio::sync::Mutex::new(None),
1128            unresponsive: Arc::new(parking_lot::Mutex::new(rustc_hash::FxHashMap::default())),
1129            self_locality: parking_lot::RwLock::new(Locality::default()),
1130            #[cfg(feature = "cluster")]
1131            leader_lease: std::sync::OnceLock::new(),
1132        };
1133        controller.notify_leader_eligibility_change();
1134        controller
1135    }
1136
1137    /// Install the durable authority used to validate clustered checkpoint barriers.
1138    #[cfg(feature = "cluster")]
1139    pub fn set_leader_lease_store(&self, store: Arc<super::LeaderLeaseStore>) {
1140        self.barrier.set_leader_lease_store(store);
1141    }
1142
1143    /// Serve only leader proofs that remain live on this process's monotonic lease gate.
1144    #[cfg(feature = "cluster")]
1145    pub fn install_local_leader_proof_provider(self: &Arc<Self>) {
1146        let controller = Arc::downgrade(self);
1147        self.barrier
1148            .set_local_leader_proof_provider(Arc::new(move || {
1149                controller
1150                    .upgrade()
1151                    .and_then(|controller| controller.capture_leader_proof())
1152            }));
1153    }
1154
1155    /// Exact durable authority installed for this cluster controller.
1156    ///
1157    /// Cluster checkpoint code must use this handle rather than standalone outcome keys.
1158    ///
1159    /// # Errors
1160    /// Returns [`super::ClusterCheckpointAuthorityError::NotConfigured`] when no authority exists.
1161    #[cfg(feature = "cluster")]
1162    pub fn checkpoint_authority(
1163        &self,
1164    ) -> Result<Arc<super::LeaderLeaseStore>, super::ClusterCheckpointAuthorityError> {
1165        self.barrier.checkpoint_authority()
1166    }
1167
1168    /// Latest recovery-safe cluster watermark installed from an immutable Commit outcome or a
1169    /// validated recovery capsule.
1170    #[must_use]
1171    pub fn cluster_min_watermark(&self) -> Option<i64> {
1172        let v = self.cluster_min_watermark.load(Ordering::Acquire);
1173        if v == i64::MIN {
1174            None
1175        } else {
1176            Some(v)
1177        }
1178    }
1179
1180    /// Mirror the leader's computed cluster-min watermark into the atomic so its own operators
1181    /// match followers. Monotonic — never lowers the published value.
1182    pub fn publish_cluster_min_watermark(&self, wm: i64) {
1183        let mut cur = self.cluster_min_watermark.load(Ordering::Acquire);
1184        while wm > cur {
1185            match self.cluster_min_watermark.compare_exchange(
1186                cur,
1187                wm,
1188                Ordering::AcqRel,
1189                Ordering::Acquire,
1190            ) {
1191                Ok(_) => break,
1192                Err(observed) => cur = observed,
1193            }
1194        }
1195    }
1196
1197    /// This instance's ID.
1198    #[must_use]
1199    pub fn instance_id(&self) -> NodeId {
1200        self.instance_id
1201    }
1202
1203    /// The cluster gossip KV, for advertising/discovering per-stream state.
1204    #[must_use]
1205    pub fn kv(&self) -> &Arc<dyn ClusterKv> {
1206        &self.kv
1207    }
1208
1209    /// Current leader: the lowest available active certified participant. A locally certified
1210    /// incumbent may retain candidacy while it drains, but a remote draining process is never
1211    /// nominated. When no certified participant is available, only a controller wired to durable
1212    /// leader fencing may nominate the lowest idle worker so it can repair assignment authority.
1213    #[must_use]
1214    pub fn current_leader(&self) -> Option<NodeId> {
1215        let members = self.members_rx.borrow();
1216        let participants = self.leadership_participants.read();
1217        let unresponsive = self.unresponsive.lock();
1218        let is_participant = |node: NodeId| {
1219            participants
1220                .as_ref()
1221                .is_none_or(|(_, roster)| roster.binary_search(&node.0).is_ok())
1222        };
1223        let mut eligible: Vec<NodeId> = members
1224            .iter()
1225            .filter(|m| {
1226                m.id != self.instance_id
1227                    && matches!(m.state, NodeState::Active)
1228                    && !unresponsive.contains_key(&m.id.0)
1229            })
1230            .map(|m| m.id)
1231            .collect();
1232        if self.leader_eligible.load(Ordering::SeqCst)
1233            && self.process_lease_is_live()
1234            && !unresponsive.contains_key(&self.instance_id.0)
1235        {
1236            eligible.push(self.instance_id);
1237        }
1238        let certified = eligible
1239            .iter()
1240            .copied()
1241            .filter(|node| is_participant(*node))
1242            .collect::<Vec<_>>();
1243        if let Some(leader) = leader_of(&certified) {
1244            return Some(leader);
1245        }
1246        #[cfg(feature = "cluster")]
1247        if self.has_leader_lease_fencing() {
1248            return leader_of(&eligible);
1249        }
1250        None
1251    }
1252
1253    /// True if this node is the gossip-elected candidate (lowest active id), ignoring the
1254    /// lease. The lease manager acquires only while this holds.
1255    #[must_use]
1256    pub fn is_gossip_leader(&self) -> bool {
1257        self.current_leader() == Some(self.instance_id)
1258    }
1259
1260    /// True if this node may act as leader — the gate all leader-gated work inherits. With a
1261    /// lease wired, also requires this exact process's live local-monotonic grant.
1262    #[must_use]
1263    pub fn is_leader(&self) -> bool {
1264        if !self.process_lease_is_live() {
1265            return false;
1266        }
1267        if !self.is_gossip_leader() {
1268            return false;
1269        }
1270        #[cfg(feature = "cluster")]
1271        if let Some(gate) = self.leader_lease.get() {
1272            return super::lease_grants_leadership(
1273                &gate.lease.borrow(),
1274                &gate.owner,
1275                &gate.deadline,
1276            );
1277        }
1278        true
1279    }
1280
1281    /// Whether this controller is wired to a durable leader lease. Gossip-only leadership is
1282    /// adequate for best-effort coordination but cannot certify exactly-once cluster decisions.
1283    #[must_use]
1284    #[cfg(feature = "cluster")]
1285    pub fn has_leader_lease_fencing(&self) -> bool {
1286        self.leader_lease.get().is_some()
1287    }
1288
1289    /// Current live fencing token when this node owns the durable lease.
1290    ///
1291    /// This is an observation for detecting that an operation crossed leadership terms. Reading
1292    /// it does **not** fence a later object-store write: a durable mutation is fenced only when
1293    /// the storage operation atomically validates the token. Callers must not stamp this value
1294    /// into an otherwise unconditional write and treat that as a correctness boundary.
1295    #[must_use]
1296    #[cfg(feature = "cluster")]
1297    pub fn leader_fencing_token(&self) -> Option<u64> {
1298        self.capture_leader_proof().map(|proof| proof.fencing_token)
1299    }
1300
1301    /// Capture the exact durable leader term currently authorized by local monotonic leases.
1302    ///
1303    /// Gossip determines candidacy only. A proof is available solely when this process is the
1304    /// candidate, both process and leader deadlines are live, and the observed durable grant has
1305    /// this process's exact owner identity.
1306    #[must_use]
1307    #[cfg(feature = "cluster")]
1308    pub fn capture_leader_proof(&self) -> Option<super::LeaderProof> {
1309        if !self.process_lease_proof_is_live() || !self.is_gossip_leader() {
1310            return None;
1311        }
1312        let gate = self.leader_lease.get()?;
1313        let lease = gate.lease.borrow();
1314        if !super::lease_grants_leadership(&lease, &gate.owner, &gate.deadline) {
1315            return None;
1316        }
1317        lease.as_ref().map(super::LeaderLease::proof)
1318    }
1319
1320    /// Whether a captured proof remains the exact current locally authorized leader term.
1321    #[must_use]
1322    #[cfg(feature = "cluster")]
1323    pub fn proof_is_live(&self, proof: &super::LeaderProof) -> bool {
1324        if !self.process_lease_proof_is_live() || !self.is_gossip_leader() {
1325            return false;
1326        }
1327        let Some(gate) = self.leader_lease.get() else {
1328            return false;
1329        };
1330        super::lease_grants_proof(&gate.lease.borrow(), &gate.owner, &gate.deadline, proof)
1331    }
1332
1333    /// Capture the durable leader grant while forming a cluster with no active member.
1334    ///
1335    /// Every joining process may contend in a cold start, but the shared lease store admits only
1336    /// one exact owner. As soon as an active member is visible, a joining process ceases to be a
1337    /// candidate and cannot use its observation to publish catalog state.
1338    #[must_use]
1339    #[cfg(feature = "cluster")]
1340    pub fn capture_catalog_bootstrap_proof(&self) -> Option<super::LeaderProof> {
1341        if !self.process_lease_proof_is_live() || !self.is_leader_lease_candidate() {
1342            return None;
1343        }
1344        let gate = self.leader_lease.get()?;
1345        let lease = gate.lease.borrow();
1346        if !super::lease_grants_leadership(&lease, &gate.owner, &gate.deadline) {
1347            return None;
1348        }
1349        lease.as_ref().map(super::LeaderLease::proof)
1350    }
1351
1352    /// Whether a catalog-bootstrap proof remains owned by this cold-start candidate.
1353    #[must_use]
1354    #[cfg(feature = "cluster")]
1355    pub fn catalog_bootstrap_proof_is_live(&self, proof: &super::LeaderProof) -> bool {
1356        if !self.process_lease_proof_is_live() || !self.is_leader_lease_candidate() {
1357            return false;
1358        }
1359        let Some(gate) = self.leader_lease.get() else {
1360            return false;
1361        };
1362        super::lease_grants_proof(&gate.lease.borrow(), &gate.owner, &gate.deadline, proof)
1363    }
1364
1365    /// Subscribe to leader-grant changes for evented proof cancellation.
1366    ///
1367    /// After notification, callers must use [`Self::proof_is_live`]. Candidacy changes are
1368    /// available separately through [`Self::leader_candidacy_watch`]. The lease manager publishes
1369    /// `None` when its monotonic deadline expires, so no polling loop is required.
1370    #[must_use]
1371    #[cfg(feature = "cluster")]
1372    pub fn leader_grant_watch(&self) -> Option<watch::Receiver<Option<super::LeaderLease>>> {
1373        self.leader_lease.get().map(|gate| gate.lease.clone())
1374    }
1375
1376    #[cfg(feature = "cluster")]
1377    fn process_lease_proof_is_live(&self) -> bool {
1378        self.process_lease_live.load(Ordering::Acquire)
1379            && self
1380                .process_lease_deadline
1381                .get()
1382                .is_some_and(|deadline| deadline.is_live())
1383    }
1384
1385    /// Wire the exact owner and local deadline used to fence leader work.
1386    ///
1387    /// # Errors
1388    /// Rejects an owner for another node or duplicate installation.
1389    #[cfg(feature = "cluster")]
1390    pub fn set_leader_lease_watch(
1391        &self,
1392        lease: watch::Receiver<Option<super::LeaderLease>>,
1393        owner: super::LeaderLeaseOwner,
1394        deadline: Arc<super::LeaseDeadline>,
1395    ) -> Result<(), String> {
1396        if owner.node != self.instance_id || owner.boot.is_nil() || owner.process_term == 0 {
1397            return Err("leader lease owner does not match this process".into());
1398        }
1399        self.leader_lease
1400            .set(LeaderLeaseGate {
1401                lease,
1402                owner,
1403                deadline,
1404            })
1405            .map_err(|_| "leader lease fencing is already installed".into())
1406    }
1407
1408    fn notify_leader_eligibility_change(&self) {
1409        let eligible = self.is_leader_lease_candidate();
1410        self.leader_candidacy.send_if_modified(|published| {
1411            let next = published
1412                .transition(eligible)
1413                .unwrap_or_else(super::LeaderCandidacy::terminal);
1414            if next == *published {
1415                false
1416            } else {
1417                *published = next;
1418                true
1419            }
1420        });
1421    }
1422
1423    fn is_leader_lease_candidate(&self) -> bool {
1424        if self.is_gossip_leader() {
1425            return true;
1426        }
1427        !self.active.load(Ordering::Acquire)
1428            && !self.is_draining()
1429            && self.process_lease_is_live()
1430            && !self
1431                .members_rx
1432                .borrow()
1433                .iter()
1434                .any(|member| matches!(member.state, NodeState::Active))
1435    }
1436
1437    /// Evented lease candidacy, including cold cluster formation and normal gossip leadership.
1438    #[cfg(feature = "cluster")]
1439    #[must_use]
1440    pub fn leader_candidacy_watch(self: &Arc<Self>) -> watch::Receiver<super::LeaderCandidacy> {
1441        let mut members = self.members_rx.clone();
1442        members.borrow_and_update();
1443        self.notify_leader_eligibility_change();
1444        let candidacy = self.leader_candidacy.clone();
1445        let candidate_rx = candidacy.subscribe();
1446        let controller = Arc::downgrade(self);
1447        tokio::spawn(async move {
1448            loop {
1449                tokio::select! {
1450                    biased;
1451                    () = candidacy.closed() => return,
1452                    changed = members.changed() => {
1453                        if changed.is_err() {
1454                            candidacy.send_replace(super::LeaderCandidacy::terminal());
1455                            return;
1456                        }
1457                    }
1458                }
1459                let Some(controller) = controller.upgrade() else {
1460                    return;
1461                };
1462                controller.notify_leader_eligibility_change();
1463            }
1464        });
1465        candidate_rx
1466    }
1467
1468    /// Mark this node's active status.
1469    pub fn set_active(&self, active: bool) {
1470        let _transition = self.process_authority_transition.lock();
1471        let active = active && self.process_lease_is_live();
1472        self.active.store(active, Ordering::SeqCst);
1473        let eligible = active && !self.is_draining();
1474        if self.leader_eligible.swap(eligible, Ordering::SeqCst) != eligible {
1475            self.notify_leader_eligibility_change();
1476        }
1477    }
1478
1479    /// Permanently fence controller mutations after stable-node lease loss.
1480    pub fn fence_process_lease(&self) {
1481        let _transition = self.process_authority_transition.lock();
1482        self.process_lease_live.store(false, Ordering::SeqCst);
1483        if let Some(deadline) = self.process_lease_deadline.get() {
1484            deadline.fence();
1485        }
1486        self.active.store(false, Ordering::SeqCst);
1487        self.recovering.store(true, Ordering::SeqCst);
1488        let roster_changed = self.leadership_participants.write().take().is_some();
1489        if self.leader_eligible.swap(false, Ordering::SeqCst) || roster_changed {
1490            self.notify_leader_eligibility_change();
1491        }
1492        self.checkpoint_assignment_fence.send_replace(None);
1493        self.checkpoint_drain_transition.send_replace(None);
1494    }
1495
1496    /// Whether this process still owns its stable node identity.
1497    #[must_use]
1498    pub fn process_lease_is_live(&self) -> bool {
1499        self.process_lease_live.load(Ordering::Acquire)
1500            && self
1501                .process_lease_deadline
1502                .get()
1503                .is_none_or(|deadline| deadline.is_live())
1504    }
1505
1506    fn recovery_process_lease_is_live(&self) -> bool {
1507        self.process_lease_live.load(Ordering::Acquire)
1508            && self
1509                .process_lease_deadline
1510                .get()
1511                .is_some_and(|deadline| deadline.is_live())
1512    }
1513
1514    /// Wait until the process-local lease deadline expires or is explicitly fenced.
1515    pub async fn wait_for_process_lease_loss(&self) {
1516        if !self.process_lease_live.load(Ordering::Acquire) {
1517            return;
1518        }
1519        let Some(deadline) = self.process_lease_deadline.get() else {
1520            std::future::pending::<()>().await;
1521            return;
1522        };
1523        deadline.wait_until_expired().await;
1524    }
1525
1526    /// Install the stable-node lease deadline before the runtime starts.
1527    ///
1528    /// Reinstalling the same shared deadline is idempotent. A different deadline, or installation
1529    /// after terminal process fencing, is rejected so controller and data-plane gates cannot
1530    /// silently follow different lease clocks.
1531    ///
1532    /// # Errors
1533    /// Rejects installation after fencing or replacement with a different deadline.
1534    pub fn set_process_lease_deadline(
1535        &self,
1536        deadline: Arc<super::LeaseDeadline>,
1537    ) -> Result<(), String> {
1538        let _transition = self.process_authority_transition.lock();
1539        if !self.process_lease_live.load(Ordering::Acquire) {
1540            return Err("process lease authority is already terminally fenced".into());
1541        }
1542        if let Some(current) = self.process_lease_deadline.get() {
1543            if !Arc::ptr_eq(current, &deadline) {
1544                return Err("process lease deadline is already installed".into());
1545            }
1546            #[cfg(feature = "cluster")]
1547            self.barrier.install_process_lease_deadline(deadline)?;
1548            return Ok(());
1549        }
1550        #[cfg(feature = "cluster")]
1551        self.barrier
1552            .install_process_lease_deadline(Arc::clone(&deadline))?;
1553        self.process_lease_deadline
1554            .set(deadline)
1555            .map_err(|_| "process lease deadline is already installed".to_string())
1556    }
1557
1558    /// Shared process-local lease deadline, when cluster runtime wiring is complete.
1559    #[must_use]
1560    pub fn process_lease_deadline(&self) -> Option<Arc<super::LeaseDeadline>> {
1561        self.process_lease_deadline.get().cloned()
1562    }
1563
1564    /// Install the shared stable-node fencing authority before recovery starts.
1565    ///
1566    /// # Errors
1567    /// Rejects replacing an already-installed authority.
1568    pub fn set_process_lease_authority(
1569        &self,
1570        authority: Arc<super::process_lease::ProcessLeaseAuthority>,
1571    ) -> Result<(), String> {
1572        self.process_lease_authority
1573            .set(authority)
1574            .map_err(|_| "process lease authority is already installed".to_string())
1575    }
1576
1577    /// Prove that an exact assignment participant was durably superseded within `deadline`.
1578    ///
1579    /// # Errors
1580    /// Fails closed when no shared authority is installed or fencing cannot be proven in time.
1581    pub async fn fence_process_incarnation(
1582        &self,
1583        participant: CheckpointParticipant,
1584        deadline: tokio::time::Instant,
1585    ) -> Result<super::process_lease::ProcessLeaseFence, String> {
1586        self.process_lease_authority
1587            .get()
1588            .ok_or_else(|| "process lease authority is not installed".to_string())?
1589            .fence_incarnation(participant, deadline)
1590            .await
1591            .map_err(|error| error.to_string())
1592    }
1593
1594    /// Bound process fencing by the authority TTL plus the caller's existing control-plane I/O
1595    /// budget, avoiding a second configurable timeout dimension.
1596    ///
1597    /// # Errors
1598    /// Fails when no shared authority is installed or the deadline cannot be represented.
1599    pub fn process_fencing_deadline(
1600        &self,
1601        io_budget: Duration,
1602    ) -> Result<tokio::time::Instant, String> {
1603        self.process_lease_authority
1604            .get()
1605            .ok_or_else(|| "process lease authority is not installed".to_string())?
1606            .fencing_deadline(io_budget)
1607            .map_err(|error| error.to_string())
1608    }
1609
1610    /// Verify one successor roster identity against the current durable process-lease head.
1611    ///
1612    /// # Errors
1613    /// Fails when no authority is installed or the bounded durable read is uncertain.
1614    pub async fn verify_current_process_incarnation(
1615        &self,
1616        participant: CheckpointParticipant,
1617        deadline: tokio::time::Instant,
1618    ) -> Result<bool, String> {
1619        self.process_lease_authority
1620            .get()
1621            .ok_or_else(|| "process lease authority is not installed".to_string())?
1622            .verify_current_participant(participant, deadline)
1623            .await
1624            .map_err(|error| error.to_string())
1625    }
1626
1627    /// Re-read the exact durable records behind a process fence within `deadline`.
1628    ///
1629    /// # Errors
1630    /// Fails closed when no shared authority is installed or verification cannot finish in time.
1631    pub async fn verify_process_lease_fence(
1632        &self,
1633        fence: &super::process_lease::ProcessLeaseFence,
1634        deadline: tokio::time::Instant,
1635    ) -> Result<bool, String> {
1636        self.process_lease_authority
1637            .get()
1638            .ok_or_else(|| "process lease authority is not installed".to_string())?
1639            .verify_fence(fence, deadline)
1640            .await
1641            .map_err(|error| error.to_string())
1642    }
1643
1644    /// Admit a recovery assignment only after re-verifying every durable process revocation.
1645    ///
1646    /// Keeping this operation on the controller binds the process-lease authority and leader
1647    /// authority installed for this runtime. Callers cannot bypass revocation verification by
1648    /// writing a structurally valid recovery decision directly to the leader store.
1649    ///
1650    /// # Errors
1651    /// Fails closed when either authority is absent, a process fence is not durable, the deadline
1652    /// expires, leadership changes, the decision conflicts, or storage is unavailable.
1653    pub async fn record_assignment_recovery_decision(
1654        &self,
1655        proof: &super::LeaderProof,
1656        decision: super::AssignmentRecoveryDecision,
1657        deadline: tokio::time::Instant,
1658    ) -> Result<super::RecordAssignmentRecoveryDecisionResult, String> {
1659        let process_authority = Arc::clone(
1660            self.process_lease_authority
1661                .get()
1662                .ok_or_else(|| "process lease authority is not installed".to_string())?,
1663        );
1664        let checks = futures::stream::iter(decision.removed_process_fences.iter().cloned())
1665            .map(|fence| {
1666                let process_authority = Arc::clone(&process_authority);
1667                async move { process_authority.verify_fence(&fence, deadline).await }
1668            })
1669            .buffer_unordered(CONTROL_ROSTER_IO_CONCURRENCY)
1670            .collect::<Vec<_>>()
1671            .await;
1672        for check in checks {
1673            match check {
1674                Ok(true) => {}
1675                Ok(false) => {
1676                    return Err("assignment recovery contains an unverified process fence".into());
1677                }
1678                Err(error) => {
1679                    return Err(format!(
1680                        "assignment recovery process fence verification failed: {error}"
1681                    ));
1682                }
1683            }
1684        }
1685        if tokio::time::Instant::now() >= deadline {
1686            return Err("assignment recovery authority admission exceeded its deadline".into());
1687        }
1688        let authority = self
1689            .checkpoint_authority()
1690            .map_err(|error| error.to_string())?;
1691        Box::pin(tokio::time::timeout_at(
1692            deadline,
1693            authority.record_assignment_recovery_decision(proof, decision),
1694        ))
1695        .await
1696        .map_err(|_| "assignment recovery authority admission exceeded its deadline".to_string())?
1697        .map_err(|error| error.to_string())
1698    }
1699
1700    /// Live instance IDs: `Active` peers plus self.
1701    #[must_use]
1702    pub fn live_instances(&self) -> Vec<NodeId> {
1703        let mut ids: Vec<NodeId> = self
1704            .members_rx
1705            .borrow()
1706            .iter()
1707            .filter(|m| m.id != self.instance_id && matches!(m.state, NodeState::Active))
1708            .map(|m| m.id)
1709            .collect();
1710        if self.active.load(Ordering::SeqCst) && self.process_lease_is_live() {
1711            ids.push(self.instance_id);
1712        }
1713        ids
1714    }
1715
1716    /// Nodes that must participate in a checkpoint for the current assignment.
1717    ///
1718    /// Draining owners remain responsible for their old vnodes through the handoff checkpoint,
1719    /// while joining, suspected, left, and missing nodes cannot contribute to a restorable cut.
1720    /// This is deliberately distinct from [`Self::assignable_instances`], which excludes draining
1721    /// nodes so they never receive new ownership.
1722    #[must_use]
1723    pub fn checkpoint_instances(&self) -> Vec<NodeId> {
1724        let mut ids: Vec<NodeId> = self
1725            .members_rx
1726            .borrow()
1727            .iter()
1728            .filter(|member| {
1729                member.id != self.instance_id
1730                    && matches!(member.state, NodeState::Active | NodeState::Draining)
1731            })
1732            .map(|member| member.id)
1733            .collect();
1734        if self.active.load(Ordering::SeqCst) && self.process_lease_is_live() {
1735            ids.push(self.instance_id);
1736        }
1737        ids.sort_unstable();
1738        ids.dedup();
1739        ids
1740    }
1741
1742    /// Record peers that failed to ack a capture quorum in time.
1743    pub fn note_unresponsive(&self, peers: &[NodeId]) {
1744        let fence = self.checkpoint_assignment_fence.borrow().clone();
1745        let mut map = self.unresponsive.lock();
1746        let mut changed = false;
1747        for p in peers {
1748            let incarnation = fence
1749                .as_ref()
1750                .and_then(|certificate| certificate.participant_incarnation(p.0));
1751            changed |= map.insert(p.0, incarnation) != Some(incarnation);
1752        }
1753        drop(map);
1754        if changed {
1755            self.notify_leader_eligibility_change();
1756        }
1757    }
1758
1759    /// Clear peers that acked (they are demonstrably alive).
1760    pub fn note_responsive(&self, peers: &[NodeId]) {
1761        let mut map = self.unresponsive.lock();
1762        let mut changed = false;
1763        for p in peers {
1764            changed |= map.remove(&p.0).is_some();
1765        }
1766        drop(map);
1767        if changed {
1768            self.notify_leader_eligibility_change();
1769        }
1770    }
1771
1772    /// Whether `peer` has an unresolved capture-quorum failure.
1773    #[must_use]
1774    pub fn is_unresponsive(&self, peer: NodeId) -> bool {
1775        self.unresponsive.lock().contains_key(&peer.0)
1776    }
1777
1778    /// Admit a placement candidate only when it was never quarantined or it is a different boot
1779    /// incarnation. Callers must obtain `participant` from the lease-validated durable control KV.
1780    pub fn admit_successor_process(&self, participant: CheckpointParticipant) -> bool {
1781        let mut unresponsive = self.unresponsive.lock();
1782        let admitted = match unresponsive.get(&participant.node_id).copied() {
1783            None => true,
1784            Some(Some(failed_boot)) if failed_boot != participant.boot_incarnation => {
1785                unresponsive.remove(&participant.node_id);
1786                drop(unresponsive);
1787                self.notify_leader_eligibility_change();
1788                return true;
1789            }
1790            Some(Some(_) | None) => false,
1791        };
1792        drop(unresponsive);
1793        admitted
1794    }
1795
1796    /// Mark this node as draining. Returns whether this exact certified leader must retain its
1797    /// lease long enough to coordinate the predecessor cut. Idempotent.
1798    pub fn begin_drain(&self) -> bool {
1799        #[cfg(feature = "cluster")]
1800        let has_durable_leadership = self.has_leader_lease_fencing();
1801        #[cfg(not(feature = "cluster"))]
1802        let has_durable_leadership = false;
1803        let retain_leadership = has_durable_leadership
1804            && self.is_leader()
1805            && self
1806                .checkpoint_assignment_fence
1807                .borrow()
1808                .as_ref()
1809                .is_some_and(|fence| {
1810                    fence.is_canonical()
1811                        && fence.participant_incarnation(self.instance_id.0)
1812                            == Some(self.recovery_incarnation)
1813                });
1814        self.draining.store(true, Ordering::SeqCst);
1815        if !retain_leadership && self.leader_eligible.swap(false, Ordering::SeqCst) {
1816            self.notify_leader_eligibility_change();
1817        }
1818        retain_leadership
1819    }
1820
1821    /// Whether this node is draining.
1822    #[must_use]
1823    pub fn is_draining(&self) -> bool {
1824        self.draining.load(Ordering::SeqCst)
1825    }
1826
1827    /// Set or clear the coordinated-recovery fence.
1828    pub fn set_recovering(&self, recovering: bool) {
1829        let _transition = self.process_authority_transition.lock();
1830        let recovering = recovering || !self.process_lease_is_live();
1831        self.recovering.store(recovering, Ordering::SeqCst);
1832    }
1833
1834    /// Whether a coordinated restart is in flight on this node.
1835    #[must_use]
1836    pub fn is_recovering(&self) -> bool {
1837        self.recovering.load(Ordering::SeqCst)
1838    }
1839
1840    async fn write_recovery_value(&self, key: &str, value: String) -> Result<(), String> {
1841        if !self.process_lease_is_live() {
1842            return Err("stable node process lease is no longer live".into());
1843        }
1844        tokio::time::timeout(
1845            RECOVERY_CONTROL_IO_TIMEOUT,
1846            self.recovery_kv.write_checked(key, value),
1847        )
1848        .await
1849        .map_err(|_| format!("recovery control write for {key} timed out"))?
1850        .map_err(|error| format!("recovery control write for {key} failed: {error}"))
1851    }
1852
1853    async fn write_recovery_value_exact(&self, key: &str, value: String) -> Result<(), String> {
1854        self.write_recovery_value(key, value.clone()).await?;
1855        let observed = self
1856            .read_recovery_value(self.instance_id, key)
1857            .await?
1858            .ok_or_else(|| format!("recovery control value for {key} vanished after write"))?;
1859        if observed != value {
1860            return Err(format!(
1861                "recovery control read-back mismatch for {key}; write was not durable"
1862            ));
1863        }
1864        Ok(())
1865    }
1866
1867    async fn read_recovery_value(&self, node: NodeId, key: &str) -> Result<Option<String>, String> {
1868        tokio::time::timeout(
1869            RECOVERY_CONTROL_IO_TIMEOUT,
1870            self.recovery_kv.read_from_checked(node, key),
1871        )
1872        .await
1873        .map_err(|_| format!("recovery control read for {key} from {node} timed out"))?
1874        .map_err(|error| format!("recovery control read for {key} from {node} failed: {error}"))
1875    }
1876
1877    async fn scan_recovery_values(&self, key: &str) -> Result<Vec<(NodeId, String)>, String> {
1878        tokio::time::timeout(
1879            RECOVERY_CONTROL_IO_TIMEOUT,
1880            self.recovery_kv.scan_checked(key),
1881        )
1882        .await
1883        .map_err(|_| format!("recovery control scan for {key} timed out"))?
1884        .map_err(|error| format!("recovery control scan for {key} failed: {error}"))
1885    }
1886
1887    /// Highest durable recovery generation replicated by any visible participant.
1888    ///
1889    /// # Errors
1890    /// Fails closed when the durable scan is unavailable or contains malformed state.
1891    pub async fn max_recovery_generation(&self) -> Result<u64, String> {
1892        let mut maximum = 0;
1893        for (node, raw) in self.scan_recovery_values("control:recovery-gen").await? {
1894            let generation = raw.parse::<u64>().map_err(|error| {
1895                format!("invalid replicated recovery generation from {node}: {error}")
1896            })?;
1897            maximum = maximum.max(generation);
1898        }
1899        Ok(maximum)
1900    }
1901
1902    /// Monotonically persist this participant's recovery generation and read it back exactly.
1903    ///
1904    /// # Errors
1905    /// Rejects zero, regression, unavailable durable storage, or a mismatched read-back.
1906    pub async fn adopt_recovery_generation(&self, generation: u64) -> Result<(), String> {
1907        if generation == 0 {
1908            return Err("recovery generation must be nonzero".into());
1909        }
1910        let current = self
1911            .read_recovery_value(self.instance_id, "control:recovery-gen")
1912            .await?
1913            .map(|raw| {
1914                raw.parse::<u64>()
1915                    .map_err(|error| format!("invalid local recovery generation: {error}"))
1916            })
1917            .transpose()?;
1918        if let Some(current) = current {
1919            if current > generation {
1920                return Err(format!(
1921                    "local recovery generation {current} is newer than proposed generation {generation}"
1922                ));
1923            }
1924            if current == generation {
1925                return Ok(());
1926            }
1927        }
1928        self.write_recovery_value_exact("control:recovery-gen", generation.to_string())
1929            .await
1930    }
1931
1932    async fn read_recovery_stopped_reports(
1933        &self,
1934        round: &RecoveryRound,
1935        roster: &[CheckpointParticipant],
1936    ) -> Result<Vec<RecoveryStoppedReport>, RecoveryControlError> {
1937        round.validate().map_err(RecoveryControlError::Conflict)?;
1938        if roster
1939            .windows(2)
1940            .any(|pair| pair[0].node_id >= pair[1].node_id)
1941            || roster.iter().any(|participant| {
1942                round.stopped_participant_incarnation(NodeId(participant.node_id))
1943                    != Some(participant.boot_incarnation)
1944            })
1945        {
1946            return Err(RecoveryControlError::Conflict(
1947                "recovery stopped-report read roster is not a canonical round subset".into(),
1948            ));
1949        }
1950        let reads = futures::stream::iter(roster.iter().copied().map(|participant| async move {
1951            let value = self
1952                .read_recovery_value(NodeId(participant.node_id), RECOVERY_STOPPED_REPORT_KEY)
1953                .await;
1954            (participant, value)
1955        }))
1956        .buffer_unordered(CONTROL_ROSTER_IO_CONCURRENCY)
1957        .collect::<Vec<_>>()
1958        .await;
1959        let mut reports = Vec::new();
1960        for (participant, value) in reads {
1961            let Some(raw) = value.map_err(RecoveryControlError::Uncertain)? else {
1962                continue;
1963            };
1964            let publisher = NodeId(participant.node_id);
1965            let report = parse_recovery_stopped_report_shape(&raw, publisher)
1966                .map_err(RecoveryControlError::Conflict)?;
1967            if report.round_id.generation < round.id.generation {
1968                continue;
1969            }
1970            if report.round_id.generation > round.id.generation {
1971                // A slot alone is not authority: corroborate that its exact publishing boot
1972                // durably adopted at least this generation. A stale or partially written newer
1973                // value remains pending for the old round instead of forcing abandon loops.
1974                let adopted = self
1975                    .read_recovery_value(publisher, "control:recovery-gen")
1976                    .await
1977                    .map_err(RecoveryControlError::Uncertain)?;
1978                let incarnation = self
1979                    .read_recovery_value(publisher, RECOVERY_INCARNATION_KEY)
1980                    .await
1981                    .map_err(RecoveryControlError::Uncertain)?;
1982                let Some((adopted, incarnation)) = adopted.zip(incarnation) else {
1983                    continue;
1984                };
1985                let adopted = adopted.parse::<u64>().map_err(|error| {
1986                    RecoveryControlError::Conflict(format!(
1987                        "invalid replicated recovery generation from {publisher}: {error}"
1988                    ))
1989                })?;
1990                let incarnation = Uuid::parse_str(&incarnation).map_err(|error| {
1991                    RecoveryControlError::Conflict(format!(
1992                        "invalid recovery incarnation published by {publisher}: {error}"
1993                    ))
1994                })?;
1995                if adopted >= report.round_id.generation
1996                    && incarnation == report.publisher.boot_incarnation
1997                {
1998                    reports.push(report);
1999                }
2000                continue;
2001            }
2002            report.validate_semantics(round).map_err(|error| {
2003                RecoveryControlError::Conflict(format!(
2004                    "same-generation recovery stopped report from {publisher} conflicts with the exact frozen round: {error}"
2005                ))
2006            })?;
2007            reports.push(report);
2008        }
2009        reports.sort_unstable_by_key(|report| report.publisher.node_id);
2010        Ok(reports)
2011    }
2012
2013    async fn read_recovery_announcement_map(
2014        &self,
2015        key: &str,
2016    ) -> Result<Vec<(NodeId, RecoveryAnnouncement)>, String> {
2017        let mut announcements = Vec::new();
2018        for (node, raw) in self.scan_recovery_values(key).await? {
2019            let announcement = parse_recovery_announcement_ack(&raw, node)?;
2020            announcements.push((node, announcement));
2021        }
2022        Ok(announcements)
2023    }
2024
2025    /// This process's boot-unique recovery identity.
2026    #[must_use]
2027    pub fn recovery_incarnation(&self) -> Uuid {
2028        self.recovery_incarnation
2029    }
2030
2031    /// Publish and read back this process's incarnation metadata.
2032    ///
2033    /// This does not grant recovery-write authority. Cluster startup must use
2034    /// [`Self::publish_leased_recovery_incarnation`] to bind the durable process term.
2035    ///
2036    /// # Errors
2037    /// Returns an error when the control write/read is unavailable or the read-back differs.
2038    pub async fn publish_recovery_incarnation(&self) -> Result<(), String> {
2039        self.write_recovery_value(
2040            RECOVERY_INCARNATION_KEY,
2041            self.recovery_incarnation.to_string(),
2042        )
2043        .await?;
2044        let observed = self
2045            .read_recovery_value(self.instance_id, RECOVERY_INCARNATION_KEY)
2046            .await?
2047            .ok_or_else(|| "recovery incarnation was not readable after publication".to_string())?;
2048        let observed = Uuid::parse_str(&observed)
2049            .map_err(|error| format!("invalid recovery incarnation after publication: {error}"))?;
2050        if observed != self.recovery_incarnation {
2051            return Err("recovery incarnation read-back mismatch".into());
2052        }
2053        Ok(())
2054    }
2055
2056    /// Publish the recovery identity only when it matches an acquired stable-node lease.
2057    ///
2058    /// # Errors
2059    /// Rejects a lease for another node or boot identity, or a failed durable publication.
2060    pub async fn publish_leased_recovery_incarnation(
2061        &self,
2062        lease: &super::ProcessLease,
2063    ) -> Result<(), String> {
2064        lease
2065            .validate(self.instance_id)
2066            .map_err(|error| error.to_string())?;
2067        if lease.node != self.instance_id || lease.owner != self.recovery_incarnation {
2068            return Err("process lease does not bind this recovery incarnation".into());
2069        }
2070        if !self.recovery_process_lease_is_live() {
2071            return Err("live process lease deadline is not installed".into());
2072        }
2073        let publisher = RecoveryFaultPublisher {
2074            participant: CheckpointParticipant {
2075                node_id: lease.node.0,
2076                boot_incarnation: lease.owner,
2077            },
2078            process_term: lease.term,
2079        };
2080        if !self
2081            .recovery_fault_publisher_is_current(publisher)
2082            .await
2083            .map_err(|error| error.to_string())?
2084        {
2085            return Err("process lease is not the current durable stable-node term".into());
2086        }
2087        if !self.recovery_process_lease_is_live() {
2088            return Err("process lease changed before publishing recovery identity".into());
2089        }
2090        self.barrier.install_local_process_lease(lease)?;
2091        if let Err(error) = self.publish_recovery_incarnation().await {
2092            self.fence_process_lease();
2093            return Err(error);
2094        }
2095        if !self.recovery_process_lease_is_live() {
2096            self.fence_process_lease();
2097            return Err("process lease changed while publishing recovery identity".into());
2098        }
2099        match self.recovery_fault_publisher_is_current(publisher).await {
2100            Ok(true) if self.recovery_process_lease_is_live() => {}
2101            Ok(_) => {
2102                self.fence_process_lease();
2103                return Err("process lease changed while publishing recovery identity".into());
2104            }
2105            Err(error) => {
2106                self.fence_process_lease();
2107                return Err(format!(
2108                    "process lease authority became uncertain after recovery publication: {error}"
2109                ));
2110            }
2111        }
2112        self.recovery_process_term
2113            .store(lease.term, Ordering::Release);
2114        Ok(())
2115    }
2116
2117    /// Allocate the next process-local recovery-fault request identity.
2118    ///
2119    /// Shared authority converts this ordinal into a cluster-wide fault sequence. Retries retain
2120    /// the original ordinal; exhaustion fails closed instead of wrapping.
2121    ///
2122    /// # Errors
2123    /// Returns an error if the process-local sequence is exhausted or becomes noncanonical.
2124    pub fn next_recovery_fault_request(&self) -> Result<RecoveryFaultRequest, String> {
2125        let sequence = self
2126            .recovery_fault_request_sequence
2127            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
2128                current.checked_add(1)
2129            })
2130            .map_err(|_| "recovery fault request sequence exhausted".to_string())?;
2131        Ok(RecoveryFaultRequest {
2132            sequence: std::num::NonZeroU64::new(sequence)
2133                .ok_or_else(|| "recovery fault request allocator produced zero".to_string())?,
2134        })
2135    }
2136
2137    /// Reconstitute one previously allocated request for a bounded retry.
2138    ///
2139    /// # Errors
2140    /// Rejects zero and ordinals that this controller process has not allocated.
2141    pub fn recovery_fault_request(&self, sequence: u64) -> Result<RecoveryFaultRequest, String> {
2142        let sequence = std::num::NonZeroU64::new(sequence)
2143            .ok_or_else(|| "recovery fault request sequence must be nonzero".to_string())?;
2144        if sequence.get() >= self.recovery_fault_request_sequence.load(Ordering::Acquire) {
2145            return Err("recovery fault request was not allocated by this process".into());
2146        }
2147        Ok(RecoveryFaultRequest { sequence })
2148    }
2149
2150    fn recovery_fault_publisher(&self) -> Result<RecoveryFaultPublisher, String> {
2151        let publisher = RecoveryFaultPublisher {
2152            participant: CheckpointParticipant {
2153                node_id: self.instance_id.0,
2154                boot_incarnation: self.recovery_incarnation,
2155            },
2156            process_term: self.recovery_process_term.load(Ordering::Acquire),
2157        };
2158        publisher.validate()?;
2159        Ok(publisher)
2160    }
2161
2162    async fn recovery_fault_publisher_is_current(
2163        &self,
2164        publisher: RecoveryFaultPublisher,
2165    ) -> Result<bool, RecoveryControlError> {
2166        if !self.recovery_process_lease_is_live() {
2167            return Ok(false);
2168        }
2169        let authority = self.process_lease_authority.get().ok_or_else(|| {
2170            RecoveryControlError::Conflict("process lease authority is not installed".into())
2171        })?;
2172        let deadline = tokio::time::Instant::now()
2173            .checked_add(RECOVERY_CONTROL_IO_TIMEOUT)
2174            .ok_or_else(|| {
2175                RecoveryControlError::Conflict(
2176                    "recovery fault process-lease deadline overflow".into(),
2177                )
2178            })?;
2179        let current = authority
2180            .verify_current_participant_term(
2181                publisher.participant,
2182                publisher.process_term,
2183                deadline,
2184            )
2185            .await
2186            .map_err(RecoveryControlError::from_process_authority)?;
2187        Ok(current && self.recovery_process_lease_is_live())
2188    }
2189
2190    async fn recovery_incarnation_is_current(&self) -> Result<bool, String> {
2191        let Some(raw) = self
2192            .read_recovery_value(self.instance_id, RECOVERY_INCARNATION_KEY)
2193            .await?
2194        else {
2195            return Ok(false);
2196        };
2197        let observed = Uuid::parse_str(&raw)
2198            .map_err(|error| format!("invalid local recovery incarnation: {error}"))?;
2199        Ok(observed == self.recovery_incarnation)
2200    }
2201
2202    /// Resolve the exact live boot identity for every canonical participant.
2203    ///
2204    /// # Errors
2205    /// Fails closed on a missing, malformed, nil, duplicate, or unexpected participant identity.
2206    pub async fn recovery_participant_incarnations(
2207        &self,
2208        participants: &[u64],
2209    ) -> Result<Vec<CheckpointParticipant>, String> {
2210        let available = self
2211            .available_recovery_participant_incarnations(participants)
2212            .await?;
2213        if available.len() != participants.len() {
2214            let available_ids: std::collections::BTreeSet<u64> = available
2215                .iter()
2216                .map(|participant| participant.node_id)
2217                .collect();
2218            let missing = participants
2219                .iter()
2220                .find(|node_id| !available_ids.contains(node_id))
2221                .copied()
2222                .unwrap_or(0);
2223            return Err(format!(
2224                "node {missing} has no current recovery incarnation"
2225            ));
2226        }
2227        Ok(available)
2228    }
2229
2230    /// Resolve every currently readable boot identity among a canonical candidate set. Missing
2231    /// candidates are omitted so placement can exclude lease-revoked or not-yet-started nodes
2232    /// without weakening exact checkpoint/recovery roster validation.
2233    ///
2234    /// # Errors
2235    /// Fails closed on malformed input, a durable scan error, or a malformed/duplicate identity.
2236    pub async fn available_recovery_participant_incarnations(
2237        &self,
2238        participants: &[u64],
2239    ) -> Result<Vec<CheckpointParticipant>, String> {
2240        if participants.is_empty()
2241            || participants.contains(&0)
2242            || participants.windows(2).any(|pair| pair[0] >= pair[1])
2243        {
2244            return Err("recovery incarnation roster requires canonical participants".into());
2245        }
2246        let expected: std::collections::BTreeSet<u64> = participants.iter().copied().collect();
2247        let mut reported = std::collections::BTreeMap::new();
2248        for (node, raw) in self.scan_recovery_values(RECOVERY_INCARNATION_KEY).await? {
2249            if !expected.contains(&node.0) {
2250                continue;
2251            }
2252            let incarnation = Uuid::parse_str(&raw).map_err(|error| {
2253                format!("invalid recovery incarnation published by {node}: {error}")
2254            })?;
2255            if incarnation.is_nil() {
2256                return Err(format!("nil recovery incarnation published by {node}"));
2257            }
2258            if reported.insert(node.0, incarnation).is_some() {
2259                return Err(format!(
2260                    "duplicate recovery incarnation published by {node}"
2261                ));
2262            }
2263        }
2264        Ok(participants
2265            .iter()
2266            .filter_map(|node_id| {
2267                reported
2268                    .get(node_id)
2269                    .copied()
2270                    .map(|incarnation| CheckpointParticipant {
2271                        node_id: *node_id,
2272                        boot_incarnation: incarnation,
2273                    })
2274            })
2275            .collect())
2276    }
2277
2278    /// Whether every current assignment-owner boot identity still equals the frozen round.
2279    ///
2280    /// # Errors
2281    /// Returns an error when the current incarnation roster is unavailable or malformed.
2282    pub async fn recovery_incarnations_match(&self, round: &RecoveryRound) -> Result<bool, String> {
2283        Ok(self
2284            .recovery_participant_incarnations(&round.assignment_fence.participant_ids())
2285            .await?
2286            == round.assignment_fence.participants)
2287    }
2288
2289    /// Whether every owner and evidence reporter still has the boot identity frozen for stopping.
2290    ///
2291    /// This check belongs only to the Prepare/stopped-evidence boundary. Evidence reporters do not
2292    /// join restore or release liveness quorums after their stopped reports are durable.
2293    ///
2294    /// # Errors
2295    /// Returns an error when the current incarnation roster is unavailable or malformed.
2296    pub async fn recovery_stopped_incarnations_match(
2297        &self,
2298        round: &RecoveryRound,
2299    ) -> Result<bool, RecoveryControlError> {
2300        self.recovery_roster_incarnations_match_control(&round.stopped_roster())
2301            .await
2302    }
2303
2304    async fn recovery_incarnations_match_control(
2305        &self,
2306        round: &RecoveryRound,
2307    ) -> Result<bool, RecoveryControlError> {
2308        self.recovery_roster_incarnations_match_control(&round.assignment_fence.participants)
2309            .await
2310    }
2311
2312    async fn recovery_roster_incarnations_match_control(
2313        &self,
2314        roster: &[CheckpointParticipant],
2315    ) -> Result<bool, RecoveryControlError> {
2316        let expected: std::collections::BTreeSet<u64> = roster
2317            .iter()
2318            .map(|participant| participant.node_id)
2319            .collect();
2320        let mut reported = std::collections::BTreeMap::new();
2321        for (node, raw) in self
2322            .scan_recovery_values(RECOVERY_INCARNATION_KEY)
2323            .await
2324            .map_err(RecoveryControlError::Uncertain)?
2325        {
2326            if !expected.contains(&node.0) {
2327                continue;
2328            }
2329            let incarnation = Uuid::parse_str(&raw).map_err(|error| {
2330                RecoveryControlError::Conflict(format!(
2331                    "invalid recovery incarnation published by {node}: {error}"
2332                ))
2333            })?;
2334            if incarnation.is_nil() || reported.insert(node.0, incarnation).is_some() {
2335                return Err(RecoveryControlError::Conflict(format!(
2336                    "noncanonical recovery incarnation published by {node}"
2337                )));
2338            }
2339        }
2340        if reported.len() != roster.len() {
2341            return Ok(false);
2342        }
2343        Ok(roster.iter().all(|participant| {
2344            reported.get(&participant.node_id) == Some(&participant.boot_incarnation)
2345        }))
2346    }
2347
2348    async fn read_recovery_fault_inventory_control(
2349        &self,
2350    ) -> Result<RecoveryFaultInventory, RecoveryControlError> {
2351        self.checkpoint_authority()
2352            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
2353            .recovery_fault_inventory()
2354            .await
2355            .map_err(RecoveryControlError::from_authority)
2356    }
2357
2358    /// Atomically observed shared-authority recovery-fault inventory.
2359    ///
2360    /// # Errors
2361    /// Returns an error when the leader authority is unavailable or malformed.
2362    pub async fn read_recovery_fault_inventory(&self) -> Result<RecoveryFaultInventory, String> {
2363        self.read_recovery_fault_inventory_control()
2364            .await
2365            .map_err(|error| error.to_string())
2366    }
2367
2368    /// Coherent committed-release and fault view from shared recovery authority.
2369    ///
2370    /// # Errors
2371    /// Returns a classified error when the authority or immutable terminal cannot be validated.
2372    pub async fn read_recovery_admission_snapshot(
2373        &self,
2374    ) -> Result<RecoveryAdmissionSnapshot, RecoveryControlError> {
2375        self.checkpoint_authority()
2376            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
2377            .recovery_admission_snapshot()
2378            .await
2379            .map_err(RecoveryControlError::from_authority)
2380    }
2381
2382    /// Confirm this recovery view still has the same terminal, no active faults, and the exact
2383    /// audited leader term.
2384    ///
2385    /// # Errors
2386    /// Returns a classified error when the current shared authority cannot be validated.
2387    pub async fn recovery_admission_is_current(
2388        &self,
2389        snapshot: &RecoveryAdmissionSnapshot,
2390        leader_proof: &LeaderProof,
2391    ) -> Result<bool, RecoveryControlError> {
2392        self.checkpoint_authority()
2393            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
2394            .recovery_admission_is_current(snapshot, leader_proof)
2395            .await
2396            .map_err(RecoveryControlError::from_authority)
2397    }
2398
2399    /// Publish this process's fault request so the leader drives a recovery round.
2400    ///
2401    /// # Errors
2402    /// Fails when the process lease is stale or the request cannot be ordered in shared authority.
2403    pub async fn report_fault(
2404        &self,
2405        request: RecoveryFaultRequest,
2406    ) -> Result<RecoveryFaultReportOutcome, String> {
2407        let seq = request.sequence();
2408        let _guard = self.recovery_writes.lock().await;
2409        let publisher = self.recovery_fault_publisher()?;
2410        if !self
2411            .recovery_fault_publisher_is_current(publisher)
2412            .await
2413            .map_err(|error| error.to_string())?
2414        {
2415            return Err("stable node process lease is no longer current".into());
2416        }
2417        let authority = self
2418            .checkpoint_authority()
2419            .map_err(|error| error.to_string())?;
2420        let result = Box::pin(authority.record_recovery_fault(publisher, seq))
2421            .await
2422            .map_err(|error| RecoveryControlError::from_authority(error).to_string())?;
2423        if !self
2424            .recovery_fault_publisher_is_current(publisher)
2425            .await
2426            .map_err(|error| error.to_string())?
2427        {
2428            return Err("stable node process lease changed while publishing recovery fault".into());
2429        }
2430        match result {
2431            super::leader_lease::RecordRecoveryFaultResult::Active => {
2432                Ok(RecoveryFaultReportOutcome::Active)
2433            }
2434            super::leader_lease::RecordRecoveryFaultResult::AlreadyCleared => {
2435                Ok(RecoveryFaultReportOutcome::AlreadyCleared)
2436            }
2437            super::leader_lease::RecordRecoveryFaultResult::CoveredByNewerRequest => {
2438                Ok(RecoveryFaultReportOutcome::CoveredByNewerRequest)
2439            }
2440            super::leader_lease::RecordRecoveryFaultResult::Superseded => {
2441                Err("recovery fault request was superseded by a newer local process".into())
2442            }
2443        }
2444    }
2445
2446    /// Validate this process's atomically released fault while retaining the local fault-write
2447    /// fence. The caller must hold the returned guard through its source-gate transition.
2448    ///
2449    /// `Ok(None)` means the terminal is no longer current, a newer active fault exists anywhere,
2450    /// or the local active/tombstoned slot does not match it. An exact tombstone is idempotent
2451    /// success only while the shared fault inventory remains settled.
2452    ///
2453    /// # Errors
2454    /// Returns an error for a nonterminal release, malformed state, or failed durable I/O.
2455    pub async fn begin_recovery_release(
2456        &self,
2457        terminal: &RecoveryAnnouncement,
2458    ) -> Result<Option<RecoveryReleaseGuard<'_>>, RecoveryControlError> {
2459        terminal
2460            .validate()
2461            .map_err(RecoveryControlError::Conflict)?;
2462        if !matches!(terminal.phase, RecoverPhase::ReleaseCommitted { .. }) {
2463            return Err(RecoveryControlError::Conflict(
2464                "recovery fault consumption requires a terminal Release".into(),
2465            ));
2466        }
2467        let guard = self.recovery_writes.lock().await;
2468        let publisher = self
2469            .recovery_fault_publisher()
2470            .map_err(RecoveryControlError::Conflict)?;
2471        if !self.recovery_fault_publisher_is_current(publisher).await? {
2472            return Err(RecoveryControlError::Superseded(
2473                "stable node process lease is no longer current".into(),
2474            ));
2475        }
2476        let authorized = self
2477            .checkpoint_authority()
2478            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
2479            .authorize_recovery_release(publisher, terminal)
2480            .await
2481            .map_err(RecoveryControlError::from_authority)?;
2482        if !authorized {
2483            return Ok(None);
2484        }
2485        if !self.recovery_fault_publisher_is_current(publisher).await? {
2486            return Err(RecoveryControlError::Superseded(
2487                "stable node process lease changed while authorizing recovery Release".into(),
2488            ));
2489        }
2490        Ok(Some(RecoveryReleaseGuard {
2491            _write_guard: guard,
2492        }))
2493    }
2494
2495    /// This stable node's active durable fault sequence, when present.
2496    ///
2497    /// # Errors
2498    /// Returns an error when shared authority is unavailable or malformed.
2499    pub async fn read_local_fault_report_control(
2500        &self,
2501    ) -> Result<Option<u64>, RecoveryControlError> {
2502        Ok(self
2503            .read_recovery_fault_inventory_control()
2504            .await?
2505            .faults
2506            .into_iter()
2507            .find(|fault| fault.reporter == self.instance_id)
2508            .map(|fault| fault.sequence))
2509    }
2510
2511    /// This process's durable nonzero fault report with a display-stable error.
2512    ///
2513    /// # Errors
2514    /// Returns an error when the point read fails or the local slot is malformed.
2515    pub async fn read_local_fault_report(&self) -> Result<Option<u64>, String> {
2516        self.read_local_fault_report_control()
2517            .await
2518            .map_err(|error| error.to_string())
2519    }
2520
2521    /// Each active stable-node report and its globally monotonic authority sequence.
2522    ///
2523    /// # Errors
2524    /// Returns an error when shared authority is unavailable or malformed.
2525    pub async fn read_fault_reports(&self) -> Result<Vec<(NodeId, u64)>, String> {
2526        Ok(self
2527            .read_recovery_fault_inventory()
2528            .await?
2529            .faults
2530            .into_iter()
2531            .map(|fault| (fault.reporter, fault.sequence))
2532            .collect())
2533    }
2534
2535    async fn audit_recovery_faults_control(
2536        &self,
2537        round: &RecoveryRound,
2538    ) -> Result<(), RecoveryControlError> {
2539        let inventory = self.read_recovery_fault_inventory_control().await?;
2540        if inventory.revision == round.fault_revision && inventory.faults == round.faults {
2541            Ok(())
2542        } else {
2543            Err(RecoveryControlError::Superseded(
2544                "recovery fault set changed before Release commit".into(),
2545            ))
2546        }
2547    }
2548
2549    async fn audit_pending_release_faults_control(
2550        &self,
2551        release: &RecoveryAnnouncement,
2552    ) -> Result<(), RecoveryControlError> {
2553        let release_id =
2554            RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
2555        let mut cached = self.pending_release_fault_audit.lock().await;
2556        let now = tokio::time::Instant::now();
2557        if cached
2558            .as_ref()
2559            .is_some_and(|(audited, valid_until)| audited == &release_id && now < *valid_until)
2560        {
2561            return Ok(());
2562        }
2563        self.audit_recovery_faults_control(&release.round).await?;
2564        *cached = Some((
2565            release_id,
2566            tokio::time::Instant::now() + PENDING_RELEASE_FAULT_AUDIT_INTERVAL,
2567        ));
2568        Ok(())
2569    }
2570
2571    /// Publish that this node restored the exact frozen recovery round.
2572    ///
2573    /// # Errors
2574    /// Returns an error for an invalid round or when this node is outside its frozen quorum.
2575    pub async fn announce_recovered(&self, start: &RecoveryAnnouncement) -> Result<(), String> {
2576        start.validate()?;
2577        if !matches!(start.phase, RecoverPhase::Start { .. }) {
2578            return Err("restore acknowledgement must bind a Start target".into());
2579        }
2580        if !start.round.contains_owner(self.instance_id) {
2581            return Err("node outside recovery quorum cannot acknowledge restore".into());
2582        }
2583        if start.round.owner_incarnation(self.instance_id) != Some(self.recovery_incarnation) {
2584            return Err("restore acknowledgement has a stale local process incarnation".into());
2585        }
2586        if !self.recovery_incarnation_is_current().await? {
2587            return Err("restore acknowledgement came from a superseded local process".into());
2588        }
2589        let encoded = serde_json::to_string(&RecoveryAnnouncementAck {
2590            announcement: start.clone(),
2591            incarnation: self.recovery_incarnation,
2592        })
2593        .map_err(|error| format!("could not encode recovery ack: {error}"))?;
2594        self.write_recovery_value_exact("control:recovered", encoded)
2595            .await
2596    }
2597
2598    /// Each visible node's exact restored recovery round.
2599    ///
2600    /// # Errors
2601    /// Fails closed when any visible acknowledgement is malformed.
2602    pub async fn read_recovered(&self) -> Result<Vec<(NodeId, RecoveryAnnouncement)>, String> {
2603        self.read_recovery_announcement_map("control:recovered")
2604            .await
2605    }
2606
2607    /// Publish that this exact process is prepared for the pending release intent.
2608    ///
2609    /// Readiness is published only after local recovery state, shuffle loss accounting, and
2610    /// assignment transport authority are installed while source intake remains closed.
2611    ///
2612    /// # Errors
2613    /// Returns an error for a non-Release phase, changed local fault, or stale process.
2614    pub async fn announce_release_ready(
2615        &self,
2616        release: &RecoveryAnnouncement,
2617    ) -> Result<(), RecoveryControlError> {
2618        let release_id =
2619            RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
2620        if release.round.owner_incarnation(self.instance_id) != Some(self.recovery_incarnation) {
2621            return Err(RecoveryControlError::Superseded(
2622                "release readiness has a stale local process incarnation".into(),
2623            ));
2624        }
2625        if !self
2626            .recovery_incarnation_is_current()
2627            .await
2628            .map_err(RecoveryControlError::Uncertain)?
2629        {
2630            return Err(RecoveryControlError::Superseded(
2631                "release readiness came from a superseded local process".into(),
2632            ));
2633        }
2634        if self.read_local_fault_report_control().await?
2635            != release.round.fault_sequence(self.instance_id)
2636        {
2637            return Err(RecoveryControlError::Superseded(
2638                "local fault set changed before release readiness".into(),
2639            ));
2640        }
2641        match self.observe_recover_control().await? {
2642            Some(active) if active == *release => {}
2643            _ => {
2644                return Err(RecoveryControlError::Superseded(
2645                    "release readiness no longer matches the active intent".into(),
2646                ));
2647            }
2648        }
2649        let participant = CheckpointParticipant {
2650            node_id: self.instance_id.0,
2651            boot_incarnation: self.recovery_incarnation,
2652        };
2653        let encoded = encode_release_ready_ack(&RecoveryReleaseReadyAck {
2654            release: release_id,
2655            participant,
2656        })
2657        .map_err(RecoveryControlError::Conflict)?;
2658        self.write_recovery_value_exact(RELEASE_READY_ACK_KEY, encoded)
2659            .await
2660            .map_err(RecoveryControlError::Uncertain)
2661    }
2662
2663    /// Point-read the exact frozen owner roster's compact readiness records.
2664    ///
2665    /// Unrelated visible nodes are never scanned. Older records count as missing; malformed,
2666    /// same-generation divergent, or newer records are explicit conflicts.
2667    ///
2668    /// # Errors
2669    /// Returns an error only when an exact point read is transport-uncertain.
2670    async fn read_release_ready(
2671        &self,
2672        release: &RecoveryAnnouncement,
2673    ) -> Result<ReleaseReadyStatus, RecoveryControlError> {
2674        let expected =
2675            RecoveryReleaseId::for_pending(release).map_err(RecoveryControlError::Conflict)?;
2676        let reads = futures::stream::iter(
2677            release
2678                .round
2679                .assignment_fence
2680                .participants
2681                .iter()
2682                .copied()
2683                .map(|participant| async move {
2684                    let value = self
2685                        .read_recovery_value(NodeId(participant.node_id), RELEASE_READY_ACK_KEY)
2686                        .await;
2687                    (participant, value)
2688                }),
2689        )
2690        .buffer_unordered(CONTROL_ROSTER_IO_CONCURRENCY)
2691        .collect::<Vec<_>>()
2692        .await;
2693        let mut reads = reads;
2694        reads.sort_unstable_by_key(|(participant, _)| participant.node_id);
2695        let mut missing = Vec::new();
2696        for (participant, value) in reads {
2697            let raw = value.map_err(RecoveryControlError::Uncertain)?;
2698            let Some(raw) = raw else {
2699                missing.push(NodeId(participant.node_id));
2700                continue;
2701            };
2702            let ack = parse_release_ready_ack(&raw, NodeId(participant.node_id))
2703                .map_err(RecoveryControlError::Conflict)?;
2704            if ack.participant != participant {
2705                return Err(RecoveryControlError::Conflict(format!(
2706                    "release readiness from {} does not match the frozen process",
2707                    participant.node_id
2708                )));
2709            }
2710            if ack.release != expected {
2711                if ack.release.generation < expected.generation {
2712                    missing.push(NodeId(participant.node_id));
2713                    continue;
2714                }
2715                if ack.release.generation > expected.generation {
2716                    return Err(RecoveryControlError::Superseded(format!(
2717                        "release readiness from {} has newer generation {}",
2718                        participant.node_id, ack.release.generation
2719                    )));
2720                }
2721                return Err(RecoveryControlError::Conflict(format!(
2722                    "release readiness from {} conflicts with generation {}",
2723                    participant.node_id, expected.generation
2724                )));
2725            }
2726        }
2727        if !missing.is_empty() {
2728            return Ok(ReleaseReadyStatus::Pending { missing });
2729        }
2730        Ok(ReleaseReadyStatus::Complete)
2731    }
2732
2733    /// Node ids eligible to own vnodes: `Active` peers, plus self unless draining. Unlike
2734    /// [`Self::live_instances`], non-`Active` peers are filtered so they never receive vnodes.
2735    #[must_use]
2736    pub fn assignable_instances(&self) -> Vec<NodeId> {
2737        let mut ids = assignable_node_ids(&self.members_rx.borrow());
2738        ids.retain(|id| *id != self.instance_id);
2739        if self.active.load(Ordering::SeqCst)
2740            && !self.is_draining()
2741            && !self.instance_id.is_unassigned()
2742        {
2743            ids.push(self.instance_id);
2744        }
2745        ids.sort_unstable();
2746        ids.dedup();
2747        ids
2748    }
2749
2750    /// Record this node's own locality. Call once at startup.
2751    pub fn set_self_locality(&self, locality: Locality) {
2752        *self.self_locality.write() = locality;
2753    }
2754
2755    /// [`Self::assignable_instances`] paired with each node's [`Locality`]
2756    /// (peers' from `members_rx`, self's from [`Self::set_self_locality`]).
2757    #[must_use]
2758    pub fn assignable_with_locality(&self) -> Vec<(NodeId, Locality)> {
2759        let members = self.members_rx.borrow();
2760        self.assignable_instances()
2761            .into_iter()
2762            .map(|id| {
2763                let locality = if id == self.instance_id {
2764                    self.self_locality.read().clone()
2765                } else {
2766                    members
2767                        .iter()
2768                        .find(|m| m.id == id)
2769                        .and_then(|m| m.metadata.failure_domain.as_deref())
2770                        .map(Locality::parse)
2771                        .unwrap_or_default()
2772                };
2773                (id, locality)
2774            })
2775            .collect()
2776    }
2777
2778    /// Cloneable membership watch for reacting to join/leave events without polling.
2779    #[must_use]
2780    pub fn members_watch(&self) -> watch::Receiver<Vec<NodeInfo>> {
2781        self.members_rx.clone()
2782    }
2783
2784    async fn drain_transition_authority_is_current(
2785        &self,
2786        transition: &AssignmentDrainTransition,
2787    ) -> Result<bool, String> {
2788        #[cfg(feature = "cluster")]
2789        {
2790            let authority = self
2791                .checkpoint_authority()
2792                .map_err(|error| error.to_string())?;
2793            let Some(lease) = authority.load().await.map_err(|error| error.to_string())? else {
2794                return Ok(false);
2795            };
2796            Ok(lease.matches_proof(&transition.leader))
2797        }
2798        #[cfg(not(feature = "cluster"))]
2799        {
2800            let _ = transition;
2801            Err("assignment drain authority requires the cluster feature".into())
2802        }
2803    }
2804
2805    /// Publish and exactly read back proof that every local source reached one exact external
2806    /// input cut for `transition`.
2807    ///
2808    /// # Errors
2809    /// Rejects a nonparticipant, stale boot, superseded process, malformed certificate, or failed
2810    /// durable write/read-back.
2811    pub async fn announce_drain_ack(
2812        &self,
2813        transition: &AssignmentDrainTransition,
2814    ) -> Result<(), String> {
2815        if !transition.is_canonical() {
2816            return Err("drain acknowledgement requires an exact assignment transition".into());
2817        }
2818        let participant = CheckpointParticipant {
2819            node_id: self.instance_id.0,
2820            boot_incarnation: self.recovery_incarnation,
2821        };
2822        if transition
2823            .predecessor
2824            .participant_incarnation(participant.node_id)
2825            != Some(participant.boot_incarnation)
2826        {
2827            return Err("drain acknowledgement does not bind the local process".into());
2828        }
2829        if self.checkpoint_drain_transition.borrow().as_ref() != Some(transition) {
2830            return Err("drain acknowledgement is not the locally audited transition".into());
2831        }
2832        if !self.process_lease_is_live()
2833            || !self
2834                .drain_transition_authority_is_current(transition)
2835                .await?
2836        {
2837            return Err("drain acknowledgement authority is no longer live".into());
2838        }
2839        if !self.recovery_incarnation_is_current().await? {
2840            return Err("drain acknowledgement came from a superseded local process".into());
2841        }
2842        self.write_recovery_value_exact(
2843            DRAIN_ACK_KEY,
2844            encode_drain_ack(&DrainAck::for_transition(participant, transition))?,
2845        )
2846        .await
2847    }
2848
2849    /// Whether every process in the exact frozen assignment roster durably acknowledged the same
2850    /// certificate and still owns its certified boot identity.
2851    ///
2852    /// Records from nodes outside `fence` are ignored because durable per-node slots can outlive
2853    /// membership. A missing or different-version record never contributes to quorum.
2854    ///
2855    /// # Errors
2856    /// Fails closed when the certificate, current boot roster, durable scan, or an expected
2857    /// participant's record is malformed.
2858    pub async fn drain_ack_quorum_reached(
2859        &self,
2860        transition: &AssignmentDrainTransition,
2861    ) -> Result<bool, String> {
2862        if !transition.is_canonical() {
2863            return Err("drain quorum requires a canonical assignment transition".into());
2864        }
2865        let locally_audited =
2866            self.checkpoint_drain_transition.borrow().as_ref() == Some(transition);
2867        if !locally_audited
2868            || !self
2869                .drain_transition_authority_is_current(transition)
2870                .await?
2871        {
2872            return Ok(false);
2873        }
2874        if self
2875            .recovery_participant_incarnations(&transition.predecessor.participant_ids())
2876            .await?
2877            != transition.predecessor.participants
2878        {
2879            return Ok(false);
2880        }
2881
2882        let expected: std::collections::BTreeSet<NodeId> = transition
2883            .required_participants()
2884            .iter()
2885            .map(|participant| NodeId(participant.node_id))
2886            .collect();
2887        let mut seen = std::collections::BTreeSet::new();
2888        let mut matching = std::collections::BTreeSet::new();
2889        for (publisher, raw) in self.scan_recovery_values(DRAIN_ACK_KEY).await? {
2890            if !expected.contains(&publisher) {
2891                continue;
2892            }
2893            if !seen.insert(publisher) {
2894                return Err(format!(
2895                    "duplicate drain acknowledgement from expected participant {publisher}"
2896                ));
2897            }
2898            let ack = parse_drain_ack(&raw, publisher)?;
2899            if ack.matches_transition(transition) {
2900                matching.insert(publisher);
2901            }
2902        }
2903        if matching != expected {
2904            return Ok(false);
2905        }
2906        // Close the read/read race: a process can restart after the first incarnation scan but
2907        // before its old acknowledgement is observed. Revalidate after the exact ack cut.
2908        Ok(self
2909            .recovery_participant_incarnations(&transition.predecessor.participant_ids())
2910            .await?
2911            == transition.predecessor.participants
2912            && self
2913                .drain_transition_authority_is_current(transition)
2914                .await?)
2915    }
2916
2917    /// Publish and exactly read back this process's adopted assignment map.
2918    ///
2919    /// # Errors
2920    /// Fails closed for a malformed/stale process report or unavailable control storage.
2921    pub async fn announce_adopted_assignment(
2922        &self,
2923        adoption: &CheckpointAssignmentAdoption,
2924    ) -> Result<(), String> {
2925        if !adoption.is_canonical()
2926            || adoption.participant.node_id != self.instance_id.0
2927            || adoption.participant.boot_incarnation != self.recovery_incarnation
2928            || !self.recovery_incarnation_is_current().await?
2929        {
2930            return Err("adopted assignment report does not bind the current process".into());
2931        }
2932        let encoded = serde_json::to_string(adoption)
2933            .map_err(|error| format!("could not encode adopted assignment: {error}"))?;
2934        self.write_recovery_value_exact("control:adopted-assignment", encoded)
2935            .await
2936    }
2937
2938    /// Each visible process's exact adopted assignment identity.
2939    ///
2940    /// # Errors
2941    /// Fails closed if any visible report is malformed or claims another publisher.
2942    pub async fn read_adopted_assignments(
2943        &self,
2944    ) -> Result<Vec<(NodeId, CheckpointAssignmentAdoption)>, String> {
2945        self.scan_recovery_values("control:adopted-assignment")
2946            .await?
2947            .into_iter()
2948            .map(|(node, raw)| {
2949                let adoption: CheckpointAssignmentAdoption = serde_json::from_str(&raw)
2950                    .map_err(|error| format!("invalid adopted assignment from {node}: {error}"))?;
2951                if !adoption.is_canonical() || adoption.participant.node_id != node.0 {
2952                    return Err(format!(
2953                        "adopted assignment from {node} has a non-canonical publisher"
2954                    ));
2955                }
2956                Ok((node, adoption))
2957            })
2958            .collect()
2959    }
2960
2961    /// Publish this node's version-bound checkpoint fence. Called off the hot path by the snapshot
2962    /// watcher; admission and recovery revalidate it locally against current membership.
2963    pub fn publish_checkpoint_assignment_fence(&self, fence: Option<CheckpointAssignmentFence>) {
2964        let _transition = self.process_authority_transition.lock();
2965        let fence = if self.process_lease_is_live() {
2966            fence
2967        } else {
2968            None
2969        };
2970        if let Some(fence) = fence.as_ref().filter(|fence| fence.is_canonical()) {
2971            let participants = fence.participant_ids();
2972            let changed = {
2973                let mut installed = self.leadership_participants.write();
2974                match installed.as_ref() {
2975                    Some((version, _)) if *version >= fence.assignment_version => false,
2976                    _ => {
2977                        *installed = Some((fence.assignment_version, participants));
2978                        true
2979                    }
2980                }
2981            };
2982            if changed {
2983                self.notify_leader_eligibility_change();
2984            }
2985        }
2986        self.checkpoint_assignment_fence.send_replace(fence);
2987    }
2988
2989    /// Publish the exact locally audited drain transition, or clear it after commit/abort.
2990    pub fn publish_checkpoint_drain_transition(
2991        &self,
2992        transition: Option<AssignmentDrainTransition>,
2993    ) {
2994        let _transition = self.process_authority_transition.lock();
2995        let transition = if self.process_lease_is_live() {
2996            transition
2997        } else {
2998            None
2999        };
3000        self.checkpoint_drain_transition.send_replace(transition);
3001    }
3002
3003    /// Clear the locally audited drain transition only when it still equals `expected`.
3004    ///
3005    /// The comparison and clear share the process-authority transition lock with publication and
3006    /// terminal process fencing, so a newer transition cannot be erased by stale reconciliation.
3007    #[must_use]
3008    pub fn clear_checkpoint_drain_transition_if_matches(
3009        &self,
3010        expected: &AssignmentDrainTransition,
3011    ) -> bool {
3012        let _transition = self.process_authority_transition.lock();
3013        let matches = self.checkpoint_drain_transition.borrow().as_ref() == Some(expected);
3014        if matches {
3015            self.checkpoint_drain_transition.send_replace(None);
3016        }
3017        matches
3018    }
3019
3020    /// Current locally audited drain transition.
3021    #[must_use]
3022    pub fn checkpoint_drain_transition(&self) -> Option<AssignmentDrainTransition> {
3023        self.checkpoint_drain_transition.borrow().clone()
3024    }
3025
3026    /// Subscribe to local assignment-fence changes so a blocking durability probe can be
3027    /// interrupted when its checkpoint cut becomes stale.
3028    #[must_use]
3029    pub fn checkpoint_assignment_watch(
3030        &self,
3031    ) -> watch::Receiver<Option<CheckpointAssignmentFence>> {
3032        self.checkpoint_assignment_fence.subscribe()
3033    }
3034
3035    /// Return the exact locally certified assignment cut while every participant remains
3036    /// checkpoint-capable. Active workers that own no vnode do not expand the checkpoint quorum.
3037    /// The clone is retained by the admitted attempt and propagated to followers.
3038    #[must_use]
3039    pub fn checkpoint_assignment_fence(
3040        &self,
3041        assignment_version: u64,
3042    ) -> Option<CheckpointAssignmentFence> {
3043        let checkpoint_capable: Vec<u64> = self
3044            .checkpoint_instances()
3045            .into_iter()
3046            .map(|node| node.0)
3047            .collect();
3048        self.checkpoint_assignment_fence
3049            .borrow()
3050            .as_ref()
3051            .filter(|fence| {
3052                fence.is_canonical()
3053                    && fence.assignment_version == assignment_version
3054                    && fence
3055                        .participant_incarnation(self.instance_id.0)
3056                        .is_none_or(|incarnation| incarnation == self.recovery_incarnation)
3057                    && fence.participants.iter().all(|participant| {
3058                        checkpoint_capable
3059                            .binary_search(&participant.node_id)
3060                            .is_ok()
3061                    })
3062            })
3063            .cloned()
3064    }
3065
3066    /// Return the locally certified assignment only when the announcing leader proof exactly
3067    /// matches the current durable authority. A predecessor retained during an in-progress drain
3068    /// additionally remains bound to the leader term that opened that drain.
3069    #[cfg(feature = "cluster")]
3070    pub async fn checkpoint_assignment_fence_for_leader(
3071        &self,
3072        assignment_version: u64,
3073        leader: &crate::checkpoint::LeaderProof,
3074    ) -> Option<CheckpointAssignmentFence> {
3075        if !leader.is_canonical() {
3076            return None;
3077        }
3078        let fence = self.checkpoint_assignment_fence(assignment_version)?;
3079        let authority = self.checkpoint_authority().ok()?;
3080        let lease = authority.load().await.ok().flatten()?;
3081        if !lease.matches_proof(leader) {
3082            return None;
3083        }
3084        self.checkpoint_assignment_fence_after_authority_validation(fence, leader)
3085    }
3086
3087    #[cfg(feature = "cluster")]
3088    fn checkpoint_assignment_fence_after_authority_validation(
3089        &self,
3090        fence: CheckpointAssignmentFence,
3091        leader: &crate::checkpoint::LeaderProof,
3092    ) -> Option<CheckpointAssignmentFence> {
3093        let transition = self.checkpoint_drain_transition.borrow();
3094        if transition
3095            .as_ref()
3096            .is_some_and(|transition| transition.predecessor == fence)
3097            && transition.as_ref().map(|transition| &transition.leader) != Some(leader)
3098        {
3099            return None;
3100        }
3101        Some(fence)
3102    }
3103
3104    /// Start the direct gRPC barrier sync server.
3105    ///
3106    /// # Errors
3107    /// Propagates [`BarrierCoordinator::start_server`] errors.
3108    #[cfg(feature = "cluster")]
3109    pub async fn start_barrier_server(
3110        &self,
3111        bind_addr: std::net::SocketAddr,
3112        advertise_host: Option<String>,
3113    ) -> Result<std::net::SocketAddr, String> {
3114        self.barrier.start_server(bind_addr, advertise_host).await
3115    }
3116
3117    /// Start a cluster control endpoint whose first advertisement is bound to an acquired
3118    /// stable-node process lease.
3119    ///
3120    /// # Errors
3121    /// Rejects a lease for another node or boot, a conflicting prior identity, or server start.
3122    #[cfg(feature = "cluster")]
3123    pub async fn start_leased_barrier_server(
3124        &self,
3125        bind_addr: std::net::SocketAddr,
3126        advertise_host: Option<String>,
3127        process_lease: &super::ProcessLease,
3128    ) -> Result<std::net::SocketAddr, String> {
3129        process_lease
3130            .validate(self.instance_id)
3131            .map_err(|error| error.to_string())?;
3132        if process_lease.owner != self.recovery_incarnation {
3133            return Err("control endpoint lease does not bind this process incarnation".into());
3134        }
3135        if !self.recovery_process_lease_is_live() {
3136            return Err("live process lease deadline is not installed".into());
3137        }
3138        self.barrier.install_local_process_lease(process_lease)?;
3139        self.barrier.start_server(bind_addr, advertise_host).await
3140    }
3141
3142    /// Confirm that one exact remote process still holds a proof read from durable authority.
3143    ///
3144    /// The RPC returns only a challenge acknowledgement and never returns authority material.
3145    ///
3146    /// # Errors
3147    /// Fails closed when the control RPC is unavailable, malformed, or misses `deadline`.
3148    #[cfg(feature = "cluster")]
3149    pub async fn confirm_remote_leader_proof(
3150        &self,
3151        proof: &LeaderProof,
3152        deadline: tokio::time::Instant,
3153    ) -> Result<bool, String> {
3154        self.barrier
3155            .confirm_remote_leader_proof(proof, deadline)
3156            .await
3157    }
3158
3159    #[cfg(feature = "cluster")]
3160    async fn confirm_assignment_leader_proof(
3161        &self,
3162        leader: NodeId,
3163        proof: &LeaderProof,
3164        deadline: tokio::time::Instant,
3165    ) -> Result<(), String> {
3166        if tokio::time::Instant::now() >= deadline {
3167            return Err("assignment leader authority audit deadline expired".into());
3168        }
3169        if proof.owner.node_id != leader.0 {
3170            return Err("assignment leader proof does not match the current leader".into());
3171        }
3172        let confirmed = if leader == self.instance_id {
3173            self.capture_leader_proof().as_ref() == Some(proof)
3174        } else {
3175            self.confirm_remote_leader_proof(proof, deadline).await?
3176        };
3177        if !confirmed {
3178            return Err(format!(
3179                "assignment leader process {} has no live exact grant",
3180                leader.0
3181            ));
3182        }
3183        Ok(())
3184    }
3185
3186    /// Audit one exact assignment leader against durable and process-local authority.
3187    ///
3188    /// This is a single bounded attempt intended for a caller that already holds its assignment
3189    /// and execution locks. `expected_proof` binds predecessor execution during an active drain;
3190    /// a stable assignment passes `None` and adopts the current durable grant. The audit never
3191    /// waits for convergence and never mutates assignment, lease, or source-gate state.
3192    ///
3193    /// # Errors
3194    /// Fails closed when the installed assignment, elected participant, leader authority, live
3195    /// local/remote grant, or durable process term changes or cannot be verified before `deadline`.
3196    #[cfg(feature = "cluster")]
3197    pub async fn audit_assignment_leader_authority(
3198        &self,
3199        fence: &CheckpointAssignmentFence,
3200        expected_proof: Option<&LeaderProof>,
3201        deadline: tokio::time::Instant,
3202    ) -> Result<LeaderProof, String> {
3203        if tokio::time::Instant::now() >= deadline {
3204            return Err("assignment leader authority audit deadline expired".into());
3205        }
3206        if !fence.is_canonical()
3207            || self
3208                .checkpoint_assignment_fence(fence.assignment_version)
3209                .as_ref()
3210                != Some(fence)
3211        {
3212            return Err(
3213                "assignment leader authority audit requires the exact installed fence".into(),
3214            );
3215        }
3216        if expected_proof.is_some_and(|proof| !proof.is_canonical()) {
3217            return Err("assignment leader authority audit expected proof is not canonical".into());
3218        }
3219
3220        let leader = self
3221            .current_leader()
3222            .ok_or_else(|| "assignment leader authority audit has no current leader".to_string())?;
3223        let participant = fence
3224            .participants
3225            .iter()
3226            .find(|participant| participant.node_id == leader.0)
3227            .copied()
3228            .ok_or_else(|| {
3229                "current leader is not a participant in the exact assignment fence".to_string()
3230            })?;
3231        let authority = self
3232            .checkpoint_authority()
3233            .map_err(|error| format!("assignment leader authority is unavailable: {error}"))?;
3234        let initial = tokio::time::timeout_at(deadline, authority.load())
3235            .await
3236            .map_err(|_| "assignment leader authority initial read timed out".to_string())?
3237            .map_err(|error| format!("assignment leader authority initial read failed: {error}"))?
3238            .ok_or_else(|| "assignment leader authority has no durable grant".to_string())?;
3239        if initial.owner.node != leader || initial.owner.boot != participant.boot_incarnation {
3240            return Err(
3241                "durable leader grant does not match the elected assignment participant".into(),
3242            );
3243        }
3244        let proof = match expected_proof {
3245            Some(expected) if initial.matches_proof(expected) => expected.clone(),
3246            Some(_) => {
3247                return Err(
3248                    "durable leader grant does not match the drain-bound expected proof".into(),
3249                );
3250            }
3251            None => initial.proof(),
3252        };
3253
3254        self.confirm_assignment_leader_proof(leader, &proof, deadline)
3255            .await?;
3256        let process_authority = self
3257            .process_lease_authority
3258            .get()
3259            .ok_or_else(|| "process lease authority is not installed".to_string())?;
3260        let process_current = process_authority
3261            .verify_current_participant_term(participant, proof.owner.process_term, deadline)
3262            .await
3263            .map_err(|error| {
3264                format!("assignment leader process-term verification failed: {error}")
3265            })?;
3266        if !process_current {
3267            return Err("assignment leader durable process term is no longer current".to_string());
3268        }
3269        self.confirm_assignment_leader_proof(leader, &proof, deadline)
3270            .await?;
3271
3272        let final_grant = tokio::time::timeout_at(deadline, authority.load())
3273            .await
3274            .map_err(|_| "assignment leader authority final read timed out".to_string())?
3275            .map_err(|error| format!("assignment leader authority final read failed: {error}"))?
3276            .ok_or_else(|| "assignment leader authority grant disappeared".to_string())?;
3277        if final_grant.owner != initial.owner
3278            || final_grant.token != initial.token
3279            || final_grant.seq < initial.seq
3280            || !final_grant.matches_proof(&proof)
3281        {
3282            return Err("assignment leader durable grant changed during the audit".into());
3283        }
3284        if self.current_leader() != Some(leader)
3285            || self
3286                .checkpoint_assignment_fence(fence.assignment_version)
3287                .as_ref()
3288                != Some(fence)
3289        {
3290            return Err("assignment leader or exact fence changed during the audit".into());
3291        }
3292        if tokio::time::Instant::now() >= deadline {
3293            return Err("assignment leader authority audit deadline expired".into());
3294        }
3295        Ok(proof)
3296    }
3297
3298    /// Leader-side announce.
3299    ///
3300    /// # Errors
3301    /// Propagates [`BarrierCoordinator::announce`] errors.
3302    pub async fn announce_barrier(&self, ann: &BarrierAnnouncement) -> Result<(), String> {
3303        if !self.process_lease_is_live() {
3304            return Err("stable node process lease is no longer live".into());
3305        }
3306        self.barrier.announce(ann).await
3307    }
3308
3309    /// Leader-side assignment-certified Prepare publication with its configured quorum window.
3310    ///
3311    /// # Errors
3312    /// Propagates process-lease and [`BarrierCoordinator::announce_prepare`] errors.
3313    #[cfg(feature = "cluster")]
3314    pub async fn announce_prepare_barrier(
3315        &self,
3316        ann: &BarrierAnnouncement,
3317        quorum_window: Duration,
3318    ) -> Result<(), String> {
3319        if !self.process_lease_is_live() {
3320            return Err("stable node process lease is no longer live".into());
3321        }
3322        self.barrier.announce_prepare(ann, quorum_window).await
3323    }
3324
3325    /// Observe the merged barrier history, validating durable authority only when `predicate`
3326    /// selects the announcement. Malformed or conflicting histories fail before filtering.
3327    ///
3328    /// # Errors
3329    /// Propagates merge, transport, and matching reversible-authority failures.
3330    pub async fn observe_barrier_matching<F>(
3331        &self,
3332        mut predicate: F,
3333    ) -> Result<Option<BarrierAnnouncement>, String>
3334    where
3335        F: FnMut(&BarrierAnnouncement) -> bool,
3336    {
3337        let Some(leader) = self.current_leader() else {
3338            return Ok(None);
3339        };
3340        let Some(announcement) = self.barrier.observe_hint(leader).await? else {
3341            return Ok(None);
3342        };
3343        if !predicate(&announcement) {
3344            return Ok(None);
3345        }
3346        #[cfg(feature = "cluster")]
3347        self.barrier.validate_observed(&announcement).await?;
3348        Ok(Some(announcement))
3349    }
3350
3351    /// Observe a clustered `Prepare`, validate its leader with one durable-authority read, and
3352    /// report the local assignment disposition without consulting authority again.
3353    ///
3354    /// # Errors
3355    /// Rejects missing, stale, or conflicting authority and assignment certificates.
3356    #[cfg(feature = "cluster")]
3357    pub async fn observe_checkpoint_prepare(
3358        &self,
3359    ) -> Result<Option<CheckpointPrepareObservation>, String> {
3360        let Some(leader) = self.current_leader() else {
3361            return Ok(None);
3362        };
3363        let Some(announcement) = self.barrier.observe_hint(leader).await? else {
3364            return Ok(None);
3365        };
3366        if announcement.phase != super::Phase::Prepare {
3367            return Ok(None);
3368        }
3369        self.barrier
3370            .validate_checkpoint_prepare(&announcement)
3371            .await?;
3372        let proof = announcement
3373            .leader_proof
3374            .as_ref()
3375            .filter(|proof| proof.is_canonical())
3376            .ok_or_else(|| "leader Prepare omitted its canonical authority proof".to_string())?;
3377        let assignment_error = match announcement.assignment_fence.as_ref() {
3378            None => Some(
3379                "[LDB-6055] leader Prepare omitted its canonical assignment certificate"
3380                    .to_string(),
3381            ),
3382            Some(fence) if !fence.is_canonical() => Some(
3383                "[LDB-6055] leader Prepare carried a non-canonical assignment certificate"
3384                    .to_string(),
3385            ),
3386            Some(fence) => self
3387                .checkpoint_assignment_fence(fence.assignment_version)
3388                .and_then(|certified| {
3389                    self.checkpoint_assignment_fence_after_authority_validation(certified, proof)
3390                })
3391                .map_or_else(
3392                    || {
3393                        Some(format!(
3394                            "[LDB-6055] follower cannot certify leader Prepare assignment {}",
3395                            fence.assignment_version
3396                        ))
3397                    },
3398                    |certified| {
3399                        (certified != *fence).then(|| {
3400                            format!(
3401                                "[LDB-6055] follower assignment differs from leader Prepare assignment {}",
3402                                fence.assignment_version
3403                            )
3404                        })
3405                    },
3406                ),
3407        };
3408        Ok(Some(match assignment_error {
3409            Some(error) => CheckpointPrepareObservation::AssignmentRejected {
3410                announcement,
3411                error,
3412            },
3413            None => CheckpointPrepareObservation::AssignmentReady(announcement),
3414        }))
3415    }
3416
3417    /// Subscribe to direct checkpoint announcements. Consumers must retain a bounded KV poll:
3418    /// the watch is a latency path, while the merged gossip history remains the fallback.
3419    #[cfg(feature = "cluster")]
3420    #[must_use]
3421    pub fn checkpoint_announcement_watch(
3422        &self,
3423    ) -> Option<watch::Receiver<Option<BarrierAnnouncement>>> {
3424        self.barrier.announcement_watch()
3425    }
3426
3427    /// Local monotonic receipt time for this exact direct Prepare, if gRPC delivered it.
3428    #[must_use]
3429    pub fn checkpoint_prepare_received_at(
3430        &self,
3431        prepare: &BarrierAnnouncement,
3432    ) -> Option<std::time::Instant> {
3433        self.barrier.prepare_received_at(prepare)
3434    }
3435
3436    /// Follower-side ack.
3437    ///
3438    /// # Errors
3439    /// Propagates [`BarrierCoordinator::ack`] errors.
3440    pub async fn ack_barrier(&self, ack: &BarrierAck) -> Result<(), String> {
3441        if !self.process_lease_is_live() {
3442            return Err("stable node process lease is no longer live".into());
3443        }
3444        self.barrier.ack(ack).await
3445    }
3446
3447    async fn durable_recovery_proof_is_current(&self, proof: &LeaderProof) -> Result<bool, String> {
3448        if !proof.is_canonical() {
3449            return Ok(false);
3450        }
3451        let authority = self
3452            .checkpoint_authority()
3453            .map_err(|error| format!("durable recovery authority is unavailable: {error}"))?;
3454        let Some(lease) = authority
3455            .load()
3456            .await
3457            .map_err(|error| format!("durable recovery authority read failed: {error}"))?
3458        else {
3459            return Ok(false);
3460        };
3461        Ok(lease.matches_proof(proof))
3462    }
3463
3464    async fn recovery_driver_proof_is_current(
3465        &self,
3466        round: &RecoveryRound,
3467    ) -> Result<bool, String> {
3468        Ok(round.id.driver == self.instance_id
3469            && self.proof_is_live(&round.leader_proof)
3470            && self
3471                .durable_recovery_proof_is_current(&round.leader_proof)
3472                .await?)
3473    }
3474
3475    async fn require_recovery_driver_proof(
3476        &self,
3477        round: &RecoveryRound,
3478        boundary: &str,
3479    ) -> Result<(), String> {
3480        if self.recovery_driver_proof_is_current(round).await? {
3481            Ok(())
3482        } else {
3483            Err(format!(
3484                "recovery driver proof is no longer live at {boundary}"
3485            ))
3486        }
3487    }
3488
3489    async fn require_recovery_driver_proof_control(
3490        &self,
3491        round: &RecoveryRound,
3492        boundary: &str,
3493    ) -> Result<(), RecoveryControlError> {
3494        if round.id.driver != self.instance_id || !self.proof_is_live(&round.leader_proof) {
3495            return Err(RecoveryControlError::Superseded(format!(
3496                "recovery driver proof is no longer live at {boundary}"
3497            )));
3498        }
3499        let authority = self
3500            .checkpoint_authority()
3501            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
3502        let current = authority.load().await.map_err(|error| match error {
3503            super::LeaseError::Io(reason) => RecoveryControlError::Uncertain(reason),
3504            error => RecoveryControlError::Conflict(error.to_string()),
3505        })?;
3506        if !current.is_some_and(|lease| lease.matches_proof(&round.leader_proof)) {
3507            return Err(RecoveryControlError::Superseded(format!(
3508                "durable recovery driver proof changed at {boundary}"
3509            )));
3510        }
3511        Ok(())
3512    }
3513
3514    async fn recovery_evidence_roster_matches(
3515        &self,
3516        round: &RecoveryRound,
3517    ) -> Result<bool, String> {
3518        let candidates = round
3519            .faults
3520            .iter()
3521            .filter(|fault| !round.assignment_fence.contains(fault.reporter.0))
3522            .map(|fault| fault.reporter.0)
3523            .collect::<Vec<_>>();
3524        let available = if candidates.is_empty() {
3525            Vec::new()
3526        } else {
3527            self.available_recovery_participant_incarnations(&candidates)
3528                .await?
3529        };
3530        Ok(available == round.evidence_participants)
3531    }
3532
3533    /// Announce phase 1 with the immutable stopped/evidence roster.
3534    ///
3535    /// # Errors
3536    /// Returns an error unless this node is the round's current leader and driver.
3537    pub async fn announce_recover_prepare(&self, round: &RecoveryRound) -> Result<(), String> {
3538        round.validate()?;
3539        if round.id.driver != self.instance_id {
3540            return Err("only the current leader may prepare its recovery round".into());
3541        }
3542        self.require_recovery_driver_proof(round, "Prepare preflight")
3543            .await?;
3544        if !self.recovery_evidence_roster_matches(round).await? {
3545            return Err("available recovery evidence roster changed before Prepare".into());
3546        }
3547        if !self
3548            .recovery_stopped_incarnations_match(round)
3549            .await
3550            .map_err(|error| error.to_string())?
3551        {
3552            return Err("recovery stopped-process roster changed before Prepare".into());
3553        }
3554        let _guard = self.recovery_writes.lock().await;
3555        self.require_recovery_driver_proof(round, "Prepare publication")
3556            .await?;
3557        if !self.recovery_evidence_roster_matches(round).await? {
3558            return Err("available recovery evidence roster changed during Prepare".into());
3559        }
3560        let announcement = RecoveryAnnouncement {
3561            round: round.clone(),
3562            phase: RecoverPhase::Prepare,
3563        };
3564        let encoded = encode_recovery_announcement(&announcement)?;
3565        self.write_recovery_value_exact("control:recover", encoded)
3566            .await?;
3567        self.require_recovery_driver_proof(round, "Prepare read-back")
3568            .await
3569    }
3570
3571    /// Transition the identical prepared round to `Start` with a target bound into the
3572    /// announcement. A missing or different `Prepare` is never upgraded.
3573    ///
3574    /// # Errors
3575    /// Returns an error on lost leadership, an invalid round, or a mismatched prior phase.
3576    pub async fn announce_recover_start(
3577        &self,
3578        round: &RecoveryRound,
3579        epoch: u64,
3580    ) -> Result<(), String> {
3581        round.validate()?;
3582        if round.id.driver != self.instance_id {
3583            return Err("only the current leader may start its recovery round".into());
3584        }
3585        self.require_recovery_driver_proof(round, "Start preflight")
3586            .await?;
3587        let _guard = self.recovery_writes.lock().await;
3588        if !self.recovery_incarnations_match(round).await? {
3589            return Err(
3590                "recovery driver or process-incarnation roster changed before Start".into(),
3591            );
3592        }
3593        let current = self
3594            .read_recovery_value(self.instance_id, "control:recover")
3595            .await?
3596            .ok_or_else(|| "recovery Prepare disappeared before Start".to_string())?;
3597        let prepared = parse_recovery_announcement(&current)?
3598            .ok_or_else(|| "recovery Prepare was cleared before Start".to_string())?;
3599        if prepared.round != *round || prepared.phase != RecoverPhase::Prepare {
3600            return Err("recovery Start does not match the exact active Prepare".into());
3601        }
3602        self.require_recovery_driver_proof(round, "Start publication")
3603            .await?;
3604        let announcement = RecoveryAnnouncement {
3605            round: round.clone(),
3606            phase: RecoverPhase::Start { epoch },
3607        };
3608        let encoded = encode_recovery_announcement(&announcement)?;
3609        self.write_recovery_value_exact("control:recover", encoded)
3610            .await?;
3611        self.require_recovery_driver_proof(round, "Start read-back")
3612            .await
3613    }
3614
3615    /// Transition the identical `Start` to a pending `Release`. Source gates remain closed until
3616    /// the leader commits the exact compact readiness roster.
3617    ///
3618    /// # Errors
3619    /// Returns an error on lost leadership, a changed incarnation roster, or a mismatched Start.
3620    pub async fn announce_recover_release(
3621        &self,
3622        round: &RecoveryRound,
3623        epoch: u64,
3624    ) -> Result<(), String> {
3625        round.validate()?;
3626        if round.id.driver != self.instance_id {
3627            return Err("only the current leader may release its recovery round".into());
3628        }
3629        self.require_recovery_driver_proof(round, "Release preflight")
3630            .await?;
3631        let _guard = self.recovery_writes.lock().await;
3632        if !self.recovery_incarnations_match(round).await? {
3633            return Err(
3634                "recovery driver or process-incarnation roster changed before Release".into(),
3635            );
3636        }
3637        let current = self
3638            .read_recovery_value(self.instance_id, "control:recover")
3639            .await?
3640            .ok_or_else(|| "recovery Start disappeared before Release".to_string())?;
3641        let started = parse_recovery_announcement(&current)?
3642            .ok_or_else(|| "recovery Start was cleared before Release".to_string())?;
3643        if started.round != *round || started.phase != (RecoverPhase::Start { epoch }) {
3644            return Err("recovery Release does not match the exact active Start target".into());
3645        }
3646        self.require_recovery_driver_proof(round, "Release publication")
3647            .await?;
3648        let release = RecoveryAnnouncement {
3649            round: round.clone(),
3650            phase: RecoverPhase::Release { epoch },
3651        };
3652        let encoded = encode_recovery_announcement(&release)?;
3653        self.write_recovery_value_exact("control:recover", encoded)
3654            .await?;
3655        self.require_recovery_driver_proof(round, "Release read-back")
3656            .await
3657    }
3658
3659    /// Commit a pending release after every frozen owner published its compact readiness record.
3660    ///
3661    /// A pending attempt audits the frozen fault set before returning incomplete readiness. Once
3662    /// complete, the process roster and fault set are validated under the driver's phase-transition
3663    /// mutex before admitting the content-addressed terminal into durable leader authority.
3664    ///
3665    /// # Errors
3666    /// Returns a classified uncertain, conflict, or superseded outcome. Missing readiness remains
3667    /// a normal pending status.
3668    pub async fn try_commit_recover_release(
3669        &self,
3670        release: &RecoveryAnnouncement,
3671    ) -> Result<ReleaseCommitStatus, RecoveryControlError> {
3672        release.validate().map_err(RecoveryControlError::Conflict)?;
3673        let RecoverPhase::Release { epoch } = release.phase else {
3674            return Err(RecoveryControlError::Conflict(
3675                "release commit must bind a pending Release target".into(),
3676            ));
3677        };
3678        let round = &release.round;
3679        if round.id.driver != self.instance_id {
3680            return Err(RecoveryControlError::Superseded(
3681                "only the current leader may commit its recovery Release".into(),
3682            ));
3683        }
3684        self.require_recovery_driver_proof_control(round, "Release commit preflight")
3685            .await?;
3686        match self.read_release_ready(release).await? {
3687            ReleaseReadyStatus::Complete => {}
3688            ReleaseReadyStatus::Pending { missing } => {
3689                self.audit_pending_release_faults_control(release).await?;
3690                return Ok(ReleaseCommitStatus::Pending { missing });
3691            }
3692        }
3693        let committed = RecoveryAnnouncement {
3694            round: round.clone(),
3695            phase: RecoverPhase::ReleaseCommitted { epoch },
3696        };
3697        let authority = self
3698            .checkpoint_authority()
3699            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
3700        let reference = authority
3701            .stage_recovery_release_terminal(&committed)
3702            .await
3703            .map_err(RecoveryControlError::from_authority)?;
3704
3705        let _guard = self.recovery_writes.lock().await;
3706        if !self.recovery_incarnations_match_control(round).await? {
3707            return Err(RecoveryControlError::Superseded(
3708                "recovery process-incarnation roster changed before Release commit".into(),
3709            ));
3710        }
3711        self.audit_recovery_faults_control(round).await?;
3712        let Some(current) = self
3713            .read_recovery_value(self.instance_id, "control:recover")
3714            .await
3715            .map_err(RecoveryControlError::Uncertain)?
3716        else {
3717            return Err(RecoveryControlError::Superseded(
3718                "pending recovery Release disappeared before commit".into(),
3719            ));
3720        };
3721        let active = match parse_recovery_announcement(&current) {
3722            Ok(Some(active)) => active,
3723            Ok(None) => {
3724                return Err(RecoveryControlError::Superseded(
3725                    "pending recovery Release was cleared before commit".into(),
3726                ));
3727            }
3728            Err(reason) => return Err(RecoveryControlError::Conflict(reason)),
3729        };
3730        if active != *release {
3731            let error = if active.round.id.generation > round.id.generation {
3732                RecoveryControlError::Superseded(
3733                    "a newer recovery intent replaced the pending Release".into(),
3734                )
3735            } else {
3736                RecoveryControlError::Conflict(
3737                    "recovery Release commit does not match the exact pending intent".into(),
3738                )
3739            };
3740            return Err(error);
3741        }
3742        self.require_recovery_driver_proof_control(round, "Release commit publication")
3743            .await?;
3744        match Box::pin(authority.record_recovery_release_commit(&round.leader_proof, reference))
3745            .await
3746            .map_err(RecoveryControlError::from_authority)?
3747        {
3748            super::leader_lease::RecordRecoveryReleaseCommitResult::Created(_)
3749            | super::leader_lease::RecordRecoveryReleaseCommitResult::Unchanged(_) => {
3750                Ok(ReleaseCommitStatus::Committed {
3751                    terminal: committed,
3752                })
3753            }
3754            super::leader_lease::RecordRecoveryReleaseCommitResult::Conflict { winner } => {
3755                if winner.generation() > round.id.generation {
3756                    Err(RecoveryControlError::Superseded(format!(
3757                        "recovery release generation {} replaced generation {}",
3758                        winner.generation(),
3759                        round.id.generation
3760                    )))
3761                } else {
3762                    Err(RecoveryControlError::Conflict(format!(
3763                        "recovery release generation {} has a different durable winner",
3764                        round.id.generation
3765                    )))
3766                }
3767            }
3768            super::leader_lease::RecordRecoveryReleaseCommitResult::FaultsChanged => {
3769                Err(RecoveryControlError::Superseded(
3770                    "recovery fault inventory changed at Release authority admission".into(),
3771                ))
3772            }
3773        }
3774    }
3775
3776    /// Active recovery announcement with semantic failures separated from uncertain I/O.
3777    ///
3778    /// # Errors
3779    /// Classifies malformed state, superseded authority, and retryable durable I/O separately.
3780    pub async fn observe_recover_control(
3781        &self,
3782    ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
3783        let Some(current_driver) = self.current_leader() else {
3784            return Ok(None);
3785        };
3786        let Some(raw) = self
3787            .read_recovery_value(current_driver, "control:recover")
3788            .await
3789            .map_err(RecoveryControlError::Uncertain)?
3790        else {
3791            return Ok(None);
3792        };
3793        let Some(announcement) =
3794            parse_recovery_announcement(&raw).map_err(RecoveryControlError::Conflict)?
3795        else {
3796            return Ok(None);
3797        };
3798        if matches!(announcement.phase, RecoverPhase::ReleaseCommitted { .. }) {
3799            return Err(RecoveryControlError::Conflict(
3800                "committed recovery release appeared in the mutable intent slot".into(),
3801            ));
3802        }
3803        if announcement.round.id.driver != current_driver {
3804            return Err(RecoveryControlError::Conflict(format!(
3805                "recovery publisher {current_driver} is not declared driver {}",
3806                announcement.round.id.driver
3807            )));
3808        }
3809        let authority = self
3810            .checkpoint_authority()
3811            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?;
3812        let Some(authority_before) = authority.load().await.map_err(|error| match error {
3813            super::LeaseError::Io(reason) => RecoveryControlError::Uncertain(reason),
3814            error => RecoveryControlError::Conflict(error.to_string()),
3815        })?
3816        else {
3817            return Err(RecoveryControlError::Superseded(
3818                "durable recovery authority has no leader".into(),
3819            ));
3820        };
3821        if !authority_before.matches_proof(&announcement.round.leader_proof) {
3822            return Err(RecoveryControlError::Superseded(format!(
3823                "recovery phase from {current_driver} does not match durable leader authority"
3824            )));
3825        }
3826        let Some(authority_after) = authority.load().await.map_err(|error| match error {
3827            super::LeaseError::Io(reason) => RecoveryControlError::Uncertain(reason),
3828            error => RecoveryControlError::Conflict(error.to_string()),
3829        })?
3830        else {
3831            return Err(RecoveryControlError::Superseded(
3832                "durable recovery authority vanished during observation".into(),
3833            ));
3834        };
3835        if self.current_leader() != Some(current_driver)
3836            || !authority_after.matches_proof(&announcement.round.leader_proof)
3837        {
3838            return Err(RecoveryControlError::Superseded(format!(
3839                "recovery authority changed while observing {current_driver}"
3840            )));
3841        }
3842        Ok(Some(announcement))
3843    }
3844
3845    /// Latest irrevocable recovery release admitted by the append-only leader authority.
3846    ///
3847    /// # Errors
3848    /// Classifies missing/corrupt terminal state as conflict, takeover as supersession, and
3849    /// durable I/O as uncertainty.
3850    pub async fn latest_committed_recover_release(
3851        &self,
3852    ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
3853        self.checkpoint_authority()
3854            .map_err(|error| RecoveryControlError::Conflict(error.to_string()))?
3855            .latest_recovery_release_terminal()
3856            .await
3857            .map_err(RecoveryControlError::from_authority)
3858    }
3859
3860    /// Resolve the exact committed terminal for one pending release intent across leader
3861    /// takeover. An older terminal is unrelated; a same-generation divergence is corruption and a
3862    /// newer terminal supersedes the caller.
3863    ///
3864    /// # Errors
3865    /// Returns a classified conflict, supersession, or uncertain durable read.
3866    pub async fn observe_committed_recover_release(
3867        &self,
3868        round: &RecoveryRound,
3869        epoch: u64,
3870    ) -> Result<Option<RecoveryAnnouncement>, RecoveryControlError> {
3871        round.validate().map_err(RecoveryControlError::Conflict)?;
3872        let expected = RecoveryAnnouncement {
3873            round: round.clone(),
3874            phase: RecoverPhase::ReleaseCommitted { epoch },
3875        };
3876        let Some(terminal) = self.latest_committed_recover_release().await? else {
3877            return Ok(None);
3878        };
3879        match terminal.round.id.generation.cmp(&round.id.generation) {
3880            std::cmp::Ordering::Less => Ok(None),
3881            std::cmp::Ordering::Greater => Err(RecoveryControlError::Superseded(format!(
3882                "recovery release generation {} replaced generation {}",
3883                terminal.round.id.generation, round.id.generation
3884            ))),
3885            std::cmp::Ordering::Equal if terminal == expected => Ok(Some(terminal)),
3886            std::cmp::Ordering::Equal => Err(RecoveryControlError::Conflict(format!(
3887                "committed recovery release generation {} differs from the expected round",
3888                round.id.generation
3889            ))),
3890        }
3891    }
3892
3893    /// Best-effort cleanup for this driver's mutable `Release` discovery hint after the exact
3894    /// terminal is irrevocably present in leader authority. Cleanup never contributes to commit
3895    /// validity; followers resolve the terminal from authority even if this write is uncertain.
3896    ///
3897    /// # Errors
3898    /// Classifies malformed or divergent local intent separately from retryable I/O.
3899    pub async fn retire_committed_recover_release_hint(
3900        &self,
3901        round: &RecoveryRound,
3902        epoch: u64,
3903    ) -> Result<bool, RecoveryControlError> {
3904        round.validate().map_err(RecoveryControlError::Conflict)?;
3905        if round.id.driver != self.instance_id {
3906            return Err(RecoveryControlError::Conflict(
3907                "only the publishing driver may retire its recovery Release hint".into(),
3908            ));
3909        }
3910        if self
3911            .observe_committed_recover_release(round, epoch)
3912            .await?
3913            .is_none()
3914        {
3915            return Ok(false);
3916        }
3917        let pending = RecoveryAnnouncement {
3918            round: round.clone(),
3919            phase: RecoverPhase::Release { epoch },
3920        };
3921        let _guard = self.recovery_writes.lock().await;
3922        let Some(raw) = self
3923            .read_recovery_value(self.instance_id, "control:recover")
3924            .await
3925            .map_err(RecoveryControlError::Uncertain)?
3926        else {
3927            return Ok(false);
3928        };
3929        let active = parse_recovery_announcement(&raw)
3930            .map_err(RecoveryControlError::Conflict)?
3931            .ok_or_else(|| {
3932                RecoveryControlError::Conflict(
3933                    "recovery Release hint decoded as an empty announcement".into(),
3934                )
3935            })?;
3936        if active != pending {
3937            return if active.round.id.generation > round.id.generation {
3938                Err(RecoveryControlError::Superseded(
3939                    "a newer recovery intent replaced the committed Release hint".into(),
3940                ))
3941            } else {
3942                Err(RecoveryControlError::Conflict(
3943                    "mutable recovery intent differs from its committed Release".into(),
3944                ))
3945            };
3946        }
3947        self.write_recovery_value_exact("control:recover", String::new())
3948            .await
3949            .map_err(RecoveryControlError::Uncertain)?;
3950        Ok(true)
3951    }
3952
3953    /// Active nonterminal recovery announcement from the locally elected driver.
3954    ///
3955    /// # Errors
3956    /// Returns a display-stable classified control error.
3957    pub async fn observe_recover(&self) -> Result<Option<RecoveryAnnouncement>, String> {
3958        self.observe_recover_control()
3959            .await
3960            .map_err(|error| error.to_string())
3961    }
3962
3963    /// Whether the round's declared driver is the current elected leader in this local view.
3964    #[must_use]
3965    pub fn recovery_driver_is_current(&self, round: &RecoveryRound) -> bool {
3966        self.current_leader() == Some(round.id.driver)
3967    }
3968
3969    /// Whether the assignment-owner quorum names this exact process, not only its stable node id.
3970    #[must_use]
3971    pub fn recovery_round_contains_current_process(&self, round: &RecoveryRound) -> bool {
3972        round.owner_incarnation(self.instance_id) == Some(self.recovery_incarnation)
3973    }
3974
3975    /// Whether this exact owner or evidence-reporter process must stop for the round's Prepare.
3976    #[must_use]
3977    pub fn recovery_round_requires_current_process_stop(&self, round: &RecoveryRound) -> bool {
3978        round.stopped_participant_incarnation(self.instance_id) == Some(self.recovery_incarnation)
3979    }
3980
3981    /// Ack phase 1 for the exact frozen round.
3982    ///
3983    /// # Errors
3984    /// Returns an error for invalid state or when this node is outside the stopped roster.
3985    pub async fn announce_stopped(
3986        &self,
3987        round: &RecoveryRound,
3988        prepared_witnesses: Vec<PreparedCheckpointWitness>,
3989    ) -> Result<(), String> {
3990        round.validate()?;
3991        if !round.contains_stopped_participant(self.instance_id) {
3992            return Err("node outside recovery stopped roster cannot acknowledge Prepare".into());
3993        }
3994        if !self.recovery_round_requires_current_process_stop(round) {
3995            return Err("Prepare acknowledgement has a stale local process incarnation".into());
3996        }
3997        if !self.recovery_incarnation_is_current().await? {
3998            return Err("Prepare acknowledgement came from a superseded local process".into());
3999        }
4000        let report = RecoveryStoppedReport::new(
4001            round,
4002            CheckpointParticipant {
4003                node_id: self.instance_id.0,
4004                boot_incarnation: self.recovery_incarnation,
4005            },
4006            prepared_witnesses,
4007        )?;
4008        let encoded = encode_recovery_stopped_report(&report, round)?;
4009        self.write_recovery_value_exact(RECOVERY_STOPPED_REPORT_KEY, encoded)
4010            .await
4011    }
4012
4013    /// Point-read only the still-missing members of an exact stopped roster.
4014    ///
4015    /// # Errors
4016    /// Returns a conflict for a noncanonical or out-of-round subset and preserves the same
4017    /// uncertainty/conflict/supersession classification used by recovery quorum polling.
4018    pub async fn read_stopped(
4019        &self,
4020        round: &RecoveryRound,
4021        participants: &[NodeId],
4022    ) -> Result<Vec<RecoveryStoppedReport>, RecoveryControlError> {
4023        if participants.windows(2).any(|pair| pair[0].0 >= pair[1].0)
4024            || participants.iter().any(NodeId::is_unassigned)
4025        {
4026            return Err(RecoveryControlError::Conflict(
4027                "recovery stopped-report subset requires canonical participants".into(),
4028            ));
4029        }
4030        let roster = participants
4031            .iter()
4032            .map(|node| {
4033                round
4034                    .stopped_participant_incarnation(*node)
4035                    .map(|boot_incarnation| CheckpointParticipant {
4036                        node_id: node.0,
4037                        boot_incarnation,
4038                    })
4039                    .ok_or_else(|| {
4040                        RecoveryControlError::Conflict(format!(
4041                            "node {node} is outside the recovery stopped roster"
4042                        ))
4043                    })
4044            })
4045            .collect::<Result<Vec<_>, _>>()?;
4046        self.read_recovery_stopped_reports(round, &roster).await
4047    }
4048
4049    /// Clear only this driver's still-identical recovery announcement. The per-controller lock
4050    /// makes the read/clear conditional with respect to a concurrent local phase transition.
4051    ///
4052    /// # Errors
4053    /// Returns an error when the visible local announcement is malformed.
4054    pub async fn clear_recover(&self, round: &RecoveryRound) -> Result<bool, String> {
4055        let _guard = self.recovery_writes.lock().await;
4056        let Some(raw) = self
4057            .read_recovery_value(self.instance_id, "control:recover")
4058            .await?
4059        else {
4060            return Ok(false);
4061        };
4062        let Some(active) = parse_recovery_announcement(&raw)? else {
4063            return Ok(false);
4064        };
4065        if active.round != *round {
4066            return Ok(false);
4067        }
4068        if matches!(active.phase, RecoverPhase::Release { .. }) {
4069            return Ok(false);
4070        }
4071        self.write_recovery_value_exact("control:recover", String::new())
4072            .await?;
4073        Ok(true)
4074    }
4075
4076    /// Wait until the merged barrier history yields an announcement matching `pred`, or `timeout`
4077    /// expires (→ `Ok(None)`). Observation remains side-effect free; event-time progress must come
4078    /// from immutable checkpoint authority. Push-driven off the gRPC announcement watch when available; gossip-KV-only
4079    /// deployments (and KV-only announcements) are covered by a
4080    /// fallback poll — 250ms with the watch, 25ms without.
4081    ///
4082    /// # Errors
4083    /// Returns the first observation error instead of converting a known protocol or transport
4084    /// failure into a timeout.
4085    #[cfg(feature = "cluster")]
4086    pub async fn wait_for_barrier<F>(
4087        &self,
4088        mut pred: F,
4089        timeout: Duration,
4090    ) -> Result<Option<BarrierAnnouncement>, String>
4091    where
4092        F: FnMut(&BarrierAnnouncement) -> bool,
4093    {
4094        let mut watch = self.barrier.announcement_watch();
4095        // Recomputed per iteration: when the watch sender drops
4096        // mid-wait, the fallback must tighten to the no-watch cadence.
4097        let poll_for = |watch: &Option<_>| {
4098            if watch.is_some() {
4099                Duration::from_millis(250)
4100            } else {
4101                Duration::from_millis(25)
4102            }
4103        };
4104        let deadline = tokio::time::Instant::now() + timeout;
4105        loop {
4106            if let Some(ann) = self.observe_barrier_matching(&mut pred).await? {
4107                return Ok(Some(ann));
4108            }
4109            if tokio::time::Instant::now() >= deadline {
4110                return Ok(None);
4111            }
4112            let poll = poll_for(&watch);
4113            let pushed = async {
4114                match watch.as_mut() {
4115                    Some(w) => w.changed().await.is_ok(),
4116                    None => std::future::pending().await,
4117                }
4118            };
4119            tokio::select! {
4120                ok = pushed => {
4121                    if !ok {
4122                        // Sender gone (server shutdown) — degrade to
4123                        // polling instead of spinning on the error.
4124                        watch = None;
4125                    }
4126                }
4127                () = tokio::time::sleep(poll) => {}
4128                () = tokio::time::sleep_until(deadline) => return Ok(None),
4129            }
4130        }
4131    }
4132
4133    /// Leader-side: poll until quorum or `deadline`.
4134    pub async fn wait_for_quorum(
4135        &self,
4136        prepare: &BarrierAnnouncement,
4137        expected: &[NodeId],
4138        deadline: Duration,
4139    ) -> QuorumOutcome {
4140        self.barrier
4141            .wait_for_quorum(prepare, expected, deadline)
4142            .await
4143    }
4144
4145    /// Assignment snapshot store, if configured.
4146    #[must_use]
4147    pub fn snapshot_store(&self) -> Option<&AssignmentSnapshotStore> {
4148        self.snapshot.as_deref()
4149    }
4150}
4151
4152#[cfg(test)]
4153mod tests;