Skip to main content

reddb_server/cluster/
ownership.rs

1//! The global shard ownership catalog (issue #989, PRD #987, ADR 0037).
2//!
3//! The shard ownership catalog is the **source of truth for range routing and
4//! failover** in a multi-writer cluster. Per the glossary it is *"explicit,
5//! versioned RedDB catalog state that records shard/range bounds, current writer
6//! owner, replicas, and ownership epoch/version"* and — crucially — it is
7//! *"special global control-plane state replicated to all data members rather
8//! than sharded like ordinary user collections"*.
9//!
10//! That last point shapes the whole module. The catalog is the thing that tells
11//! you *where* a collection's data lives, so it cannot itself be located by the
12//! same user-data sharding it describes — that would be circular. Instead every
13//! data member holds a full [`ShardOwnershipCatalog`] replica and routes against
14//! it locally ([`ShardOwnershipCatalog::route`]). Replication is modelled as
15//! shipping versioned [`RangeOwnership`] entries that each member applies through
16//! the same [`apply_update`](ShardOwnershipCatalog::apply_update) path that the
17//! Supervisor leader writes through — and that path rejects stale versions, so a
18//! late or out-of-order replica update can never overwrite newer ownership.
19//!
20//! ## What the catalog records
21//!
22//! One [`RangeOwnership`] entry per owned shard/range carries everything routing
23//! and fencing need:
24//!
25//! * [`CollectionId`] + [`RangeId`] — *which* range of *which* collection.
26//! * [`ShardKeyMode`] — [`Hash`](ShardKeyMode::Hash) (the default, for uniform
27//!   distribution) or [`Ordered`](ShardKeyMode::Ordered) (declared when range
28//!   locality and ordered scans matter more than hotspot resistance).
29//! * [`RangeBounds`] — the half-open `[lower, upper)` partition this entry owns.
30//! * `owner` ([`NodeIdentity`]) + `replicas` — the current single writer for the
31//!   range and its read/catch-up copies.
32//! * [`OwnershipEpoch`] + [`CatalogVersion`] — the fencing epoch (bumped on owner
33//!   change so a stale old owner is fenced) and the monotonic write version
34//!   (bumped on *every* accepted update so stale writes are rejected).
35//! * [`PlacementMetadata`] — replication factor and free-form placement
36//!   attributes (region/zone/weight) the rebalancer reads.
37//!
38//! Ownership changes are produced as *transitions* — new entries built with
39//! [`RangeOwnership::transfer_to`] / [`update_replicas`](RangeOwnership::update_replicas)
40//! / [`update_placement`](RangeOwnership::update_placement) — never arbitrary row
41//! edits, matching ADR 0037's "transitions, not arbitrary row edits".
42//!
43//! Everything here is a pure data model with no I/O, so the routing, versioning,
44//! and replication story is exercised deterministically.
45
46use std::collections::{BTreeMap, BTreeSet};
47
48use super::identity::NodeIdentity;
49use super::slot::hash_shard_key_to_range_key;
50
51/// A user collection's stable identity, as recorded in the catalog.
52///
53/// The catalog is keyed by collection (and range within it); this is the
54/// collection's own name, not a shard-routed handle. Resolving a
55/// [`CollectionId`] to its ranges needs only the catalog itself — no user-data
56/// sharding — which is what lets the catalog be the thing that *bootstraps*
57/// routing.
58#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
59pub struct CollectionId(String);
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CollectionIdError;
63
64impl std::fmt::Display for CollectionIdError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "collection id is empty")
67    }
68}
69
70impl std::error::Error for CollectionIdError {}
71
72impl CollectionId {
73    /// Build a collection id from a non-empty name.
74    pub fn new(value: impl AsRef<str>) -> Result<Self, CollectionIdError> {
75        let value = value.as_ref().trim();
76        if value.is_empty() {
77            return Err(CollectionIdError);
78        }
79        Ok(Self(value.to_string()))
80    }
81
82    pub fn as_str(&self) -> &str {
83        &self.0
84    }
85}
86
87impl std::fmt::Display for CollectionId {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str(&self.0)
90    }
91}
92
93/// Stable identity for the collection group that one Placement Authority owns.
94///
95/// A group is the control-plane sharding unit for placement decisions. Related
96/// collections can share a group so they stay in one authority domain; a large
97/// collection can instead be the group's only member when it needs isolated
98/// placement policy.
99#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
100pub struct CollectionGroupId(String);
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct CollectionGroupIdError;
104
105impl std::fmt::Display for CollectionGroupIdError {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        write!(f, "collection group id is empty")
108    }
109}
110
111impl std::error::Error for CollectionGroupIdError {}
112
113impl CollectionGroupId {
114    /// Build a collection group id from a non-empty name.
115    pub fn new(value: impl AsRef<str>) -> Result<Self, CollectionGroupIdError> {
116        let value = value.as_ref().trim();
117        if value.is_empty() {
118            return Err(CollectionGroupIdError);
119        }
120        Ok(Self(value.to_string()))
121    }
122
123    pub fn for_collection(collection: &CollectionId) -> Self {
124        Self(collection.as_str().to_string())
125    }
126
127    pub fn as_str(&self) -> &str {
128        &self.0
129    }
130}
131
132impl std::fmt::Display for CollectionGroupId {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.write_str(&self.0)
135    }
136}
137
138/// One Placement Authority assignment for a non-overlapping collection-catalog
139/// slice.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct CollectionGroupAuthority {
142    group: CollectionGroupId,
143    authority: NodeIdentity,
144    slices: Vec<AuthorityCatalogSlice>,
145}
146
147/// One range-bounded shard ownership catalog slice published by a Placement
148/// Authority.
149#[derive(Debug, Clone, PartialEq, Eq)]
150struct AuthorityCatalogSlice {
151    collection: CollectionId,
152    bounds: RangeBounds,
153}
154
155impl CollectionGroupAuthority {
156    /// Assign `collections` to one group owned by `authority`.
157    pub fn new(
158        group: CollectionGroupId,
159        authority: NodeIdentity,
160        collections: impl IntoIterator<Item = CollectionId>,
161    ) -> Result<Self, PlacementAuthorityError> {
162        let collections = collections.into_iter().collect::<BTreeSet<_>>();
163        if collections.is_empty() {
164            return Err(PlacementAuthorityError::EmptyCatalogSlice { group });
165        }
166        let slices = collections
167            .into_iter()
168            .map(|collection| AuthorityCatalogSlice {
169                collection,
170                bounds: RangeBounds::full(),
171            })
172            .collect();
173        Ok(Self {
174            group,
175            authority,
176            slices,
177        })
178    }
179
180    /// Assign explicit range-bounded catalog slices to one group.
181    pub fn with_slices(
182        group: CollectionGroupId,
183        authority: NodeIdentity,
184        slices: impl IntoIterator<Item = (CollectionId, RangeBounds)>,
185    ) -> Result<Self, PlacementAuthorityError> {
186        let slices = slices
187            .into_iter()
188            .map(|(collection, bounds)| AuthorityCatalogSlice { collection, bounds })
189            .collect::<Vec<_>>();
190        if slices.is_empty() {
191            return Err(PlacementAuthorityError::EmptyCatalogSlice { group });
192        }
193        Ok(Self {
194            group,
195            authority,
196            slices,
197        })
198    }
199
200    pub fn group(&self) -> &CollectionGroupId {
201        &self.group
202    }
203
204    pub fn authority(&self) -> &NodeIdentity {
205        &self.authority
206    }
207
208    pub fn collections(&self) -> impl Iterator<Item = &CollectionId> {
209        self.slices.iter().map(|slice| &slice.collection)
210    }
211
212    pub fn slices(&self) -> impl Iterator<Item = (&CollectionId, &RangeBounds)> {
213        self.slices
214            .iter()
215            .map(|slice| (&slice.collection, &slice.bounds))
216    }
217
218    fn overlaps(&self, other: &CollectionGroupAuthority) -> Option<(CollectionId, RangeBounds)> {
219        for existing in &self.slices {
220            for attempted in &other.slices {
221                if existing.collection == attempted.collection {
222                    if let Some(overlap) = existing.bounds.intersection(&attempted.bounds) {
223                        return Some((existing.collection.clone(), overlap));
224                    }
225                }
226            }
227        }
228        None
229    }
230}
231
232/// Why a Placement Authority assignment was rejected.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum PlacementAuthorityError {
235    /// A group assignment must publish at least one collection in its catalog
236    /// slice.
237    EmptyCatalogSlice { group: CollectionGroupId },
238    /// The group already has exactly one Placement Authority.
239    DuplicateCollectionGroup { group: CollectionGroupId },
240    /// Two Placement Authority assignments would own the same collection-catalog
241    /// slice.
242    OverlappingCatalogSlice {
243        collection: CollectionId,
244        existing_group: CollectionGroupId,
245        existing_authority: NodeIdentity,
246        attempted_group: CollectionGroupId,
247        attempted_authority: NodeIdentity,
248        overlap: RangeBounds,
249    },
250}
251
252impl std::fmt::Display for PlacementAuthorityError {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        match self {
255            Self::EmptyCatalogSlice { group } => {
256                write!(f, "collection group {group} has an empty catalog slice")
257            }
258            Self::DuplicateCollectionGroup { group } => {
259                write!(
260                    f,
261                    "collection group {group} already has a placement authority"
262                )
263            }
264            Self::OverlappingCatalogSlice {
265                collection,
266                existing_group,
267                existing_authority,
268                attempted_group,
269                attempted_authority,
270                overlap,
271            } => write!(
272                f,
273                "placement authority {attempted_authority} for group {attempted_group} overlaps placement authority {existing_authority} for group {existing_group} on {collection}{overlap}"
274            ),
275        }
276    }
277}
278
279impl std::error::Error for PlacementAuthorityError {}
280
281/// Placement Authority assignments keyed by Collection group.
282#[derive(Debug, Clone, Default)]
283pub struct PlacementAuthorityCatalog {
284    assignments: BTreeMap<CollectionGroupId, CollectionGroupAuthority>,
285}
286
287impl PlacementAuthorityCatalog {
288    /// An empty Placement Authority catalog.
289    pub fn new() -> Self {
290        Self::default()
291    }
292
293    /// Publish one group-owned, non-overlapping collection-catalog slice.
294    pub fn assign(
295        &mut self,
296        assignment: CollectionGroupAuthority,
297    ) -> Result<(), PlacementAuthorityError> {
298        self.validate_assignment(&assignment)?;
299        self.assignments
300            .insert(assignment.group().clone(), assignment);
301        Ok(())
302    }
303
304    pub fn authority_for_group(
305        &self,
306        group: &CollectionGroupId,
307    ) -> Option<&CollectionGroupAuthority> {
308        self.assignments.get(group)
309    }
310
311    pub fn authority_for_collection(
312        &self,
313        collection: &CollectionId,
314    ) -> Option<&CollectionGroupAuthority> {
315        self.assignments.values().find(|assignment| {
316            assignment
317                .slices
318                .iter()
319                .any(|slice| &slice.collection == collection)
320        })
321    }
322
323    /// Replace one group with the two assignments produced by a collection-group
324    /// split. Both replacements are checked against each other and all surviving
325    /// assignments before mutation.
326    pub fn split_collection_group_slice(
327        &mut self,
328        retired_group: &CollectionGroupId,
329        retained: CollectionGroupAuthority,
330        carved: CollectionGroupAuthority,
331    ) -> Result<(), PlacementAuthorityError> {
332        self.replace_assignments([retired_group], [retained, carved])
333    }
334
335    /// Replace multiple adjacent group slices with one merged assignment.
336    pub fn merge_collection_group_slices<'a>(
337        &mut self,
338        retired_groups: impl IntoIterator<Item = &'a CollectionGroupId>,
339        merged: CollectionGroupAuthority,
340    ) -> Result<(), PlacementAuthorityError> {
341        self.replace_assignments(retired_groups, [merged])
342    }
343
344    /// Replace a source group with its retained slice plus a reassigned slice.
345    pub fn reassign_collection_group_slice(
346        &mut self,
347        retired_group: &CollectionGroupId,
348        retained: CollectionGroupAuthority,
349        reassigned: CollectionGroupAuthority,
350    ) -> Result<(), PlacementAuthorityError> {
351        self.replace_assignments([retired_group], [retained, reassigned])
352    }
353
354    /// Administrative replacement uses the same invariant checker as ordinary
355    /// placement transitions; force may bypass operator policy, not catalog
356    /// integrity.
357    pub fn force_replace_assignments<'a>(
358        &mut self,
359        retired_groups: impl IntoIterator<Item = &'a CollectionGroupId>,
360        replacements: impl IntoIterator<Item = CollectionGroupAuthority>,
361    ) -> Result<(), PlacementAuthorityError> {
362        self.replace_assignments(retired_groups, replacements)
363    }
364
365    fn validate_assignment(
366        &self,
367        assignment: &CollectionGroupAuthority,
368    ) -> Result<(), PlacementAuthorityError> {
369        if self.assignments.contains_key(assignment.group()) {
370            return Err(PlacementAuthorityError::DuplicateCollectionGroup {
371                group: assignment.group().clone(),
372            });
373        }
374        for existing in self.assignments.values() {
375            if let Some((collection, overlap)) = existing.overlaps(assignment) {
376                return Err(PlacementAuthorityError::OverlappingCatalogSlice {
377                    collection,
378                    existing_group: existing.group().clone(),
379                    existing_authority: existing.authority().clone(),
380                    attempted_group: assignment.group().clone(),
381                    attempted_authority: assignment.authority().clone(),
382                    overlap,
383                });
384            }
385        }
386        Ok(())
387    }
388
389    fn replace_assignments<'a>(
390        &mut self,
391        retired_groups: impl IntoIterator<Item = &'a CollectionGroupId>,
392        replacements: impl IntoIterator<Item = CollectionGroupAuthority>,
393    ) -> Result<(), PlacementAuthorityError> {
394        let retired_groups = retired_groups.into_iter().cloned().collect::<BTreeSet<_>>();
395        let replacements = replacements.into_iter().collect::<Vec<_>>();
396        let mut next = self
397            .assignments
398            .iter()
399            .filter(|(group, _)| !retired_groups.contains(group))
400            .map(|(group, assignment)| (group.clone(), assignment.clone()))
401            .collect::<BTreeMap<_, _>>();
402
403        for replacement in replacements {
404            if let Some(existing) = next.get(replacement.group()) {
405                if let Some((collection, overlap)) = existing.overlaps(&replacement) {
406                    return Err(PlacementAuthorityError::OverlappingCatalogSlice {
407                        collection,
408                        existing_group: existing.group().clone(),
409                        existing_authority: existing.authority().clone(),
410                        attempted_group: replacement.group().clone(),
411                        attempted_authority: replacement.authority().clone(),
412                        overlap,
413                    });
414                }
415                if existing.authority() != replacement.authority() {
416                    return Err(PlacementAuthorityError::DuplicateCollectionGroup {
417                        group: replacement.group().clone(),
418                    });
419                }
420                let mut merged = existing.clone();
421                merged.slices.extend(replacement.slices);
422                next.insert(merged.group().clone(), merged);
423                continue;
424            }
425            for existing in next.values() {
426                if let Some((collection, overlap)) = existing.overlaps(&replacement) {
427                    return Err(PlacementAuthorityError::OverlappingCatalogSlice {
428                        collection,
429                        existing_group: existing.group().clone(),
430                        existing_authority: existing.authority().clone(),
431                        attempted_group: replacement.group().clone(),
432                        attempted_authority: replacement.authority().clone(),
433                        overlap,
434                    });
435                }
436            }
437            next.insert(replacement.group().clone(), replacement);
438        }
439
440        self.assignments = next;
441        Ok(())
442    }
443}
444
445/// Stable identity of the Placement Authority that publishes a collection group.
446#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
447pub struct PlacementAuthorityId(String);
448
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct PlacementAuthorityIdError;
451
452impl std::fmt::Display for PlacementAuthorityIdError {
453    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454        write!(f, "placement authority id is empty")
455    }
456}
457
458impl std::error::Error for PlacementAuthorityIdError {}
459
460impl PlacementAuthorityId {
461    pub fn new(value: impl AsRef<str>) -> Result<Self, PlacementAuthorityIdError> {
462        let value = value.as_ref().trim();
463        if value.is_empty() {
464            return Err(PlacementAuthorityIdError);
465        }
466        Ok(Self(value.to_string()))
467    }
468
469    pub fn default_global() -> Self {
470        Self("global-placement-authority".to_string())
471    }
472
473    pub fn as_str(&self) -> &str {
474        &self.0
475    }
476}
477
478impl std::fmt::Display for PlacementAuthorityId {
479    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        f.write_str(&self.0)
481    }
482}
483
484/// Stable identifier for one shard/range within a collection.
485///
486/// Ranges are owned at sub-collection granularity (ADR 0037), so a collection
487/// can have many of these — each is one independently-owned, independently-routed
488/// partition. The id is stable across ownership transitions: moving a range to a
489/// new owner keeps its [`RangeId`] and bumps its epoch/version, it does not mint
490/// a new range.
491#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
492pub struct RangeId(u64);
493
494impl RangeId {
495    pub fn new(value: u64) -> Self {
496        Self(value)
497    }
498
499    pub fn value(self) -> u64 {
500        self.0
501    }
502}
503
504impl std::fmt::Display for RangeId {
505    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
506        write!(f, "{}", self.0)
507    }
508}
509
510/// Collection-level partitioning mode for shard/range ownership.
511///
512/// Per the glossary, *"Hash mode is the default for uniform distribution;
513/// ordered mode is declared when range locality and ordered scans matter more
514/// than automatic hotspot resistance."* The mode is fixed per collection: every
515/// range of a collection shares its mode, and an entry whose mode disagrees with
516/// its collection's declared mode is rejected
517/// ([`CatalogError::ShardKeyModeMismatch`]).
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
519pub enum ShardKeyMode {
520    /// Uniform hash distribution — the default. Bounds are over hash tokens.
521    #[default]
522    Hash,
523    /// Ordered key ranges, declared for range locality / ordered scans. Bounds
524    /// are over the ordered shard key itself.
525    Ordered,
526}
527
528/// One edge of a [`RangeBounds`].
529///
530/// Bounds are byte strings so the same type serves both shard key modes: a
531/// [`Hash`](ShardKeyMode::Hash) range bounds hash-token bytes, an
532/// [`Ordered`](ShardKeyMode::Ordered) range bounds the ordered key bytes
533/// directly. [`Min`](RangeBound::Min)/[`Max`](RangeBound::Max) are the open ends
534/// of the keyspace.
535#[derive(Debug, Clone, PartialEq, Eq)]
536pub enum RangeBound {
537    /// The open low end of the keyspace (everything is `>= Min`).
538    Min,
539    /// A concrete boundary key.
540    Key(Vec<u8>),
541    /// The open high end of the keyspace (everything is `< Max`).
542    Max,
543}
544
545impl RangeBound {
546    /// A boundary at the given key bytes.
547    pub fn key(bytes: impl Into<Vec<u8>>) -> Self {
548        RangeBound::Key(bytes.into())
549    }
550
551    /// Total order over keyspace positions: `Min < every Key < Max`, with keys
552    /// compared lexicographically. This is what makes both `contains` and
553    /// `overlaps` plain comparisons.
554    fn position(&self) -> Position<'_> {
555        match self {
556            RangeBound::Min => Position::Min,
557            RangeBound::Key(k) => Position::Key(k),
558            RangeBound::Max => Position::Max,
559        }
560    }
561}
562
563impl std::fmt::Display for RangeBound {
564    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565        match self {
566            Self::Min => f.write_str("Min"),
567            Self::Max => f.write_str("Max"),
568            Self::Key(bytes) => {
569                f.write_str("0x")?;
570                for byte in bytes {
571                    write!(f, "{byte:02x}")?;
572                }
573                Ok(())
574            }
575        }
576    }
577}
578
579#[derive(PartialEq, Eq, PartialOrd, Ord)]
580enum Position<'a> {
581    Min,
582    Key(&'a [u8]),
583    Max,
584}
585
586/// The half-open `[lower, upper)` partition a range owns.
587///
588/// Half-open bounds tile the keyspace without gaps or double-cover: adjacent
589/// ranges share a boundary key that belongs to exactly one of them. `lower` must
590/// be strictly below `upper`, so an empty or inverted range cannot be
591/// constructed.
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub struct RangeBounds {
594    lower: RangeBound,
595    upper: RangeBound,
596}
597
598#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct RangeBoundsError;
600
601impl std::fmt::Display for RangeBoundsError {
602    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603        write!(
604            f,
605            "range lower bound must be strictly below the upper bound"
606        )
607    }
608}
609
610impl std::error::Error for RangeBoundsError {}
611
612impl RangeBounds {
613    /// Bounds from an explicit `[lower, upper)` pair. Errors if the range would
614    /// be empty or inverted (`lower >= upper`).
615    pub fn new(lower: RangeBound, upper: RangeBound) -> Result<Self, RangeBoundsError> {
616        if lower.position() >= upper.position() {
617            return Err(RangeBoundsError);
618        }
619        Ok(Self { lower, upper })
620    }
621
622    /// The whole keyspace, `[Min, Max)` — a single range covering a collection.
623    pub fn full() -> Self {
624        Self {
625            lower: RangeBound::Min,
626            upper: RangeBound::Max,
627        }
628    }
629
630    pub fn lower(&self) -> &RangeBound {
631        &self.lower
632    }
633
634    pub fn upper(&self) -> &RangeBound {
635        &self.upper
636    }
637
638    /// Does `key` fall inside this half-open range? Lower bound inclusive, upper
639    /// bound exclusive — the routing predicate.
640    pub fn contains(&self, key: &[u8]) -> bool {
641        let key = Position::Key(key);
642        self.lower.position() <= key && key < self.upper.position()
643    }
644
645    /// Do these two ranges share any key? Used to keep a collection's ranges
646    /// non-overlapping so routing resolves to exactly one owner.
647    pub fn overlaps(&self, other: &RangeBounds) -> bool {
648        self.lower.position() < other.upper.position()
649            && other.lower.position() < self.upper.position()
650    }
651
652    /// The exact half-open range shared by these bounds, if any.
653    pub fn intersection(&self, other: &RangeBounds) -> Option<RangeBounds> {
654        if !self.overlaps(other) {
655            return None;
656        }
657        let lower = if self.lower.position() >= other.lower.position() {
658            self.lower.clone()
659        } else {
660            other.lower.clone()
661        };
662        let upper = if self.upper.position() <= other.upper.position() {
663            self.upper.clone()
664        } else {
665            other.upper.clone()
666        };
667        Some(RangeBounds { lower, upper })
668    }
669
670    /// Split this `[lower, upper)` range at `at` into a lower child
671    /// `[lower, at)` and an upper child `[at, upper)`. The split point must fall
672    /// **strictly inside** the range (`lower < at < upper`); a point at or
673    /// outside a bound would carve off an empty child and is rejected with
674    /// [`RangeBoundsError`]. The two children tile the original exactly — no gap,
675    /// no overlap — which is what lets a [split-and-move](super::move_range) shrink
676    /// the retained child and create the moved child without making routing
677    /// ambiguous.
678    pub fn split_at(&self, at: &[u8]) -> Result<(RangeBounds, RangeBounds), RangeBoundsError> {
679        let at_pos = Position::Key(at);
680        if at_pos <= self.lower.position() || at_pos >= self.upper.position() {
681            return Err(RangeBoundsError);
682        }
683        let lower = RangeBounds {
684            lower: self.lower.clone(),
685            upper: RangeBound::key(at.to_vec()),
686        };
687        let upper = RangeBounds {
688            lower: RangeBound::key(at.to_vec()),
689            upper: self.upper.clone(),
690        };
691        Ok((lower, upper))
692    }
693}
694
695impl std::fmt::Display for RangeBounds {
696    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        write!(f, "[{}, {})", self.lower, self.upper)
698    }
699}
700
701/// Monotonic write version of a single catalog entry.
702///
703/// Every accepted update to a range bumps its version. An update that does not
704/// strictly advance the version is stale and is rejected — this is the
705/// compare-and-advance rule that makes catalog replication safe regardless of
706/// delivery order.
707#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
708pub struct CatalogVersion(u64);
709
710impl CatalogVersion {
711    /// The version a range is created at.
712    pub fn initial() -> Self {
713        Self(1)
714    }
715
716    pub fn value(self) -> u64 {
717        self.0
718    }
719
720    fn next(self) -> Self {
721        Self(self.0 + 1)
722    }
723}
724
725impl std::fmt::Display for CatalogVersion {
726    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
727        write!(f, "{}", self.0)
728    }
729}
730
731/// Fencing epoch for a range's write authority.
732///
733/// Distinct from [`CatalogVersion`]: the version advances on *any* catalog edit,
734/// but the epoch advances only when **write authority moves** (a new owner). A
735/// WAL/logical record stamped with an epoch older than the catalog's current
736/// epoch is from a fenced old owner and must be rejected (ADR 0037, "fencing is
737/// enforced below routing").
738#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
739pub struct OwnershipEpoch(u64);
740
741impl OwnershipEpoch {
742    /// The epoch a range is created at.
743    pub fn initial() -> Self {
744        Self(1)
745    }
746
747    pub fn value(self) -> u64 {
748        self.0
749    }
750
751    fn next(self) -> Self {
752        Self(self.0 + 1)
753    }
754}
755
756impl std::fmt::Display for OwnershipEpoch {
757    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758        write!(f, "{}", self.0)
759    }
760}
761
762/// Placement metadata the rebalancer reads when planning transitions.
763///
764/// The MVP carries the range's replication factor plus a free-form attribute map
765/// for region/zone/operator-weight hints. It is descriptive control-plane data,
766/// not an authorization source.
767#[derive(Debug, Clone, Default, PartialEq, Eq)]
768pub struct PlacementMetadata {
769    replication_factor: usize,
770    attributes: BTreeMap<String, String>,
771}
772
773impl PlacementMetadata {
774    /// Placement with a target replication factor and no attributes.
775    pub fn with_replication_factor(replication_factor: usize) -> Self {
776        Self {
777            replication_factor,
778            attributes: BTreeMap::new(),
779        }
780    }
781
782    /// Attach a placement attribute (e.g. `region` → `us-east-1`).
783    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
784        self.attributes.insert(key.into(), value.into());
785        self
786    }
787
788    pub fn replication_factor(&self) -> usize {
789        self.replication_factor
790    }
791
792    pub fn attribute(&self, key: &str) -> Option<&str> {
793        self.attributes.get(key).map(String::as_str)
794    }
795}
796
797/// One owned shard/range: the catalog's unit of routing and fencing.
798///
799/// An entry is self-describing — it carries its collection, range id, mode,
800/// bounds, owner, replicas, epoch, version, and placement — so a data member
801/// that receives it by replication can route and fence without consulting any
802/// other state. New ownership states are produced as *transitions* off an
803/// existing entry ([`transfer_to`](Self::transfer_to),
804/// [`update_replicas`](Self::update_replicas),
805/// [`update_placement`](Self::update_placement)), each of which advances the
806/// version — and, for an owner change, the fencing epoch.
807#[derive(Debug, Clone, PartialEq, Eq)]
808pub struct RangeOwnership {
809    collection: CollectionId,
810    collection_group: CollectionGroupId,
811    placement_authority: PlacementAuthorityId,
812    range_id: RangeId,
813    shard_key_mode: ShardKeyMode,
814    bounds: RangeBounds,
815    owner: NodeIdentity,
816    replicas: Vec<NodeIdentity>,
817    compressed_archive_replicas: Vec<NodeIdentity>,
818    epoch: OwnershipEpoch,
819    version: CatalogVersion,
820    placement: PlacementMetadata,
821}
822
823#[derive(Debug, Clone, PartialEq, Eq)]
824pub struct HotMirrorCandidate {
825    node: NodeIdentity,
826    durable_lsn: u64,
827}
828
829impl HotMirrorCandidate {
830    pub fn new(node: NodeIdentity, durable_lsn: u64) -> Self {
831        Self { node, durable_lsn }
832    }
833
834    pub fn node(&self) -> &NodeIdentity {
835        &self.node
836    }
837
838    pub fn durable_lsn(&self) -> u64 {
839        self.durable_lsn
840    }
841}
842
843#[derive(Debug, Clone, PartialEq, Eq)]
844pub enum HotMirrorPromotionRefusal {
845    NotReplica {
846        candidate: NodeIdentity,
847        role: RangeRole,
848    },
849    WatermarkNotCovered {
850        candidate_lsn: u64,
851        watermark: u64,
852    },
853}
854
855#[derive(Debug, Clone, PartialEq, Eq)]
856pub struct HotMirrorPromotion {
857    previous: RangeOwnership,
858    promoted: RangeOwnership,
859}
860
861impl HotMirrorPromotion {
862    pub fn previous(&self) -> &RangeOwnership {
863        &self.previous
864    }
865
866    pub fn promoted(&self) -> &RangeOwnership {
867        &self.promoted
868    }
869}
870
871impl RangeOwnership {
872    /// The initial ownership state for a freshly created range: version and
873    /// epoch both at their [`initial`](CatalogVersion::initial) values.
874    #[allow(clippy::too_many_arguments)]
875    pub fn establish(
876        collection: CollectionId,
877        range_id: RangeId,
878        shard_key_mode: ShardKeyMode,
879        bounds: RangeBounds,
880        owner: NodeIdentity,
881        replicas: impl IntoIterator<Item = NodeIdentity>,
882        placement: PlacementMetadata,
883    ) -> Self {
884        let collection_group = CollectionGroupId::for_collection(&collection);
885        Self {
886            collection,
887            collection_group,
888            placement_authority: PlacementAuthorityId::default_global(),
889            range_id,
890            shard_key_mode,
891            bounds,
892            owner,
893            replicas: replicas.into_iter().collect(),
894            compressed_archive_replicas: Vec::new(),
895            epoch: OwnershipEpoch::initial(),
896            version: CatalogVersion::initial(),
897            placement,
898        }
899    }
900
901    pub fn collection(&self) -> &CollectionId {
902        &self.collection
903    }
904
905    pub fn collection_group(&self) -> &CollectionGroupId {
906        &self.collection_group
907    }
908
909    pub fn placement_authority(&self) -> &PlacementAuthorityId {
910        &self.placement_authority
911    }
912
913    pub fn range_id(&self) -> RangeId {
914        self.range_id
915    }
916
917    pub fn shard_key_mode(&self) -> ShardKeyMode {
918        self.shard_key_mode
919    }
920
921    pub fn bounds(&self) -> &RangeBounds {
922        &self.bounds
923    }
924
925    pub fn owner(&self) -> &NodeIdentity {
926        &self.owner
927    }
928
929    pub fn replicas(&self) -> &[NodeIdentity] {
930        &self.replicas
931    }
932
933    /// Promotable hot mirrors for this range. These are caught-up copies that
934    /// may become owner through a fenced promotion; they are not write
935    /// authorities while listed here.
936    pub fn hot_mirror_replicas(&self) -> &[NodeIdentity] {
937        &self.replicas
938    }
939
940    /// Restore-only compressed archive replicas. These copies are retained for
941    /// recovery and must not be treated as promotion candidates until a later
942    /// validation step proves them usable.
943    pub fn compressed_archive_replicas(&self) -> &[NodeIdentity] {
944        &self.compressed_archive_replicas
945    }
946
947    pub fn epoch(&self) -> OwnershipEpoch {
948        self.epoch
949    }
950
951    pub fn version(&self) -> CatalogVersion {
952        self.version
953    }
954
955    pub fn placement(&self) -> &PlacementMetadata {
956        &self.placement
957    }
958
959    /// The catalog key for this entry: `(collection, range_id)`.
960    fn key(&self) -> (CollectionId, RangeId) {
961        (self.collection.clone(), self.range_id)
962    }
963
964    pub fn with_collection_group(mut self, collection_group: CollectionGroupId) -> Self {
965        self.collection_group = collection_group;
966        self
967    }
968
969    pub fn with_placement_authority(mut self, placement_authority: PlacementAuthorityId) -> Self {
970        self.placement_authority = placement_authority;
971        self
972    }
973
974    /// A transition that moves write authority to `new_owner` with `new_replicas`.
975    /// Advances **both** the version (it is a catalog write) and the ownership
976    /// epoch (write authority moved, so any old owner is fenced).
977    pub fn transfer_to(
978        &self,
979        new_owner: NodeIdentity,
980        new_replicas: impl IntoIterator<Item = NodeIdentity>,
981    ) -> Self {
982        Self {
983            owner: new_owner,
984            replicas: new_replicas.into_iter().collect(),
985            epoch: self.epoch.next(),
986            version: self.version.next(),
987            ..self.clone()
988        }
989    }
990
991    /// Promote a hot mirror only if it is a current replica and its durable
992    /// frontier covers the range commit watermark. The accepted path delegates
993    /// to [`transfer_to`](Self::transfer_to), so publishing the promotion bumps
994    /// the ownership epoch before the new owner can pass the write gate.
995    pub fn promote_hot_mirror(
996        &self,
997        candidate: &HotMirrorCandidate,
998        commit_watermark: u64,
999    ) -> Result<HotMirrorPromotion, HotMirrorPromotionRefusal> {
1000        let role = self.role_of(candidate.node());
1001        if role != RangeRole::Replica {
1002            return Err(HotMirrorPromotionRefusal::NotReplica {
1003                candidate: candidate.node().clone(),
1004                role,
1005            });
1006        }
1007        if candidate.durable_lsn() < commit_watermark {
1008            return Err(HotMirrorPromotionRefusal::WatermarkNotCovered {
1009                candidate_lsn: candidate.durable_lsn(),
1010                watermark: commit_watermark,
1011            });
1012        }
1013
1014        let new_replicas = std::iter::once(self.owner().clone())
1015            .chain(
1016                self.replicas()
1017                    .iter()
1018                    .filter(|replica| *replica != candidate.node())
1019                    .cloned(),
1020            )
1021            .collect::<Vec<_>>();
1022        Ok(HotMirrorPromotion {
1023            previous: self.clone(),
1024            promoted: self.transfer_to(candidate.node().clone(), new_replicas),
1025        })
1026    }
1027
1028    /// A transition that changes only the replica set. Advances the version but
1029    /// **not** the epoch: write authority did not move, so no owner is fenced.
1030    pub fn update_replicas(&self, new_replicas: impl IntoIterator<Item = NodeIdentity>) -> Self {
1031        Self {
1032            replicas: new_replicas.into_iter().collect(),
1033            version: self.version.next(),
1034            ..self.clone()
1035        }
1036    }
1037
1038    /// A transition that changes the restore-only archive replica set. Advances
1039    /// the version but not the epoch because write authority did not move.
1040    pub fn with_compressed_archive_replicas(
1041        &self,
1042        archive_replicas: impl IntoIterator<Item = NodeIdentity>,
1043    ) -> Self {
1044        Self {
1045            compressed_archive_replicas: archive_replicas.into_iter().collect(),
1046            version: self.version.next(),
1047            ..self.clone()
1048        }
1049    }
1050
1051    /// A transition that changes both non-owner replica roles together.
1052    pub fn update_replica_roles(
1053        &self,
1054        hot_mirror_replicas: impl IntoIterator<Item = NodeIdentity>,
1055        compressed_archive_replicas: impl IntoIterator<Item = NodeIdentity>,
1056    ) -> Self {
1057        Self {
1058            replicas: hot_mirror_replicas.into_iter().collect(),
1059            compressed_archive_replicas: compressed_archive_replicas.into_iter().collect(),
1060            version: self.version.next(),
1061            ..self.clone()
1062        }
1063    }
1064
1065    /// A transition that changes only placement metadata. Advances the version
1066    /// but not the epoch.
1067    pub fn update_placement(&self, placement: PlacementMetadata) -> Self {
1068        Self {
1069            placement,
1070            version: self.version.next(),
1071            ..self.clone()
1072        }
1073    }
1074
1075    /// A transition that **re-bounds** the range without moving write authority —
1076    /// the retained-child step of a range split, which narrows this entry to the
1077    /// keys its owner keeps while a sibling entry takes the carved-off subrange.
1078    /// Advances the version but **not** the epoch: the same owner keeps writing
1079    /// the retained keys, so no one is fenced.
1080    pub fn with_bounds(&self, bounds: RangeBounds) -> Self {
1081        Self {
1082            bounds,
1083            version: self.version.next(),
1084            ..self.clone()
1085        }
1086    }
1087
1088    /// This node's [`RangeRole`] for *this* range (issue #990).
1089    ///
1090    /// A data member is the single writer ([`Owner`](RangeRole::Owner)) of a
1091    /// range, holds a read/catch-up copy ([`Replica`](RangeRole::Replica)), or
1092    /// holds no copy at all ([`NoCopy`](RangeRole::NoCopy)). The role is
1093    /// per-range, not a global node role: the same node can be owner of one
1094    /// range, replica of another, and uninvolved in a third — which is why this
1095    /// is the input to the ownership-aware public-write gate rather than the
1096    /// instance-wide [`WriteGate`](crate::runtime::write_gate::WriteGate).
1097    pub fn role_of(&self, node: &NodeIdentity) -> RangeRole {
1098        if self.owner == *node {
1099            RangeRole::Owner
1100        } else if self.replicas.iter().any(|replica| replica == node) {
1101            RangeRole::Replica
1102        } else if self
1103            .compressed_archive_replicas
1104            .iter()
1105            .any(|archive_replica| archive_replica == node)
1106        {
1107            RangeRole::ArchiveReplica
1108        } else {
1109            RangeRole::NoCopy
1110        }
1111    }
1112
1113    /// This node's non-owner replica role for this range, if it has one.
1114    pub fn replica_role_of(&self, node: &NodeIdentity) -> Option<ReplicaRole> {
1115        if self.replicas.iter().any(|replica| replica == node) {
1116            Some(ReplicaRole::HotMirror)
1117        } else if self
1118            .compressed_archive_replicas
1119            .iter()
1120            .any(|replica| replica == node)
1121        {
1122            Some(ReplicaRole::CompressedArchive)
1123        } else {
1124            None
1125        }
1126    }
1127
1128    fn validate_replica_roles(&self) -> Result<(), CatalogError> {
1129        let mut seen = BTreeSet::new();
1130        for replica in &self.replicas {
1131            if replica == &self.owner || !seen.insert(replica) {
1132                return Err(CatalogError::InvalidReplicaRoles {
1133                    collection: self.collection.clone(),
1134                    range_id: self.range_id,
1135                });
1136            }
1137        }
1138        for replica in &self.compressed_archive_replicas {
1139            if replica == &self.owner || !seen.insert(replica) {
1140                return Err(CatalogError::InvalidReplicaRoles {
1141                    collection: self.collection.clone(),
1142                    range_id: self.range_id,
1143                });
1144            }
1145        }
1146        Ok(())
1147    }
1148}
1149
1150/// The explicit role of a non-owner range replica.
1151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1152pub enum ReplicaRole {
1153    /// A caught-up copy that can be considered for fenced promotion. It is not a
1154    /// write authority until promotion installs it as owner.
1155    HotMirror,
1156    /// A compressed restore copy. It is restore-only until separate validation
1157    /// proves it can safely rejoin the hot set.
1158    CompressedArchive,
1159}
1160
1161impl ReplicaRole {
1162    pub fn is_promotion_candidate(self) -> bool {
1163        matches!(self, ReplicaRole::HotMirror)
1164    }
1165
1166    pub fn is_restore_only(self) -> bool {
1167        matches!(self, ReplicaRole::CompressedArchive)
1168    }
1169}
1170
1171/// A data member's role for one specific range (issue #990, PRD #987).
1172///
1173/// Distinguishes the three positions a node can hold relative to a range, which
1174/// the ownership-aware write gate
1175/// ([`admit_public_write`](ShardOwnershipCatalog::admit_public_write)) turns
1176/// into an allow/reject decision: only the current [`Owner`](Self::Owner) may
1177/// take a *public* write for the range; a [`Replica`](Self::Replica) and a
1178/// [`NoCopy`](Self::NoCopy) node both reject it and the caller must route to the
1179/// owner. (A replica still applies the owner's changes through the privileged
1180/// internal apply path — that path is gated by the range-authority fence from
1181/// issue #991, not by this public gate.)
1182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1183pub enum RangeRole {
1184    /// The current single writer for the range — the only role a public write
1185    /// may land on.
1186    Owner,
1187    /// Holds a read/catch-up copy but is not the writer. Public writes are
1188    /// rejected and routed to the owner; replicated changes still flow in via
1189    /// the privileged internal apply path.
1190    Replica,
1191    /// Holds a compressed archive copy. Archive replicas are recovery sources
1192    /// only after restore, checksum validation, and watermark validation.
1193    ArchiveReplica,
1194    /// Holds no copy of the range at all.
1195    NoCopy,
1196}
1197
1198impl RangeRole {
1199    /// Whether this role may accept a *public* write for the range. Only the
1200    /// owner may; replica and no-copy may not.
1201    pub fn may_write_public(self) -> bool {
1202        matches!(self, RangeRole::Owner)
1203    }
1204
1205    pub fn is_direct_promotion_candidate(self) -> bool {
1206        matches!(self, RangeRole::Replica)
1207    }
1208
1209    fn label(self) -> &'static str {
1210        match self {
1211            RangeRole::Owner => "owner",
1212            RangeRole::Replica => "replica",
1213            RangeRole::ArchiveReplica => "archive-replica",
1214            RangeRole::NoCopy => "no-copy",
1215        }
1216    }
1217}
1218
1219/// Whether an accepted update created a new range or advanced an existing one.
1220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1221pub enum UpdateOutcome {
1222    /// The range did not exist and was created at this version.
1223    Created,
1224    /// An existing range advanced to a newer version.
1225    Updated,
1226}
1227
1228/// Why a catalog update was rejected.
1229#[derive(Debug, Clone, PartialEq, Eq)]
1230pub enum CatalogError {
1231    /// The update's version did not strictly advance the range's current
1232    /// version — a stale or out-of-order write. Carries both versions so the
1233    /// caller (or a replica) can see how far behind it was.
1234    StaleVersion {
1235        collection: CollectionId,
1236        range_id: RangeId,
1237        current: CatalogVersion,
1238        attempted: CatalogVersion,
1239    },
1240    /// The entry's shard key mode disagrees with the collection's declared mode.
1241    /// A collection is hash- *or* ordered-partitioned, never both.
1242    ShardKeyModeMismatch {
1243        collection: CollectionId,
1244        declared: ShardKeyMode,
1245        attempted: ShardKeyMode,
1246    },
1247    /// Creating this range would overlap an existing range of the same
1248    /// collection, which would make routing ambiguous.
1249    OverlappingRange {
1250        collection: CollectionId,
1251        existing: RangeId,
1252        attempted: RangeId,
1253    },
1254    /// The owner was also listed as a non-owner replica, or one node appeared in
1255    /// more than one replica role. That would blur the single-writer invariant.
1256    InvalidReplicaRoles {
1257        collection: CollectionId,
1258        range_id: RangeId,
1259    },
1260}
1261
1262impl std::fmt::Display for CatalogError {
1263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1264        match self {
1265            Self::StaleVersion {
1266                collection,
1267                range_id,
1268                current,
1269                attempted,
1270            } => write!(
1271                f,
1272                "stale catalog update for {collection}/{range_id}: current version {current}, attempted {attempted}"
1273            ),
1274            Self::ShardKeyModeMismatch {
1275                collection,
1276                declared,
1277                attempted,
1278            } => write!(
1279                f,
1280                "collection {collection} is declared {declared:?} but range uses {attempted:?}"
1281            ),
1282            Self::OverlappingRange {
1283                collection,
1284                existing,
1285                attempted,
1286            } => write!(
1287                f,
1288                "range {attempted} overlaps existing range {existing} of collection {collection}"
1289            ),
1290            Self::InvalidReplicaRoles {
1291                collection,
1292                range_id,
1293            } => write!(
1294                f,
1295                "range {range_id} of collection {collection} has invalid replica role metadata"
1296            ),
1297        }
1298    }
1299}
1300
1301impl std::error::Error for CatalogError {}
1302
1303/// Why an ownership-aware *public* write was rejected (issue #990).
1304///
1305/// This is a **routing/ownership** error, deliberately distinct from the
1306/// instance-wide read-only rejection raised by
1307/// [`WriteGate`](crate::runtime::write_gate::WriteGate): a replica/non-holder
1308/// rejecting a public write is not "this node is read-only", it is "this node is
1309/// not the authority for *this range* — route to the owner". Crucially, none of
1310/// these rejections fall back to the privileged internal replica-apply path; a
1311/// public write that is not for this node's owned range never reaches storage.
1312#[derive(Debug, Clone, PartialEq, Eq)]
1313pub enum RangeWriteReject {
1314    /// No range of the collection covers the routed key, so the write cannot be
1315    /// placed. The caller must (re)resolve routing against a fresher catalog.
1316    NoRange { collection: CollectionId },
1317    /// This node holds the range but is not its owner (a [`Replica`]), or holds
1318    /// no copy at all ([`NoCopy`]). Either way a public write must be routed to
1319    /// `owner`, never applied locally.
1320    ///
1321    /// [`Replica`]: RangeRole::Replica
1322    /// [`NoCopy`]: RangeRole::NoCopy
1323    NotOwner {
1324        collection: CollectionId,
1325        range_id: RangeId,
1326        role: RangeRole,
1327        owner: NodeIdentity,
1328    },
1329    /// This node *is* the range owner, but the write was authorised under an
1330    /// ownership epoch that no longer matches the catalog — a write fenced out
1331    /// because ownership has since moved (its epoch advanced). Carries both
1332    /// epochs so the caller can see how far the routing decision was behind.
1333    StaleEpoch {
1334        collection: CollectionId,
1335        range_id: RangeId,
1336        expected: OwnershipEpoch,
1337        current: OwnershipEpoch,
1338    },
1339}
1340
1341impl std::fmt::Display for RangeWriteReject {
1342    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1343        match self {
1344            Self::NoRange { collection } => write!(
1345                f,
1346                "no range of collection {collection} covers the routed key — re-resolve routing"
1347            ),
1348            Self::NotOwner {
1349                collection,
1350                range_id,
1351                role,
1352                owner,
1353            } => write!(
1354                f,
1355                "this node is {} of {collection}/{range_id}, not its owner — route the write to {owner}",
1356                role.label()
1357            ),
1358            Self::StaleEpoch {
1359                collection,
1360                range_id,
1361                expected,
1362                current,
1363            } => write!(
1364                f,
1365                "stale ownership epoch for {collection}/{range_id}: write authorised under epoch {expected}, current is {current}"
1366            ),
1367        }
1368    }
1369}
1370
1371impl std::error::Error for RangeWriteReject {}
1372
1373/// The global shard ownership catalog held by every data member.
1374///
1375/// This single type plays both roles in ADR 0037's model: it is the authoritative
1376/// state the Cluster Supervisor leader writes through, and it is the replica each
1377/// data member holds and routes against. Both write through
1378/// [`apply_update`](Self::apply_update), so the stale-version rejection that
1379/// makes leader writes versioned is the *same* rule that makes replica
1380/// application order-independent. Nothing here needs user-data sharding to find
1381/// an entry: ranges are addressed directly by `(collection, range_id)`, and
1382/// routing ([`route`](Self::route)) is a local scan of the replica.
1383#[derive(Debug, Clone, Default)]
1384pub struct ShardOwnershipCatalog {
1385    /// Declared shard key mode per collection. A collection is recorded here the
1386    /// moment its first range is created (or via [`declare_collection`]).
1387    ///
1388    /// [`declare_collection`]: Self::declare_collection
1389    collections: BTreeMap<CollectionId, ShardKeyMode>,
1390    ranges: BTreeMap<(CollectionId, RangeId), RangeOwnership>,
1391}
1392
1393impl ShardOwnershipCatalog {
1394    /// An empty catalog — a cluster with no collections placed yet.
1395    pub fn new() -> Self {
1396        Self::default()
1397    }
1398
1399    /// Declare a collection's shard key mode up front. Hash is the default, so
1400    /// this is mainly how an operator opts a collection into
1401    /// [`Ordered`](ShardKeyMode::Ordered) mode before any range exists. Declaring
1402    /// the same mode twice is idempotent; redeclaring a different mode for a
1403    /// collection that already has a mode is a [`ShardKeyModeMismatch`].
1404    ///
1405    /// [`ShardKeyModeMismatch`]: CatalogError::ShardKeyModeMismatch
1406    pub fn declare_collection(
1407        &mut self,
1408        collection: CollectionId,
1409        mode: ShardKeyMode,
1410    ) -> Result<(), CatalogError> {
1411        match self.collections.get(&collection) {
1412            Some(&declared) if declared != mode => Err(CatalogError::ShardKeyModeMismatch {
1413                collection,
1414                declared,
1415                attempted: mode,
1416            }),
1417            _ => {
1418                self.collections.insert(collection, mode);
1419                Ok(())
1420            }
1421        }
1422    }
1423
1424    /// The declared shard key mode of `collection`, if it has any ranges or was
1425    /// explicitly declared.
1426    pub fn shard_key_mode(&self, collection: &CollectionId) -> Option<ShardKeyMode> {
1427        self.collections.get(collection).copied()
1428    }
1429
1430    /// Apply a versioned ownership update — the single write path for both leader
1431    /// writes and replica application.
1432    ///
1433    /// Creation (the range does not yet exist) auto-declares the collection's
1434    /// mode from the entry and checks the new range does not overlap a sibling.
1435    /// Updating an existing range requires the entry's version to **strictly
1436    /// advance** the current version; anything else is a
1437    /// [`StaleVersion`](CatalogError::StaleVersion) rejection that leaves the
1438    /// catalog untouched. Either way the entry's mode must match the collection's
1439    /// declared mode.
1440    pub fn apply_update(&mut self, entry: RangeOwnership) -> Result<UpdateOutcome, CatalogError> {
1441        entry.validate_replica_roles()?;
1442
1443        // Mode must agree with the collection (auto-declared on first range).
1444        match self.collections.get(entry.collection()) {
1445            Some(&declared) if declared != entry.shard_key_mode() => {
1446                return Err(CatalogError::ShardKeyModeMismatch {
1447                    collection: entry.collection().clone(),
1448                    declared,
1449                    attempted: entry.shard_key_mode(),
1450                });
1451            }
1452            _ => {}
1453        }
1454
1455        let key = entry.key();
1456        match self.ranges.get(&key) {
1457            Some(current) => {
1458                if entry.version() <= current.version() {
1459                    return Err(CatalogError::StaleVersion {
1460                        collection: entry.collection().clone(),
1461                        range_id: entry.range_id(),
1462                        current: current.version(),
1463                        attempted: entry.version(),
1464                    });
1465                }
1466                if let Some(existing) = self.overlapping_sibling(&entry) {
1467                    return Err(CatalogError::OverlappingRange {
1468                        collection: entry.collection().clone(),
1469                        existing,
1470                        attempted: entry.range_id(),
1471                    });
1472                }
1473                self.collections
1474                    .insert(entry.collection().clone(), entry.shard_key_mode());
1475                self.ranges.insert(key, entry);
1476                Ok(UpdateOutcome::Updated)
1477            }
1478            None => {
1479                // Creating a range: it must not overlap any sibling range of the
1480                // same collection, or routing would be ambiguous.
1481                if let Some(existing) = self.overlapping_sibling(&entry) {
1482                    return Err(CatalogError::OverlappingRange {
1483                        collection: entry.collection().clone(),
1484                        existing,
1485                        attempted: entry.range_id(),
1486                    });
1487                }
1488                self.collections
1489                    .insert(entry.collection().clone(), entry.shard_key_mode());
1490                self.ranges.insert(key, entry);
1491                Ok(UpdateOutcome::Created)
1492            }
1493        }
1494    }
1495
1496    fn overlapping_sibling(&self, entry: &RangeOwnership) -> Option<RangeId> {
1497        self.ranges_for(entry.collection())
1498            .find(|range| {
1499                range.range_id() != entry.range_id() && range.bounds().overlaps(entry.bounds())
1500            })
1501            .map(RangeOwnership::range_id)
1502    }
1503
1504    /// The current ownership of one range, addressed directly by identity — no
1505    /// routing required, because the catalog is what routing is built on.
1506    pub fn range(&self, collection: &CollectionId, range_id: RangeId) -> Option<&RangeOwnership> {
1507        self.ranges.get(&(collection.clone(), range_id))
1508    }
1509
1510    /// Every range of `collection`, in range-id order.
1511    pub fn ranges_for<'a>(
1512        &'a self,
1513        collection: &CollectionId,
1514    ) -> impl Iterator<Item = &'a RangeOwnership> {
1515        let collection = collection.clone();
1516        self.ranges
1517            .iter()
1518            .filter(move |((c, _), _)| *c == collection)
1519            .map(|(_, r)| r)
1520    }
1521
1522    /// Route a normalized range key to the range that owns it — the catalog read
1523    /// every routing decision makes. Returns the owning [`RangeOwnership`] (whose
1524    /// `owner`, `epoch`, and `replicas` the caller uses to send and fence the
1525    /// write), or `None` if no range covers the key yet.
1526    pub fn route(&self, collection: &CollectionId, key: &[u8]) -> Option<&RangeOwnership> {
1527        self.ranges_for(collection)
1528            .find(|r| r.bounds().contains(key))
1529    }
1530
1531    /// Route a logical shard key according to the collection's declared mode.
1532    ///
1533    /// Ordered collections route the shard key bytes directly. Hash collections
1534    /// first map the shard key into a stable hash slot and route that slot's
1535    /// range key through the same `collection -> range` catalog.
1536    pub fn route_shard_key(
1537        &self,
1538        collection: &CollectionId,
1539        shard_key: &[u8],
1540    ) -> Option<&RangeOwnership> {
1541        match self.shard_key_mode(collection)? {
1542            ShardKeyMode::Ordered => self.route(collection, shard_key),
1543            ShardKeyMode::Hash => {
1544                let range_key = hash_shard_key_to_range_key(shard_key);
1545                self.route(collection, &range_key)
1546            }
1547        }
1548    }
1549
1550    /// This node's [`RangeRole`] for a directly-addressed range (issue #990).
1551    /// Returns `None` when no such range exists in the catalog — distinct from
1552    /// [`NoCopy`](RangeRole::NoCopy), which means the range exists but this node
1553    /// holds no copy of it.
1554    pub fn role_at(
1555        &self,
1556        node: &NodeIdentity,
1557        collection: &CollectionId,
1558        range_id: RangeId,
1559    ) -> Option<RangeRole> {
1560        self.range(collection, range_id)
1561            .map(|range| range.role_of(node))
1562    }
1563
1564    /// Ownership-aware gate for a **public** write (issue #990, PRD #987).
1565    ///
1566    /// Routes `key` to its range, then admits the write only when `node` is the
1567    /// range's current [`Owner`](RangeRole::Owner) **and** `expected_epoch`
1568    /// matches the range's current ownership epoch. On success returns the owned
1569    /// [`RangeOwnership`] (so the caller can proceed with the write against the
1570    /// authoritative epoch); otherwise a [`RangeWriteReject`] explaining why.
1571    ///
1572    /// This is the public surface's gate — the counterpart of the instance-wide
1573    /// [`WriteGate`](crate::runtime::write_gate::WriteGate) for multi-writer,
1574    /// per-range ownership. The internal replica-apply path does **not** consult
1575    /// it: replicated changes flow into a replica through the privileged apply
1576    /// path (fenced by issue #991's range-authority watermark), so a node that
1577    /// rejects a *public* write here can still legitimately apply the owner's
1578    /// replicated changes for the very same range.
1579    pub fn admit_public_write(
1580        &self,
1581        node: &NodeIdentity,
1582        collection: &CollectionId,
1583        key: &[u8],
1584        expected_epoch: OwnershipEpoch,
1585    ) -> Result<&RangeOwnership, RangeWriteReject> {
1586        let range =
1587            self.route_shard_key(collection, key)
1588                .ok_or_else(|| RangeWriteReject::NoRange {
1589                    collection: collection.clone(),
1590                })?;
1591        let role = range.role_of(node);
1592        if !role.may_write_public() {
1593            return Err(RangeWriteReject::NotOwner {
1594                collection: collection.clone(),
1595                range_id: range.range_id(),
1596                role,
1597                owner: range.owner().clone(),
1598            });
1599        }
1600        if expected_epoch != range.epoch() {
1601            return Err(RangeWriteReject::StaleEpoch {
1602                collection: collection.clone(),
1603                range_id: range.range_id(),
1604                expected: expected_epoch,
1605                current: range.epoch(),
1606            });
1607        }
1608        Ok(range)
1609    }
1610
1611    /// Total number of owned ranges across all collections.
1612    pub fn range_count(&self) -> usize {
1613        self.ranges.len()
1614    }
1615
1616    /// All ranges, in `(collection, range_id)` order — the full catalog content
1617    /// a joining member adopts as its starting replica
1618    /// (see [`ControlPlaneSnapshot`](super::join::ControlPlaneSnapshot)).
1619    pub fn entries(&self) -> impl Iterator<Item = &RangeOwnership> {
1620        self.ranges.values()
1621    }
1622}
1623
1624#[cfg(test)]
1625mod tests {
1626    use super::*;
1627
1628    fn collection(name: &str) -> CollectionId {
1629        CollectionId::new(name).unwrap()
1630    }
1631
1632    fn ident(cn: &str) -> NodeIdentity {
1633        NodeIdentity::from_certificate_subject(cn).unwrap()
1634    }
1635
1636    fn bounds(lower: &[u8], upper: &[u8]) -> RangeBounds {
1637        RangeBounds::new(RangeBound::key(lower), RangeBound::key(upper)).unwrap()
1638    }
1639
1640    /// A hash range over `[lower, Max)` owned by `owner`.
1641    fn hash_range(coll: &CollectionId, id: u64, bnds: RangeBounds, owner: &str) -> RangeOwnership {
1642        RangeOwnership::establish(
1643            coll.clone(),
1644            RangeId::new(id),
1645            ShardKeyMode::Hash,
1646            bnds,
1647            ident(owner),
1648            [ident("CN=replica-1")],
1649            PlacementMetadata::with_replication_factor(3),
1650        )
1651    }
1652
1653    fn single_hash_slot_bounds(key: &[u8]) -> RangeBounds {
1654        let slot = super::super::slot::hash_shard_key_to_slot(key);
1655        let lower = RangeBound::key(slot.range_key());
1656        let upper = match slot.value().checked_add(1) {
1657            Some(next) if next < super::super::slot::PRODUCTION_HASH_SLOT_COUNT => {
1658                RangeBound::key(super::super::slot::HashSlot::new(next).unwrap().range_key())
1659            }
1660            _ => RangeBound::Max,
1661        };
1662        RangeBounds::new(lower, upper).unwrap()
1663    }
1664
1665    #[test]
1666    fn hot_mirror_behind_commit_watermark_cannot_be_promoted() {
1667        let orders = collection("orders");
1668        let current = hash_range(&orders, 1, RangeBounds::full(), "CN=owner-a");
1669        let candidate = HotMirrorCandidate::new(ident("CN=replica-1"), 99);
1670
1671        assert_eq!(
1672            current.promote_hot_mirror(&candidate, 100),
1673            Err(HotMirrorPromotionRefusal::WatermarkNotCovered {
1674                candidate_lsn: 99,
1675                watermark: 100,
1676            }),
1677        );
1678    }
1679
1680    #[test]
1681    fn hot_mirror_covering_watermark_can_be_selected_for_promotion() {
1682        let orders = collection("orders");
1683        let current = hash_range(&orders, 1, RangeBounds::full(), "CN=owner-a");
1684        let candidate = HotMirrorCandidate::new(ident("CN=replica-1"), 100);
1685
1686        let promotion = current.promote_hot_mirror(&candidate, 100).unwrap();
1687
1688        assert_eq!(promotion.previous(), &current);
1689        assert_eq!(promotion.promoted().owner(), &ident("CN=replica-1"));
1690        assert_eq!(promotion.promoted().epoch(), current.epoch().next());
1691        assert_eq!(
1692            promotion.promoted().role_of(&ident("CN=owner-a")),
1693            RangeRole::Replica,
1694        );
1695    }
1696
1697    #[test]
1698    fn hot_mirror_promotion_publishes_epoch_before_durable_writes() {
1699        let orders = collection("orders");
1700        let key = b"order-7";
1701        let current = hash_range(&orders, 1, single_hash_slot_bounds(key), "CN=owner-a");
1702        let old_epoch = current.epoch();
1703        let promotion = current
1704            .promote_hot_mirror(&HotMirrorCandidate::new(ident("CN=replica-1"), 120), 100)
1705            .unwrap();
1706        let new_epoch = promotion.promoted().epoch();
1707        let mut catalog = ShardOwnershipCatalog::new();
1708        catalog.apply_update(current).unwrap();
1709        catalog.apply_update(promotion.promoted().clone()).unwrap();
1710
1711        assert_eq!(
1712            catalog
1713                .admit_public_write(&ident("CN=replica-1"), &orders, key, old_epoch)
1714                .unwrap_err(),
1715            RangeWriteReject::StaleEpoch {
1716                collection: orders.clone(),
1717                range_id: RangeId::new(1),
1718                expected: old_epoch,
1719                current: new_epoch,
1720            },
1721        );
1722        assert!(catalog
1723            .admit_public_write(&ident("CN=replica-1"), &orders, key, new_epoch)
1724            .is_ok());
1725    }
1726
1727    #[test]
1728    fn hot_mirror_promotion_fences_old_owner_by_epoch() {
1729        let orders = collection("orders");
1730        let key = b"order-7";
1731        let current = hash_range(&orders, 1, single_hash_slot_bounds(key), "CN=owner-a");
1732        let old_epoch = current.epoch();
1733        let promotion = current
1734            .promote_hot_mirror(&HotMirrorCandidate::new(ident("CN=replica-1"), 120), 100)
1735            .unwrap();
1736        let mut catalog = ShardOwnershipCatalog::new();
1737        catalog.apply_update(current).unwrap();
1738        catalog.apply_update(promotion.promoted().clone()).unwrap();
1739
1740        assert_eq!(
1741            catalog
1742                .admit_public_write(&ident("CN=owner-a"), &orders, key, old_epoch)
1743                .unwrap_err(),
1744            RangeWriteReject::NotOwner {
1745                collection: orders,
1746                range_id: RangeId::new(1),
1747                role: RangeRole::Replica,
1748                owner: ident("CN=replica-1"),
1749            },
1750        );
1751    }
1752
1753    #[test]
1754    fn empty_catalog_creation() {
1755        let catalog = ShardOwnershipCatalog::new();
1756        assert_eq!(catalog.range_count(), 0);
1757        assert!(catalog.shard_key_mode(&collection("orders")).is_none());
1758    }
1759
1760    #[test]
1761    fn hash_is_the_default_shard_key_mode() {
1762        // The first range of a collection auto-declares its mode; a range built
1763        // with the default mode lands the collection in Hash mode.
1764        assert_eq!(ShardKeyMode::default(), ShardKeyMode::Hash);
1765
1766        let mut catalog = ShardOwnershipCatalog::new();
1767        let orders = collection("orders");
1768        catalog
1769            .apply_update(hash_range(&orders, 1, RangeBounds::full(), "CN=node-a"))
1770            .unwrap();
1771        assert_eq!(catalog.shard_key_mode(&orders), Some(ShardKeyMode::Hash));
1772    }
1773
1774    #[test]
1775    fn hash_range_entry_routes_to_owner() {
1776        let mut catalog = ShardOwnershipCatalog::new();
1777        let orders = collection("orders");
1778
1779        // Two hash token ranges split at 0x80.
1780        catalog
1781            .apply_update(hash_range(
1782                &orders,
1783                1,
1784                RangeBounds::new(RangeBound::Min, RangeBound::key([0x80])).unwrap(),
1785                "CN=node-a",
1786            ))
1787            .unwrap();
1788        catalog
1789            .apply_update(hash_range(
1790                &orders,
1791                2,
1792                RangeBounds::new(RangeBound::key([0x80]), RangeBound::Max).unwrap(),
1793                "CN=node-b",
1794            ))
1795            .unwrap();
1796
1797        // Routing reads expose the owner for a key without any user-data sharding.
1798        assert_eq!(
1799            catalog.route(&orders, &[0x10]).unwrap().owner(),
1800            &ident("CN=node-a")
1801        );
1802        assert_eq!(
1803            catalog.route(&orders, &[0x80]).unwrap().owner(),
1804            &ident("CN=node-b")
1805        );
1806        assert_eq!(
1807            catalog.route(&orders, &[0xff]).unwrap().owner(),
1808            &ident("CN=node-b")
1809        );
1810        // The routing read also exposes replicas and fencing epoch.
1811        let r = catalog.route(&orders, &[0x10]).unwrap();
1812        assert_eq!(r.replicas(), &[ident("CN=replica-1")]);
1813        assert_eq!(r.epoch(), OwnershipEpoch::initial());
1814    }
1815
1816    #[test]
1817    fn hash_mode_routes_logical_shard_key_through_hash_slot() {
1818        let mut catalog = ShardOwnershipCatalog::new();
1819        let orders = collection("orders");
1820        let key = b"tenant:42";
1821        catalog
1822            .apply_update(hash_range(
1823                &orders,
1824                1,
1825                single_hash_slot_bounds(key),
1826                "CN=node-a",
1827            ))
1828            .unwrap();
1829
1830        let routed = catalog
1831            .route_shard_key(&orders, key)
1832            .expect("hash slot range covers the logical shard key");
1833        assert_eq!(routed.owner(), &ident("CN=node-a"));
1834    }
1835
1836    #[test]
1837    fn ordered_mode_can_be_declared_and_routed() {
1838        let mut catalog = ShardOwnershipCatalog::new();
1839        let events = collection("events");
1840        catalog
1841            .declare_collection(events.clone(), ShardKeyMode::Ordered)
1842            .unwrap();
1843        assert_eq!(catalog.shard_key_mode(&events), Some(ShardKeyMode::Ordered));
1844
1845        // Ordered ranges bound the ordered key itself: [a, m) and [m, z).
1846        catalog
1847            .apply_update(RangeOwnership::establish(
1848                events.clone(),
1849                RangeId::new(1),
1850                ShardKeyMode::Ordered,
1851                bounds(b"a", b"m"),
1852                ident("CN=node-a"),
1853                [],
1854                PlacementMetadata::with_replication_factor(3),
1855            ))
1856            .unwrap();
1857        catalog
1858            .apply_update(RangeOwnership::establish(
1859                events.clone(),
1860                RangeId::new(2),
1861                ShardKeyMode::Ordered,
1862                bounds(b"m", b"z"),
1863                ident("CN=node-b"),
1864                [],
1865                PlacementMetadata::with_replication_factor(3),
1866            ))
1867            .unwrap();
1868
1869        assert_eq!(
1870            catalog.route(&events, b"alpha").unwrap().owner(),
1871            &ident("CN=node-a")
1872        );
1873        assert_eq!(
1874            catalog.route(&events, b"mike").unwrap().owner(),
1875            &ident("CN=node-b")
1876        );
1877        // A key outside every declared range routes nowhere.
1878        assert!(catalog.route(&events, b"zzz").is_none());
1879    }
1880
1881    #[test]
1882    fn declaring_a_conflicting_mode_is_rejected() {
1883        let mut catalog = ShardOwnershipCatalog::new();
1884        let events = collection("events");
1885        catalog
1886            .declare_collection(events.clone(), ShardKeyMode::Ordered)
1887            .unwrap();
1888        // Redeclaring the same mode is fine.
1889        catalog
1890            .declare_collection(events.clone(), ShardKeyMode::Ordered)
1891            .unwrap();
1892        // A different mode is a mismatch.
1893        let err = catalog
1894            .declare_collection(events.clone(), ShardKeyMode::Hash)
1895            .unwrap_err();
1896        assert_eq!(
1897            err,
1898            CatalogError::ShardKeyModeMismatch {
1899                collection: events.clone(),
1900                declared: ShardKeyMode::Ordered,
1901                attempted: ShardKeyMode::Hash,
1902            }
1903        );
1904        // And a range whose mode disagrees with the declared collection is rejected.
1905        let err = catalog
1906            .apply_update(hash_range(&events, 1, RangeBounds::full(), "CN=node-a"))
1907            .unwrap_err();
1908        assert!(matches!(err, CatalogError::ShardKeyModeMismatch { .. }));
1909    }
1910
1911    #[test]
1912    fn version_bumps_on_owner_transfer_and_epoch_fences() {
1913        let mut catalog = ShardOwnershipCatalog::new();
1914        let orders = collection("orders");
1915        catalog
1916            .apply_update(hash_range(&orders, 1, RangeBounds::full(), "CN=node-a"))
1917            .unwrap();
1918
1919        let current = catalog.range(&orders, RangeId::new(1)).unwrap();
1920        assert_eq!(current.version(), CatalogVersion::initial());
1921        assert_eq!(current.epoch(), OwnershipEpoch::initial());
1922
1923        // Owner transfer advances both version and fencing epoch.
1924        let moved = current.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]);
1925        let outcome = catalog.apply_update(moved).unwrap();
1926        assert_eq!(outcome, UpdateOutcome::Updated);
1927
1928        let after = catalog.range(&orders, RangeId::new(1)).unwrap();
1929        assert_eq!(after.owner(), &ident("CN=node-b"));
1930        assert_eq!(after.version().value(), 2);
1931        assert_eq!(after.epoch().value(), 2); // old owner is now fenced
1932
1933        // A replica-set change advances the version but NOT the epoch.
1934        let replicas_changed = after.update_replicas([ident("CN=node-c")]);
1935        catalog.apply_update(replicas_changed).unwrap();
1936        let after2 = catalog.range(&orders, RangeId::new(1)).unwrap();
1937        assert_eq!(after2.version().value(), 3);
1938        assert_eq!(after2.epoch().value(), 2); // write authority did not move
1939        assert_eq!(after2.replicas(), &[ident("CN=node-c")]);
1940    }
1941
1942    #[test]
1943    fn stale_update_is_rejected_and_leaves_catalog_unchanged() {
1944        let mut catalog = ShardOwnershipCatalog::new();
1945        let orders = collection("orders");
1946        catalog
1947            .apply_update(hash_range(&orders, 1, RangeBounds::full(), "CN=node-a"))
1948            .unwrap();
1949
1950        let v1 = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1951        // Advance to v2.
1952        catalog
1953            .apply_update(v1.transfer_to(ident("CN=node-b"), []))
1954            .unwrap();
1955        assert_eq!(
1956            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
1957            &ident("CN=node-b")
1958        );
1959
1960        // Re-applying the original v1 entry (and even a fresh v1-versioned write)
1961        // is stale: version 1 does not advance past the current version 2.
1962        let err = catalog.apply_update(v1.clone()).unwrap_err();
1963        assert_eq!(
1964            err,
1965            CatalogError::StaleVersion {
1966                collection: orders.clone(),
1967                range_id: RangeId::new(1),
1968                current: CatalogVersion::initial().next(),
1969                attempted: CatalogVersion::initial(),
1970            }
1971        );
1972        // The stale write did not roll ownership back.
1973        assert_eq!(
1974            catalog.range(&orders, RangeId::new(1)).unwrap().owner(),
1975            &ident("CN=node-b")
1976        );
1977        assert_eq!(
1978            catalog
1979                .range(&orders, RangeId::new(1))
1980                .unwrap()
1981                .version()
1982                .value(),
1983            2
1984        );
1985    }
1986
1987    #[test]
1988    fn overlapping_range_creation_is_rejected() {
1989        let mut catalog = ShardOwnershipCatalog::new();
1990        let orders = collection("orders");
1991        catalog
1992            .apply_update(hash_range(
1993                &orders,
1994                1,
1995                bounds(&[0x00], &[0x80]),
1996                "CN=node-a",
1997            ))
1998            .unwrap();
1999        // A new range overlapping [0x00, 0x80) is ambiguous for routing.
2000        let err = catalog
2001            .apply_update(hash_range(
2002                &orders,
2003                2,
2004                bounds(&[0x40], &[0xc0]),
2005                "CN=node-b",
2006            ))
2007            .unwrap_err();
2008        assert_eq!(
2009            err,
2010            CatalogError::OverlappingRange {
2011                collection: orders.clone(),
2012                existing: RangeId::new(1),
2013                attempted: RangeId::new(2),
2014            }
2015        );
2016        assert_eq!(catalog.range_count(), 1);
2017    }
2018
2019    #[test]
2020    fn overlapping_range_update_is_rejected() {
2021        let mut catalog = ShardOwnershipCatalog::new();
2022        let orders = collection("orders");
2023        catalog
2024            .apply_update(hash_range(
2025                &orders,
2026                1,
2027                bounds(&[0x00], &[0x80]),
2028                "CN=node-a",
2029            ))
2030            .unwrap();
2031        catalog
2032            .apply_update(hash_range(
2033                &orders,
2034                2,
2035                RangeBounds::new(RangeBound::key([0x80]), RangeBound::Max).unwrap(),
2036                "CN=node-b",
2037            ))
2038            .unwrap();
2039
2040        let widened = catalog
2041            .range(&orders, RangeId::new(1))
2042            .unwrap()
2043            .with_bounds(bounds(&[0x00], &[0xc0]));
2044        let err = catalog.apply_update(widened).unwrap_err();
2045        assert_eq!(
2046            err,
2047            CatalogError::OverlappingRange {
2048                collection: orders.clone(),
2049                existing: RangeId::new(2),
2050                attempted: RangeId::new(1),
2051            }
2052        );
2053        assert_eq!(
2054            catalog.range(&orders, RangeId::new(1)).unwrap().bounds(),
2055            &bounds(&[0x00], &[0x80])
2056        );
2057    }
2058
2059    #[test]
2060    fn catalog_replicates_to_data_members_with_read_visibility() {
2061        // Leader writes the catalog; a data member holds its own replica and
2062        // applies the same versioned updates — no user-data sharding involved.
2063        let orders = collection("orders");
2064        let mut leader = ShardOwnershipCatalog::new();
2065        let mut data_member = ShardOwnershipCatalog::new();
2066
2067        // Leader creates a range; ship the entry to the data member.
2068        let create = hash_range(&orders, 1, RangeBounds::full(), "CN=node-a");
2069        leader.apply_update(create.clone()).unwrap();
2070        assert_eq!(
2071            data_member.apply_update(create).unwrap(),
2072            UpdateOutcome::Created
2073        );
2074
2075        // The data member can route locally to the same owner the leader has.
2076        assert_eq!(
2077            data_member.route(&orders, b"any-key").unwrap().owner(),
2078            &ident("CN=node-a")
2079        );
2080
2081        // Leader transfers ownership; replicate the v2 entry.
2082        let v2 = leader
2083            .range(&orders, RangeId::new(1))
2084            .unwrap()
2085            .transfer_to(ident("CN=node-b"), []);
2086        leader.apply_update(v2.clone()).unwrap();
2087        assert_eq!(
2088            data_member.apply_update(v2.clone()).unwrap(),
2089            UpdateOutcome::Updated
2090        );
2091        assert_eq!(
2092            data_member.route(&orders, b"any-key").unwrap().owner(),
2093            &ident("CN=node-b")
2094        );
2095
2096        // Out-of-order / duplicate replication: re-delivering v2 after it is
2097        // applied is stale on the replica and rejected, so it stays consistent.
2098        let err = data_member.apply_update(v2).unwrap_err();
2099        assert!(matches!(err, CatalogError::StaleVersion { .. }));
2100        assert_eq!(
2101            data_member
2102                .range(&orders, RangeId::new(1))
2103                .unwrap()
2104                .version()
2105                .value(),
2106            2
2107        );
2108    }
2109
2110    #[test]
2111    fn collection_group_scope_is_owned_by_one_placement_authority() {
2112        let group = CollectionGroupId::new("commerce").unwrap();
2113        let assignment = CollectionGroupAuthority::new(
2114            group.clone(),
2115            ident("CN=placement-authority-a"),
2116            [collection("orders"), collection("order_items")],
2117        )
2118        .unwrap();
2119        let mut catalog = PlacementAuthorityCatalog::new();
2120
2121        catalog.assign(assignment.clone()).unwrap();
2122
2123        assert_eq!(
2124            catalog.authority_for_group(&group).unwrap().authority(),
2125            &ident("CN=placement-authority-a")
2126        );
2127        assert_eq!(
2128            catalog
2129                .authority_for_collection(&collection("orders"))
2130                .unwrap(),
2131            &assignment
2132        );
2133
2134        let err = catalog
2135            .assign(
2136                CollectionGroupAuthority::new(
2137                    group.clone(),
2138                    ident("CN=placement-authority-b"),
2139                    [collection("invoices")],
2140                )
2141                .unwrap(),
2142            )
2143            .unwrap_err();
2144        assert_eq!(
2145            err,
2146            PlacementAuthorityError::DuplicateCollectionGroup {
2147                group: group.clone(),
2148            }
2149        );
2150    }
2151
2152    #[test]
2153    fn collection_group_catalog_slices_do_not_overlap() {
2154        let commerce = CollectionGroupId::new("commerce").unwrap();
2155        let analytics = CollectionGroupId::new("analytics-events").unwrap();
2156        let support = CollectionGroupId::new("support").unwrap();
2157        let orders = collection("orders");
2158        let order_items = collection("order_items");
2159        let events = collection("events");
2160        let tickets = collection("tickets");
2161        let mut catalog = PlacementAuthorityCatalog::new();
2162
2163        // Related collections intentionally share one authority domain.
2164        catalog
2165            .assign(
2166                CollectionGroupAuthority::new(
2167                    commerce.clone(),
2168                    ident("CN=placement-authority-a"),
2169                    [orders.clone(), order_items],
2170                )
2171                .unwrap(),
2172            )
2173            .unwrap();
2174        assert_eq!(
2175            catalog.authority_for_collection(&orders).unwrap().group(),
2176            &commerce
2177        );
2178
2179        // A large collection can be isolated as its own group.
2180        catalog
2181            .assign(
2182                CollectionGroupAuthority::new(
2183                    analytics.clone(),
2184                    ident("CN=placement-authority-b"),
2185                    [events.clone()],
2186                )
2187                .unwrap(),
2188            )
2189            .unwrap();
2190        assert_eq!(
2191            catalog.authority_for_collection(&events).unwrap().group(),
2192            &analytics
2193        );
2194
2195        let err = catalog
2196            .assign(
2197                CollectionGroupAuthority::new(
2198                    support.clone(),
2199                    ident("CN=placement-authority-c"),
2200                    [tickets, events.clone()],
2201                )
2202                .unwrap(),
2203            )
2204            .unwrap_err();
2205        assert_eq!(
2206            err,
2207            PlacementAuthorityError::OverlappingCatalogSlice {
2208                collection: events,
2209                existing_group: analytics,
2210                existing_authority: ident("CN=placement-authority-b"),
2211                attempted_group: support,
2212                attempted_authority: ident("CN=placement-authority-c"),
2213                overlap: RangeBounds::full(),
2214            }
2215        );
2216    }
2217
2218    #[test]
2219    fn placement_authority_creation_rejects_overlapping_range_slice() {
2220        let orders = collection("orders");
2221        let commerce = CollectionGroupId::new("commerce").unwrap();
2222        let support = CollectionGroupId::new("support").unwrap();
2223        let mut catalog = PlacementAuthorityCatalog::new();
2224        catalog
2225            .assign(
2226                CollectionGroupAuthority::with_slices(
2227                    commerce.clone(),
2228                    ident("CN=placement-authority-a"),
2229                    [(orders.clone(), bounds(b"a", b"m"))],
2230                )
2231                .unwrap(),
2232            )
2233            .unwrap();
2234
2235        let err = catalog
2236            .assign(
2237                CollectionGroupAuthority::with_slices(
2238                    support.clone(),
2239                    ident("CN=placement-authority-b"),
2240                    [(orders.clone(), bounds(b"h", b"z"))],
2241                )
2242                .unwrap(),
2243            )
2244            .unwrap_err();
2245
2246        assert_eq!(
2247            err,
2248            PlacementAuthorityError::OverlappingCatalogSlice {
2249                collection: orders,
2250                existing_group: commerce,
2251                existing_authority: ident("CN=placement-authority-a"),
2252                attempted_group: support,
2253                attempted_authority: ident("CN=placement-authority-b"),
2254                overlap: bounds(b"h", b"m"),
2255            }
2256        );
2257    }
2258
2259    #[test]
2260    fn placement_authority_split_rejects_overlapping_replacement() {
2261        let orders = collection("orders");
2262        let commerce = CollectionGroupId::new("commerce").unwrap();
2263        let archive = CollectionGroupId::new("archive").unwrap();
2264        let mut catalog = PlacementAuthorityCatalog::new();
2265        catalog
2266            .assign(
2267                CollectionGroupAuthority::with_slices(
2268                    commerce.clone(),
2269                    ident("CN=placement-authority-a"),
2270                    [(orders.clone(), bounds(b"a", b"z"))],
2271                )
2272                .unwrap(),
2273            )
2274            .unwrap();
2275
2276        let err = catalog
2277            .split_collection_group_slice(
2278                &commerce,
2279                CollectionGroupAuthority::with_slices(
2280                    commerce.clone(),
2281                    ident("CN=placement-authority-a"),
2282                    [(orders.clone(), bounds(b"a", b"n"))],
2283                )
2284                .unwrap(),
2285                CollectionGroupAuthority::with_slices(
2286                    archive.clone(),
2287                    ident("CN=placement-authority-b"),
2288                    [(orders.clone(), bounds(b"m", b"z"))],
2289                )
2290                .unwrap(),
2291            )
2292            .unwrap_err();
2293
2294        assert_eq!(
2295            err,
2296            PlacementAuthorityError::OverlappingCatalogSlice {
2297                collection: orders,
2298                existing_group: commerce,
2299                existing_authority: ident("CN=placement-authority-a"),
2300                attempted_group: archive,
2301                attempted_authority: ident("CN=placement-authority-b"),
2302                overlap: bounds(b"m", b"n"),
2303            }
2304        );
2305    }
2306
2307    #[test]
2308    fn placement_authority_merge_rejects_overlap_with_surviving_authority() {
2309        let orders = collection("orders");
2310        let commerce = CollectionGroupId::new("commerce").unwrap();
2311        let archive = CollectionGroupId::new("archive").unwrap();
2312        let support = CollectionGroupId::new("support").unwrap();
2313        let mut catalog = PlacementAuthorityCatalog::new();
2314        catalog
2315            .assign(
2316                CollectionGroupAuthority::with_slices(
2317                    commerce.clone(),
2318                    ident("CN=placement-authority-a"),
2319                    [(orders.clone(), bounds(b"a", b"m"))],
2320                )
2321                .unwrap(),
2322            )
2323            .unwrap();
2324        catalog
2325            .assign(
2326                CollectionGroupAuthority::with_slices(
2327                    archive.clone(),
2328                    ident("CN=placement-authority-b"),
2329                    [(orders.clone(), bounds(b"m", b"t"))],
2330                )
2331                .unwrap(),
2332            )
2333            .unwrap();
2334        catalog
2335            .assign(
2336                CollectionGroupAuthority::with_slices(
2337                    support.clone(),
2338                    ident("CN=placement-authority-c"),
2339                    [(orders.clone(), bounds(b"t", b"z"))],
2340                )
2341                .unwrap(),
2342            )
2343            .unwrap();
2344
2345        let err = catalog
2346            .merge_collection_group_slices(
2347                [&commerce, &archive],
2348                CollectionGroupAuthority::with_slices(
2349                    commerce.clone(),
2350                    ident("CN=placement-authority-a"),
2351                    [(orders.clone(), bounds(b"a", b"u"))],
2352                )
2353                .unwrap(),
2354            )
2355            .unwrap_err();
2356
2357        assert_eq!(
2358            err,
2359            PlacementAuthorityError::OverlappingCatalogSlice {
2360                collection: orders,
2361                existing_group: support,
2362                existing_authority: ident("CN=placement-authority-c"),
2363                attempted_group: commerce,
2364                attempted_authority: ident("CN=placement-authority-a"),
2365                overlap: bounds(b"t", b"u"),
2366            }
2367        );
2368    }
2369
2370    #[test]
2371    fn placement_authority_reassign_rejects_overlapping_target_slice() {
2372        let orders = collection("orders");
2373        let commerce = CollectionGroupId::new("commerce").unwrap();
2374        let archive = CollectionGroupId::new("archive").unwrap();
2375        let mut catalog = PlacementAuthorityCatalog::new();
2376        catalog
2377            .assign(
2378                CollectionGroupAuthority::with_slices(
2379                    commerce.clone(),
2380                    ident("CN=placement-authority-a"),
2381                    [(orders.clone(), bounds(b"a", b"m"))],
2382                )
2383                .unwrap(),
2384            )
2385            .unwrap();
2386        catalog
2387            .assign(
2388                CollectionGroupAuthority::with_slices(
2389                    archive.clone(),
2390                    ident("CN=placement-authority-b"),
2391                    [(orders.clone(), bounds(b"m", b"z"))],
2392                )
2393                .unwrap(),
2394            )
2395            .unwrap();
2396
2397        let err = catalog
2398            .reassign_collection_group_slice(
2399                &commerce,
2400                CollectionGroupAuthority::with_slices(
2401                    commerce.clone(),
2402                    ident("CN=placement-authority-a"),
2403                    [(orders.clone(), bounds(b"a", b"h"))],
2404                )
2405                .unwrap(),
2406                CollectionGroupAuthority::with_slices(
2407                    archive.clone(),
2408                    ident("CN=placement-authority-b"),
2409                    [(orders.clone(), bounds(b"h", b"n"))],
2410                )
2411                .unwrap(),
2412            )
2413            .unwrap_err();
2414
2415        assert_eq!(
2416            err,
2417            PlacementAuthorityError::OverlappingCatalogSlice {
2418                collection: orders,
2419                existing_group: archive.clone(),
2420                existing_authority: ident("CN=placement-authority-b"),
2421                attempted_group: archive,
2422                attempted_authority: ident("CN=placement-authority-b"),
2423                overlap: bounds(b"m", b"n"),
2424            }
2425        );
2426    }
2427
2428    #[test]
2429    fn forced_placement_authority_replacement_still_rejects_overlap() {
2430        let orders = collection("orders");
2431        let commerce = CollectionGroupId::new("commerce").unwrap();
2432        let archive = CollectionGroupId::new("archive").unwrap();
2433        let support = CollectionGroupId::new("support").unwrap();
2434        let mut catalog = PlacementAuthorityCatalog::new();
2435        catalog
2436            .assign(
2437                CollectionGroupAuthority::with_slices(
2438                    commerce.clone(),
2439                    ident("CN=placement-authority-a"),
2440                    [(orders.clone(), bounds(b"a", b"m"))],
2441                )
2442                .unwrap(),
2443            )
2444            .unwrap();
2445        catalog
2446            .assign(
2447                CollectionGroupAuthority::with_slices(
2448                    archive.clone(),
2449                    ident("CN=placement-authority-b"),
2450                    [(orders.clone(), bounds(b"m", b"z"))],
2451                )
2452                .unwrap(),
2453            )
2454            .unwrap();
2455
2456        let err = catalog
2457            .force_replace_assignments(
2458                [&commerce],
2459                [CollectionGroupAuthority::with_slices(
2460                    support.clone(),
2461                    ident("CN=placement-authority-root"),
2462                    [(orders.clone(), bounds(b"h", b"t"))],
2463                )
2464                .unwrap()],
2465            )
2466            .unwrap_err();
2467
2468        assert_eq!(
2469            err,
2470            PlacementAuthorityError::OverlappingCatalogSlice {
2471                collection: orders,
2472                existing_group: archive,
2473                existing_authority: ident("CN=placement-authority-b"),
2474                attempted_group: support,
2475                attempted_authority: ident("CN=placement-authority-root"),
2476                overlap: bounds(b"m", b"t"),
2477            }
2478        );
2479    }
2480
2481    #[test]
2482    fn range_bounds_reject_empty_or_inverted() {
2483        assert!(RangeBounds::new(RangeBound::key([0x10]), RangeBound::key([0x10])).is_err());
2484        assert!(RangeBounds::new(RangeBound::key([0x20]), RangeBound::key([0x10])).is_err());
2485        assert!(RangeBounds::new(RangeBound::Max, RangeBound::Min).is_err());
2486        assert!(RangeBounds::full().contains(b"anything"));
2487    }
2488
2489    // ---------------------------------------------------------------
2490    // Issue #990 — per-range role model and ownership-aware write gate.
2491    // ---------------------------------------------------------------
2492
2493    /// A range owned by `owner` with an explicit replica set.
2494    fn range_with(
2495        coll: &CollectionId,
2496        id: u64,
2497        bnds: RangeBounds,
2498        owner: &str,
2499        replicas: &[&str],
2500    ) -> RangeOwnership {
2501        RangeOwnership::establish(
2502            coll.clone(),
2503            RangeId::new(id),
2504            ShardKeyMode::Hash,
2505            bnds,
2506            ident(owner),
2507            replicas.iter().map(|r| ident(r)).collect::<Vec<_>>(),
2508            PlacementMetadata::with_replication_factor(3),
2509        )
2510    }
2511
2512    #[test]
2513    fn role_of_distinguishes_owner_replica_and_no_copy() {
2514        let orders = collection("orders");
2515        let range = range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &["CN=node-b"]);
2516
2517        assert_eq!(range.role_of(&ident("CN=node-a")), RangeRole::Owner);
2518        assert_eq!(range.role_of(&ident("CN=node-b")), RangeRole::Replica);
2519        assert_eq!(range.role_of(&ident("CN=node-c")), RangeRole::NoCopy);
2520        assert!(RangeRole::Owner.may_write_public());
2521        assert!(!RangeRole::Replica.may_write_public());
2522        assert!(!RangeRole::NoCopy.may_write_public());
2523    }
2524
2525    #[test]
2526    fn replica_role_metadata_distinguishes_hot_mirror_and_archive() {
2527        let orders = collection("orders");
2528        let range = range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &["CN=node-b"])
2529            .with_compressed_archive_replicas([ident("CN=node-c")]);
2530
2531        assert_eq!(range.hot_mirror_replicas(), &[ident("CN=node-b")]);
2532        assert_eq!(range.compressed_archive_replicas(), &[ident("CN=node-c")]);
2533        assert_eq!(
2534            range.replica_role_of(&ident("CN=node-b")),
2535            Some(ReplicaRole::HotMirror)
2536        );
2537        assert_eq!(
2538            range.replica_role_of(&ident("CN=node-c")),
2539            Some(ReplicaRole::CompressedArchive)
2540        );
2541        assert!(ReplicaRole::HotMirror.is_promotion_candidate());
2542        assert!(!ReplicaRole::CompressedArchive.is_promotion_candidate());
2543        assert!(ReplicaRole::CompressedArchive.is_restore_only());
2544    }
2545
2546    #[test]
2547    fn catalog_rejects_owner_or_duplicate_replica_roles() {
2548        let orders = collection("orders");
2549        let mut catalog = ShardOwnershipCatalog::new();
2550
2551        let owner_also_hot =
2552            range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &["CN=node-a"]);
2553        assert!(matches!(
2554            catalog.apply_update(owner_also_hot).unwrap_err(),
2555            CatalogError::InvalidReplicaRoles { .. }
2556        ));
2557
2558        let owner_also_archive = range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &[])
2559            .with_compressed_archive_replicas([ident("CN=node-a")]);
2560        assert!(matches!(
2561            catalog.apply_update(owner_also_archive).unwrap_err(),
2562            CatalogError::InvalidReplicaRoles { .. }
2563        ));
2564
2565        let duplicate_hot_and_archive =
2566            range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &["CN=node-b"])
2567                .with_compressed_archive_replicas([ident("CN=node-b")]);
2568        assert!(matches!(
2569            catalog.apply_update(duplicate_hot_and_archive).unwrap_err(),
2570            CatalogError::InvalidReplicaRoles { .. }
2571        ));
2572    }
2573
2574    #[test]
2575    fn role_is_per_range_not_a_global_node_role() {
2576        // node-a owns range 1 and is only a replica of range 2 — the same node
2577        // holds different roles for different ranges of the same collection.
2578        let mut catalog = ShardOwnershipCatalog::new();
2579        let orders = collection("orders");
2580        catalog
2581            .apply_update(range_with(
2582                &orders,
2583                1,
2584                RangeBounds::new(RangeBound::Min, RangeBound::key([0x80])).unwrap(),
2585                "CN=node-a",
2586                &["CN=node-b"],
2587            ))
2588            .unwrap();
2589        catalog
2590            .apply_update(range_with(
2591                &orders,
2592                2,
2593                RangeBounds::new(RangeBound::key([0x80]), RangeBound::Max).unwrap(),
2594                "CN=node-b",
2595                &["CN=node-a"],
2596            ))
2597            .unwrap();
2598
2599        let node_a = ident("CN=node-a");
2600        assert_eq!(
2601            catalog.role_at(&node_a, &orders, RangeId::new(1)),
2602            Some(RangeRole::Owner)
2603        );
2604        assert_eq!(
2605            catalog.role_at(&node_a, &orders, RangeId::new(2)),
2606            Some(RangeRole::Replica)
2607        );
2608        // A range that does not exist is None, not NoCopy.
2609        assert_eq!(catalog.role_at(&node_a, &orders, RangeId::new(99)), None);
2610        // And a collection nobody placed yet routes nowhere.
2611        assert_eq!(
2612            catalog.role_at(&node_a, &collection("ghost"), RangeId::new(1)),
2613            None
2614        );
2615    }
2616
2617    #[test]
2618    fn public_write_admitted_on_owner_at_matching_epoch() {
2619        let mut catalog = ShardOwnershipCatalog::new();
2620        let orders = collection("orders");
2621        catalog
2622            .apply_update(range_with(
2623                &orders,
2624                1,
2625                RangeBounds::full(),
2626                "CN=node-a",
2627                &["CN=node-b"],
2628            ))
2629            .unwrap();
2630
2631        let admitted = catalog
2632            .admit_public_write(
2633                &ident("CN=node-a"),
2634                &orders,
2635                b"k",
2636                OwnershipEpoch::initial(),
2637            )
2638            .expect("owner at current epoch may write");
2639        assert_eq!(admitted.owner(), &ident("CN=node-a"));
2640        assert_eq!(admitted.range_id(), RangeId::new(1));
2641    }
2642
2643    #[test]
2644    fn public_write_uses_hash_slot_routing_for_hash_collections() {
2645        let mut catalog = ShardOwnershipCatalog::new();
2646        let orders = collection("orders");
2647        let key = b"tenant:42";
2648        catalog
2649            .apply_update(hash_range(
2650                &orders,
2651                1,
2652                single_hash_slot_bounds(key),
2653                "CN=node-a",
2654            ))
2655            .unwrap();
2656
2657        let admitted = catalog
2658            .admit_public_write(&ident("CN=node-a"), &orders, key, OwnershipEpoch::initial())
2659            .expect("hash owner admits write routed by shard-key slot");
2660        assert_eq!(admitted.range_id(), RangeId::new(1));
2661    }
2662
2663    #[test]
2664    fn public_write_rejected_on_replica_with_routing_error() {
2665        let mut catalog = ShardOwnershipCatalog::new();
2666        let orders = collection("orders");
2667        catalog
2668            .apply_update(range_with(
2669                &orders,
2670                1,
2671                RangeBounds::full(),
2672                "CN=node-a",
2673                &["CN=node-b"],
2674            ))
2675            .unwrap();
2676
2677        // node-b holds a copy but is a replica — a public write must be routed
2678        // to the owner, not applied locally.
2679        let err = catalog
2680            .admit_public_write(
2681                &ident("CN=node-b"),
2682                &orders,
2683                b"k",
2684                OwnershipEpoch::initial(),
2685            )
2686            .unwrap_err();
2687        match err {
2688            RangeWriteReject::NotOwner {
2689                role, ref owner, ..
2690            } => {
2691                assert_eq!(role, RangeRole::Replica);
2692                assert_eq!(owner, &ident("CN=node-a"));
2693            }
2694            other => panic!("expected NotOwner(Replica), got {other:?}"),
2695        }
2696        // The rejection names the owner so the caller can re-route.
2697        assert!(err.to_string().contains("route the write to"));
2698    }
2699
2700    #[test]
2701    fn public_write_rejected_on_no_copy_holder() {
2702        let mut catalog = ShardOwnershipCatalog::new();
2703        let orders = collection("orders");
2704        catalog
2705            .apply_update(range_with(
2706                &orders,
2707                1,
2708                RangeBounds::full(),
2709                "CN=node-a",
2710                &["CN=node-b"],
2711            ))
2712            .unwrap();
2713
2714        // node-c holds no copy of the range at all.
2715        let err = catalog
2716            .admit_public_write(
2717                &ident("CN=node-c"),
2718                &orders,
2719                b"k",
2720                OwnershipEpoch::initial(),
2721            )
2722            .unwrap_err();
2723        match err {
2724            RangeWriteReject::NotOwner { role, .. } => assert_eq!(role, RangeRole::NoCopy),
2725            other => panic!("expected NotOwner(NoCopy), got {other:?}"),
2726        }
2727    }
2728
2729    #[test]
2730    fn public_write_rejected_on_stale_ownership_epoch() {
2731        // Ownership moved a→b and back to a, advancing the epoch twice. A write
2732        // the routing layer authorised under the original epoch must be fenced
2733        // even though node-a is, once again, the current owner.
2734        let mut catalog = ShardOwnershipCatalog::new();
2735        let orders = collection("orders");
2736        let v1 = range_with(&orders, 1, RangeBounds::full(), "CN=node-a", &["CN=node-b"]);
2737        let original_epoch = v1.epoch();
2738        catalog.apply_update(v1.clone()).unwrap();
2739
2740        let v2 = v1.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]);
2741        catalog.apply_update(v2.clone()).unwrap();
2742        let v3 = v2.transfer_to(ident("CN=node-a"), [ident("CN=node-b")]);
2743        catalog.apply_update(v3.clone()).unwrap();
2744
2745        // node-a is the current owner again, but at a newer epoch.
2746        assert_ne!(original_epoch, v3.epoch());
2747        let err = catalog
2748            .admit_public_write(&ident("CN=node-a"), &orders, b"k", original_epoch)
2749            .unwrap_err();
2750        match err {
2751            RangeWriteReject::StaleEpoch {
2752                expected, current, ..
2753            } => {
2754                assert_eq!(expected, original_epoch);
2755                assert_eq!(current, v3.epoch());
2756            }
2757            other => panic!("expected StaleEpoch, got {other:?}"),
2758        }
2759        // The same owner at the *current* epoch is admitted.
2760        assert!(catalog
2761            .admit_public_write(&ident("CN=node-a"), &orders, b"k", v3.epoch())
2762            .is_ok());
2763    }
2764
2765    #[test]
2766    fn public_write_rejected_when_no_range_covers_the_key() {
2767        let catalog = ShardOwnershipCatalog::new();
2768        let orders = collection("orders");
2769        let err = catalog
2770            .admit_public_write(
2771                &ident("CN=node-a"),
2772                &orders,
2773                b"k",
2774                OwnershipEpoch::initial(),
2775            )
2776            .unwrap_err();
2777        assert!(matches!(err, RangeWriteReject::NoRange { .. }));
2778    }
2779
2780    #[test]
2781    fn internal_apply_path_stays_privileged_for_a_public_write_replica() {
2782        // A node that rejects a *public* write because it is only a replica must
2783        // still admit the owner's replicated changes through the privileged
2784        // internal apply path — that path is gated by issue #991's range
2785        // authority fence, not by this public ownership gate.
2786        use crate::replication::cdc::{ChangeOperation, ChangeRecord, RangeAuthority};
2787
2788        let mut catalog = ShardOwnershipCatalog::new();
2789        let orders = collection("orders");
2790        catalog
2791            .apply_update(range_with(
2792                &orders,
2793                7,
2794                RangeBounds::full(),
2795                "CN=node-a",
2796                &["CN=node-b"],
2797            ))
2798            .unwrap();
2799
2800        // Public gate: node-b is a replica → rejected, never reaches storage.
2801        assert!(matches!(
2802            catalog
2803                .admit_public_write(
2804                    &ident("CN=node-b"),
2805                    &orders,
2806                    b"k",
2807                    OwnershipEpoch::initial()
2808                )
2809                .unwrap_err(),
2810            RangeWriteReject::NotOwner {
2811                role: RangeRole::Replica,
2812                ..
2813            }
2814        ));
2815
2816        // Internal apply: the owner's replicated change for the same range is
2817        // admitted by the range-authority fence on the replica.
2818        let record = ChangeRecord {
2819            term: 1,
2820            lsn: 1,
2821            timestamp: 0,
2822            operation: ChangeOperation::Insert,
2823            collection: orders.as_str().to_string(),
2824            entity_id: 1,
2825            entity_kind: "row".to_string(),
2826            entity_bytes: Some(vec![1]),
2827            metadata: None,
2828            refresh_records: None,
2829            range_id: None,
2830            ownership_epoch: None,
2831        }
2832        .with_range_authority(7, OwnershipEpoch::initial().value());
2833        let fence = RangeAuthority {
2834            range_id: 7,
2835            min_term: 1,
2836            min_ownership_epoch: OwnershipEpoch::initial().value(),
2837        };
2838        assert!(
2839            fence.admit(&record).is_ok(),
2840            "replica internal apply must remain privileged for the owner's changes"
2841        );
2842    }
2843}