Skip to main content

laminar_core/
checkpoint_decision.rs

1//! Durable checkpoint identity, ID allocation, and immutable terminal outcomes.
2
3#[cfg(feature = "cluster")]
4use std::collections::BinaryHeap;
5use std::path::{Path as FsPath, PathBuf};
6use std::sync::{Arc, OnceLock, Weak};
7
8use bytes::{Bytes, BytesMut};
9use futures::StreamExt;
10use object_store::path::Path as OsPath;
11use object_store::{
12    GetOptions, GetRange, GetResult, ObjectStore, ObjectStoreExt, PutMode, PutOptions, PutPayload,
13    UpdateVersion,
14};
15#[cfg(feature = "cluster")]
16use sha2::{Digest, Sha256};
17
18use crate::checkpoint::{
19    CheckpointAssignmentFence, ClusterRecoveryCapsule, LeaderProof, PipelineIdentity,
20    RecoveryCapsuleRef,
21};
22use crate::state::CheckpointAttempt;
23
24/// Durable checkpoint metadata store.
25pub struct CheckpointDecisionStore {
26    store: Arc<dyn ObjectStore>,
27    update_mode: DecisionStoreUpdateMode,
28    /// Serializes deployment creation and checkpoint-ID allocation from this instance.
29    metadata_write_lock: tokio::sync::Mutex<()>,
30    /// Serializes local read/compare/overwrite transitions across every store instance that owns
31    /// this namespace. Shared stores use native object-store CAS instead.
32    local_metadata_rmw_lock: Option<Arc<tokio::sync::Mutex<()>>>,
33    /// Last checkpoint-ID head observed by this instance. Shared-store CAS detects stale entries.
34    checkpoint_id_head: parking_lot::Mutex<Option<VersionedCheckpointIdHead>>,
35    /// Active immutable reservation block for the certified local single writer.
36    local_reservation: parking_lot::Mutex<LocalReservationState>,
37    deployment_id: tokio::sync::OnceCell<String>,
38}
39
40#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42enum DecisionStoreUpdateMode {
43    NativeCas,
44    LocalSingleWriter,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48enum LocalMetadataNamespace {
49    /// Generic local stores share an authority by cloning the same `Arc<dyn ObjectStore>`.
50    #[cfg(test)]
51    StoreAuthority(usize),
52    /// Filesystem constructors can identify independently opened stores by canonical root.
53    Filesystem(PathBuf),
54}
55
56fn shared_local_metadata_rmw_lock(
57    namespace: LocalMetadataNamespace,
58) -> Arc<tokio::sync::Mutex<()>> {
59    static LOCKS: OnceLock<
60        parking_lot::Mutex<
61            rustc_hash::FxHashMap<LocalMetadataNamespace, Weak<tokio::sync::Mutex<()>>>,
62        >,
63    > = OnceLock::new();
64
65    let mut locks = LOCKS
66        .get_or_init(|| parking_lot::Mutex::new(rustc_hash::FxHashMap::default()))
67        .lock();
68    locks.retain(|_, lock| lock.strong_count() != 0);
69    if let Some(lock) = locks.get(&namespace).and_then(Weak::upgrade) {
70        return lock;
71    }
72    let lock = Arc::new(tokio::sync::Mutex::new(()));
73    locks.insert(namespace, Arc::downgrade(&lock));
74    lock
75}
76
77impl std::fmt::Debug for CheckpointDecisionStore {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("CheckpointDecisionStore")
80            .field("update_mode", &self.update_mode)
81            .finish_non_exhaustive()
82    }
83}
84
85/// Errors raised by [`CheckpointDecisionStore`] operations.
86#[derive(Debug, thiserror::Error)]
87pub enum DecisionError {
88    /// Underlying object-store I/O failure.
89    #[error("object store I/O: {0}")]
90    Io(String),
91    /// A persisted decision is malformed or conflicts with the requested cut.
92    #[error("checkpoint decision conflict: {0}")]
93    Conflict(String),
94    /// The immutable inventory changed while it was being enumerated, normally because GC
95    /// retired a resolved pair. Callers may retry the complete audit from a fresh LIST.
96    #[error("checkpoint decision inventory changed during audit: {0}")]
97    InventoryChanged(String),
98}
99
100/// Runtime scope of a durable checkpoint outcome.
101#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "snake_case")]
103pub enum CheckpointScope {
104    /// Embedded or standalone recovery domain.
105    Local,
106    /// Multi-participant cluster recovery domain.
107    Cluster,
108}
109
110/// Irrevocable terminal verdict for one concrete checkpoint attempt.
111#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
112#[serde(rename_all = "snake_case")]
113pub enum CheckpointVerdict {
114    /// Every participant prepared successfully and recovery must finish commit.
115    Commit,
116    /// Recovery must roll back this exact checkpoint attempt.
117    Abort,
118}
119
120/// Single create-once terminal outcome for one epoch.
121#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
122pub struct CheckpointOutcome {
123    /// Outcome payload format.
124    pub version: u32,
125    /// Recovery domain that created the outcome.
126    pub scope: CheckpointScope,
127    /// Terminal epoch, matching the object path.
128    pub epoch: u64,
129    /// Exact checkpoint attempt resolved for the epoch.
130    pub checkpoint_id: u64,
131    /// Durable deployment incarnation that owns the outcome.
132    pub deployment_id: String,
133    /// Exact assignment certificate for a cluster outcome.
134    pub assignment_fence: Option<CheckpointAssignmentFence>,
135    /// Exact leader authority that selected a cluster outcome.
136    pub leader_proof: Option<LeaderProof>,
137    /// Exact global recovery image selected by a cluster Commit.
138    pub recovery_capsule: Option<RecoveryCapsuleRef>,
139    /// Create-once terminal verdict.
140    pub verdict: CheckpointVerdict,
141}
142
143/// Result of attempting to create a terminal checkpoint outcome.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum RecordOutcomeResult {
146    /// This call durably created the outcome.
147    Created(CheckpointOutcome),
148    /// An identical outcome was already durable.
149    Unchanged(CheckpointOutcome),
150    /// A different outcome won the create-once race for the epoch.
151    Conflict {
152        /// Durable winner that callers must obey.
153        winner: CheckpointOutcome,
154    },
155}
156
157/// Scalar continuity boundary for outcome retention.
158///
159/// This is not a checkpoint recovery target: it deliberately carries neither an assignment fence,
160/// leader proof, verdict, nor manifest owner.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct OutcomeRetentionBoundary {
163    /// Outcomes below this epoch are continuity-only and their raw records may be absent.
164    pub before_epoch: u64,
165    /// Greatest committed checkpoint ID compacted below the horizon, if any.
166    pub committed_checkpoint_id: Option<u64>,
167    /// Greatest terminal epoch compacted below the horizon, including aborts.
168    pub highest_closed_epoch: Option<u64>,
169}
170
171const CHECKPOINT_OUTCOME_VERSION: u32 = 2;
172const CHECKPOINT_OUTCOME_MAX_BYTES: u64 = 64 * 1_024;
173const OUTCOME_GC_FLOOR_MAX_BYTES: u64 = 256 * 1_024;
174#[cfg(feature = "cluster")]
175const RECOVERY_CAPSULE_GC_BATCH_SIZE: usize = 64;
176#[cfg(feature = "cluster")]
177const RECOVERY_CAPSULE_GC_CURSOR_MAX_BYTES: u64 = 1_024;
178
179impl CheckpointOutcome {
180    pub(crate) fn validate_shape(&self, path_epoch: u64) -> Result<(), DecisionError> {
181        if self.version != CHECKPOINT_OUTCOME_VERSION {
182            return Err(DecisionError::Conflict(format!(
183                "outcome for epoch {path_epoch} has unsupported version {}; expected \
184                 {CHECKPOINT_OUTCOME_VERSION}",
185                self.version
186            )));
187        }
188        if self.epoch == 0 || self.epoch != path_epoch {
189            return Err(DecisionError::Conflict(format!(
190                "outcome path epoch {path_epoch} does not match non-zero payload epoch {}",
191                self.epoch
192            )));
193        }
194        if self.checkpoint_id == 0 {
195            return Err(DecisionError::Conflict(format!(
196                "outcome for epoch {path_epoch} has checkpoint ID 0"
197            )));
198        }
199        if self.epoch != self.checkpoint_id {
200            return Err(DecisionError::Conflict(format!(
201                "outcome for epoch {path_epoch} has non-canonical checkpoint ID {}; runtime \
202                 outcomes require epoch == checkpoint ID",
203                self.checkpoint_id
204            )));
205        }
206
207        let deployment = uuid::Uuid::parse_str(&self.deployment_id).map_err(|error| {
208            DecisionError::Conflict(format!(
209                "outcome for epoch {path_epoch} has invalid deployment identity: {error}"
210            ))
211        })?;
212        if deployment.is_nil() || deployment.to_string() != self.deployment_id {
213            return Err(DecisionError::Conflict(format!(
214                "outcome for epoch {path_epoch} must use a canonical non-nil deployment identity"
215            )));
216        }
217
218        match (
219            self.scope,
220            self.assignment_fence.as_ref(),
221            self.leader_proof.as_ref(),
222        ) {
223            (CheckpointScope::Local, None, None) => {}
224            (CheckpointScope::Local, _, _) => {
225                return Err(DecisionError::Conflict(format!(
226                    "local outcome for epoch {path_epoch} cannot carry an assignment fence or \
227                     leader proof"
228                )));
229            }
230            (CheckpointScope::Cluster, Some(fence), Some(proof))
231                if fence.is_canonical()
232                    && proof.is_canonical()
233                    && fence.participant_incarnation(proof.owner.node_id)
234                        == Some(proof.owner.boot_id) => {}
235            (CheckpointScope::Cluster, Some(fence), Some(proof))
236                if !fence.is_canonical() || !proof.is_canonical() =>
237            {
238                return Err(DecisionError::Conflict(format!(
239                    "cluster outcome for epoch {path_epoch} has a non-canonical assignment fence \
240                     or leader proof"
241                )));
242            }
243            (CheckpointScope::Cluster, Some(_), Some(proof)) => {
244                return Err(DecisionError::Conflict(format!(
245                    "cluster outcome for epoch {path_epoch} leader node {} boot {} is absent from \
246                     the assignment fence",
247                    proof.owner.node_id, proof.owner.boot_id
248                )));
249            }
250            (CheckpointScope::Cluster, _, _) => {
251                return Err(DecisionError::Conflict(format!(
252                    "cluster outcome for epoch {path_epoch} requires an assignment fence and \
253                     leader proof"
254                )));
255            }
256        }
257
258        match (self.scope, &self.verdict, self.recovery_capsule.as_ref()) {
259            (CheckpointScope::Local, _, None)
260            | (CheckpointScope::Cluster, CheckpointVerdict::Abort, None) => {}
261            (CheckpointScope::Cluster, CheckpointVerdict::Commit, Some(reference)) => {
262                reference.validate().map_err(|error| {
263                    DecisionError::Conflict(format!(
264                        "cluster commit outcome for epoch {path_epoch} has an invalid recovery capsule reference: {error}"
265                    ))
266                })?;
267            }
268            (CheckpointScope::Local, _, Some(_)) => {
269                return Err(DecisionError::Conflict(format!(
270                    "local outcome for epoch {path_epoch} cannot carry a recovery capsule"
271                )));
272            }
273            (CheckpointScope::Cluster, CheckpointVerdict::Commit, None) => {
274                return Err(DecisionError::Conflict(format!(
275                    "cluster commit outcome for epoch {path_epoch} requires a recovery capsule"
276                )));
277            }
278            (CheckpointScope::Cluster, CheckpointVerdict::Abort, Some(_)) => {
279                return Err(DecisionError::Conflict(format!(
280                    "cluster abort outcome for epoch {path_epoch} cannot carry a recovery capsule"
281                )));
282            }
283        }
284        Ok(())
285    }
286
287    /// Whether this outcome irrevocably selected commit.
288    #[must_use]
289    pub fn is_commit(&self) -> bool {
290        matches!(self.verdict, CheckpointVerdict::Commit)
291    }
292}
293
294/// Durable checkpoint namespace identity and its shared-store allocation head.
295///
296/// `id` never changes. Deleting this single authority creates a new deployment identity before
297/// checkpoint IDs restart, so surviving external sinks cannot confuse the new sequence with the
298/// previous writer. `allocation_id` identifies the last CAS proposal for lost-response recovery.
299#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
300#[serde(deny_unknown_fields)]
301struct DeploymentIdentity {
302    version: u32,
303    id: String,
304    allocator_mode: DecisionStoreUpdateMode,
305    checkpoint_id: u64,
306    allocation_id: String,
307}
308
309const DEPLOYMENT_IDENTITY_VERSION: u32 = 2;
310const DEPLOYMENT_IDENTITY_MAX_BYTES: u64 = 1_024;
311
312/// Durable proof that every named checkpoint-committable sink may have opened this attempt.
313///
314/// The witness is created before any external begin call and remains live until the exact attempt
315/// reaches a terminal outcome or every named sink confirms rollback. Recovery must reconcile the
316/// attempt before opening a later one.
317#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
318#[serde(deny_unknown_fields)]
319pub struct CheckpointSinkOpenWitness {
320    version: u32,
321    /// Durable deployment incarnation that owns this witness.
322    pub deployment_id: String,
323    /// Logical pipeline and recovery-state ABI identity.
324    pub pipeline_identity: PipelineIdentity,
325    /// Runtime participant that owns the named sink handles (`0` in embedded/local mode).
326    pub participant_id: u64,
327    /// Exact canonical attempt that may be externally open.
328    pub attempt: CheckpointAttempt,
329    /// Canonically sorted, unique checkpoint-committable sink names.
330    pub committable_sinks: Vec<String>,
331    /// Unique create proposal used to reconcile an ambiguous object-store response.
332    create_token: String,
333}
334
335const CHECKPOINT_SINK_OPEN_WITNESS_VERSION: u32 = 1;
336const CHECKPOINT_SINK_OPEN_WITNESS_MAX_BYTES: u64 = 64 * 1_024;
337const CHECKPOINT_SINK_OPEN_WITNESS_MAX_SINKS: usize = 1_024;
338
339/// Versioned singleton that never returns to an absent state after its first open transition.
340#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
341#[serde(deny_unknown_fields)]
342struct CheckpointSinkOpenWitnessSlot {
343    version: u32,
344    state: CheckpointSinkOpenWitnessSlotState,
345}
346
347#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
348#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
349enum CheckpointSinkOpenWitnessSlotState {
350    Open {
351        witness: CheckpointSinkOpenWitness,
352    },
353    Closed {
354        witness: CheckpointSinkOpenWitness,
355        close_token: String,
356    },
357}
358
359impl CheckpointSinkOpenWitnessSlot {
360    fn open(witness: CheckpointSinkOpenWitness) -> Self {
361        Self {
362            version: CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION,
363            state: CheckpointSinkOpenWitnessSlotState::Open { witness },
364        }
365    }
366
367    fn closed(witness: CheckpointSinkOpenWitness) -> Self {
368        Self {
369            version: CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION,
370            state: CheckpointSinkOpenWitnessSlotState::Closed {
371                witness,
372                close_token: uuid::Uuid::now_v7().to_string(),
373            },
374        }
375    }
376
377    const fn witness(&self) -> &CheckpointSinkOpenWitness {
378        match &self.state {
379            CheckpointSinkOpenWitnessSlotState::Open { witness }
380            | CheckpointSinkOpenWitnessSlotState::Closed { witness, .. } => witness,
381        }
382    }
383}
384
385const CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION: u32 = 1;
386
387#[derive(Debug)]
388struct VersionedCheckpointSinkOpenWitnessSlot {
389    slot: CheckpointSinkOpenWitnessSlot,
390    update_version: UpdateVersion,
391}
392
393#[derive(Debug, Clone)]
394struct VersionedCheckpointIdHead {
395    head: DeploymentIdentity,
396    update_version: UpdateVersion,
397}
398
399/// In-memory cursor within a durable immutable local ID block. A restarted process always claims
400/// a later block and therefore burns any IDs the previous process did not consume.
401#[derive(Debug, Default)]
402struct LocalReservationState {
403    initialized: bool,
404    highest_block: Option<u64>,
405    next_id: Option<u64>,
406    block_end: u64,
407}
408
409const LOCAL_RESERVATION_BLOCK_SIZE: u64 = 65_536;
410
411/// Monotonic tombstone for terminal outcomes below `before_epoch`.
412///
413/// The terminal and committed anchors are continuity metadata only. Recovery must select a live
414/// commit outcome at or above the floor rather than treating an anchor whose checkpoint artifacts
415/// may have been deleted as a recovery cut. The canonical object is advanced with compare-and-swap
416/// so concurrent retention workers cannot regress either the horizon or its anchors.
417#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
418struct OutcomeGcFloor {
419    version: u32,
420    deployment_id: String,
421    before_epoch: u64,
422    terminal_anchor: Option<CheckpointOutcome>,
423    committed_anchor: Option<CheckpointOutcome>,
424}
425
426const OUTCOME_GC_FLOOR_VERSION: u32 = 4;
427
428#[derive(Debug)]
429struct VersionedOutcomeGcFloor {
430    floor: OutcomeGcFloor,
431    update_version: UpdateVersion,
432}
433
434#[cfg(feature = "cluster")]
435#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
436#[serde(deny_unknown_fields)]
437struct RecoveryCapsuleGcCursor {
438    version: u32,
439    deployment_id: String,
440    /// Last path examined in the current pass. `None` starts a new pass from the prefix root.
441    offset: Option<String>,
442}
443
444#[cfg(feature = "cluster")]
445const RECOVERY_CAPSULE_GC_CURSOR_VERSION: u32 = 1;
446
447#[cfg(feature = "cluster")]
448#[derive(Debug)]
449struct RecoveryCapsuleListCandidate {
450    location: String,
451    meta: object_store::ObjectMeta,
452}
453
454#[cfg(feature = "cluster")]
455enum RecoveryCapsuleObjectWork {
456    Retained,
457    Deleted,
458    Quarantined,
459    Failed,
460}
461
462#[cfg(feature = "cluster")]
463impl PartialEq for RecoveryCapsuleListCandidate {
464    fn eq(&self, other: &Self) -> bool {
465        self.location == other.location
466    }
467}
468
469#[cfg(feature = "cluster")]
470impl Eq for RecoveryCapsuleListCandidate {}
471
472#[cfg(feature = "cluster")]
473impl PartialOrd for RecoveryCapsuleListCandidate {
474    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
475        Some(self.cmp(other))
476    }
477}
478
479#[cfg(feature = "cluster")]
480impl Ord for RecoveryCapsuleListCandidate {
481    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
482        self.location.cmp(&other.location)
483    }
484}
485
486#[cfg(feature = "cluster")]
487fn retain_lexically_oldest_recovery_capsule(
488    oldest: &mut BinaryHeap<RecoveryCapsuleListCandidate>,
489    meta: object_store::ObjectMeta,
490) {
491    let candidate = RecoveryCapsuleListCandidate {
492        location: meta.location.to_string(),
493        meta,
494    };
495    if oldest.len() < RECOVERY_CAPSULE_GC_BATCH_SIZE {
496        oldest.push(candidate);
497    } else if oldest.peek().is_some_and(|largest| &candidate < largest) {
498        oldest.pop();
499        oldest.push(candidate);
500    }
501}
502
503#[cfg(feature = "cluster")]
504#[derive(Debug)]
505struct VersionedRecoveryCapsuleGcCursor {
506    cursor: RecoveryCapsuleGcCursor,
507    update_version: UpdateVersion,
508}
509
510/// Result of one bounded cluster recovery-capsule maintenance step.
511#[cfg(feature = "cluster")]
512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
513pub struct RecoveryCapsuleGcStep {
514    /// Number of active-prefix entries selected and processed in this step.
515    pub examined: usize,
516    /// Number of old unreferenced capsules deleted.
517    pub deleted: usize,
518    /// Number of malformed or corrupt objects moved out of the active prefix.
519    pub quarantined: usize,
520    /// Whether an existing floor requires periodic rescans for delayed or previously failed work.
521    pub pending: bool,
522}
523
524impl CheckpointDecisionStore {
525    fn ensure_control_record_size(
526        record: &str,
527        size: u64,
528        maximum: u64,
529    ) -> Result<(), DecisionError> {
530        if size > maximum {
531            return Err(DecisionError::Conflict(format!(
532                "{record} is {size} bytes; maximum is {maximum}"
533            )));
534        }
535        Ok(())
536    }
537
538    fn encode_control_record<T: serde::Serialize>(
539        record: &str,
540        value: &T,
541        maximum: u64,
542    ) -> Result<Bytes, DecisionError> {
543        let payload = serde_json::to_vec(value)
544            .map(Bytes::from)
545            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
546        let size = u64::try_from(payload.len()).unwrap_or(u64::MAX);
547        Self::ensure_control_record_size(record, size, maximum)?;
548        Ok(payload)
549    }
550
551    async fn read_control_record_bytes(
552        result: GetResult,
553        record: &str,
554        maximum: u64,
555        expected_size: Option<u64>,
556    ) -> Result<Bytes, DecisionError> {
557        Self::ensure_control_record_size(record, result.meta.size, maximum)?;
558        if let Some(expected_size) = expected_size {
559            if result.meta.size != expected_size {
560                return Err(DecisionError::Conflict(format!(
561                    "{record} is {} bytes, expected {expected_size}",
562                    result.meta.size
563                )));
564            }
565        }
566        let metadata_size = result.meta.size;
567        if result.range.start != 0 || result.range.end != metadata_size {
568            return Err(DecisionError::Conflict(format!(
569                "{record} returned byte range {}..{}, expected 0..{metadata_size}",
570                result.range.start, result.range.end
571            )));
572        }
573        let capacity = usize::try_from(metadata_size).map_err(|_| {
574            DecisionError::Conflict(format!(
575                "{record} length {metadata_size} exceeds this process address space"
576            ))
577        })?;
578        let mut bytes = BytesMut::with_capacity(capacity);
579        let mut stream = result.into_stream();
580        while let Some(chunk) = stream.next().await {
581            let chunk = chunk.map_err(|error| DecisionError::Io(error.to_string()))?;
582            let next_len = bytes.len().checked_add(chunk.len()).ok_or_else(|| {
583                DecisionError::Conflict(format!("{record} payload length overflow"))
584            })?;
585            if next_len > capacity {
586                return Err(DecisionError::Conflict(format!(
587                    "{record} payload exceeded its advertised {metadata_size}-byte length"
588                )));
589            }
590            bytes.extend_from_slice(&chunk);
591        }
592        if bytes.len() != capacity {
593            return Err(DecisionError::Conflict(format!(
594                "{record} payload length changed while reading"
595            )));
596        }
597        Ok(bytes.freeze())
598    }
599
600    async fn get_control_record(
601        &self,
602        path: &OsPath,
603        record: &str,
604        maximum: u64,
605    ) -> Result<Option<GetResult>, DecisionError> {
606        let request_end = maximum.checked_add(1).ok_or_else(|| {
607            DecisionError::Conflict(format!("{record} maximum cannot be range-bounded"))
608        })?;
609        let result = match self
610            .store
611            .get_opts(
612                path,
613                GetOptions {
614                    range: Some(GetRange::Bounded(0..request_end)),
615                    ..GetOptions::default()
616                },
617            )
618            .await
619        {
620            Ok(result) => result,
621            Err(object_store::Error::NotFound { .. }) => return Ok(None),
622            Err(error) => return Err(DecisionError::Io(error.to_string())),
623        };
624        Self::ensure_control_record_size(record, result.meta.size, maximum)?;
625        if result.range.start != 0 || result.range.end != result.meta.size {
626            return Err(DecisionError::Conflict(format!(
627                "{record} returned byte range {}..{}, inconsistent with advertised size {}",
628                result.range.start, result.range.end, result.meta.size
629            )));
630        }
631        Ok(Some(result))
632    }
633
634    fn require_native_cas_token(
635        &self,
636        record: &str,
637        update_version: &UpdateVersion,
638    ) -> Result<(), DecisionError> {
639        if self.update_mode == DecisionStoreUpdateMode::NativeCas
640            && update_version.e_tag.is_none()
641            && update_version.version.is_none()
642        {
643            return Err(DecisionError::Conflict(format!(
644                "shared {record} has neither an ETag nor an object version for CAS"
645            )));
646        }
647        Ok(())
648    }
649
650    /// Wrap shared storage that must provide native conditional updates.
651    #[must_use]
652    pub fn new(store: Arc<dyn ObjectStore>) -> Self {
653        Self::with_update_mode(store, DecisionStoreUpdateMode::NativeCas, None)
654    }
655
656    #[cfg(test)]
657    fn local_single_writer(store: Arc<dyn ObjectStore>) -> Self {
658        let authority = Arc::as_ptr(&store).cast::<()>() as usize;
659        let lock =
660            shared_local_metadata_rmw_lock(LocalMetadataNamespace::StoreAuthority(authority));
661        Self::with_update_mode(
662            store,
663            DecisionStoreUpdateMode::LocalSingleWriter,
664            Some(lock),
665        )
666    }
667
668    /// Open crash-durable checkpoint metadata in a caller-owned local directory.
669    /// The caller must retain its exclusive namespace lease for the store's write lifetime.
670    ///
671    /// # Errors
672    /// Returns an I/O error when the directory cannot be created, synchronized, or opened.
673    pub fn local_filesystem(root: impl AsRef<FsPath>) -> Result<Self, DecisionError> {
674        let root = root.as_ref();
675        let store: Arc<dyn ObjectStore> = Arc::new(
676            crate::durable_local_store::DurableLocalObjectStore::new(root)
677                .map_err(|error| DecisionError::Io(error.to_string()))?,
678        );
679        let canonical_root =
680            std::fs::canonicalize(root).map_err(|error| DecisionError::Io(error.to_string()))?;
681        let lock =
682            shared_local_metadata_rmw_lock(LocalMetadataNamespace::Filesystem(canonical_root));
683        Ok(Self::with_update_mode(
684            store,
685            DecisionStoreUpdateMode::LocalSingleWriter,
686            Some(lock),
687        ))
688    }
689
690    fn with_update_mode(
691        store: Arc<dyn ObjectStore>,
692        update_mode: DecisionStoreUpdateMode,
693        local_metadata_rmw_lock: Option<Arc<tokio::sync::Mutex<()>>>,
694    ) -> Self {
695        debug_assert_eq!(
696            update_mode == DecisionStoreUpdateMode::LocalSingleWriter,
697            local_metadata_rmw_lock.is_some()
698        );
699        Self {
700            store,
701            update_mode,
702            metadata_write_lock: tokio::sync::Mutex::new(()),
703            local_metadata_rmw_lock,
704            checkpoint_id_head: parking_lot::Mutex::new(None),
705            local_reservation: parking_lot::Mutex::new(LocalReservationState::default()),
706            deployment_id: tokio::sync::OnceCell::new(),
707        }
708    }
709
710    fn outcome_root() -> OsPath {
711        OsPath::from("checkpoint-outcomes/")
712    }
713
714    fn outcome_path(epoch: u64) -> OsPath {
715        OsPath::from(format!("checkpoint-outcomes/epoch={epoch}/outcome"))
716    }
717
718    fn recovery_capsule_path(reference: &RecoveryCapsuleRef) -> OsPath {
719        OsPath::from(format!(
720            "checkpoint-recovery-capsules/epoch={:020}/checkpoint={:020}/sha256={}",
721            reference.epoch, reference.checkpoint_id, reference.sha256
722        ))
723    }
724
725    #[cfg(feature = "cluster")]
726    fn recovery_capsule_root() -> OsPath {
727        OsPath::from("checkpoint-recovery-capsules/")
728    }
729
730    #[cfg(feature = "cluster")]
731    fn recovery_capsule_coordinates_from_path(location: &str) -> Option<(u64, u64, &str)> {
732        let suffix = location.strip_prefix("checkpoint-recovery-capsules/epoch=")?;
733        let (epoch, suffix) = suffix.split_once("/checkpoint=")?;
734        let (checkpoint_id, digest) = suffix.split_once("/sha256=")?;
735        if epoch.len() != 20
736            || checkpoint_id.len() != 20
737            || digest.len() != 64
738            || digest.contains('/')
739        {
740            return None;
741        }
742        Some((epoch.parse().ok()?, checkpoint_id.parse().ok()?, digest))
743    }
744
745    #[cfg(feature = "cluster")]
746    fn recovery_capsule_gc_cursor_path(deployment_id: &str) -> OsPath {
747        OsPath::from(format!(
748            "checkpoint-recovery-capsule-gc/deployment={deployment_id}/cursor"
749        ))
750    }
751
752    #[cfg(feature = "cluster")]
753    fn recovery_capsule_quarantine_path(location: &str) -> OsPath {
754        let digest = format!("{:x}", Sha256::digest(location.as_bytes()));
755        OsPath::from(format!(
756            "checkpoint-recovery-capsule-quarantine/path-sha256={digest}"
757        ))
758    }
759
760    fn outcome_gc_floor_path(deployment_id: &str) -> OsPath {
761        OsPath::from(format!(
762            "checkpoint-outcome-gc/deployment={deployment_id}/floor"
763        ))
764    }
765
766    fn local_reservation_root() -> OsPath {
767        OsPath::from("checkpoint-id-blocks/")
768    }
769
770    fn local_reservation_path(block: u64) -> OsPath {
771        OsPath::from(format!("checkpoint-id-blocks/block={block:020}"))
772    }
773
774    fn deployment_identity_path() -> OsPath {
775        OsPath::from("checkpoint-deployment/identity.json")
776    }
777
778    fn sink_open_witness_path() -> OsPath {
779        OsPath::from("checkpoint-sink-open-witness/witness.json")
780    }
781
782    #[cfg(feature = "cluster")]
783    async fn read_recovery_capsule_gc_cursor(
784        &self,
785        deployment_id: &str,
786    ) -> Result<Option<VersionedRecoveryCapsuleGcCursor>, DecisionError> {
787        let path = Self::recovery_capsule_gc_cursor_path(deployment_id);
788        let Some(result) = self
789            .get_control_record(
790                &path,
791                "recovery capsule GC cursor",
792                RECOVERY_CAPSULE_GC_CURSOR_MAX_BYTES,
793            )
794            .await?
795        else {
796            return Ok(None);
797        };
798        let update_version = UpdateVersion {
799            e_tag: result.meta.e_tag.clone(),
800            version: result.meta.version.clone(),
801        };
802        self.require_native_cas_token("recovery capsule GC cursor", &update_version)?;
803        let bytes = Self::read_control_record_bytes(
804            result,
805            "recovery capsule GC cursor",
806            RECOVERY_CAPSULE_GC_CURSOR_MAX_BYTES,
807            None,
808        )
809        .await?;
810        let cursor: RecoveryCapsuleGcCursor = serde_json::from_slice(&bytes).map_err(|error| {
811            DecisionError::Conflict(format!("recovery capsule GC cursor: {error}"))
812        })?;
813        if cursor.version != RECOVERY_CAPSULE_GC_CURSOR_VERSION
814            || cursor.deployment_id != deployment_id
815            || cursor.offset.as_ref().is_some_and(|offset| {
816                !offset.starts_with("checkpoint-recovery-capsules/")
817                    || OsPath::parse(offset).is_err()
818            })
819        {
820            return Err(DecisionError::Conflict(
821                "recovery capsule GC cursor does not match its path, deployment, or progress"
822                    .into(),
823            ));
824        }
825        let canonical = serde_json::to_vec(&cursor)
826            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
827        if canonical.as_slice() != bytes.as_ref() {
828            return Err(DecisionError::Conflict(
829                "recovery capsule GC cursor does not use its canonical body".into(),
830            ));
831        }
832        Ok(Some(VersionedRecoveryCapsuleGcCursor {
833            cursor,
834            update_version,
835        }))
836    }
837
838    #[cfg(feature = "cluster")]
839    async fn compare_and_swap_recovery_capsule_gc_cursor(
840        &self,
841        cursor: &RecoveryCapsuleGcCursor,
842        expected: Option<UpdateVersion>,
843    ) -> Result<bool, DecisionError> {
844        let path = Self::recovery_capsule_gc_cursor_path(&cursor.deployment_id);
845        let payload = Self::encode_control_record(
846            "recovery capsule GC cursor",
847            cursor,
848            RECOVERY_CAPSULE_GC_CURSOR_MAX_BYTES,
849        )?;
850        let options = PutOptions {
851            mode: expected.map_or(PutMode::Create, PutMode::Update),
852            ..PutOptions::default()
853        };
854        match self
855            .store
856            .put_opts(&path, PutPayload::from(payload), options)
857            .await
858        {
859            Ok(_) => Ok(true),
860            Err(
861                object_store::Error::Precondition { .. }
862                | object_store::Error::AlreadyExists { .. }
863                | object_store::Error::NotFound { .. },
864            ) => Ok(false),
865            Err(error) => match self
866                .read_recovery_capsule_gc_cursor(&cursor.deployment_id)
867                .await?
868            {
869                Some(current) if current.cursor == *cursor => Ok(true),
870                _ => Err(DecisionError::Io(error.to_string())),
871            },
872        }
873    }
874
875    /// Create the canonical content-addressed body for a cluster recovery capsule.
876    ///
877    /// Identical retries converge on the existing immutable body. The returned reference is safe
878    /// to publish only after this method succeeds.
879    ///
880    /// # Errors
881    /// Object-store I/O, malformed capsule content, deployment mismatch, or a conflicting body.
882    pub async fn create_recovery_capsule(
883        &self,
884        capsule: &ClusterRecoveryCapsule,
885    ) -> Result<RecoveryCapsuleRef, DecisionError> {
886        let (encoded, reference) = capsule
887            .encode_and_reference()
888            .map_err(DecisionError::Conflict)?;
889        let deployment_id = self.load_or_create_deployment_id().await?;
890        if capsule.deployment_id != deployment_id {
891            return Err(DecisionError::Conflict(format!(
892                "recovery capsule belongs to deployment {}, current deployment is {deployment_id}",
893                capsule.deployment_id
894            )));
895        }
896
897        let path = Self::recovery_capsule_path(&reference);
898        let options = PutOptions {
899            mode: PutMode::Create,
900            ..PutOptions::default()
901        };
902        match self
903            .store
904            .put_opts(&path, PutPayload::from(Bytes::from(encoded)), options)
905            .await
906        {
907            Ok(_) => Ok(reference),
908            Err(put_error) => match self.load_recovery_capsule(&reference).await {
909                Ok(stored) if stored == *capsule => Ok(reference),
910                Ok(_) => Err(DecisionError::Conflict(format!(
911                    "recovery capsule '{}' differs from the proposed content",
912                    reference.sha256
913                ))),
914                Err(reconcile_error) => Err(DecisionError::Io(format!(
915                    "recovery capsule write failed ({put_error}); reconciliation failed ({reconcile_error})"
916                ))),
917            },
918        }
919    }
920
921    /// Load and verify one exact content-addressed recovery capsule.
922    ///
923    /// Verification covers the object path, recorded and observed lengths, canonical JSON body,
924    /// SHA-256 reference, deployment identity, and capsule-internal invariants.
925    ///
926    /// # Errors
927    /// Object-store I/O, a missing object, malformed content, or any reference mismatch.
928    pub async fn load_recovery_capsule(
929        &self,
930        reference: &RecoveryCapsuleRef,
931    ) -> Result<ClusterRecoveryCapsule, DecisionError> {
932        reference.validate().map_err(DecisionError::Conflict)?;
933        let record = format!("recovery capsule '{}'", reference.sha256);
934        let result = self
935            .get_control_record(
936                &Self::recovery_capsule_path(reference),
937                &record,
938                u64::try_from(crate::checkpoint::MAX_RECOVERY_CAPSULE_BYTES).unwrap_or(u64::MAX),
939            )
940            .await?
941            .ok_or_else(|| {
942                DecisionError::Conflict(format!(
943                    "recovery capsule '{}' is missing",
944                    reference.sha256
945                ))
946            })?;
947        let bytes = Self::read_control_record_bytes(
948            result,
949            &record,
950            u64::try_from(crate::checkpoint::MAX_RECOVERY_CAPSULE_BYTES).unwrap_or(u64::MAX),
951            Some(reference.len),
952        )
953        .await?;
954        let capsule: ClusterRecoveryCapsule = serde_json::from_slice(&bytes).map_err(|error| {
955            DecisionError::Conflict(format!("recovery capsule '{}': {error}", reference.sha256))
956        })?;
957        let (canonical, actual_reference) = capsule
958            .encode_and_reference()
959            .map_err(DecisionError::Conflict)?;
960        if actual_reference != *reference || canonical.as_slice() != bytes.as_ref() {
961            return Err(DecisionError::Conflict(format!(
962                "recovery capsule '{}' does not match its content-addressed reference",
963                reference.sha256
964            )));
965        }
966        let deployment_id = self.load_or_create_deployment_id().await?;
967        if capsule.deployment_id != deployment_id {
968            return Err(DecisionError::Conflict(format!(
969                "recovery capsule belongs to deployment {}, current deployment is {deployment_id}",
970                capsule.deployment_id
971            )));
972        }
973        Ok(capsule)
974    }
975
976    pub(crate) async fn validate_recovery_capsule_for_outcome(
977        &self,
978        outcome: &CheckpointOutcome,
979    ) -> Result<(), DecisionError> {
980        outcome.validate_shape(outcome.epoch)?;
981        let reference = outcome.recovery_capsule.as_ref().ok_or_else(|| {
982            DecisionError::Conflict(format!(
983                "cluster commit outcome for epoch {} requires a recovery capsule",
984                outcome.epoch
985            ))
986        })?;
987        let capsule = self.load_recovery_capsule(reference).await?;
988        if capsule.attempt.epoch != outcome.epoch
989            || capsule.attempt.checkpoint_id != outcome.checkpoint_id
990            || Some(&capsule.assignment_fence) != outcome.assignment_fence.as_ref()
991            || capsule.deployment_id != outcome.deployment_id
992        {
993            return Err(DecisionError::Conflict(format!(
994                "cluster commit outcome for epoch {} does not match recovery capsule '{}'",
995                outcome.epoch, reference.sha256
996            )));
997        }
998        Ok(())
999    }
1000
1001    fn validate_deployment_identity(
1002        identity: &DeploymentIdentity,
1003    ) -> Result<String, DecisionError> {
1004        if identity.version != DEPLOYMENT_IDENTITY_VERSION {
1005            return Err(DecisionError::Conflict(format!(
1006                "deployment identity version {} is unsupported (expected \
1007                 {DEPLOYMENT_IDENTITY_VERSION})",
1008                identity.version
1009            )));
1010        }
1011        let parsed = uuid::Uuid::parse_str(&identity.id).map_err(|error| {
1012            DecisionError::Conflict(format!("deployment identity is not a UUID: {error}"))
1013        })?;
1014        let canonical = parsed.to_string();
1015        if canonical != identity.id || parsed.is_nil() {
1016            return Err(DecisionError::Conflict(
1017                "deployment identity must be a canonical non-nil UUID".into(),
1018            ));
1019        }
1020        let allocation_id = uuid::Uuid::parse_str(&identity.allocation_id).map_err(|error| {
1021            DecisionError::Conflict(format!(
1022                "deployment identity has invalid allocation identity: {error}"
1023            ))
1024        })?;
1025        if allocation_id.is_nil() || allocation_id.to_string() != identity.allocation_id {
1026            return Err(DecisionError::Conflict(
1027                "deployment identity must use a canonical non-nil allocation identity".into(),
1028            ));
1029        }
1030        Ok(canonical)
1031    }
1032
1033    async fn read_deployment_identity(
1034        &self,
1035    ) -> Result<Option<VersionedCheckpointIdHead>, DecisionError> {
1036        let Some(result) = self
1037            .get_control_record(
1038                &Self::deployment_identity_path(),
1039                "deployment identity",
1040                DEPLOYMENT_IDENTITY_MAX_BYTES,
1041            )
1042            .await?
1043        else {
1044            return Ok(None);
1045        };
1046        let update_version = UpdateVersion {
1047            e_tag: result.meta.e_tag.clone(),
1048            version: result.meta.version.clone(),
1049        };
1050        self.require_native_cas_token("deployment identity", &update_version)?;
1051        let bytes = Self::read_control_record_bytes(
1052            result,
1053            "deployment identity",
1054            DEPLOYMENT_IDENTITY_MAX_BYTES,
1055            None,
1056        )
1057        .await?;
1058        let identity: DeploymentIdentity = serde_json::from_slice(&bytes)
1059            .map_err(|error| DecisionError::Conflict(format!("deployment identity: {error}")))?;
1060        Self::validate_deployment_identity(&identity)?;
1061        if identity.allocator_mode != self.update_mode {
1062            return Err(DecisionError::Conflict(format!(
1063                "deployment identity allocator mode {:?} cannot be opened as {:?}",
1064                identity.allocator_mode, self.update_mode
1065            )));
1066        }
1067        let canonical = serde_json::to_vec(&identity)
1068            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
1069        if canonical.as_slice() != bytes.as_ref() {
1070            return Err(DecisionError::Conflict(
1071                "deployment identity does not use its canonical body".into(),
1072            ));
1073        }
1074        Ok(Some(VersionedCheckpointIdHead {
1075            head: identity,
1076            update_version,
1077        }))
1078    }
1079
1080    /// Load the checkpoint namespace's create-once deployment incarnation, creating it when the
1081    /// durable store is empty. Concurrent cluster members converge through object-store CAS.
1082    ///
1083    /// # Errors
1084    /// Object-store I/O or a malformed/conflicting persisted identity.
1085    pub async fn load_or_create_deployment_id(&self) -> Result<String, DecisionError> {
1086        if let Some(identity) = self.deployment_id.get() {
1087            return Ok(identity.clone());
1088        }
1089        let _guard = self.metadata_write_lock.lock().await;
1090        if let Some(identity) = self.deployment_id.get() {
1091            return Ok(identity.clone());
1092        }
1093        if let Some(stored) = self.read_deployment_identity().await? {
1094            let identity = stored.head.id.clone();
1095            self.cache_checkpoint_id_head(Some(stored));
1096            let _ = self.deployment_id.set(identity.clone());
1097            return Ok(identity);
1098        }
1099
1100        let identity = DeploymentIdentity {
1101            version: DEPLOYMENT_IDENTITY_VERSION,
1102            id: uuid::Uuid::now_v7().to_string(),
1103            allocator_mode: self.update_mode,
1104            checkpoint_id: 0,
1105            allocation_id: uuid::Uuid::now_v7().to_string(),
1106        };
1107        let payload = Self::encode_control_record(
1108            "deployment identity",
1109            &identity,
1110            DEPLOYMENT_IDENTITY_MAX_BYTES,
1111        )?;
1112        let options = PutOptions {
1113            mode: PutMode::Create,
1114            ..PutOptions::default()
1115        };
1116        match self
1117            .store
1118            .put_opts(
1119                &Self::deployment_identity_path(),
1120                PutPayload::from(payload),
1121                options,
1122            )
1123            .await
1124        {
1125            Ok(put_result) => {
1126                let update_version: UpdateVersion = put_result.into();
1127                if self.update_mode == DecisionStoreUpdateMode::NativeCas
1128                    && update_version.e_tag.is_none()
1129                    && update_version.version.is_none()
1130                {
1131                    self.cache_checkpoint_id_head(None);
1132                } else {
1133                    self.cache_checkpoint_id_head(Some(VersionedCheckpointIdHead {
1134                        head: identity.clone(),
1135                        update_version,
1136                    }));
1137                }
1138                let _ = self.deployment_id.set(identity.id.clone());
1139                Ok(identity.id)
1140            }
1141            Err(
1142                object_store::Error::Precondition { .. }
1143                | object_store::Error::AlreadyExists { .. },
1144            ) => {
1145                let stored = self.read_deployment_identity().await?.ok_or_else(|| {
1146                    DecisionError::Conflict(
1147                        "deployment identity disappeared after create conflict".into(),
1148                    )
1149                })?;
1150                let identity = stored.head.id.clone();
1151                self.cache_checkpoint_id_head(Some(stored));
1152                let _ = self.deployment_id.set(identity.clone());
1153                Ok(identity)
1154            }
1155            Err(error) => match self.read_deployment_identity().await? {
1156                Some(stored) => {
1157                    let identity = stored.head.id.clone();
1158                    self.cache_checkpoint_id_head(Some(stored));
1159                    let _ = self.deployment_id.set(identity.clone());
1160                    Ok(identity)
1161                }
1162                None => Err(DecisionError::Io(error.to_string())),
1163            },
1164        }
1165    }
1166
1167    fn local_reservation_block(location: &str) -> Result<u64, DecisionError> {
1168        let value = location
1169            .strip_prefix("checkpoint-id-blocks/block=")
1170            .ok_or_else(|| {
1171                DecisionError::Conflict(format!("malformed checkpoint ID block path: {location}"))
1172            })?;
1173        if value.len() != 20 || value.contains('/') {
1174            return Err(DecisionError::Conflict(format!(
1175                "malformed checkpoint ID block path: {location}"
1176            )));
1177        }
1178        let block = value.parse::<u64>().map_err(|_| {
1179            DecisionError::Conflict(format!("malformed checkpoint ID block path: {location}"))
1180        })?;
1181        if Self::local_reservation_path(block).as_ref() != location {
1182            return Err(DecisionError::Conflict(format!(
1183                "checkpoint ID block path is not canonical: {location}"
1184            )));
1185        }
1186        Self::local_reservation_block_bounds(block)?;
1187        Ok(block)
1188    }
1189
1190    const fn local_reservation_block_for(checkpoint_id: u64) -> u64 {
1191        (checkpoint_id - 1) / LOCAL_RESERVATION_BLOCK_SIZE
1192    }
1193
1194    fn local_reservation_block_bounds(block: u64) -> Result<(u64, u64), DecisionError> {
1195        let start = block
1196            .checked_mul(LOCAL_RESERVATION_BLOCK_SIZE)
1197            .and_then(|value| value.checked_add(1))
1198            .ok_or_else(|| {
1199                DecisionError::Conflict("checkpoint ID space exhausted u64".to_owned())
1200            })?;
1201        let end = start.saturating_add(LOCAL_RESERVATION_BLOCK_SIZE - 1);
1202        Ok((start, end))
1203    }
1204
1205    async fn initialize_local_reservation(&self) -> Result<(), DecisionError> {
1206        if self.local_reservation.lock().initialized {
1207            return Ok(());
1208        }
1209
1210        let mut entries = self.store.list(Some(&Self::local_reservation_root()));
1211        let mut highest = None;
1212        while let Some(entry) = entries.next().await {
1213            let entry = entry.map_err(|error| DecisionError::Io(error.to_string()))?;
1214            let block = Self::local_reservation_block(entry.location.as_ref())?;
1215            highest = Some(highest.map_or(block, |current: u64| current.max(block)));
1216        }
1217        let mut state = self.local_reservation.lock();
1218        state.initialized = true;
1219        state.highest_block = highest;
1220        state.next_id = None;
1221        state.block_end = 0;
1222        Ok(())
1223    }
1224
1225    fn consume_local_reservation(&self, minimum: u64) -> Option<u64> {
1226        let mut state = self.local_reservation.lock();
1227        let next_id = state.next_id?;
1228        let checkpoint_id = next_id.max(minimum);
1229        if checkpoint_id > state.block_end {
1230            state.next_id = None;
1231            return None;
1232        }
1233        state.next_id = checkpoint_id
1234            .checked_add(1)
1235            .filter(|next| *next <= state.block_end);
1236        Some(checkpoint_id)
1237    }
1238
1239    async fn allocate_local_checkpoint_id_at_least(
1240        &self,
1241        minimum: u64,
1242    ) -> Result<u64, DecisionError> {
1243        self.initialize_local_reservation().await?;
1244        if let Some(checkpoint_id) = self.consume_local_reservation(minimum) {
1245            return Ok(checkpoint_id);
1246        }
1247
1248        let minimum_block = Self::local_reservation_block_for(minimum);
1249        let highest_block = self.local_reservation.lock().highest_block;
1250        let mut candidate = match highest_block {
1251            Some(block) => block
1252                .checked_add(1)
1253                .map(|next| next.max(minimum_block))
1254                .ok_or_else(|| {
1255                    DecisionError::Conflict("checkpoint ID space exhausted u64".to_owned())
1256                })?,
1257            None => minimum_block,
1258        };
1259
1260        loop {
1261            let (start, end) = Self::local_reservation_block_bounds(candidate)?;
1262            let result = self
1263                .store
1264                .put_opts(
1265                    &Self::local_reservation_path(candidate),
1266                    PutPayload::from(Bytes::new()),
1267                    PutOptions {
1268                        mode: PutMode::Create,
1269                        ..PutOptions::default()
1270                    },
1271                )
1272                .await;
1273            match result {
1274                Ok(_) => {
1275                    let checkpoint_id = start.max(minimum);
1276                    let mut state = self.local_reservation.lock();
1277                    state.highest_block = Some(
1278                        state
1279                            .highest_block
1280                            .map_or(candidate, |current| current.max(candidate)),
1281                    );
1282                    state.block_end = end;
1283                    state.next_id = checkpoint_id
1284                        .checked_add(1)
1285                        .filter(|next| *next <= state.block_end);
1286                    return Ok(checkpoint_id);
1287                }
1288                Err(
1289                    object_store::Error::Precondition { .. }
1290                    | object_store::Error::AlreadyExists { .. },
1291                ) => {
1292                    candidate = candidate.checked_add(1).ok_or_else(|| {
1293                        DecisionError::Conflict("checkpoint ID space exhausted u64".to_owned())
1294                    })?;
1295                    self.local_reservation.lock().highest_block = Some(candidate - 1);
1296                    tokio::task::yield_now().await;
1297                }
1298                Err(error) => return Err(DecisionError::Io(error.to_string())),
1299            }
1300        }
1301    }
1302
1303    async fn read_checkpoint_id_head(
1304        &self,
1305        deployment_id: &str,
1306    ) -> Result<Option<VersionedCheckpointIdHead>, DecisionError> {
1307        let observed = self.read_deployment_identity().await?;
1308        if let Some(observed) = observed.as_ref() {
1309            if observed.head.id != deployment_id {
1310                return Err(DecisionError::Conflict(format!(
1311                    "checkpoint ID head belongs to deployment {}, current deployment is {deployment_id}",
1312                    observed.head.id
1313                )));
1314            }
1315        }
1316        Ok(observed)
1317    }
1318
1319    fn cache_checkpoint_id_head(&self, head: Option<VersionedCheckpointIdHead>) {
1320        *self.checkpoint_id_head.lock() = head;
1321    }
1322
1323    fn validate_checkpoint_id_head_progress(
1324        prior: Option<&VersionedCheckpointIdHead>,
1325        observed: Option<&VersionedCheckpointIdHead>,
1326    ) -> Result<(), DecisionError> {
1327        match (prior, observed) {
1328            (Some(prior), None) => Err(DecisionError::Conflict(format!(
1329                "checkpoint ID head disappeared after durable ID {}",
1330                prior.head.checkpoint_id
1331            ))),
1332            (Some(prior), Some(observed))
1333                if observed.head.checkpoint_id < prior.head.checkpoint_id =>
1334            {
1335                Err(DecisionError::Conflict(format!(
1336                    "checkpoint ID head regressed from {} to {}",
1337                    prior.head.checkpoint_id, observed.head.checkpoint_id
1338                )))
1339            }
1340            (Some(prior), Some(observed))
1341                if observed.head.checkpoint_id == prior.head.checkpoint_id
1342                    && observed.head != prior.head =>
1343            {
1344                Err(DecisionError::Conflict(format!(
1345                    "checkpoint ID {} changed allocation identity without advancing",
1346                    prior.head.checkpoint_id
1347                )))
1348            }
1349            _ => Ok(()),
1350        }
1351    }
1352
1353    async fn allocate_shared_checkpoint_id_at_least(
1354        &self,
1355        deployment_id: &str,
1356        minimum: u64,
1357    ) -> Result<u64, DecisionError> {
1358        let allocation_id = uuid::Uuid::now_v7().to_string();
1359        let mut current = self.checkpoint_id_head.lock().clone();
1360        if current.is_none() {
1361            current = self.read_checkpoint_id_head(deployment_id).await?;
1362            if current.is_none() {
1363                return Err(DecisionError::Conflict(format!(
1364                    "checkpoint ID authority for deployment {deployment_id} disappeared"
1365                )));
1366            }
1367            self.cache_checkpoint_id_head(current.clone());
1368        }
1369
1370        loop {
1371            let versioned = current.as_ref().ok_or_else(|| {
1372                DecisionError::Conflict(format!(
1373                    "checkpoint ID authority for deployment {deployment_id} disappeared"
1374                ))
1375            })?;
1376            let checkpoint_id = versioned
1377                .head
1378                .checkpoint_id
1379                .checked_add(1)
1380                .map(|next| next.max(minimum))
1381                .ok_or_else(|| {
1382                    DecisionError::Conflict("checkpoint ID space exhausted u64".to_owned())
1383                })?;
1384            let head = DeploymentIdentity {
1385                version: DEPLOYMENT_IDENTITY_VERSION,
1386                id: deployment_id.to_owned(),
1387                allocator_mode: self.update_mode,
1388                checkpoint_id,
1389                allocation_id: allocation_id.clone(),
1390            };
1391            let payload = serde_json::to_vec(&head)
1392                .map(Bytes::from)
1393                .map_err(|error| DecisionError::Conflict(error.to_string()))?;
1394            let mode = PutMode::Update(versioned.update_version.clone());
1395            let result = self
1396                .store
1397                .put_opts(
1398                    &Self::deployment_identity_path(),
1399                    PutPayload::from(payload),
1400                    PutOptions {
1401                        mode,
1402                        ..PutOptions::default()
1403                    },
1404                )
1405                .await;
1406            match result {
1407                Ok(put_result) => {
1408                    let update_version: UpdateVersion = put_result.into();
1409                    if update_version.e_tag.is_none() && update_version.version.is_none() {
1410                        // The create/update itself is authoritative, but this response cannot
1411                        // safely seed the next CAS. Force a metadata read on the next allocation.
1412                        self.cache_checkpoint_id_head(None);
1413                    } else {
1414                        self.cache_checkpoint_id_head(Some(VersionedCheckpointIdHead {
1415                            head,
1416                            update_version,
1417                        }));
1418                    }
1419                    return Ok(checkpoint_id);
1420                }
1421                Err(
1422                    object_store::Error::Precondition { .. }
1423                    | object_store::Error::AlreadyExists { .. }
1424                    | object_store::Error::NotFound { .. },
1425                ) => {
1426                    let observed = self.read_checkpoint_id_head(deployment_id).await?;
1427                    Self::validate_checkpoint_id_head_progress(
1428                        current.as_ref(),
1429                        observed.as_ref(),
1430                    )?;
1431                    current = observed;
1432                    self.cache_checkpoint_id_head(current.clone());
1433                    tokio::task::yield_now().await;
1434                }
1435                Err(error) => {
1436                    let observed = self.read_checkpoint_id_head(deployment_id).await?;
1437                    Self::validate_checkpoint_id_head_progress(
1438                        current.as_ref(),
1439                        observed.as_ref(),
1440                    )?;
1441                    if observed.as_ref().is_some_and(|value| value.head == head) {
1442                        self.cache_checkpoint_id_head(observed);
1443                        return Ok(checkpoint_id);
1444                    }
1445                    if observed
1446                        .as_ref()
1447                        .is_some_and(|value| value.head.checkpoint_id >= checkpoint_id)
1448                    {
1449                        current = observed;
1450                        self.cache_checkpoint_id_head(current.clone());
1451                        tokio::task::yield_now().await;
1452                        continue;
1453                    }
1454                    self.cache_checkpoint_id_head(observed);
1455                    return Err(DecisionError::Io(error.to_string()));
1456                }
1457            }
1458        }
1459    }
1460
1461    /// Allocate the next globally ordered checkpoint ID at or above `minimum`.
1462    ///
1463    /// Shared stores advance one fixed-size durable high-water object with native compare-and-
1464    /// swap. The allocation identity in each proposal reconciles a lost write response without
1465    /// returning an ID won by another coordinator. Node-local filesystems claim immutable blocks
1466    /// and allocate from the active block in memory. They are cancellation-safe under the
1467    /// constructor's exclusive single-writer namespace contract, remove durable I/O from the hot
1468    /// path, and burn the unused block tail on restart. Gaps are valid and IDs are never reused.
1469    ///
1470    /// # Errors
1471    /// Object-store I/O, malformed or foreign durable state, a shared store without conditional
1472    /// update support, or exhaustion of the `u64` ID space.
1473    pub async fn allocate_checkpoint_id_at_least(
1474        &self,
1475        minimum: u64,
1476    ) -> Result<u64, DecisionError> {
1477        if minimum == 0 {
1478            return Err(DecisionError::Conflict(
1479                "minimum checkpoint ID must be nonzero".to_owned(),
1480            ));
1481        }
1482
1483        let deployment_id = self.load_or_create_deployment_id().await?;
1484        let _guard = self.metadata_write_lock.lock().await;
1485        match self.update_mode {
1486            DecisionStoreUpdateMode::NativeCas => {
1487                self.allocate_shared_checkpoint_id_at_least(&deployment_id, minimum)
1488                    .await
1489            }
1490            DecisionStoreUpdateMode::LocalSingleWriter => {
1491                self.allocate_local_checkpoint_id_at_least(minimum).await
1492            }
1493        }
1494    }
1495
1496    #[cfg(test)]
1497    /// Allocate from the default floor in unit tests.
1498    ///
1499    /// # Errors
1500    ///
1501    /// Returns an error when the durable allocator is unavailable or invalid.
1502    pub async fn allocate_checkpoint_id(&self) -> Result<u64, DecisionError> {
1503        self.allocate_checkpoint_id_at_least(1).await
1504    }
1505
1506    fn validate_sink_open_witness_shape(
1507        witness: &CheckpointSinkOpenWitness,
1508    ) -> Result<(), DecisionError> {
1509        if witness.version != CHECKPOINT_SINK_OPEN_WITNESS_VERSION {
1510            return Err(DecisionError::Conflict(format!(
1511                "sink-open witness version {} is unsupported (expected \
1512                 {CHECKPOINT_SINK_OPEN_WITNESS_VERSION})",
1513                witness.version
1514            )));
1515        }
1516        let deployment = uuid::Uuid::parse_str(&witness.deployment_id).map_err(|error| {
1517            DecisionError::Conflict(format!(
1518                "sink-open witness has invalid deployment identity: {error}"
1519            ))
1520        })?;
1521        if deployment.is_nil() || deployment.to_string() != witness.deployment_id {
1522            return Err(DecisionError::Conflict(
1523                "sink-open witness must use a canonical non-nil deployment identity".into(),
1524            ));
1525        }
1526        if let Some(error) = witness.pipeline_identity.validation_error() {
1527            return Err(DecisionError::Conflict(format!(
1528                "sink-open witness has an invalid pipeline identity: {error}"
1529            )));
1530        }
1531        if !witness.attempt.is_canonical() {
1532            return Err(DecisionError::Conflict(
1533                "sink-open witness must use one nonzero canonical checkpoint ID".into(),
1534            ));
1535        }
1536        if witness.committable_sinks.is_empty()
1537            || witness.committable_sinks.len() > CHECKPOINT_SINK_OPEN_WITNESS_MAX_SINKS
1538        {
1539            return Err(DecisionError::Conflict(format!(
1540                "sink-open witness must name between 1 and \
1541                 {CHECKPOINT_SINK_OPEN_WITNESS_MAX_SINKS} committable sinks"
1542            )));
1543        }
1544        if witness
1545            .committable_sinks
1546            .iter()
1547            .any(|name| name.is_empty() || name.trim() != name)
1548        {
1549            return Err(DecisionError::Conflict(
1550                "sink-open witness contains a non-canonical sink name".into(),
1551            ));
1552        }
1553        if witness
1554            .committable_sinks
1555            .windows(2)
1556            .any(|pair| pair[0] >= pair[1])
1557        {
1558            return Err(DecisionError::Conflict(
1559                "sink-open witness sink names must be strictly sorted and unique".into(),
1560            ));
1561        }
1562        let create_token = uuid::Uuid::parse_str(&witness.create_token).map_err(|error| {
1563            DecisionError::Conflict(format!(
1564                "sink-open witness has invalid create token: {error}"
1565            ))
1566        })?;
1567        if create_token.is_nil() || create_token.to_string() != witness.create_token {
1568            return Err(DecisionError::Conflict(
1569                "sink-open witness must use a canonical non-nil create token".into(),
1570            ));
1571        }
1572        Ok(())
1573    }
1574
1575    fn validate_sink_open_witness_slot_shape(
1576        slot: &CheckpointSinkOpenWitnessSlot,
1577    ) -> Result<(), DecisionError> {
1578        if slot.version != CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION {
1579            return Err(DecisionError::Conflict(format!(
1580                "sink-open witness slot version {} is unsupported (expected \
1581                 {CHECKPOINT_SINK_OPEN_WITNESS_SLOT_VERSION})",
1582                slot.version
1583            )));
1584        }
1585        Self::validate_sink_open_witness_shape(slot.witness())?;
1586        if let CheckpointSinkOpenWitnessSlotState::Closed { close_token, .. } = &slot.state {
1587            let token = uuid::Uuid::parse_str(close_token).map_err(|error| {
1588                DecisionError::Conflict(format!(
1589                    "sink-open witness slot has invalid close token: {error}"
1590                ))
1591            })?;
1592            if token.is_nil() || token.to_string() != *close_token {
1593                return Err(DecisionError::Conflict(
1594                    "sink-open witness slot must use a canonical non-nil close token".into(),
1595                ));
1596            }
1597        }
1598        Ok(())
1599    }
1600
1601    fn encode_sink_open_witness_slot(
1602        slot: &CheckpointSinkOpenWitnessSlot,
1603    ) -> Result<Bytes, DecisionError> {
1604        Self::validate_sink_open_witness_slot_shape(slot)?;
1605        Self::encode_control_record(
1606            "sink-open witness slot",
1607            slot,
1608            CHECKPOINT_SINK_OPEN_WITNESS_MAX_BYTES,
1609        )
1610    }
1611
1612    async fn read_sink_open_witness_record(
1613        &self,
1614    ) -> Result<Option<VersionedCheckpointSinkOpenWitnessSlot>, DecisionError> {
1615        let Some(result) = self
1616            .get_control_record(
1617                &Self::sink_open_witness_path(),
1618                "sink-open witness",
1619                CHECKPOINT_SINK_OPEN_WITNESS_MAX_BYTES,
1620            )
1621            .await?
1622        else {
1623            return Ok(None);
1624        };
1625        let update_version = UpdateVersion {
1626            e_tag: result.meta.e_tag.clone(),
1627            version: result.meta.version.clone(),
1628        };
1629        self.require_native_cas_token("sink-open witness slot", &update_version)?;
1630        let bytes = Self::read_control_record_bytes(
1631            result,
1632            "sink-open witness",
1633            CHECKPOINT_SINK_OPEN_WITNESS_MAX_BYTES,
1634            None,
1635        )
1636        .await?;
1637        let slot: CheckpointSinkOpenWitnessSlot = serde_json::from_slice(&bytes)
1638            .map_err(|error| DecisionError::Conflict(format!("sink-open witness slot: {error}")))?;
1639        Self::validate_sink_open_witness_slot_shape(&slot)?;
1640        let canonical = serde_json::to_vec(&slot)
1641            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
1642        if canonical.as_slice() != bytes.as_ref() {
1643            return Err(DecisionError::Conflict(format!(
1644                "sink-open witness slot for checkpoint {} does not use its canonical body",
1645                slot.witness().attempt.checkpoint_id
1646            )));
1647        }
1648        Ok(Some(VersionedCheckpointSinkOpenWitnessSlot {
1649            slot,
1650            update_version,
1651        }))
1652    }
1653
1654    fn validate_sink_open_witness_slot_deployment(
1655        slot: &CheckpointSinkOpenWitnessSlot,
1656        deployment_id: &str,
1657    ) -> Result<(), DecisionError> {
1658        if slot.witness().deployment_id == deployment_id {
1659            return Ok(());
1660        }
1661        Err(DecisionError::Conflict(format!(
1662            "sink-open witness belongs to deployment {}, current deployment is {deployment_id}",
1663            slot.witness().deployment_id
1664        )))
1665    }
1666
1667    fn sink_open_witness_put_mode(&self, expected: Option<UpdateVersion>) -> PutMode {
1668        match (self.update_mode, expected) {
1669            (_, None) => PutMode::Create,
1670            (DecisionStoreUpdateMode::NativeCas, Some(version)) => PutMode::Update(version),
1671            (DecisionStoreUpdateMode::LocalSingleWriter, Some(_)) => PutMode::Overwrite,
1672        }
1673    }
1674
1675    async fn put_sink_open_witness_slot(
1676        &self,
1677        slot: &CheckpointSinkOpenWitnessSlot,
1678        expected: Option<UpdateVersion>,
1679    ) -> Result<(), object_store::Error> {
1680        let payload = Self::encode_sink_open_witness_slot(slot).map_err(|error| {
1681            object_store::Error::Generic {
1682                store: "CheckpointDecisionStore",
1683                source: Box::new(error),
1684            }
1685        })?;
1686        self.store
1687            .put_opts(
1688                &Self::sink_open_witness_path(),
1689                PutPayload::from(payload),
1690                PutOptions {
1691                    mode: self.sink_open_witness_put_mode(expected),
1692                    ..PutOptions::default()
1693                },
1694            )
1695            .await
1696            .map(|_| ())
1697    }
1698
1699    /// Read the singleton sink-open owner record.
1700    ///
1701    /// # Errors
1702    /// Object-store I/O, malformed/non-canonical metadata, or foreign deployment state.
1703    pub async fn sink_open_witness(
1704        &self,
1705    ) -> Result<Option<CheckpointSinkOpenWitness>, DecisionError> {
1706        let deployment_id = self.load_or_create_deployment_id().await?;
1707        let Some(versioned) = self.read_sink_open_witness_record().await? else {
1708            return Ok(None);
1709        };
1710        Self::validate_sink_open_witness_slot_deployment(&versioned.slot, &deployment_id)?;
1711        match versioned.slot.state {
1712            CheckpointSinkOpenWitnessSlotState::Open { witness } => Ok(Some(witness)),
1713            CheckpointSinkOpenWitnessSlotState::Closed { .. } => Ok(None),
1714        }
1715    }
1716
1717    /// Create the durable witness before invoking any checkpoint-committable sink begin call.
1718    ///
1719    /// `committable_sinks` must already be strictly sorted and unique so duplicate runtime names
1720    /// cannot be silently collapsed into one recovery participant.
1721    ///
1722    /// # Errors
1723    /// Object-store I/O, invalid input, or any malformed, foreign, or conflicting live witness.
1724    pub async fn create_sink_open_witness(
1725        &self,
1726        pipeline_identity: PipelineIdentity,
1727        participant_id: u64,
1728        attempt: CheckpointAttempt,
1729        committable_sinks: Vec<String>,
1730    ) -> Result<CheckpointSinkOpenWitness, DecisionError> {
1731        let candidate = CheckpointSinkOpenWitness {
1732            version: CHECKPOINT_SINK_OPEN_WITNESS_VERSION,
1733            deployment_id: self.load_or_create_deployment_id().await?,
1734            pipeline_identity,
1735            participant_id,
1736            attempt,
1737            committable_sinks,
1738            create_token: uuid::Uuid::now_v7().to_string(),
1739        };
1740        Self::validate_sink_open_witness_shape(&candidate)?;
1741        Self::encode_sink_open_witness_slot(&CheckpointSinkOpenWitnessSlot::open(
1742            candidate.clone(),
1743        ))?;
1744        // An accepted open must always have enough room for its mandatory close tombstone.
1745        Self::encode_sink_open_witness_slot(&CheckpointSinkOpenWitnessSlot::closed(
1746            candidate.clone(),
1747        ))?;
1748
1749        match self.update_mode {
1750            DecisionStoreUpdateMode::NativeCas => {
1751                self.create_sink_open_witness_inner(candidate).await
1752            }
1753            DecisionStoreUpdateMode::LocalSingleWriter => {
1754                let local_lock = self.local_metadata_rmw_lock.as_ref().ok_or_else(|| {
1755                    DecisionError::Conflict(
1756                        "local decision store is missing its namespace write lock".to_owned(),
1757                    )
1758                })?;
1759                let _guard = local_lock.lock().await;
1760                self.create_sink_open_witness_inner(candidate).await
1761            }
1762        }
1763    }
1764
1765    async fn create_sink_open_witness_inner(
1766        &self,
1767        candidate: CheckpointSinkOpenWitness,
1768    ) -> Result<CheckpointSinkOpenWitness, DecisionError> {
1769        let current = self.read_sink_open_witness_record().await?;
1770        let (expected, prior_slot) = match current {
1771            None => (None, None),
1772            Some(versioned) => {
1773                Self::validate_sink_open_witness_slot_deployment(
1774                    &versioned.slot,
1775                    &candidate.deployment_id,
1776                )?;
1777                match &versioned.slot.state {
1778                    CheckpointSinkOpenWitnessSlotState::Open { witness } => {
1779                        return Err(DecisionError::Conflict(format!(
1780                            "sink-open witness create for checkpoint {} observed conflicting \
1781                             checkpoint {}",
1782                            candidate.attempt.checkpoint_id, witness.attempt.checkpoint_id
1783                        )));
1784                    }
1785                    CheckpointSinkOpenWitnessSlotState::Closed { witness, .. }
1786                        if candidate.attempt.checkpoint_id <= witness.attempt.checkpoint_id =>
1787                    {
1788                        return Err(DecisionError::Conflict(format!(
1789                            "sink-open witness checkpoint {} does not advance closed checkpoint {}",
1790                            candidate.attempt.checkpoint_id, witness.attempt.checkpoint_id
1791                        )));
1792                    }
1793                    CheckpointSinkOpenWitnessSlotState::Closed { .. } => {}
1794                }
1795                (Some(versioned.update_version), Some(versioned.slot))
1796            }
1797        };
1798        let candidate_slot = CheckpointSinkOpenWitnessSlot::open(candidate.clone());
1799        let create_error = match self
1800            .put_sink_open_witness_slot(&candidate_slot, expected)
1801            .await
1802        {
1803            Ok(()) => return Ok(candidate),
1804            Err(error) => error,
1805        };
1806
1807        // Only this proposal's exact create token proves that an ambiguous open transition won.
1808        if let Some(observed) = self.read_sink_open_witness_record().await? {
1809            Self::validate_sink_open_witness_slot_deployment(
1810                &observed.slot,
1811                &candidate.deployment_id,
1812            )?;
1813            if observed.slot == candidate_slot {
1814                return Ok(candidate);
1815            }
1816            let conditional_conflict = matches!(
1817                &create_error,
1818                object_store::Error::Precondition { .. }
1819                    | object_store::Error::AlreadyExists { .. }
1820                    | object_store::Error::NotFound { .. }
1821            );
1822            if !conditional_conflict
1823                && prior_slot
1824                    .as_ref()
1825                    .is_some_and(|prior| prior == &observed.slot)
1826            {
1827                return Err(DecisionError::Io(create_error.to_string()));
1828            }
1829            return Err(DecisionError::Conflict(format!(
1830                "sink-open witness create for checkpoint {} observed conflicting checkpoint {}",
1831                candidate.attempt.checkpoint_id,
1832                observed.slot.witness().attempt.checkpoint_id
1833            )));
1834        }
1835
1836        match create_error {
1837            object_store::Error::Precondition { .. }
1838            | object_store::Error::AlreadyExists { .. }
1839            | object_store::Error::NotFound { .. } => Err(DecisionError::Conflict(format!(
1840                "sink-open witness for checkpoint {} disappeared after create conflict",
1841                candidate.attempt.checkpoint_id
1842            ))),
1843            error => Err(DecisionError::Io(error.to_string())),
1844        }
1845    }
1846
1847    /// Close exactly the supplied witness after its attempt is terminal or fully rolled back.
1848    ///
1849    /// Closure durably replaces the open state. The tombstone makes an old conditional write
1850    /// harmless after a successor opens and gives ambiguous responses an exact state to reconcile.
1851    ///
1852    /// # Errors
1853    /// Object-store I/O or a malformed, foreign, or different live witness.
1854    pub async fn clear_sink_open_witness(
1855        &self,
1856        expected: &CheckpointSinkOpenWitness,
1857    ) -> Result<(), DecisionError> {
1858        Self::validate_sink_open_witness_shape(expected)?;
1859        let deployment_id = self.load_or_create_deployment_id().await?;
1860        if expected.deployment_id != deployment_id {
1861            return Err(DecisionError::Conflict(format!(
1862                "cannot clear sink-open witness from deployment {}; current deployment is \
1863                 {deployment_id}",
1864                expected.deployment_id
1865            )));
1866        }
1867        match self.update_mode {
1868            DecisionStoreUpdateMode::NativeCas => {
1869                self.clear_sink_open_witness_inner(expected).await
1870            }
1871            DecisionStoreUpdateMode::LocalSingleWriter => {
1872                let local_lock = self.local_metadata_rmw_lock.as_ref().ok_or_else(|| {
1873                    DecisionError::Conflict(
1874                        "local decision store is missing its namespace write lock".to_owned(),
1875                    )
1876                })?;
1877                let _guard = local_lock.lock().await;
1878                self.clear_sink_open_witness_inner(expected).await
1879            }
1880        }
1881    }
1882
1883    async fn clear_sink_open_witness_inner(
1884        &self,
1885        expected: &CheckpointSinkOpenWitness,
1886    ) -> Result<(), DecisionError> {
1887        let Some(current) = self.read_sink_open_witness_record().await? else {
1888            return Err(DecisionError::Conflict(format!(
1889                "sink-open witness slot for checkpoint {} is missing",
1890                expected.attempt.checkpoint_id
1891            )));
1892        };
1893        Self::validate_sink_open_witness_slot_deployment(&current.slot, &expected.deployment_id)?;
1894        match &current.slot.state {
1895            CheckpointSinkOpenWitnessSlotState::Closed { witness, .. } if witness == expected => {
1896                return Ok(());
1897            }
1898            CheckpointSinkOpenWitnessSlotState::Open { witness } if witness == expected => {}
1899            _ => {
1900                return Err(DecisionError::Conflict(format!(
1901                    "cannot clear sink-open witness for checkpoint {}; current slot names \
1902                     checkpoint {} with a different create identity",
1903                    expected.attempt.checkpoint_id,
1904                    current.slot.witness().attempt.checkpoint_id
1905                )));
1906            }
1907        }
1908
1909        let closed = CheckpointSinkOpenWitnessSlot::closed(expected.clone());
1910        let close_error = match self
1911            .put_sink_open_witness_slot(&closed, Some(current.update_version))
1912            .await
1913        {
1914            Ok(()) => return Ok(()),
1915            Err(error) => error,
1916        };
1917        match self.read_sink_open_witness_record().await? {
1918            Some(observed) if observed.slot == closed => Ok(()),
1919            Some(observed) => {
1920                Self::validate_sink_open_witness_slot_deployment(
1921                    &observed.slot,
1922                    &expected.deployment_id,
1923                )?;
1924                match &observed.slot.state {
1925                    CheckpointSinkOpenWitnessSlotState::Closed { witness, .. }
1926                        if witness == expected =>
1927                    {
1928                        // Another exact close is equivalent even though its token differs.
1929                        Ok(())
1930                    }
1931                    CheckpointSinkOpenWitnessSlotState::Open { witness } if witness == expected => {
1932                        Err(DecisionError::Io(close_error.to_string()))
1933                    }
1934                    _ => {
1935                        // A different valid generation can only follow a successful close CAS.
1936                        // The stale transition cannot touch it because its object version differs.
1937                        Ok(())
1938                    }
1939                }
1940            }
1941            None => Err(DecisionError::Conflict(format!(
1942                "sink-open witness slot disappeared while closing checkpoint {}: {close_error}",
1943                expected.attempt.checkpoint_id
1944            ))),
1945        }
1946    }
1947
1948    /// Epoch segment of a canonical create-once terminal outcome object.
1949    fn outcome_epoch_segment(loc: &str) -> Option<&str> {
1950        let segment = loc
1951            .strip_prefix("checkpoint-outcomes/")?
1952            .strip_suffix("/outcome")?
1953            .strip_prefix("epoch=")?;
1954        let epoch = segment.parse::<u64>().ok()?;
1955        (epoch != 0 && Self::outcome_path(epoch).as_ref() == loc).then_some(segment)
1956    }
1957
1958    pub(crate) async fn canonical_outcome(
1959        &self,
1960        epoch: u64,
1961        checkpoint_id: u64,
1962        scope: CheckpointScope,
1963        assignment_fence: Option<CheckpointAssignmentFence>,
1964        leader_proof: Option<LeaderProof>,
1965        verdict: CheckpointVerdict,
1966        recovery_capsule: Option<RecoveryCapsuleRef>,
1967    ) -> Result<CheckpointOutcome, DecisionError> {
1968        let outcome = CheckpointOutcome {
1969            version: CHECKPOINT_OUTCOME_VERSION,
1970            scope,
1971            epoch,
1972            checkpoint_id,
1973            deployment_id: self.load_or_create_deployment_id().await?,
1974            assignment_fence,
1975            leader_proof,
1976            recovery_capsule,
1977            verdict,
1978        };
1979        if outcome.recovery_capsule.is_some() {
1980            self.validate_recovery_capsule_for_outcome(&outcome).await?;
1981        } else {
1982            outcome.validate_shape(epoch)?;
1983        }
1984        Ok(outcome)
1985    }
1986
1987    async fn read_outcome_record(
1988        &self,
1989        path: &OsPath,
1990        epoch: u64,
1991    ) -> Result<Option<CheckpointOutcome>, DecisionError> {
1992        let record = format!("checkpoint outcome for epoch {epoch}");
1993        let Some(result) = self
1994            .get_control_record(path, &record, CHECKPOINT_OUTCOME_MAX_BYTES)
1995            .await?
1996        else {
1997            return Ok(None);
1998        };
1999        let bytes =
2000            Self::read_control_record_bytes(result, &record, CHECKPOINT_OUTCOME_MAX_BYTES, None)
2001                .await?;
2002        let outcome: CheckpointOutcome = serde_json::from_slice(&bytes)
2003            .map_err(|error| DecisionError::Conflict(format!("outcome epoch {epoch}: {error}")))?;
2004        outcome.validate_shape(epoch)?;
2005        if outcome.scope == CheckpointScope::Cluster {
2006            return Err(DecisionError::Conflict(format!(
2007                "cluster outcome epoch {epoch} is stored outside the shared leader authority"
2008            )));
2009        }
2010        let canonical = serde_json::to_vec(&outcome)
2011            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
2012        if canonical.as_slice() != bytes.as_ref() {
2013            return Err(DecisionError::Conflict(format!(
2014                "outcome for epoch {epoch} does not use the canonical body"
2015            )));
2016        }
2017        let expected_deployment = self.load_or_create_deployment_id().await?;
2018        if outcome.deployment_id != expected_deployment {
2019            return Err(DecisionError::Conflict(format!(
2020                "outcome for epoch {epoch} belongs to deployment {}, current deployment is \
2021                 {expected_deployment}",
2022                outcome.deployment_id
2023            )));
2024        }
2025        Ok(Some(outcome))
2026    }
2027
2028    async fn create_outcome(
2029        &self,
2030        candidate: CheckpointOutcome,
2031    ) -> Result<RecordOutcomeResult, DecisionError> {
2032        let path = Self::outcome_path(candidate.epoch);
2033        let payload = Self::encode_control_record(
2034            "checkpoint outcome",
2035            &candidate,
2036            CHECKPOINT_OUTCOME_MAX_BYTES,
2037        )?;
2038        let options = PutOptions {
2039            mode: PutMode::Create,
2040            ..PutOptions::default()
2041        };
2042        let Err(create_error) = self
2043            .store
2044            .put_opts(&path, PutPayload::from(payload), options)
2045            .await
2046        else {
2047            return Ok(RecordOutcomeResult::Created(candidate));
2048        };
2049
2050        // A failed create response may be ambiguous: read the create-once key before deciding
2051        // whether the call failed. Recovery will not proceed past a valid Prepared witness until
2052        // this exact epoch has a terminal winner.
2053        if let Some(winner) = self.read_outcome_record(&path, candidate.epoch).await? {
2054            return if winner == candidate {
2055                Ok(RecordOutcomeResult::Unchanged(winner))
2056            } else {
2057                Ok(RecordOutcomeResult::Conflict { winner })
2058            };
2059        }
2060
2061        match create_error {
2062            object_store::Error::Precondition { .. }
2063            | object_store::Error::AlreadyExists { .. } => Err(DecisionError::Conflict(format!(
2064                "outcome for epoch {} disappeared after create conflict",
2065                candidate.epoch
2066            ))),
2067            error => Err(DecisionError::Io(error.to_string())),
2068        }
2069    }
2070
2071    async fn read_outcome_gc_floor(
2072        &self,
2073        deployment_id: &str,
2074    ) -> Result<Option<VersionedOutcomeGcFloor>, DecisionError> {
2075        let path = Self::outcome_gc_floor_path(deployment_id);
2076        let Some(result) = self
2077            .get_control_record(&path, "outcome GC floor", OUTCOME_GC_FLOOR_MAX_BYTES)
2078            .await?
2079        else {
2080            return Ok(None);
2081        };
2082        let update_version = UpdateVersion {
2083            e_tag: result.meta.e_tag.clone(),
2084            version: result.meta.version.clone(),
2085        };
2086        self.require_native_cas_token("outcome GC floor", &update_version)?;
2087        let bytes = Self::read_control_record_bytes(
2088            result,
2089            "outcome GC floor",
2090            OUTCOME_GC_FLOOR_MAX_BYTES,
2091            None,
2092        )
2093        .await?;
2094        let floor: OutcomeGcFloor = serde_json::from_slice(&bytes)
2095            .map_err(|error| DecisionError::Conflict(format!("outcome GC floor: {error}")))?;
2096        let before_epoch = floor.before_epoch;
2097        if floor.version != OUTCOME_GC_FLOOR_VERSION
2098            || floor.before_epoch == 0
2099            || floor.deployment_id != deployment_id
2100        {
2101            return Err(DecisionError::Conflict(
2102                "outcome GC floor does not match its canonical path, deployment, and version"
2103                    .to_owned(),
2104            ));
2105        }
2106        if let Some(anchor) = floor.terminal_anchor.as_ref() {
2107            anchor.validate_shape(anchor.epoch)?;
2108            if anchor.epoch >= floor.before_epoch || anchor.deployment_id != floor.deployment_id {
2109                return Err(DecisionError::Conflict(format!(
2110                    "outcome GC floor {before_epoch} has invalid terminal anchor epoch {} checkpoint {}",
2111                    anchor.epoch, anchor.checkpoint_id
2112                )));
2113            }
2114        }
2115        if let Some(anchor) = floor.committed_anchor.as_ref() {
2116            anchor.validate_shape(anchor.epoch)?;
2117            if !anchor.is_commit()
2118                || anchor.epoch >= floor.before_epoch
2119                || anchor.deployment_id != floor.deployment_id
2120            {
2121                return Err(DecisionError::Conflict(format!(
2122                    "outcome GC floor {before_epoch} has invalid committed anchor epoch {} checkpoint {}",
2123                    anchor.epoch, anchor.checkpoint_id
2124                )));
2125            }
2126            let terminal = floor.terminal_anchor.as_ref().ok_or_else(|| {
2127                DecisionError::Conflict(format!(
2128                    "outcome GC floor {before_epoch} has a committed anchor without a terminal anchor"
2129                ))
2130            })?;
2131            let ordered = if anchor.epoch == terminal.epoch {
2132                anchor == terminal
2133            } else {
2134                anchor.epoch < terminal.epoch && anchor.checkpoint_id < terminal.checkpoint_id
2135            };
2136            if !ordered {
2137                return Err(DecisionError::Conflict(format!(
2138                    "outcome GC floor {before_epoch} committed anchor epoch {} checkpoint {} is not ordered before terminal anchor epoch {} checkpoint {}",
2139                    anchor.epoch,
2140                    anchor.checkpoint_id,
2141                    terminal.epoch,
2142                    terminal.checkpoint_id
2143                )));
2144            }
2145        }
2146        if floor
2147            .terminal_anchor
2148            .as_ref()
2149            .is_some_and(CheckpointOutcome::is_commit)
2150            && floor.committed_anchor != floor.terminal_anchor
2151        {
2152            return Err(DecisionError::Conflict(format!(
2153                "outcome GC floor {before_epoch} does not retain its terminal commit as the committed anchor"
2154            )));
2155        }
2156        let canonical = serde_json::to_vec(&floor)
2157            .map_err(|error| DecisionError::Conflict(error.to_string()))?;
2158        if canonical.as_slice() != bytes.as_ref() {
2159            return Err(DecisionError::Conflict(format!(
2160                "outcome GC floor {before_epoch} does not use its canonical body"
2161            )));
2162        }
2163        Ok(Some(VersionedOutcomeGcFloor {
2164            floor,
2165            update_version,
2166        }))
2167    }
2168
2169    async fn current_outcome_gc_floor(&self) -> Result<Option<OutcomeGcFloor>, DecisionError> {
2170        let deployment_id = self.load_or_create_deployment_id().await?;
2171        Ok(self
2172            .read_outcome_gc_floor(&deployment_id)
2173            .await?
2174            .map(|versioned| versioned.floor))
2175    }
2176
2177    async fn ensure_outcome_not_tombstoned(
2178        &self,
2179        outcome: &CheckpointOutcome,
2180    ) -> Result<(), DecisionError> {
2181        if let Some(floor) = self.current_outcome_gc_floor().await? {
2182            if outcome.epoch < floor.before_epoch {
2183                return Err(DecisionError::Conflict(format!(
2184                    "outcome epoch {} checkpoint {} is below durable outcome GC horizon {}",
2185                    outcome.epoch, outcome.checkpoint_id, floor.before_epoch
2186                )));
2187            }
2188        }
2189        Ok(())
2190    }
2191
2192    /// Advance the canonical floor if `expected` still names its current object version.
2193    ///
2194    /// `false` is ordinary CAS contention and requires the caller to rebuild its candidate from
2195    /// the winner. Ambiguous write failures are reconciled by reading the canonical object.
2196    async fn compare_and_swap_outcome_gc_floor(
2197        &self,
2198        floor: &OutcomeGcFloor,
2199        expected: Option<UpdateVersion>,
2200    ) -> Result<bool, DecisionError> {
2201        let path = Self::outcome_gc_floor_path(&floor.deployment_id);
2202        let payload =
2203            Self::encode_control_record("outcome GC floor", floor, OUTCOME_GC_FLOOR_MAX_BYTES)?;
2204        let options = PutOptions {
2205            mode: expected.clone().map_or(PutMode::Create, PutMode::Update),
2206            ..PutOptions::default()
2207        };
2208        let result = self
2209            .store
2210            .put_opts(&path, PutPayload::from(payload.clone()), options)
2211            .await;
2212        match result {
2213            Ok(_) => Ok(true),
2214            Err(
2215                object_store::Error::Precondition { .. }
2216                | object_store::Error::AlreadyExists { .. }
2217                | object_store::Error::NotFound { .. },
2218            ) => Ok(false),
2219            Err(object_store::Error::NotImplemented { .. })
2220                if expected.is_some()
2221                    && self.update_mode == DecisionStoreUpdateMode::LocalSingleWriter =>
2222            {
2223                // LocalFileSystem atomically replaces a file but cannot condition that replace on
2224                // an ETag. The runtime topology guarantees one process writer for this namespace;
2225                // serialize its read/compare/replace so concurrent maintenance tasks cannot
2226                // regress the floor. Shared stores never enter this path.
2227                let _guard = self
2228                    .local_metadata_rmw_lock
2229                    .as_ref()
2230                    .expect("local decision store has a namespace RMW lock")
2231                    .lock()
2232                    .await;
2233                let Some(current) = self.read_outcome_gc_floor(&floor.deployment_id).await? else {
2234                    return Ok(false);
2235                };
2236                if current.floor.before_epoch >= floor.before_epoch {
2237                    return Ok(true);
2238                }
2239                if Some(&current.update_version) != expected.as_ref() {
2240                    return Ok(false);
2241                }
2242                let overwrite = PutOptions {
2243                    mode: PutMode::Overwrite,
2244                    ..PutOptions::default()
2245                };
2246                match self
2247                    .store
2248                    .put_opts(&path, PutPayload::from(payload), overwrite)
2249                    .await
2250                {
2251                    Ok(_) => Ok(true),
2252                    Err(error) => match self.read_outcome_gc_floor(&floor.deployment_id).await? {
2253                        Some(current) if current.floor.before_epoch >= floor.before_epoch => {
2254                            Ok(true)
2255                        }
2256                        _ => Err(DecisionError::Io(error.to_string())),
2257                    },
2258                }
2259            }
2260            Err(error) => match self.read_outcome_gc_floor(&floor.deployment_id).await? {
2261                Some(current) if current.floor.before_epoch >= floor.before_epoch => Ok(true),
2262                _ => Err(DecisionError::Io(error.to_string())),
2263            },
2264        }
2265    }
2266
2267    /// Create or read the one local terminal outcome allowed for an epoch.
2268    ///
2269    /// Identical retries return [`RecordOutcomeResult::Unchanged`]. A different checkpoint,
2270    /// authority, assignment, recovery image, or verdict returns the durable winner in
2271    /// [`RecordOutcomeResult::Conflict`]; it never overwrites that winner.
2272    ///
2273    /// # Errors
2274    /// Object-store I/O, malformed/non-canonical metadata, or any cluster-scoped proposal.
2275    pub async fn record_outcome(
2276        &self,
2277        epoch: u64,
2278        checkpoint_id: u64,
2279        scope: CheckpointScope,
2280        assignment_fence: Option<CheckpointAssignmentFence>,
2281        leader_proof: Option<LeaderProof>,
2282        verdict: CheckpointVerdict,
2283        recovery_capsule: Option<RecoveryCapsuleRef>,
2284    ) -> Result<RecordOutcomeResult, DecisionError> {
2285        if scope == CheckpointScope::Cluster {
2286            return Err(DecisionError::Conflict(
2287                "cluster outcomes must be admitted through the shared leader authority".into(),
2288            ));
2289        }
2290        let candidate = self
2291            .canonical_outcome(
2292                epoch,
2293                checkpoint_id,
2294                scope,
2295                assignment_fence,
2296                leader_proof,
2297                verdict,
2298                recovery_capsule,
2299            )
2300            .await?;
2301        self.ensure_outcome_not_tombstoned(&candidate).await?;
2302        let result = self.create_outcome(candidate.clone()).await?;
2303        // A floor published while the create was in flight wins. The late raw object is inert and
2304        // remains eligible for a later best-effort sweep.
2305        self.ensure_outcome_not_tombstoned(&candidate).await?;
2306        Ok(result)
2307    }
2308
2309    /// Load the standalone/local terminal outcome for `epoch`.
2310    ///
2311    /// `None` means unresolved; it is never evidence of abort.
2312    ///
2313    /// # Errors
2314    /// Object-store I/O or a malformed/conflicting outcome body.
2315    pub async fn outcome(&self, epoch: u64) -> Result<Option<CheckpointOutcome>, DecisionError> {
2316        const FLOOR_RETRIES: usize = 3;
2317        for attempt in 0..FLOOR_RETRIES {
2318            let floor_before = self.current_outcome_gc_floor().await?;
2319            if floor_before
2320                .as_ref()
2321                .is_some_and(|floor| epoch < floor.before_epoch)
2322            {
2323                let floor_after = self.current_outcome_gc_floor().await?;
2324                if floor_before == floor_after {
2325                    return Ok(None);
2326                }
2327            } else {
2328                let outcome = self
2329                    .read_outcome_record(&Self::outcome_path(epoch), epoch)
2330                    .await?;
2331                let floor_after = self.current_outcome_gc_floor().await?;
2332                if floor_before == floor_after {
2333                    return Ok(outcome.filter(|outcome| {
2334                        floor_after
2335                            .as_ref()
2336                            .is_none_or(|floor| outcome.epoch >= floor.before_epoch)
2337                    }));
2338                }
2339            }
2340            if attempt + 1 < FLOOR_RETRIES {
2341                tokio::task::yield_now().await;
2342            }
2343        }
2344        Err(DecisionError::InventoryChanged(
2345            "outcome GC floor kept advancing during exact lookup".into(),
2346        ))
2347    }
2348
2349    async fn list_outcome_epochs(&self) -> Result<Vec<u64>, DecisionError> {
2350        let mut entries = self.store.list(Some(&Self::outcome_root()));
2351        let mut epochs = Vec::new();
2352        while let Some(entry) = entries.next().await {
2353            let entry = entry.map_err(|error| DecisionError::Io(error.to_string()))?;
2354            let location = entry.location.as_ref();
2355            let epoch = Self::outcome_epoch_segment(location)
2356                .and_then(|segment| segment.parse::<u64>().ok())
2357                .filter(|epoch| *epoch != 0)
2358                .ok_or_else(|| {
2359                    DecisionError::Conflict(format!(
2360                        "malformed checkpoint outcome path: {location}"
2361                    ))
2362                })?;
2363            epochs.push(epoch);
2364        }
2365        epochs.sort_unstable();
2366        if epochs.windows(2).any(|pair| pair[0] == pair[1]) {
2367            return Err(DecisionError::Conflict(
2368                "checkpoint outcome inventory contains a duplicate epoch".into(),
2369            ));
2370        }
2371        Ok(epochs)
2372    }
2373
2374    /// Load every live standalone/local outcome in ascending epoch order.
2375    ///
2376    /// Continuity-only GC anchors are audited internally but never returned to callers because
2377    /// their checkpoint artifacts may already have been deleted.
2378    ///
2379    /// # Errors
2380    /// Object-store I/O, a malformed object name/body, or an inventory that changed during read.
2381    pub async fn outcomes(&self) -> Result<Vec<CheckpointOutcome>, DecisionError> {
2382        const FLOOR_RETRIES: usize = 3;
2383        for attempt in 0..FLOOR_RETRIES {
2384            let floor_before = self.current_outcome_gc_floor().await?;
2385            let mut outcomes = self.audited_outcomes().await?;
2386            let floor_after = self.current_outcome_gc_floor().await?;
2387            if floor_before == floor_after {
2388                if let Some(floor) = floor_after {
2389                    outcomes.retain(|outcome| outcome.epoch >= floor.before_epoch);
2390                }
2391                return Ok(outcomes);
2392            }
2393            if attempt + 1 < FLOOR_RETRIES {
2394                tokio::task::yield_now().await;
2395            }
2396        }
2397        Err(DecisionError::InventoryChanged(
2398            "outcome GC floor kept advancing while selecting live outcomes".into(),
2399        ))
2400    }
2401
2402    /// Anchor-inclusive inventory used for monotonic-history audits and retention metadata.
2403    async fn audited_outcomes(&self) -> Result<Vec<CheckpointOutcome>, DecisionError> {
2404        const INVENTORY_RETRIES: usize = 3;
2405        for attempt in 0..INVENTORY_RETRIES {
2406            match self.outcomes_once().await {
2407                Err(DecisionError::InventoryChanged(_)) if attempt + 1 < INVENTORY_RETRIES => {
2408                    tokio::task::yield_now().await;
2409                }
2410                result => return result,
2411            }
2412        }
2413        Err(DecisionError::InventoryChanged(
2414            "checkpoint outcome inventory exhausted stability retries".into(),
2415        ))
2416    }
2417
2418    async fn outcomes_once(&self) -> Result<Vec<CheckpointOutcome>, DecisionError> {
2419        let floor_before = self.current_outcome_gc_floor().await?;
2420        let min_epoch = floor_before.as_ref().map_or(0, |floor| floor.before_epoch);
2421        let epochs = self
2422            .list_outcome_epochs()
2423            .await?
2424            .into_iter()
2425            .filter(|epoch| *epoch >= min_epoch);
2426
2427        let mut outcomes = Vec::new();
2428        if let Some(anchor) = floor_before
2429            .as_ref()
2430            .and_then(|floor| floor.committed_anchor.as_ref())
2431        {
2432            outcomes.push(anchor.clone());
2433        }
2434        if let Some(anchor) = floor_before
2435            .as_ref()
2436            .and_then(|floor| floor.terminal_anchor.as_ref())
2437        {
2438            if outcomes.last() != Some(anchor) {
2439                outcomes.push(anchor.clone());
2440            }
2441        }
2442        for epoch in epochs {
2443            let outcome = self
2444                .read_outcome_record(&Self::outcome_path(epoch), epoch)
2445                .await?
2446                .ok_or_else(|| {
2447                    DecisionError::InventoryChanged(format!(
2448                        "checkpoint outcome for epoch {epoch} disappeared during inventory"
2449                    ))
2450                })?;
2451            outcomes.push(outcome);
2452        }
2453        let floor_after = self.current_outcome_gc_floor().await?;
2454        if floor_before != floor_after {
2455            return Err(DecisionError::InventoryChanged(
2456                "outcome GC floor advanced during outcome inventory".into(),
2457            ));
2458        }
2459        for pair in outcomes.windows(2) {
2460            let previous = &pair[0];
2461            let current = &pair[1];
2462            if current.epoch <= previous.epoch || current.checkpoint_id <= previous.checkpoint_id {
2463                return Err(DecisionError::Conflict(format!(
2464                    "checkpoint outcomes regress from epoch {} checkpoint {} to epoch {} checkpoint {}",
2465                    previous.epoch,
2466                    previous.checkpoint_id,
2467                    current.epoch,
2468                    current.checkpoint_id
2469                )));
2470            }
2471        }
2472        Ok(outcomes)
2473    }
2474
2475    /// Greatest terminal outcome, including the continuity anchor when it is the newest closed
2476    /// epoch retained by the store.
2477    ///
2478    /// # Errors
2479    /// Object-store I/O or malformed/conflicting outcome inventory.
2480    pub async fn highest_terminal_outcome(
2481        &self,
2482    ) -> Result<Option<CheckpointOutcome>, DecisionError> {
2483        Ok(self.audited_outcomes().await?.pop())
2484    }
2485
2486    /// Greatest durable outcome GC horizon for the current deployment, or zero before pruning.
2487    ///
2488    /// # Errors
2489    /// Object-store I/O or malformed/conflicting floor metadata.
2490    pub async fn outcome_gc_floor_horizon(&self) -> Result<u64, DecisionError> {
2491        Ok(self
2492            .current_outcome_gc_floor()
2493            .await?
2494            .map_or(0, |floor| floor.before_epoch))
2495    }
2496
2497    /// Read scalar continuity metadata for compacted terminal outcomes.
2498    ///
2499    /// The returned boundary can fence external cursor rollback, but it cannot be used to recover
2500    /// a checkpoint. Recovery must select a live commit from [`Self::outcomes`].
2501    ///
2502    /// # Errors
2503    /// Object-store I/O or malformed/conflicting floor metadata.
2504    pub async fn outcome_retention_boundary(
2505        &self,
2506    ) -> Result<OutcomeRetentionBoundary, DecisionError> {
2507        let floor = self.current_outcome_gc_floor().await?;
2508        Ok(floor.map_or(
2509            OutcomeRetentionBoundary {
2510                before_epoch: 0,
2511                committed_checkpoint_id: None,
2512                highest_closed_epoch: None,
2513            },
2514            |floor| OutcomeRetentionBoundary {
2515                before_epoch: floor.before_epoch,
2516                committed_checkpoint_id: floor.committed_anchor.map(|anchor| anchor.checkpoint_id),
2517                highest_closed_epoch: floor.terminal_anchor.map(|anchor| anchor.epoch),
2518            },
2519        ))
2520    }
2521
2522    #[cfg(feature = "cluster")]
2523    async fn quarantine_recovery_capsule_object(
2524        &self,
2525        location: &OsPath,
2526    ) -> Result<(), DecisionError> {
2527        let quarantine = Self::recovery_capsule_quarantine_path(location.as_ref());
2528        match self.store.rename(location, &quarantine).await {
2529            Ok(()) => Ok(()),
2530            Err(error) => match self.store.head(location).await {
2531                Err(object_store::Error::NotFound { .. }) => Ok(()),
2532                Ok(_) => Err(DecisionError::Io(format!(
2533                    "failed to quarantine recovery capsule '{location}': {error}"
2534                ))),
2535                Err(head_error) => Err(DecisionError::Io(format!(
2536                    "failed to quarantine recovery capsule '{location}' ({error}); reconciliation failed ({head_error})"
2537                ))),
2538            },
2539        }
2540    }
2541
2542    /// Perform one bounded-memory cleanup step for capsule epochs below a durable cluster floor.
2543    ///
2544    /// Each step full-scans the unordered listing and processes the lexically oldest bounded batch
2545    /// after the cursor. The cursor wraps so delayed creates and failed paths are retried.
2546    #[cfg(feature = "cluster")]
2547    pub(crate) async fn sweep_recovery_capsules_step(
2548        &self,
2549        before_epoch: u64,
2550        known_live_digests: &std::collections::BTreeSet<String>,
2551    ) -> Result<RecoveryCapsuleGcStep, DecisionError> {
2552        if before_epoch <= 1 {
2553            return Ok(RecoveryCapsuleGcStep {
2554                examined: 0,
2555                deleted: 0,
2556                quarantined: 0,
2557                pending: false,
2558            });
2559        }
2560
2561        let deployment_id = self.load_or_create_deployment_id().await?;
2562        let observed = self.read_recovery_capsule_gc_cursor(&deployment_id).await?;
2563        let (cursor, update_version) = observed.map_or_else(
2564            || {
2565                (
2566                    RecoveryCapsuleGcCursor {
2567                        version: RECOVERY_CAPSULE_GC_CURSOR_VERSION,
2568                        deployment_id: deployment_id.clone(),
2569                        offset: None,
2570                    },
2571                    None,
2572                )
2573            },
2574            |observed| (observed.cursor, Some(observed.update_version)),
2575        );
2576
2577        let root = Self::recovery_capsule_root();
2578        let mut listed = self.store.list(Some(&root));
2579        let mut oldest = BinaryHeap::with_capacity(RECOVERY_CAPSULE_GC_BATCH_SIZE);
2580        let mut eligible = 0usize;
2581        while let Some(entry) = listed.next().await {
2582            let entry = entry.map_err(|error| DecisionError::Io(error.to_string()))?;
2583            if cursor
2584                .offset
2585                .as_deref()
2586                .is_some_and(|offset| entry.location.as_ref() <= offset)
2587            {
2588                continue;
2589            }
2590            eligible = eligible.saturating_add(1);
2591            retain_lexically_oldest_recovery_capsule(&mut oldest, entry);
2592        }
2593        let list_exhausted = eligible <= RECOVERY_CAPSULE_GC_BATCH_SIZE;
2594        let entries = oldest
2595            .into_sorted_vec()
2596            .into_iter()
2597            .map(|candidate| candidate.meta)
2598            .collect::<Vec<_>>();
2599        let examined = entries.len();
2600
2601        let work = futures::stream::iter(entries.iter().cloned())
2602            .map(|entry| async move {
2603                let Some((epoch, checkpoint_id, digest)) =
2604                    Self::recovery_capsule_coordinates_from_path(entry.location.as_ref())
2605                else {
2606                    return match self
2607                        .quarantine_recovery_capsule_object(&entry.location)
2608                        .await
2609                    {
2610                        Ok(()) => RecoveryCapsuleObjectWork::Quarantined,
2611                        Err(error) => {
2612                            tracing::warn!(path = %entry.location, %error, "recovery capsule quarantine failed; retrying on the next scan pass");
2613                            RecoveryCapsuleObjectWork::Failed
2614                        }
2615                    };
2616                };
2617                if epoch >= before_epoch {
2618                    return RecoveryCapsuleObjectWork::Retained;
2619                }
2620                let reference = RecoveryCapsuleRef {
2621                    epoch,
2622                    checkpoint_id,
2623                    sha256: digest.to_owned(),
2624                    len: entry.size,
2625                };
2626                if reference.validate().is_err() {
2627                    return match self
2628                        .quarantine_recovery_capsule_object(&entry.location)
2629                        .await
2630                    {
2631                        Ok(()) => RecoveryCapsuleObjectWork::Quarantined,
2632                        Err(error) => {
2633                            tracing::warn!(path = %entry.location, %error, "recovery capsule quarantine failed; retrying on the next scan pass");
2634                            RecoveryCapsuleObjectWork::Failed
2635                        }
2636                    };
2637                }
2638                if known_live_digests.contains(&reference.sha256) {
2639                    return RecoveryCapsuleObjectWork::Retained;
2640                }
2641                match self.load_recovery_capsule(&reference).await {
2642                    Ok(_) => match self.store.delete(&entry.location).await {
2643                        Ok(()) | Err(object_store::Error::NotFound { .. }) => {
2644                            RecoveryCapsuleObjectWork::Deleted
2645                        }
2646                        Err(error) => {
2647                            tracing::warn!(epoch, checkpoint_id, %error, "recovery capsule delete failed; retrying on the next scan pass");
2648                            RecoveryCapsuleObjectWork::Failed
2649                        }
2650                    },
2651                    Err(DecisionError::Conflict(error)) => {
2652                        if matches!(
2653                            self.store.head(&entry.location).await,
2654                            Err(object_store::Error::NotFound { .. })
2655                        ) {
2656                            return RecoveryCapsuleObjectWork::Deleted;
2657                        }
2658                        match self
2659                            .quarantine_recovery_capsule_object(&entry.location)
2660                            .await
2661                        {
2662                            Ok(()) => {
2663                                tracing::warn!(path = %entry.location, %error, "corrupt recovery capsule quarantined");
2664                                RecoveryCapsuleObjectWork::Quarantined
2665                            }
2666                            Err(quarantine_error) => {
2667                                tracing::warn!(path = %entry.location, %error, %quarantine_error, "corrupt recovery capsule quarantine failed; retrying on the next scan pass");
2668                                RecoveryCapsuleObjectWork::Failed
2669                            }
2670                        }
2671                    }
2672                    Err(error) => {
2673                        tracing::warn!(path = %entry.location, %error, "recovery capsule read failed; retrying on the next scan pass");
2674                        RecoveryCapsuleObjectWork::Failed
2675                    }
2676                }
2677            })
2678            .buffer_unordered(4)
2679            .collect::<Vec<_>>()
2680            .await;
2681
2682        let deleted = work
2683            .iter()
2684            .filter(|result| matches!(result, RecoveryCapsuleObjectWork::Deleted))
2685            .count();
2686        let quarantined = work
2687            .iter()
2688            .filter(|result| matches!(result, RecoveryCapsuleObjectWork::Quarantined))
2689            .count();
2690
2691        let mut updated = cursor.clone();
2692        updated.offset = if list_exhausted {
2693            None
2694        } else {
2695            entries.iter().map(|entry| entry.location.to_string()).max()
2696        };
2697        let cursor_was_absent = update_version.is_none();
2698        if updated != cursor
2699            && !self
2700                .compare_and_swap_recovery_capsule_gc_cursor(&updated, update_version)
2701                .await?
2702        {
2703            return Ok(RecoveryCapsuleGcStep {
2704                examined,
2705                deleted,
2706                quarantined,
2707                pending: true,
2708            });
2709        }
2710        if cursor_was_absent && updated == cursor {
2711            let _ = self
2712                .compare_and_swap_recovery_capsule_gc_cursor(&updated, None)
2713                .await?;
2714        }
2715
2716        Ok(RecoveryCapsuleGcStep {
2717            examined,
2718            deleted,
2719            quarantined,
2720            // Maintenance intentionally keeps cycling while a floor exists so delayed creates
2721            // and previously failed paths cannot be stranded after a quiet pass.
2722            pending: true,
2723        })
2724    }
2725
2726    /// Advance the outcome GC floor for `epoch < before`, then best-effort delete raw
2727    /// tombstoned outcomes.
2728    ///
2729    /// At least one live commit must remain at or above the effective horizon. The embedded anchors
2730    /// preserve terminal and committed-cursor continuity but are never eligible as recovery cuts.
2731    /// Concurrent higher floors supersede this request and are included in the same best-effort
2732    /// sweep.
2733    ///
2734    /// # Errors
2735    /// Object-store I/O, malformed/conflicting inventory, or a horizon that would remove the last
2736    /// recoverable commit outcome.
2737    pub async fn prune_outcomes_before(&self, before: u64) -> Result<u64, DecisionError> {
2738        if before == 0 {
2739            return self.outcome_gc_floor_horizon().await;
2740        }
2741        let deployment_id = self.load_or_create_deployment_id().await?;
2742
2743        // A failed CAS means another worker advanced the floor. Rebuild from that winner instead
2744        // of publishing anchors derived from an older view: reusing a stale candidate could
2745        // discard a terminal outcome admitted between the two floor generations.
2746        loop {
2747            let observed = self.read_outcome_gc_floor(&deployment_id).await?;
2748            if observed
2749                .as_ref()
2750                .is_some_and(|versioned| versioned.floor.before_epoch >= before)
2751            {
2752                // The floor may have become durable immediately before its publisher crashed.
2753                // Re-enter the idempotent sweep so retries repair any raw outcomes or capsules
2754                // left behind by that incomplete retention pass.
2755                break;
2756            }
2757
2758            let outcomes = match self.audited_outcomes().await {
2759                Err(DecisionError::InventoryChanged(_)) => {
2760                    tokio::task::yield_now().await;
2761                    continue;
2762                }
2763                result => result?,
2764            };
2765            if !outcomes
2766                .iter()
2767                .any(|outcome| outcome.epoch >= before && outcome.is_commit())
2768            {
2769                return Err(DecisionError::Conflict(format!(
2770                    "cannot advance outcome GC floor to {before}: no live commit recovery cut would remain"
2771                )));
2772            }
2773            let floor = OutcomeGcFloor {
2774                version: OUTCOME_GC_FLOOR_VERSION,
2775                deployment_id: deployment_id.clone(),
2776                before_epoch: before,
2777                terminal_anchor: outcomes
2778                    .iter()
2779                    .rev()
2780                    .find(|outcome| outcome.epoch < before)
2781                    .cloned(),
2782                committed_anchor: outcomes
2783                    .iter()
2784                    .rev()
2785                    .find(|outcome| outcome.epoch < before && outcome.is_commit())
2786                    .cloned(),
2787            };
2788            let expected = observed.map(|versioned| versioned.update_version);
2789            if self
2790                .compare_and_swap_outcome_gc_floor(&floor, expected)
2791                .await?
2792            {
2793                break;
2794            }
2795            tokio::task::yield_now().await;
2796        }
2797
2798        let mut swept_horizon = 0;
2799        loop {
2800            let effective = self.current_outcome_gc_floor().await?.ok_or_else(|| {
2801                DecisionError::InventoryChanged(
2802                    "outcome GC floor disappeared immediately after publication".into(),
2803                )
2804            })?;
2805            if effective.before_epoch < before || effective.before_epoch < swept_horizon {
2806                return Err(DecisionError::InventoryChanged(format!(
2807                    "outcome GC floor regressed to {} after publishing {before}",
2808                    effective.before_epoch
2809                )));
2810            }
2811            if effective.before_epoch > swept_horizon {
2812                let raw_epochs = self.list_outcome_epochs().await?;
2813                for epoch in raw_epochs
2814                    .into_iter()
2815                    .filter(|epoch| *epoch < effective.before_epoch)
2816                {
2817                    match self.store.delete(&Self::outcome_path(epoch)).await {
2818                        Ok(()) | Err(object_store::Error::NotFound { .. }) => {}
2819                        Err(error) => tracing::warn!(
2820                            epoch,
2821                            %error,
2822                            "outcome prune: tombstoned outcome delete failed"
2823                        ),
2824                    }
2825                }
2826                swept_horizon = effective.before_epoch;
2827            }
2828            let current = self.current_outcome_gc_floor().await?.ok_or_else(|| {
2829                DecisionError::InventoryChanged(
2830                    "outcome GC floor disappeared during best-effort sweep".into(),
2831                )
2832            })?;
2833            if current.before_epoch == swept_horizon {
2834                return Ok(swept_horizon);
2835            }
2836            tokio::task::yield_now().await;
2837        }
2838    }
2839}
2840
2841#[cfg(test)]
2842mod tests;