Skip to main content

reddb_server/cluster/
cross_range.rs

1//! First-cut cross-range transaction and read behaviour (issue #1002, PRD #987).
2//!
3//! Once a collection is split into independently-owned ranges (issue #989's
4//! catalog, routed by [`plan_route`](ShardOwnershipCatalog::plan_route)), a
5//! single client operation can touch keys that land in *different* ranges — and,
6//! in a multi-writer cluster, those ranges can be owned by *different writers*.
7//! This module is the request-layer gate that decides what the first multi-writer
8//! cut is allowed to do with such an operation. It deliberately refuses to
9//! pretend a cross-writer operation is globally atomic or globally
10//! snapshot-consistent when nothing underneath guarantees it.
11//!
12//! Three decisions live here, all pure reads of the [`ShardOwnershipCatalog`]:
13//!
14//! * **Write transactions** ([`plan_write_transaction`]). Resolve every targeted
15//!   key to its owning range and group by writer. A transaction confined to a
16//!   *single* writer (even one that spans several of that writer's own ranges)
17//!   commits on that owner and is admitted. A transaction whose keys span ranges
18//!   owned by *different* writers has no atomic-commit path in this cut, so it is
19//!   rejected with [`WriteTransactionReject::CrossRange`] naming every writer
20//!   involved — a clear "unsupported" contract rather than a silent partial
21//!   commit.
22//!
23//! * **Explicit bounded read fanout** ([`plan_read_fanout`]). A best-effort read
24//!   may span range owners only when the caller opts into fanout and supplies a
25//!   budget. The plan collects one [`ReadLeg`] per owner and returns
26//!   [`ReadFanoutTrace`] metadata so the transport can expose participating
27//!   owners/ranges instead of hiding a scatter query. This is explicitly *not* a
28//!   globally consistent snapshot — each leg observes its owner at whatever point
29//!   it happens to be at — and the type name says so.
30//!
31//! * **Consistent / transactional reads** ([`plan_consistent_read`]). A read that
32//!   must look globally consistent needs a safe snapshot point that covers every
33//!   range it touches. The caller supplies a [`GlobalReadWatermark`]; the plan
34//!   pins each leg to that range's watermark. With no snapshot the request fails
35//!   with [`ConsistentReadReject::NoSafeSnapshot`]; with a snapshot that is
36//!   missing a targeted range it fails with [`ConsistentReadReject::WatermarkGap`].
37//!   Either way the caller learns it cannot get a consistent answer rather than
38//!   getting an inconsistent one dressed up as consistent.
39//!
40//! Like the rest of the cluster module this is a pure decision layer with no I/O:
41//! it maps a catalog plus a set of [`KeyTarget`]s to an intent, so the
42//! cross-range contract is exercised deterministically. The transport that
43//! actually fans the legs out and the storage that admits each write are layered
44//! on top.
45
46use std::collections::BTreeMap;
47
48use super::identity::NodeIdentity;
49use super::ownership::{CollectionId, OwnershipEpoch, RangeId, ShardOwnershipCatalog};
50use super::ownership_transition::CommitWatermark;
51
52/// One `(collection, key)` a cross-range operation touches.
53///
54/// A transaction or multi-key read is just a set of these; the catalog resolves
55/// each to its owning range to decide whether the operation crosses writers.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct KeyTarget {
58    collection: CollectionId,
59    key: Vec<u8>,
60}
61
62impl KeyTarget {
63    pub fn new(collection: CollectionId, key: impl Into<Vec<u8>>) -> Self {
64        Self {
65            collection,
66            key: key.into(),
67        }
68    }
69
70    pub fn collection(&self) -> &CollectionId {
71        &self.collection
72    }
73
74    pub fn key(&self) -> &[u8] {
75        &self.key
76    }
77}
78
79/// A targeted key resolved to the range that owns it — the catalog read every
80/// cross-range decision is built from.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ResolvedTarget {
83    collection: CollectionId,
84    key: Vec<u8>,
85    range_id: RangeId,
86    owner: NodeIdentity,
87    epoch: OwnershipEpoch,
88}
89
90impl ResolvedTarget {
91    pub fn collection(&self) -> &CollectionId {
92        &self.collection
93    }
94
95    pub fn key(&self) -> &[u8] {
96        &self.key
97    }
98
99    pub fn range_id(&self) -> RangeId {
100        self.range_id
101    }
102
103    pub fn owner(&self) -> &NodeIdentity {
104        &self.owner
105    }
106
107    pub fn epoch(&self) -> OwnershipEpoch {
108        self.epoch
109    }
110}
111
112/// A range a writer owns that an operation touches, with the epoch the caller
113/// must fence each write under (the same epoch
114/// [`admit_public_write`](ShardOwnershipCatalog::admit_public_write) checks).
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct RangeParticipant {
117    collection: CollectionId,
118    range_id: RangeId,
119    epoch: OwnershipEpoch,
120}
121
122impl RangeParticipant {
123    pub fn collection(&self) -> &CollectionId {
124        &self.collection
125    }
126
127    pub fn range_id(&self) -> RangeId {
128        self.range_id
129    }
130
131    pub fn epoch(&self) -> OwnershipEpoch {
132        self.epoch
133    }
134}
135
136/// One writer's participation in a cross-range write transaction: the writer and
137/// the distinct ranges of theirs the transaction touches. Used both as the
138/// admitted single-writer plan and, in the rejection, to name each writer the
139/// transaction would have had to coordinate.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct WriterParticipation {
142    writer: NodeIdentity,
143    ranges: Vec<RangeParticipant>,
144}
145
146impl WriterParticipation {
147    pub fn writer(&self) -> &NodeIdentity {
148        &self.writer
149    }
150
151    pub fn ranges(&self) -> &[RangeParticipant] {
152        &self.ranges
153    }
154}
155
156/// An admitted single-writer write transaction: every targeted key resolves to a
157/// range owned by the *same* writer, so the transaction commits atomically on
158/// that owner. Carries each participating range so the caller fences every write
159/// at the right epoch.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct WriteTransactionPlan {
162    participation: WriterParticipation,
163}
164
165impl WriteTransactionPlan {
166    /// The single writer that owns every range the transaction touches.
167    pub fn writer(&self) -> &NodeIdentity {
168        self.participation.writer()
169    }
170
171    /// The distinct ranges (with fencing epochs) the transaction writes.
172    pub fn ranges(&self) -> &[RangeParticipant] {
173        self.participation.ranges()
174    }
175}
176
177/// An admitted exact claim: every candidate key resolves to one writer, so that
178/// owner is the only member allowed to choose the winners.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct ExactClaimPlan {
181    participation: WriterParticipation,
182}
183
184impl ExactClaimPlan {
185    /// The single writer that owns every candidate range.
186    pub fn writer(&self) -> &NodeIdentity {
187        self.participation.writer()
188    }
189
190    /// The distinct ranges (with fencing epochs) the claim may inspect.
191    pub fn ranges(&self) -> &[RangeParticipant] {
192        self.participation.ranges()
193    }
194}
195
196/// Why a write transaction could not be planned in the first multi-writer cut.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum WriteTransactionReject {
199    /// The transaction named no targets — there is nothing to commit.
200    Empty,
201    /// A targeted key resolves to no range of its collection; routing is stale or
202    /// the collection is not yet placed. The caller must refresh its catalog.
203    Unroutable {
204        collection: CollectionId,
205        key: Vec<u8>,
206    },
207    /// The transaction's keys span ranges owned by **different writers**. There is
208    /// no atomic cross-writer commit path in this cut, so the transaction is
209    /// rejected rather than committed partially. Carries every writer involved (in
210    /// identity order) so the caller sees exactly which owners it straddled.
211    CrossRange { writers: Vec<WriterParticipation> },
212}
213
214impl std::fmt::Display for WriteTransactionReject {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        match self {
217            Self::Empty => write!(f, "write transaction names no targets"),
218            Self::Unroutable { collection, key } => write!(
219                f,
220                "no range of collection {collection} covers key {} — re-resolve routing",
221                DisplayKey(key)
222            ),
223            Self::CrossRange { writers } => {
224                write!(
225                    f,
226                    "cross-range write transaction spans {} writers and is unsupported: ",
227                    writers.len()
228                )?;
229                for (i, w) in writers.iter().enumerate() {
230                    if i > 0 {
231                        write!(f, ", ")?;
232                    }
233                    write!(f, "{} owns ", w.writer())?;
234                    for (j, r) in w.ranges().iter().enumerate() {
235                        if j > 0 {
236                            write!(f, "+")?;
237                        }
238                        write!(f, "{}/{}", r.collection(), r.range_id())?;
239                    }
240                }
241                Ok(())
242            }
243        }
244    }
245}
246
247impl std::error::Error for WriteTransactionReject {}
248
249/// Why an exact claim could not be planned in the first cluster contract.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum ExactClaimReject {
252    /// The claim named no candidate keys.
253    Empty,
254    /// A candidate key resolves to no range of its collection.
255    Unroutable {
256        collection: CollectionId,
257        key: Vec<u8>,
258    },
259    /// Candidate keys span ranges owned by different writers. Exact claim needs
260    /// one authority to decide all winners, so this is explicitly unsupported in
261    /// the first cluster contract.
262    CrossOwner { writers: Vec<WriterParticipation> },
263}
264
265impl std::fmt::Display for ExactClaimReject {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        match self {
268            Self::Empty => write!(f, "exact claim names no candidate targets"),
269            Self::Unroutable { collection, key } => write!(
270                f,
271                "no range of collection {collection} covers claim candidate key {} — re-resolve routing",
272                DisplayKey(key)
273            ),
274            Self::CrossOwner { writers } => {
275                write!(
276                    f,
277                    "cross-owner exact claim spans {} writers and is unsupported in the first cluster contract: ",
278                    writers.len()
279                )?;
280                for (i, w) in writers.iter().enumerate() {
281                    if i > 0 {
282                        write!(f, ", ")?;
283                    }
284                    write!(f, "{} owns ", w.writer())?;
285                    for (j, r) in w.ranges().iter().enumerate() {
286                        if j > 0 {
287                            write!(f, "+")?;
288                        }
289                        write!(f, "{}/{}", r.collection(), r.range_id())?;
290                    }
291                }
292                Ok(())
293            }
294        }
295    }
296}
297
298impl std::error::Error for ExactClaimReject {}
299
300/// One owner's leg of a read: the owner and the resolved targets to read there.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct ReadLeg {
303    owner: NodeIdentity,
304    targets: Vec<ResolvedTarget>,
305}
306
307impl ReadLeg {
308    pub fn owner(&self) -> &NodeIdentity {
309        &self.owner
310    }
311
312    pub fn targets(&self) -> &[ResolvedTarget] {
313        &self.targets
314    }
315}
316
317/// The explicit policy a caller supplies before a read may fan out.
318///
319/// The default posture is [`SingleOwnerOnly`](Self::SingleOwnerOnly): a read that
320/// would cross owners is rejected before execution. To scatter, a caller must
321/// choose [`ExplicitFanout`](Self::ExplicitFanout) and provide a
322/// [`ReadFanoutBudget`]. That is the ADR 0055 contract in code: no hidden,
323/// unbounded fanout path for cold-cache or missing-shard-key query shapes.
324#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
325pub enum ReadFanoutPolicy {
326    /// Only reads that resolve to one owner are admitted.
327    #[default]
328    SingleOwnerOnly,
329    /// Cross-owner fanout is allowed, bounded, and marked with partial-result
330    /// policy for the executor/transport.
331    ExplicitFanout {
332        budget: ReadFanoutBudget,
333        allow_partial: bool,
334    },
335}
336
337impl ReadFanoutPolicy {
338    pub fn single_owner_only() -> Self {
339        Self::SingleOwnerOnly
340    }
341
342    pub fn explicit(budget: ReadFanoutBudget) -> Self {
343        Self::ExplicitFanout {
344            budget,
345            allow_partial: false,
346        }
347    }
348
349    pub fn allowing_partial(mut self) -> Self {
350        if let Self::ExplicitFanout {
351            ref mut allow_partial,
352            ..
353        } = self
354        {
355            *allow_partial = true;
356        }
357        self
358    }
359}
360
361/// Hard caps for one explicit best-effort read fanout plan.
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub struct ReadFanoutBudget {
364    max_owners: usize,
365    max_ranges: usize,
366    max_targets: usize,
367}
368
369impl ReadFanoutBudget {
370    pub fn new(max_owners: usize, max_ranges: usize, max_targets: usize) -> Self {
371        Self {
372            max_owners,
373            max_ranges,
374            max_targets,
375        }
376    }
377
378    pub fn max_owners(&self) -> usize {
379        self.max_owners
380    }
381
382    pub fn max_ranges(&self) -> usize {
383        self.max_ranges
384    }
385
386    pub fn max_targets(&self) -> usize {
387        self.max_targets
388    }
389}
390
391impl Default for ReadFanoutBudget {
392    fn default() -> Self {
393        Self {
394            max_owners: 8,
395            max_ranges: 64,
396            max_targets: 512,
397        }
398    }
399}
400
401/// Observable metadata for a best-effort fanout plan.
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403pub struct ReadFanoutTrace {
404    target_count: usize,
405    owner_count: usize,
406    range_count: usize,
407    partial_allowed: bool,
408}
409
410impl ReadFanoutTrace {
411    pub fn target_count(&self) -> usize {
412        self.target_count
413    }
414
415    pub fn owner_count(&self) -> usize {
416        self.owner_count
417    }
418
419    pub fn range_count(&self) -> usize {
420        self.range_count
421    }
422
423    pub fn partial_allowed(&self) -> bool {
424        self.partial_allowed
425    }
426}
427
428/// A simple, best-effort cross-range read split into one [`ReadLeg`] per owner.
429///
430/// **Not** a globally consistent snapshot: each leg observes its owner at
431/// whatever point that owner is at when it answers, so two legs may reflect
432/// different moments in time. For a globally consistent answer use
433/// [`plan_consistent_read`](ShardOwnershipCatalog::plan_consistent_read).
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct ReadFanout {
436    legs: Vec<ReadLeg>,
437    trace: ReadFanoutTrace,
438}
439
440impl ReadFanout {
441    /// One leg per distinct owner, in identity order.
442    pub fn legs(&self) -> &[ReadLeg] {
443        &self.legs
444    }
445
446    /// Observable fanout metadata the executor/transport should trace.
447    pub fn trace(&self) -> ReadFanoutTrace {
448        self.trace
449    }
450
451    /// Whether the read touches more than one range.
452    pub fn is_cross_range(&self) -> bool {
453        self.trace.range_count > 1
454    }
455}
456
457/// Why a simple read fanout could not be planned.
458#[derive(Debug, Clone, PartialEq, Eq)]
459pub enum ReadFanoutReject {
460    /// The read named no targets.
461    Empty,
462    /// A targeted key resolves to no range of its collection.
463    Unroutable {
464        collection: CollectionId,
465        key: Vec<u8>,
466    },
467    /// The read would cross owners, but the caller did not explicitly opt into
468    /// fanout.
469    FanoutNotExplicit { owners: Vec<NodeIdentity> },
470    /// The request named more targets than the fanout budget permits.
471    TargetBudgetExceeded { requested: usize, max: usize },
472    /// The plan would contact more owners than the fanout budget permits.
473    OwnerBudgetExceeded { requested: usize, max: usize },
474    /// The plan would touch more ranges than the fanout budget permits.
475    RangeBudgetExceeded { requested: usize, max: usize },
476}
477
478impl std::fmt::Display for ReadFanoutReject {
479    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        match self {
481            Self::Empty => write!(f, "read fanout names no targets"),
482            Self::Unroutable { collection, key } => write!(
483                f,
484                "no range of collection {collection} covers key {} — re-resolve routing",
485                DisplayKey(key)
486            ),
487            Self::FanoutNotExplicit { owners } => {
488                write!(
489                    f,
490                    "read would fan out to {} owners but fanout was not explicit: ",
491                    owners.len()
492                )?;
493                for (i, owner) in owners.iter().enumerate() {
494                    if i > 0 {
495                        write!(f, ", ")?;
496                    }
497                    write!(f, "{owner}")?;
498                }
499                Ok(())
500            }
501            Self::TargetBudgetExceeded { requested, max } => write!(
502                f,
503                "read fanout targets {requested} keys, exceeding budget {max}"
504            ),
505            Self::OwnerBudgetExceeded { requested, max } => write!(
506                f,
507                "read fanout would contact {requested} owners, exceeding budget {max}"
508            ),
509            Self::RangeBudgetExceeded { requested, max } => write!(
510                f,
511                "read fanout would touch {requested} ranges, exceeding budget {max}"
512            ),
513        }
514    }
515}
516
517impl std::error::Error for ReadFanoutReject {}
518
519/// A safe snapshot point for a globally consistent cross-range read: a commit
520/// watermark per `(collection, range)` that the read pins itself to.
521///
522/// This is the "explicit safe snapshot/watermark path" the issue requires before
523/// a cross-range read may claim to be consistent. A consistent read must find a
524/// watermark here for **every** range it touches; a missing entry means the
525/// snapshot does not cover that range and the read cannot be served consistently.
526#[derive(Debug, Clone, Default, PartialEq, Eq)]
527pub struct GlobalReadWatermark {
528    marks: BTreeMap<(CollectionId, RangeId), CommitWatermark>,
529}
530
531impl GlobalReadWatermark {
532    pub fn new() -> Self {
533        Self::default()
534    }
535
536    /// Pin `range`'s safe read point to `watermark` (builder form).
537    pub fn with(
538        mut self,
539        collection: CollectionId,
540        range_id: RangeId,
541        watermark: CommitWatermark,
542    ) -> Self {
543        self.marks.insert((collection, range_id), watermark);
544        self
545    }
546
547    /// Record `range`'s safe read point.
548    pub fn insert(
549        &mut self,
550        collection: CollectionId,
551        range_id: RangeId,
552        watermark: CommitWatermark,
553    ) {
554        self.marks.insert((collection, range_id), watermark);
555    }
556
557    /// The pinned watermark for a range, or `None` if the snapshot does not cover
558    /// it.
559    pub fn covers(&self, collection: &CollectionId, range_id: RangeId) -> Option<CommitWatermark> {
560        self.marks.get(&(collection.clone(), range_id)).copied()
561    }
562}
563
564/// One owner's leg of a consistent read: each resolved target paired with the
565/// safe watermark its range is pinned to for this snapshot.
566#[derive(Debug, Clone, PartialEq, Eq)]
567pub struct ConsistentReadLeg {
568    owner: NodeIdentity,
569    targets: Vec<PinnedTarget>,
570}
571
572impl ConsistentReadLeg {
573    pub fn owner(&self) -> &NodeIdentity {
574        &self.owner
575    }
576
577    pub fn targets(&self) -> &[PinnedTarget] {
578        &self.targets
579    }
580}
581
582/// A resolved target pinned to the safe read watermark of its range.
583#[derive(Debug, Clone, PartialEq, Eq)]
584pub struct PinnedTarget {
585    target: ResolvedTarget,
586    watermark: CommitWatermark,
587}
588
589impl PinnedTarget {
590    pub fn target(&self) -> &ResolvedTarget {
591        &self.target
592    }
593
594    pub fn watermark(&self) -> CommitWatermark {
595        self.watermark
596    }
597}
598
599/// A globally consistent cross-range read, pinned to a safe snapshot covering
600/// every range it touches.
601#[derive(Debug, Clone, PartialEq, Eq)]
602pub struct ConsistentReadPlan {
603    legs: Vec<ConsistentReadLeg>,
604}
605
606impl ConsistentReadPlan {
607    /// One leg per distinct owner, in identity order, each pinned to its range's
608    /// safe watermark.
609    pub fn legs(&self) -> &[ConsistentReadLeg] {
610        &self.legs
611    }
612}
613
614/// Why a consistent cross-range read could not be planned.
615#[derive(Debug, Clone, PartialEq, Eq)]
616pub enum ConsistentReadReject {
617    /// The read named no targets.
618    Empty,
619    /// A targeted key resolves to no range of its collection.
620    Unroutable {
621        collection: CollectionId,
622        key: Vec<u8>,
623    },
624    /// No safe snapshot was supplied. A cross-range read cannot be served
625    /// consistently without a global watermark, so the request fails clearly
626    /// rather than silently degrading to a best-effort fanout.
627    NoSafeSnapshot,
628    /// The supplied snapshot does not cover a targeted range, so the read cannot
629    /// be pinned to a single safe point across all of its ranges.
630    WatermarkGap {
631        collection: CollectionId,
632        range_id: RangeId,
633    },
634}
635
636impl std::fmt::Display for ConsistentReadReject {
637    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
638        match self {
639            Self::Empty => write!(f, "consistent read names no targets"),
640            Self::Unroutable { collection, key } => write!(
641                f,
642                "no range of collection {collection} covers key {} — re-resolve routing",
643                DisplayKey(key)
644            ),
645            Self::NoSafeSnapshot => write!(
646                f,
647                "consistent cross-range read requires a global safe snapshot/watermark, none supplied"
648            ),
649            Self::WatermarkGap {
650                collection,
651                range_id,
652            } => write!(
653                f,
654                "safe snapshot does not cover {collection}/{range_id}; cannot serve a consistent read"
655            ),
656        }
657    }
658}
659
660impl std::error::Error for ConsistentReadReject {}
661
662/// Hex-ish key rendering for error messages — keys are arbitrary bytes.
663struct DisplayKey<'a>(&'a [u8]);
664
665impl std::fmt::Display for DisplayKey<'_> {
666    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667        write!(f, "0x")?;
668        for b in self.0 {
669            write!(f, "{b:02x}")?;
670        }
671        Ok(())
672    }
673}
674
675impl ShardOwnershipCatalog {
676    /// Resolve every target to its owning range, preserving caller order.
677    /// `Err` on the first key no range covers.
678    fn resolve_targets(
679        &self,
680        targets: &[KeyTarget],
681    ) -> Result<Vec<ResolvedTarget>, (CollectionId, Vec<u8>)> {
682        let mut resolved = Vec::with_capacity(targets.len());
683        for t in targets {
684            match self.route_shard_key(t.collection(), t.key()) {
685                Some(range) => resolved.push(ResolvedTarget {
686                    collection: t.collection().clone(),
687                    key: t.key().to_vec(),
688                    range_id: range.range_id(),
689                    owner: range.owner().clone(),
690                    epoch: range.epoch(),
691                }),
692                None => return Err((t.collection().clone(), t.key().to_vec())),
693            }
694        }
695        Ok(resolved)
696    }
697
698    /// Plan a write transaction over `targets` in the first multi-writer cut
699    /// (issue #1002).
700    ///
701    /// Resolves every targeted key to its owning range and groups by writer:
702    ///
703    /// * all targets owned by one writer → [`WriteTransactionPlan`] (the
704    ///   transaction commits atomically on that owner, even across several of its
705    ///   own ranges);
706    /// * targets span ranges of different writers →
707    ///   [`WriteTransactionReject::CrossRange`] naming every writer — this cut has
708    ///   no atomic cross-writer commit;
709    /// * a target routes nowhere → [`WriteTransactionReject::Unroutable`].
710    ///
711    /// Pure: it reads the catalog and returns intent. Each admitted write still
712    /// passes [`admit_public_write`](Self::admit_public_write) at the owner's
713    /// current epoch, so a stale plan cannot smuggle a write past fencing.
714    pub fn plan_write_transaction(
715        &self,
716        targets: &[KeyTarget],
717    ) -> Result<WriteTransactionPlan, WriteTransactionReject> {
718        if targets.is_empty() {
719            return Err(WriteTransactionReject::Empty);
720        }
721        let resolved = self
722            .resolve_targets(targets)
723            .map_err(|(collection, key)| WriteTransactionReject::Unroutable { collection, key })?;
724
725        let writers = group_by_owner(&resolved);
726        if writers.len() == 1 {
727            let (writer, ranges) = writers.into_iter().next().expect("exactly one writer");
728            Ok(WriteTransactionPlan {
729                participation: WriterParticipation { writer, ranges },
730            })
731        } else {
732            Err(WriteTransactionReject::CrossRange {
733                writers: writers
734                    .into_iter()
735                    .map(|(writer, ranges)| WriterParticipation { writer, ranges })
736                    .collect(),
737            })
738        }
739    }
740
741    /// Plan an exact claim over `targets` in the first cluster contract.
742    ///
743    /// A claim winner is a non-deterministic write decision, so only one write
744    /// authority may inspect the candidate set and choose winners. Claims whose
745    /// candidates all route to one owner are admitted for that owner. Claims
746    /// whose candidates require multiple owners are rejected explicitly; later
747    /// cluster contracts can add coordinated claims without weakening this one.
748    pub fn plan_exact_claim(
749        &self,
750        targets: &[KeyTarget],
751    ) -> Result<ExactClaimPlan, ExactClaimReject> {
752        if targets.is_empty() {
753            return Err(ExactClaimReject::Empty);
754        }
755        let resolved = self
756            .resolve_targets(targets)
757            .map_err(|(collection, key)| ExactClaimReject::Unroutable { collection, key })?;
758
759        let writers = group_by_owner(&resolved);
760        if writers.len() == 1 {
761            let (writer, ranges) = writers.into_iter().next().expect("exactly one writer");
762            Ok(ExactClaimPlan {
763                participation: WriterParticipation { writer, ranges },
764            })
765        } else {
766            Err(ExactClaimReject::CrossOwner {
767                writers: writers
768                    .into_iter()
769                    .map(|(writer, ranges)| WriterParticipation { writer, ranges })
770                    .collect(),
771            })
772        }
773    }
774
775    /// Plan a simple, best-effort read over `targets` (issue #1002, ADR 0055).
776    ///
777    /// A read that resolves to one owner is admitted under
778    /// [`ReadFanoutPolicy::SingleOwnerOnly`]. A read that would contact multiple
779    /// owners must use [`ReadFanoutPolicy::ExplicitFanout`] and fit inside the
780    /// supplied budget. The returned [`ReadFanoutTrace`] records target, owner,
781    /// range, and partial-result policy so a later executor can surface fanout
782    /// rather than making it invisible.
783    pub fn plan_read_fanout(
784        &self,
785        targets: &[KeyTarget],
786        policy: ReadFanoutPolicy,
787    ) -> Result<ReadFanout, ReadFanoutReject> {
788        if targets.is_empty() {
789            return Err(ReadFanoutReject::Empty);
790        }
791        if let ReadFanoutPolicy::ExplicitFanout { budget, .. } = policy {
792            if targets.len() > budget.max_targets() {
793                return Err(ReadFanoutReject::TargetBudgetExceeded {
794                    requested: targets.len(),
795                    max: budget.max_targets(),
796                });
797            }
798        }
799        let resolved = self
800            .resolve_targets(targets)
801            .map_err(|(collection, key)| ReadFanoutReject::Unroutable { collection, key })?;
802
803        let owner_count = distinct_owner_count(&resolved);
804        let range_count = distinct_range_count(&resolved);
805        let partial_allowed = match policy {
806            ReadFanoutPolicy::SingleOwnerOnly => {
807                if owner_count > 1 {
808                    return Err(ReadFanoutReject::FanoutNotExplicit {
809                        owners: distinct_owners(&resolved),
810                    });
811                }
812                false
813            }
814            ReadFanoutPolicy::ExplicitFanout {
815                budget,
816                allow_partial,
817            } => {
818                if owner_count > budget.max_owners() {
819                    return Err(ReadFanoutReject::OwnerBudgetExceeded {
820                        requested: owner_count,
821                        max: budget.max_owners(),
822                    });
823                }
824                if range_count > budget.max_ranges() {
825                    return Err(ReadFanoutReject::RangeBudgetExceeded {
826                        requested: range_count,
827                        max: budget.max_ranges(),
828                    });
829                }
830                allow_partial
831            }
832        };
833
834        Ok(ReadFanout {
835            legs: group_targets_by_owner(resolved),
836            trace: ReadFanoutTrace {
837                target_count: targets.len(),
838                owner_count,
839                range_count,
840                partial_allowed,
841            },
842        })
843    }
844
845    /// Plan a globally consistent cross-range read over `targets`, pinned to
846    /// `snapshot` (issue #1002).
847    ///
848    /// A consistent read must pin every range it touches to a single safe point:
849    ///
850    /// * `snapshot` is `None` → [`ConsistentReadReject::NoSafeSnapshot`]; the
851    ///   caller must obtain a global watermark first;
852    /// * a targeted range is absent from `snapshot` →
853    ///   [`ConsistentReadReject::WatermarkGap`];
854    /// * a target routes nowhere → [`ConsistentReadReject::Unroutable`];
855    /// * otherwise → a [`ConsistentReadPlan`] with each leg pinned to its range's
856    ///   watermark.
857    ///
858    /// This is the explicit safe-snapshot path: without it a cross-range read may
859    /// only be served as a best-effort [`ReadFanout`], never as a consistent one.
860    pub fn plan_consistent_read(
861        &self,
862        targets: &[KeyTarget],
863        snapshot: Option<&GlobalReadWatermark>,
864    ) -> Result<ConsistentReadPlan, ConsistentReadReject> {
865        if targets.is_empty() {
866            return Err(ConsistentReadReject::Empty);
867        }
868        let resolved = self
869            .resolve_targets(targets)
870            .map_err(|(collection, key)| ConsistentReadReject::Unroutable { collection, key })?;
871
872        let snapshot = snapshot.ok_or(ConsistentReadReject::NoSafeSnapshot)?;
873
874        // Every targeted range must be covered by the snapshot before any leg is
875        // built — a partial pin is not a consistent read.
876        let mut pinned = Vec::with_capacity(resolved.len());
877        for target in resolved {
878            let watermark = snapshot
879                .covers(target.collection(), target.range_id())
880                .ok_or_else(|| ConsistentReadReject::WatermarkGap {
881                    collection: target.collection().clone(),
882                    range_id: target.range_id(),
883                })?;
884            pinned.push(PinnedTarget { target, watermark });
885        }
886
887        Ok(ConsistentReadPlan {
888            legs: group_pinned_by_owner(pinned),
889        })
890    }
891}
892
893/// Group resolved targets by owner into `(writer, distinct ranges)` pairs, in
894/// identity order. Ranges within a writer are deduplicated and ordered by id.
895fn group_by_owner(resolved: &[ResolvedTarget]) -> Vec<(NodeIdentity, Vec<RangeParticipant>)> {
896    let mut by_owner: BTreeMap<NodeIdentity, BTreeMap<RangeId, RangeParticipant>> = BTreeMap::new();
897    for t in resolved {
898        by_owner
899            .entry(t.owner().clone())
900            .or_default()
901            .entry(t.range_id())
902            .or_insert_with(|| RangeParticipant {
903                collection: t.collection().clone(),
904                range_id: t.range_id(),
905                epoch: t.epoch(),
906            });
907    }
908    by_owner
909        .into_iter()
910        .map(|(owner, ranges)| (owner, ranges.into_values().collect()))
911        .collect()
912}
913
914/// Group resolved targets into one [`ReadLeg`] per owner, in identity order.
915fn group_targets_by_owner(resolved: Vec<ResolvedTarget>) -> Vec<ReadLeg> {
916    let mut by_owner: BTreeMap<NodeIdentity, Vec<ResolvedTarget>> = BTreeMap::new();
917    for t in resolved {
918        by_owner.entry(t.owner().clone()).or_default().push(t);
919    }
920    by_owner
921        .into_iter()
922        .map(|(owner, targets)| ReadLeg { owner, targets })
923        .collect()
924}
925
926fn distinct_owners(resolved: &[ResolvedTarget]) -> Vec<NodeIdentity> {
927    resolved
928        .iter()
929        .map(|target| target.owner().clone())
930        .collect::<std::collections::BTreeSet<_>>()
931        .into_iter()
932        .collect()
933}
934
935fn distinct_owner_count(resolved: &[ResolvedTarget]) -> usize {
936    distinct_owners(resolved).len()
937}
938
939fn distinct_range_count(resolved: &[ResolvedTarget]) -> usize {
940    resolved
941        .iter()
942        .map(|target| (target.collection().clone(), target.range_id()))
943        .collect::<std::collections::BTreeSet<_>>()
944        .len()
945}
946
947/// Group pinned targets into one [`ConsistentReadLeg`] per owner, in identity
948/// order.
949fn group_pinned_by_owner(pinned: Vec<PinnedTarget>) -> Vec<ConsistentReadLeg> {
950    let mut by_owner: BTreeMap<NodeIdentity, Vec<PinnedTarget>> = BTreeMap::new();
951    for p in pinned {
952        by_owner
953            .entry(p.target().owner().clone())
954            .or_default()
955            .push(p);
956    }
957    by_owner
958        .into_iter()
959        .map(|(owner, targets)| ConsistentReadLeg { owner, targets })
960        .collect()
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966    use crate::cluster::ownership::{PlacementMetadata, RangeBound, RangeBounds, ShardKeyMode};
967
968    fn collection(name: &str) -> CollectionId {
969        CollectionId::new(name).unwrap()
970    }
971
972    fn ident(cn: &str) -> NodeIdentity {
973        NodeIdentity::from_certificate_subject(cn).unwrap()
974    }
975
976    fn bounds(lower: &[u8], upper: &[u8]) -> RangeBounds {
977        RangeBounds::new(RangeBound::key(lower), RangeBound::key(upper)).unwrap()
978    }
979
980    /// A range over `[lower, upper)` of `coll` owned by `owner`.
981    fn range(
982        coll: &CollectionId,
983        id: u64,
984        bnds: RangeBounds,
985        owner: &str,
986    ) -> super::super::ownership::RangeOwnership {
987        super::super::ownership::RangeOwnership::establish(
988            coll.clone(),
989            RangeId::new(id),
990            ShardKeyMode::Ordered,
991            bnds,
992            ident(owner),
993            [ident("CN=replica-1")],
994            PlacementMetadata::with_replication_factor(3),
995        )
996    }
997
998    /// Catalog with `orders` split into two ranges: [a,m) owned by node-a,
999    /// [m,Max) owned by node-b.
1000    fn two_range_catalog() -> (ShardOwnershipCatalog, CollectionId) {
1001        let orders = collection("orders");
1002        let mut catalog = ShardOwnershipCatalog::new();
1003        catalog
1004            .apply_update(range(&orders, 1, bounds(b"a", b"m"), "CN=node-a"))
1005            .unwrap();
1006        catalog
1007            .apply_update(range(
1008                &orders,
1009                2,
1010                RangeBounds::new(RangeBound::key(b"m"), RangeBound::Max).unwrap(),
1011                "CN=node-b",
1012            ))
1013            .unwrap();
1014        (catalog, orders)
1015    }
1016
1017    fn target(coll: &CollectionId, key: &[u8]) -> KeyTarget {
1018        KeyTarget::new(coll.clone(), key.to_vec())
1019    }
1020
1021    // AC #5: a write transaction whose keys all land in one writer's ranges is
1022    // admitted — even when it spans several of that writer's ranges.
1023    #[test]
1024    fn single_writer_transaction_succeeds() {
1025        let orders = collection("orders");
1026        let mut catalog = ShardOwnershipCatalog::new();
1027        // Two ranges both owned by node-a.
1028        catalog
1029            .apply_update(range(&orders, 1, bounds(b"a", b"m"), "CN=node-a"))
1030            .unwrap();
1031        catalog
1032            .apply_update(range(
1033                &orders,
1034                2,
1035                RangeBounds::new(RangeBound::key(b"m"), RangeBound::Max).unwrap(),
1036                "CN=node-a",
1037            ))
1038            .unwrap();
1039
1040        let plan = catalog
1041            .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"zeb")])
1042            .expect("single-writer transaction is admitted");
1043        assert_eq!(plan.writer(), &ident("CN=node-a"));
1044        // Both of node-a's ranges participate, deduplicated and id-ordered.
1045        let ids: Vec<u64> = plan.ranges().iter().map(|r| r.range_id().value()).collect();
1046        assert_eq!(ids, vec![1, 2]);
1047        assert_eq!(plan.ranges()[0].epoch(), OwnershipEpoch::initial());
1048    }
1049
1050    // AC #5: keys that all land in a single range are trivially single-writer.
1051    #[test]
1052    fn single_range_transaction_succeeds() {
1053        let (catalog, orders) = two_range_catalog();
1054        let plan = catalog
1055            .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"bob")])
1056            .expect("single-range transaction is admitted");
1057        assert_eq!(plan.writer(), &ident("CN=node-a"));
1058        assert_eq!(plan.ranges().len(), 1);
1059        assert_eq!(plan.ranges()[0].range_id(), RangeId::new(1));
1060    }
1061
1062    // AC #1 + #2: a transaction straddling ranges owned by different writers is
1063    // detected and rejected, naming both writers.
1064    #[test]
1065    fn cross_range_write_transaction_is_rejected() {
1066        let (catalog, orders) = two_range_catalog();
1067        let err = catalog
1068            .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"zeb")])
1069            .expect_err("cross-writer transaction is rejected");
1070        match err {
1071            WriteTransactionReject::CrossRange { writers } => {
1072                assert_eq!(writers.len(), 2);
1073                assert_eq!(writers[0].writer(), &ident("CN=node-a"));
1074                assert_eq!(writers[1].writer(), &ident("CN=node-b"));
1075                assert_eq!(writers[0].ranges()[0].range_id(), RangeId::new(1));
1076                assert_eq!(writers[1].ranges()[0].range_id(), RangeId::new(2));
1077            }
1078            other => panic!("expected CrossRange, got {other:?}"),
1079        }
1080    }
1081
1082    #[test]
1083    fn empty_write_transaction_is_rejected() {
1084        let catalog = ShardOwnershipCatalog::new();
1085        assert_eq!(
1086            catalog.plan_write_transaction(&[]),
1087            Err(WriteTransactionReject::Empty)
1088        );
1089    }
1090
1091    #[test]
1092    fn unroutable_write_transaction_is_rejected() {
1093        let catalog = ShardOwnershipCatalog::new();
1094        let orders = collection("orders");
1095        match catalog.plan_write_transaction(&[target(&orders, b"x")]) {
1096            Err(WriteTransactionReject::Unroutable { collection, key }) => {
1097                assert_eq!(collection, orders);
1098                assert_eq!(key, b"x");
1099            }
1100            other => panic!("expected Unroutable, got {other:?}"),
1101        }
1102    }
1103
1104    // AC #3: a simple read fanout collects one leg per owner across ranges.
1105    #[test]
1106    fn explicit_read_fanout_collects_per_owner_legs() {
1107        let (catalog, orders) = two_range_catalog();
1108        let fanout = catalog
1109            .plan_read_fanout(
1110                &[
1111                    target(&orders, b"alice"),
1112                    target(&orders, b"zeb"),
1113                    target(&orders, b"bob"),
1114                ],
1115                ReadFanoutPolicy::explicit(ReadFanoutBudget::default()).allowing_partial(),
1116            )
1117            .expect("fanout planned");
1118        assert!(fanout.is_cross_range());
1119        assert_eq!(fanout.legs().len(), 2);
1120        assert_eq!(fanout.trace().target_count(), 3);
1121        assert_eq!(fanout.trace().owner_count(), 2);
1122        assert_eq!(fanout.trace().range_count(), 2);
1123        assert!(fanout.trace().partial_allowed());
1124        // node-a leg gets alice + bob (range 1); node-b leg gets zeb (range 2).
1125        let a = &fanout.legs()[0];
1126        assert_eq!(a.owner(), &ident("CN=node-a"));
1127        assert_eq!(a.targets().len(), 2);
1128        let b = &fanout.legs()[1];
1129        assert_eq!(b.owner(), &ident("CN=node-b"));
1130        assert_eq!(b.targets().len(), 1);
1131        assert_eq!(b.targets()[0].key(), b"zeb");
1132    }
1133
1134    #[test]
1135    fn cross_owner_read_requires_explicit_fanout() {
1136        let (catalog, orders) = two_range_catalog();
1137        match catalog.plan_read_fanout(
1138            &[target(&orders, b"alice"), target(&orders, b"zeb")],
1139            ReadFanoutPolicy::single_owner_only(),
1140        ) {
1141            Err(ReadFanoutReject::FanoutNotExplicit { owners }) => {
1142                assert_eq!(owners, vec![ident("CN=node-a"), ident("CN=node-b")]);
1143            }
1144            other => panic!("expected FanoutNotExplicit, got {other:?}"),
1145        }
1146    }
1147
1148    #[test]
1149    fn explicit_read_fanout_enforces_owner_budget() {
1150        let (catalog, orders) = two_range_catalog();
1151        match catalog.plan_read_fanout(
1152            &[target(&orders, b"alice"), target(&orders, b"zeb")],
1153            ReadFanoutPolicy::explicit(ReadFanoutBudget::new(1, 2, 8)),
1154        ) {
1155            Err(ReadFanoutReject::OwnerBudgetExceeded { requested, max }) => {
1156                assert_eq!(requested, 2);
1157                assert_eq!(max, 1);
1158            }
1159            other => panic!("expected OwnerBudgetExceeded, got {other:?}"),
1160        }
1161    }
1162
1163    #[test]
1164    fn explicit_read_fanout_enforces_range_budget() {
1165        let (catalog, orders) = two_range_catalog();
1166        match catalog.plan_read_fanout(
1167            &[target(&orders, b"alice"), target(&orders, b"zeb")],
1168            ReadFanoutPolicy::explicit(ReadFanoutBudget::new(2, 1, 8)),
1169        ) {
1170            Err(ReadFanoutReject::RangeBudgetExceeded { requested, max }) => {
1171                assert_eq!(requested, 2);
1172                assert_eq!(max, 1);
1173            }
1174            other => panic!("expected RangeBudgetExceeded, got {other:?}"),
1175        }
1176    }
1177
1178    #[test]
1179    fn explicit_read_fanout_enforces_target_budget_before_routing() {
1180        let (catalog, orders) = two_range_catalog();
1181        match catalog.plan_read_fanout(
1182            &[target(&orders, b"alice"), target(&orders, b"bob")],
1183            ReadFanoutPolicy::explicit(ReadFanoutBudget::new(2, 2, 1)),
1184        ) {
1185            Err(ReadFanoutReject::TargetBudgetExceeded { requested, max }) => {
1186                assert_eq!(requested, 2);
1187                assert_eq!(max, 1);
1188            }
1189            other => panic!("expected TargetBudgetExceeded, got {other:?}"),
1190        }
1191    }
1192
1193    #[test]
1194    fn single_owner_read_is_not_cross_range() {
1195        let (catalog, orders) = two_range_catalog();
1196        let fanout = catalog
1197            .plan_read_fanout(
1198                &[target(&orders, b"alice"), target(&orders, b"bob")],
1199                ReadFanoutPolicy::single_owner_only(),
1200            )
1201            .expect("fanout planned");
1202        assert!(!fanout.is_cross_range());
1203        assert_eq!(fanout.legs().len(), 1);
1204        assert_eq!(fanout.trace().owner_count(), 1);
1205        assert_eq!(fanout.trace().range_count(), 1);
1206        assert!(!fanout.trace().partial_allowed());
1207    }
1208
1209    #[test]
1210    fn single_owner_multi_range_read_is_cross_range_but_not_cross_owner() {
1211        let orders = collection("orders");
1212        let mut catalog = ShardOwnershipCatalog::new();
1213        catalog
1214            .apply_update(range(&orders, 1, bounds(b"a", b"m"), "CN=node-a"))
1215            .unwrap();
1216        catalog
1217            .apply_update(range(
1218                &orders,
1219                2,
1220                RangeBounds::new(RangeBound::key(b"m"), RangeBound::Max).unwrap(),
1221                "CN=node-a",
1222            ))
1223            .unwrap();
1224
1225        let fanout = catalog
1226            .plan_read_fanout(
1227                &[target(&orders, b"alice"), target(&orders, b"zeb")],
1228                ReadFanoutPolicy::single_owner_only(),
1229            )
1230            .expect("same-owner multi-range read is admitted");
1231        assert!(fanout.is_cross_range());
1232        assert_eq!(fanout.trace().owner_count(), 1);
1233        assert_eq!(fanout.trace().range_count(), 2);
1234    }
1235
1236    #[test]
1237    fn unroutable_read_fanout_is_rejected() {
1238        let catalog = ShardOwnershipCatalog::new();
1239        let orders = collection("orders");
1240        match catalog.plan_read_fanout(
1241            &[target(&orders, b"x")],
1242            ReadFanoutPolicy::single_owner_only(),
1243        ) {
1244            Err(ReadFanoutReject::Unroutable { collection, .. }) => {
1245                assert_eq!(collection, orders)
1246            }
1247            other => panic!("expected Unroutable, got {other:?}"),
1248        }
1249    }
1250
1251    // AC #4: a consistent cross-range read with no snapshot fails clearly.
1252    #[test]
1253    fn consistent_read_without_snapshot_is_rejected() {
1254        let (catalog, orders) = two_range_catalog();
1255        assert_eq!(
1256            catalog
1257                .plan_consistent_read(&[target(&orders, b"alice"), target(&orders, b"zeb")], None),
1258            Err(ConsistentReadReject::NoSafeSnapshot)
1259        );
1260    }
1261
1262    // AC #4: a snapshot missing a targeted range fails with a watermark gap.
1263    #[test]
1264    fn consistent_read_with_incomplete_snapshot_is_rejected() {
1265        let (catalog, orders) = two_range_catalog();
1266        // Snapshot covers range 1 but not range 2.
1267        let snapshot = GlobalReadWatermark::new().with(
1268            orders.clone(),
1269            RangeId::new(1),
1270            CommitWatermark::new(1, 100),
1271        );
1272        match catalog.plan_consistent_read(
1273            &[target(&orders, b"alice"), target(&orders, b"zeb")],
1274            Some(&snapshot),
1275        ) {
1276            Err(ConsistentReadReject::WatermarkGap {
1277                collection,
1278                range_id,
1279            }) => {
1280                assert_eq!(collection, orders);
1281                assert_eq!(range_id, RangeId::new(2));
1282            }
1283            other => panic!("expected WatermarkGap, got {other:?}"),
1284        }
1285    }
1286
1287    // AC #4: with a snapshot covering every targeted range, the consistent read
1288    // is planned and each leg is pinned to its range's watermark.
1289    #[test]
1290    fn consistent_read_with_full_snapshot_succeeds() {
1291        let (catalog, orders) = two_range_catalog();
1292        let snapshot = GlobalReadWatermark::new()
1293            .with(
1294                orders.clone(),
1295                RangeId::new(1),
1296                CommitWatermark::new(1, 100),
1297            )
1298            .with(
1299                orders.clone(),
1300                RangeId::new(2),
1301                CommitWatermark::new(1, 250),
1302            );
1303        let plan = catalog
1304            .plan_consistent_read(
1305                &[target(&orders, b"alice"), target(&orders, b"zeb")],
1306                Some(&snapshot),
1307            )
1308            .expect("consistent read planned");
1309        assert_eq!(plan.legs().len(), 2);
1310        let a = &plan.legs()[0];
1311        assert_eq!(a.owner(), &ident("CN=node-a"));
1312        assert_eq!(a.targets()[0].watermark(), CommitWatermark::new(1, 100));
1313        let b = &plan.legs()[1];
1314        assert_eq!(b.owner(), &ident("CN=node-b"));
1315        assert_eq!(b.targets()[0].watermark(), CommitWatermark::new(1, 250));
1316    }
1317
1318    #[test]
1319    fn empty_consistent_read_is_rejected() {
1320        let catalog = ShardOwnershipCatalog::new();
1321        assert_eq!(
1322            catalog.plan_consistent_read(&[], None),
1323            Err(ConsistentReadReject::Empty)
1324        );
1325    }
1326
1327    // The rejection contract renders a readable, writer-naming message.
1328    #[test]
1329    fn cross_range_rejection_message_names_writers() {
1330        let (catalog, orders) = two_range_catalog();
1331        let err = catalog
1332            .plan_write_transaction(&[target(&orders, b"alice"), target(&orders, b"zeb")])
1333            .unwrap_err();
1334        let msg = err.to_string();
1335        assert!(msg.contains("cross-range write transaction"));
1336        assert!(msg.contains("CN=node-a"));
1337        assert!(msg.contains("CN=node-b"));
1338    }
1339
1340    #[test]
1341    fn exact_claim_confined_to_one_owner_is_admitted() {
1342        let (catalog, orders) = two_range_catalog();
1343
1344        let plan = catalog
1345            .plan_exact_claim(&[target(&orders, b"alice"), target(&orders, b"bob")])
1346            .expect("owner-local exact claim is admitted");
1347
1348        assert_eq!(plan.writer(), &ident("CN=node-a"));
1349        assert_eq!(plan.ranges().len(), 1);
1350        assert_eq!(plan.ranges()[0].range_id(), RangeId::new(1));
1351    }
1352
1353    #[test]
1354    fn cross_owner_exact_claim_is_rejected_explicitly() {
1355        let (catalog, orders) = two_range_catalog();
1356
1357        let err = catalog
1358            .plan_exact_claim(&[target(&orders, b"alice"), target(&orders, b"zeb")])
1359            .expect_err("cross-owner exact claim is outside the first cluster contract");
1360
1361        match &err {
1362            ExactClaimReject::CrossOwner { writers } => {
1363                assert_eq!(writers.len(), 2);
1364                assert_eq!(writers[0].writer(), &ident("CN=node-a"));
1365                assert_eq!(writers[1].writer(), &ident("CN=node-b"));
1366            }
1367            other => panic!("expected CrossOwner, got {other:?}"),
1368        }
1369        assert!(err.to_string().contains("cross-owner exact claim"));
1370        assert!(err.to_string().contains("first cluster contract"));
1371    }
1372}