Skip to main content

coven_protocol/
causal_grants.rs

1//! Shared causal assignment reducer for Store membership and Circle rosters.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt::{self, Debug};
5use std::str::FromStr;
6
7use serde::{Deserialize, Serialize};
8
9use super::store_commit::ObjectHash;
10
11const MAX_CYCLIC_REVOCATION_SOURCES: usize = 12;
12
13mod fixed_sets;
14mod reduction;
15
16pub(crate) use reduction::{reduce, reduce_from_checkpoint};
17
18pub fn canonical_ready_checkpoint<'a, K: Clone + Ord + 'a>(
19    mut dependencies: impl Iterator<Item = (&'a K, &'a BTreeSet<K>)>,
20    applied: &BTreeSet<K>,
21) -> Option<K> {
22    dependencies
23        .find(|(_, required)| required.is_subset(applied))
24        .map(|(checkpoint, _)| checkpoint.clone())
25}
26
27pub(crate) fn merge_checkpoint_evidence<K, V, T, C>(
28    merged_grants: &mut BTreeMap<K, GrantState<V, T>>,
29    merged_included: &mut BTreeSet<C>,
30    grants: &BTreeMap<K, GrantState<V, T>>,
31    included: &BTreeSet<C>,
32) -> bool
33where
34    K: Clone + Ord,
35    V: Clone + Eq,
36    T: Clone + Ord,
37    C: Clone + Ord,
38{
39    for (grant, state) in grants {
40        match merged_grants.entry(grant.clone()) {
41            std::collections::btree_map::Entry::Vacant(entry) => {
42                entry.insert(state.clone());
43            }
44            std::collections::btree_map::Entry::Occupied(mut entry) => {
45                if !entry.get_mut().merge(state) {
46                    return false;
47                }
48            }
49        }
50    }
51    merged_included.extend(included.iter().cloned());
52    true
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56#[serde(transparent)]
57pub struct GrantRetirements<T: Ord>(BTreeSet<T>);
58
59impl<T: Ord> GrantRetirements<T> {
60    pub fn new(retirement: T) -> Self {
61        Self(BTreeSet::from([retirement]))
62    }
63
64    pub fn insert(&mut self, retirement: T) -> bool {
65        self.0.insert(retirement)
66    }
67
68    pub fn extend(&mut self, retirements: impl IntoIterator<Item = T>) {
69        self.0.extend(retirements);
70    }
71
72    pub fn iter(&self) -> impl Iterator<Item = &T> {
73        self.0.iter()
74    }
75
76    pub fn contains(&self, retirement: &T) -> bool {
77        self.0.contains(retirement)
78    }
79
80    pub fn as_set(&self) -> &BTreeSet<T> {
81        &self.0
82    }
83}
84
85impl<'de, T> Deserialize<'de> for GrantRetirements<T>
86where
87    T: Deserialize<'de> + Ord,
88{
89    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
90    where
91        D: serde::Deserializer<'de>,
92    {
93        let retirements = BTreeSet::deserialize(deserializer)?;
94        if retirements.is_empty() {
95            return Err(serde::de::Error::custom(
96                "grant retirement set cannot be empty",
97            ));
98        }
99        Ok(Self(retirements))
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case", deny_unknown_fields)]
105pub enum GrantState<R, T: Ord> {
106    Active {
107        record: R,
108    },
109    Tombstoned {
110        record: R,
111        retirements: GrantRetirements<T>,
112    },
113}
114
115impl<R, T: Ord> GrantState<R, T> {
116    pub fn record(&self) -> &R {
117        match self {
118            Self::Active { record } | Self::Tombstoned { record, .. } => record,
119        }
120    }
121
122    pub fn active(&self) -> Option<&R> {
123        match self {
124            Self::Active { record } => Some(record),
125            Self::Tombstoned { .. } => None,
126        }
127    }
128
129    pub fn retirements(&self) -> Option<&GrantRetirements<T>> {
130        match self {
131            Self::Active { .. } => None,
132            Self::Tombstoned { retirements, .. } => Some(retirements),
133        }
134    }
135}
136
137impl<R: Clone + Eq, T: Clone + Ord> GrantState<R, T> {
138    fn merge(&mut self, other: &Self) -> bool {
139        if self.record() != other.record() {
140            return false;
141        }
142        let retirements = match (&*self, other) {
143            (Self::Active { .. }, Self::Active { .. }) => return true,
144            (Self::Tombstoned { retirements, .. }, Self::Active { .. }) => retirements.clone(),
145            (Self::Active { .. }, Self::Tombstoned { retirements, .. }) => retirements.clone(),
146            (
147                Self::Tombstoned {
148                    retirements: current,
149                    ..
150                },
151                Self::Tombstoned { retirements, .. },
152            ) => {
153                let mut merged = current.clone();
154                merged.extend(retirements.iter().cloned());
155                merged
156            }
157        };
158        *self = Self::Tombstoned {
159            record: self.record().clone(),
160            retirements,
161        };
162        true
163    }
164}
165
166/// One domain's grants, keyed by grant id.
167pub(crate) type CausalGrants<R, T> = BTreeMap<MembershipGrantId, GrantState<R, T>>;
168
169/// The grants that currently hold their assignment, with their records.
170pub fn active_grants<R, T: Ord>(
171    grants: &CausalGrants<R, T>,
172) -> impl Iterator<Item = (&MembershipGrantId, &R)> {
173    grants
174        .iter()
175        .filter_map(|(grant, state)| state.active().map(|record| (grant, record)))
176}
177
178/// Some member holds an active Owner grant. Grant state that leaves no Owner
179/// can never be extended, so no reduction may settle on it.
180pub(crate) fn has_active_owner<R, T: Ord>(
181    grants: &CausalGrants<R, T>,
182    is_owner: impl Fn(&R) -> bool,
183) -> bool {
184    active_grants(grants).any(|(_, record)| is_owner(record))
185}
186
187/// Some member holds two active grants at once — the assignment conflict a
188/// reduction reports instead of picking one.
189pub(crate) fn has_concurrent_assignments<R, T: Ord>(
190    grants: &CausalGrants<R, T>,
191    member_pubkey: impl Fn(&R) -> &str,
192) -> bool {
193    let mut members = BTreeSet::new();
194    active_grants(grants).any(|(_, record)| !members.insert(member_pubkey(record).to_string()))
195}
196
197/// Merge one branch's grant state into a conflict result. A grant's record is
198/// immutable; divergent records are an invalid conflict, while retirement
199/// evidence accumulates across every selected branch.
200pub(crate) fn merge_conflict_grant_state<R: Clone + Eq, T: Clone + Ord>(
201    grants: &mut CausalGrants<R, T>,
202    grant: MembershipGrantId,
203    state: &GrantState<R, T>,
204) -> Result<(), ()> {
205    match grants.entry(grant) {
206        std::collections::btree_map::Entry::Vacant(entry) => {
207            entry.insert(state.clone());
208            Ok(())
209        }
210        std::collections::btree_map::Entry::Occupied(mut entry) => {
211            if entry.get().record() != state.record() {
212                return Err(());
213            }
214            if !entry.get_mut().merge(state) {
215                return Err(());
216            }
217            Ok(())
218        }
219    }
220}
221
222/// Retire `grant`, adding `retirements` to whatever evidence it already
223/// carries. A grant's record is immutable, so a divergent record is an invalid
224/// conflict.
225pub(crate) fn tombstone_conflict_grant<R: Clone + Eq, T: Clone + Ord>(
226    grants: &mut CausalGrants<R, T>,
227    grant: &MembershipGrantId,
228    record: &R,
229    retirements: &GrantRetirements<T>,
230) -> Result<(), ()> {
231    match grants.entry(grant.clone()) {
232        std::collections::btree_map::Entry::Vacant(entry) => {
233            entry.insert(GrantState::Tombstoned {
234                record: record.clone(),
235                retirements: retirements.clone(),
236            });
237        }
238        std::collections::btree_map::Entry::Occupied(mut entry) => {
239            if entry.get().record() != record {
240                return Err(());
241            }
242            let mut merged = entry
243                .get()
244                .retirements()
245                .cloned()
246                .unwrap_or_else(|| retirements.clone());
247            merged.extend(retirements.iter().cloned());
248            *entry.get_mut() = GrantState::Tombstoned {
249                record: record.clone(),
250                retirements: merged,
251            };
252        }
253    }
254    Ok(())
255}
256
257/// The grant state the branches selected by a revocation-cycle resolution agree
258/// on.
259///
260/// A grant stays active only when the first selected branch holds it active,
261/// every other selected branch holds the identical record active, and no
262/// resolver retired it. Every other grant across `branches` is retired:
263/// evidence accumulates from each branch that already retired it, and
264/// `resolution_retirements` supplies the evidence for a grant that survived its
265/// own branch but lost to the resolution.
266pub(crate) fn resolve_conflict_grants<'branch, R, T, E>(
267    branches: impl Iterator<Item = &'branch CausalGrants<R, T>> + Clone,
268    selected: impl Iterator<Item = &'branch CausalGrants<R, T>> + Clone,
269    retired_owner_grants: &BTreeSet<MembershipGrantId>,
270    resolution_retirements: impl Fn(&MembershipGrantId) -> Result<GrantRetirements<T>, E>,
271    invalid: impl Fn() -> E,
272) -> Result<CausalGrants<R, T>, E>
273where
274    R: Clone + Eq + 'branch,
275    T: Clone + Ord + 'branch,
276{
277    let mut selected = selected;
278    let first = selected.next().ok_or_else(&invalid)?;
279    let others = selected;
280    let mut resolved = active_grants(first)
281        .filter(|(grant, _)| !retired_owner_grants.contains(*grant))
282        .filter(|(grant, record)| {
283            others
284                .clone()
285                .all(|branch| branch.get(*grant).and_then(GrantState::active) == Some(*record))
286        })
287        .map(|(grant, record)| {
288            (
289                grant.clone(),
290                GrantState::Active {
291                    record: record.clone(),
292                },
293            )
294        })
295        .collect::<CausalGrants<R, T>>();
296    for branch in branches.clone() {
297        for (grant, state) in branch {
298            if state.retirements().is_some() {
299                merge_conflict_grant_state(&mut resolved, grant.clone(), state)
300                    .map_err(|()| invalid())?;
301            }
302        }
303    }
304    for branch in branches {
305        for (grant, record) in active_grants(branch) {
306            if resolved.get(grant).and_then(GrantState::active).is_some() {
307                continue;
308            }
309            let retirements = resolution_retirements(grant)?;
310            tombstone_conflict_grant(&mut resolved, grant, record, &retirements)
311                .map_err(|()| invalid())?;
312        }
313    }
314    Ok(resolved)
315}
316
317pub(crate) fn try_map_grant_state<C, A, R, T, E>(
318    state: &GrantState<GrantRecord<C, A>, CausalGrantRetirement<C>>,
319    record: R,
320    checkpoint_retirements: Option<&GrantRetirements<T>>,
321    missing_checkpoint_retirements: impl Fn() -> E,
322    map_entry: impl Fn(&C, Option<&OwnerGrantBarrier<C>>) -> Result<T, E>,
323) -> Result<GrantState<R, T>, E>
324where
325    C: CausalCoordinate,
326    A: CausalAssignment,
327    T: Clone + Ord,
328{
329    let GrantState::Tombstoned { retirements, .. } = state else {
330        return Ok(GrantState::Active { record });
331    };
332    let mut mapped: Option<GrantRetirements<T>> = None;
333    let mut add = |retirement| match &mut mapped {
334        Some(mapped) => {
335            mapped.insert(retirement);
336        }
337        None => mapped = Some(GrantRetirements::new(retirement)),
338    };
339    for retirement in retirements.iter() {
340        match retirement {
341            CausalGrantRetirement::Entry {
342                coord,
343                owner_barrier,
344            } => add(map_entry(coord, owner_barrier.as_ref())?),
345            CausalGrantRetirement::Checkpoint => {
346                let checkpoint_retirements =
347                    checkpoint_retirements.ok_or_else(&missing_checkpoint_retirements)?;
348                for retirement in checkpoint_retirements.iter().cloned() {
349                    add(retirement);
350                }
351            }
352        }
353    }
354    Ok(GrantState::Tombstoned {
355        record,
356        retirements: mapped.expect("causal tombstone has retirement evidence"),
357    })
358}
359
360pub(crate) fn merge_checkpoint_frontier<C: CausalCoordinate>(
361    merged: &mut BTreeMap<C::StreamKey, C>,
362    frontier: &[C],
363) -> bool {
364    for coord in frontier {
365        let stream = coord.stream_key();
366        match merged.get(&stream) {
367            Some(existing) if existing.seq() == coord.seq() && existing != coord => return false,
368            Some(existing) if existing.seq() >= coord.seq() => {}
369            _ => {
370                merged.insert(stream, coord.clone());
371            }
372        }
373    }
374    true
375}
376
377/// Derived identity of one causal author stream.
378#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
379pub struct AuthorStreamId([u8; 32]);
380
381#[derive(Debug, thiserror::Error)]
382pub enum AuthorStreamIdParseError {
383    #[error("author stream id must be exactly 64 lowercase hexadecimal characters: {value:?}")]
384    InvalidFormat { value: String },
385    #[error("decode author stream id: {0}")]
386    Hex(#[source] hex::FromHexError),
387    #[error("author stream id has the wrong byte length")]
388    WrongLength,
389}
390
391impl PartialEq for AuthorStreamIdParseError {
392    fn eq(&self, other: &Self) -> bool {
393        match (self, other) {
394            (Self::InvalidFormat { value: left }, Self::InvalidFormat { value: right }) => {
395                left == right
396            }
397            (Self::Hex(left), Self::Hex(right)) => left.to_string() == right.to_string(),
398            (Self::WrongLength, Self::WrongLength) => true,
399            _ => false,
400        }
401    }
402}
403
404impl Eq for AuthorStreamIdParseError {}
405
406impl AuthorStreamId {
407    pub fn from_digest(digest: ObjectHash) -> Self {
408        Self(*digest.as_bytes())
409    }
410
411    #[cfg(any(test, feature = "test-utils"))]
412    pub fn from_bytes(bytes: [u8; 32]) -> Self {
413        Self(bytes)
414    }
415}
416
417impl fmt::Display for AuthorStreamId {
418    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
419        formatter.write_str(&hex::encode(self.0))
420    }
421}
422
423impl FromStr for AuthorStreamId {
424    type Err = AuthorStreamIdParseError;
425
426    fn from_str(value: &str) -> Result<Self, Self::Err> {
427        if value.len() != 64
428            || value
429                .bytes()
430                .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
431        {
432            return Err(AuthorStreamIdParseError::InvalidFormat {
433                value: value.to_string(),
434            });
435        }
436        let bytes = hex::decode(value)
437            .map_err(AuthorStreamIdParseError::Hex)?
438            .try_into()
439            .map_err(|_| AuthorStreamIdParseError::WrongLength)?;
440        Ok(Self(bytes))
441    }
442}
443
444impl Serialize for AuthorStreamId {
445    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
446    where
447        S: serde::Serializer,
448    {
449        serializer.serialize_str(&self.to_string())
450    }
451}
452
453impl<'de> Deserialize<'de> for AuthorStreamId {
454    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
455    where
456        D: serde::Deserializer<'de>,
457    {
458        String::deserialize(deserializer)?
459            .parse()
460            .map_err(serde::de::Error::custom)
461    }
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
465#[serde(transparent)]
466pub struct MembershipGrantId(pub ObjectHash);
467
468impl MembershipGrantId {
469    #[cfg(any(test, feature = "test-utils"))]
470    pub fn from_test_label(label: &str) -> Self {
471        Self(ObjectHash::digest(label.as_bytes()))
472    }
473}
474
475impl fmt::Display for MembershipGrantId {
476    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
477        fmt::Display::fmt(&self.0, formatter)
478    }
479}
480
481pub(crate) trait CausalCoordinate: Clone + Debug + Eq + Ord {
482    type StreamKey: Clone + Debug + Eq + Ord;
483
484    fn stream_key(&self) -> Self::StreamKey;
485    fn author_pubkey(&self) -> &str;
486    fn author_owner_grant(&self) -> &MembershipGrantId;
487    fn seq(&self) -> u64;
488    fn entry_hash(&self) -> ObjectHash;
489}
490
491/// The greatest coordinate each author stream reaches across `coords`.
492///
493/// Streams are keyed by [`CausalCoordinate::stream_key`]; within a stream the
494/// highest sequence wins, and the first of an equal pair is kept.
495pub(crate) fn stream_frontier<C: CausalCoordinate>(coords: impl IntoIterator<Item = C>) -> Vec<C> {
496    let mut heads = BTreeMap::<C::StreamKey, C>::new();
497    for coord in coords {
498        match heads.entry(coord.stream_key()) {
499            std::collections::btree_map::Entry::Vacant(slot) => {
500                slot.insert(coord);
501            }
502            std::collections::btree_map::Entry::Occupied(mut slot) => {
503                if coord.seq() > slot.get().seq() {
504                    slot.insert(coord);
505                }
506            }
507        }
508    }
509    heads.into_values().collect()
510}
511
512pub(crate) fn common_frontier<C: CausalCoordinate>(frontiers: &[&[C]]) -> Vec<C> {
513    let Some(first) = frontiers.first() else {
514        return Vec::new();
515    };
516    let others = frontiers[1..]
517        .iter()
518        .map(|frontier| {
519            frontier
520                .iter()
521                .map(|coord| (coord.stream_key(), coord))
522                .collect::<BTreeMap<_, _>>()
523        })
524        .collect::<Vec<_>>();
525    first
526        .iter()
527        .filter_map(|coord| {
528            let stream = coord.stream_key();
529            let mut common = coord.clone();
530            for frontier in &others {
531                let candidate = frontier.get(&stream)?;
532                if candidate.seq() < common.seq() {
533                    common = (*candidate).clone();
534                }
535            }
536            Some(common)
537        })
538        .collect()
539}
540
541/// The frontier a resolved revocation cycle advances to: the coordinates every
542/// branch its resolvers selected has reached.
543///
544/// `branch_frontier` states how one domain's resolution names the branch it
545/// selected.
546pub(crate) fn selected_branch_frontier<'branch, C, R, E>(
547    resolutions: &[R],
548    branch_frontier: impl Fn(&R) -> Result<&'branch [C], E>,
549) -> Result<Vec<C>, E>
550where
551    C: CausalCoordinate + 'branch,
552{
553    let selected = resolutions
554        .iter()
555        .map(branch_frontier)
556        .collect::<Result<Vec<_>, _>>()?;
557    Ok(common_frontier(&selected))
558}
559
560/// An entry that begins its author stream: sequence one, no predecessor, and no
561/// dependency on the very stream it opens.
562pub(crate) fn starts_author_stream<K: Eq>(
563    seq: u64,
564    previous_hash: Option<ObjectHash>,
565    own_stream: &K,
566    dependency_streams: impl IntoIterator<Item = K>,
567) -> bool {
568    seq == 1
569        && previous_hash.is_none()
570        && dependency_streams
571            .into_iter()
572            .all(|stream| stream != *own_stream)
573}
574
575/// An entry sits where its sequence says it does: sequence one begins the
576/// author stream, every later sequence continues it from a predecessor.
577pub(crate) fn author_stream_position_is_valid<K: Eq>(
578    seq: u64,
579    previous_hash: Option<ObjectHash>,
580    own_stream: &K,
581    dependency_streams: impl IntoIterator<Item = K>,
582) -> bool {
583    match (seq, previous_hash) {
584        (1, _) => starts_author_stream(seq, previous_hash, own_stream, dependency_streams),
585        (0, _) | (_, None) => false,
586        (_, Some(_)) => true,
587    }
588}
589
590pub(crate) trait CausalAssignment: Clone + Debug + Eq {
591    fn is_owner(&self) -> bool;
592}
593
594/// One signed entry of a causal history, viewed as a node of the dependency
595/// graph: where it sits, and which coordinates it names as its predecessors.
596pub(crate) trait CausalHistoryEntry {
597    type Coord: CausalCoordinate;
598
599    fn coord(&self) -> Self::Coord;
600    fn dependencies(&self) -> &[Self::Coord];
601}
602
603/// The entries a checkpoint has not already absorbed: those standing beyond
604/// their stream's checkpointed head, plus any stream the checkpoint never saw.
605///
606/// A resumed chain replays only this suffix, because the checkpoint already
607/// carries the reduced state of everything at or below its heads.
608pub(crate) fn entries_beyond_checkpoint<'a, E: CausalHistoryEntry>(
609    entries: &'a [E],
610    raw_heads: &[E::Coord],
611) -> impl Iterator<Item = &'a E> {
612    let heads = raw_heads
613        .iter()
614        .map(|coord| (coord.stream_key(), coord.seq()))
615        .collect::<BTreeMap<_, _>>();
616    entries.iter().filter(move |entry| {
617        let coord = entry.coord();
618        heads
619            .get(&coord.stream_key())
620            .is_none_or(|head| coord.seq() > *head)
621    })
622}
623
624/// Re-state a checkpoint's own grants under new record and retirement types.
625///
626/// `record` states how one domain's record maps across; the rest is the rule
627/// every caller must agree on — a grant the checkpoint had already retired
628/// stays retired, carrying the checkpoint itself as its evidence, so replaying
629/// a suffix can never resurrect it.
630pub(crate) fn map_checkpoint_grants<R, T: Ord, MappedRecord, MappedRetirement: Ord>(
631    grants: &CausalGrants<R, T>,
632    record: impl Fn(&R) -> MappedRecord,
633    retirement: impl Fn() -> MappedRetirement,
634) -> CausalGrants<MappedRecord, MappedRetirement> {
635    grants
636        .iter()
637        .map(|(grant, state)| {
638            let record = record(state.record());
639            (
640                grant.clone(),
641                match state {
642                    GrantState::Active { .. } => GrantState::Active { record },
643                    GrantState::Tombstoned { .. } => GrantState::Tombstoned {
644                        record,
645                        retirements: GrantRetirements::new(retirement()),
646                    },
647                },
648            )
649        })
650        .collect()
651}
652
653/// The resolution references a new checkpoint carries: everything the previous
654/// checkpoint recorded plus the resolutions just applied, canonically ordered
655/// and each named once.
656pub(crate) fn checkpoint_resolution_refs<R: Clone + Ord>(
657    previous: Option<&[R]>,
658    applied: impl IntoIterator<Item = R>,
659) -> Vec<R> {
660    let mut references = previous.map_or_else(Vec::new, <[R]>::to_vec);
661    references.extend(applied);
662    references.sort();
663    references.dedup();
664    references
665}
666
667/// Every coordinate reachable from `frontier` by walking dependencies.
668///
669/// A coordinate with no entry in `entries` is included but not walked through —
670/// a frontier may name coordinates a checkpoint has already absorbed.
671pub(crate) fn history_closure<E: CausalHistoryEntry>(
672    entries: &[E],
673    frontier: &[E::Coord],
674) -> BTreeSet<E::Coord> {
675    let by_coord = entries
676        .iter()
677        .map(|entry| (entry.coord(), entry))
678        .collect::<BTreeMap<_, _>>();
679    let mut pending = frontier.iter().cloned().collect::<BTreeSet<_>>();
680    let mut included = BTreeSet::new();
681    while let Some(coord) = pending.pop_first() {
682        if !included.insert(coord.clone()) {
683            continue;
684        }
685        if let Some(entry) = by_coord.get(&coord) {
686            pending.extend(entry.dependencies().iter().cloned());
687        }
688    }
689    included
690}
691
692#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
693pub(crate) struct OwnerGrantBarrier<C: CausalCoordinate> {
694    pub observed_streams: BTreeMap<C::StreamKey, C>,
695}
696
697impl<C: CausalCoordinate> OwnerGrantBarrier<C> {
698    /// Index observed stream coordinates by their stream key.
699    pub(crate) fn from_observed(coords: impl IntoIterator<Item = C>) -> Self {
700        Self {
701            observed_streams: coords
702                .into_iter()
703                .map(|coord| (coord.stream_key(), coord))
704                .collect(),
705        }
706    }
707
708    fn includes(&self, coord: &C) -> bool {
709        self.observed_streams
710            .get(&coord.stream_key())
711            .is_some_and(|barrier| coord.seq() <= barrier.seq())
712    }
713}
714
715#[derive(Debug, Clone, PartialEq, Eq)]
716pub(crate) enum CausalChange<C: CausalCoordinate, A: CausalAssignment> {
717    Founder {
718        member_pubkey: String,
719        grant_id: MembershipGrantId,
720        assignment: A,
721    },
722    SetMember {
723        member_pubkey: String,
724        assignment: A,
725        grant_id: MembershipGrantId,
726        replaces: BTreeSet<MembershipGrantId>,
727        owner_barriers: BTreeMap<MembershipGrantId, OwnerGrantBarrier<C>>,
728    },
729    RemoveMember {
730        member_pubkey: String,
731        removes: BTreeSet<MembershipGrantId>,
732        owner_barriers: BTreeMap<MembershipGrantId, OwnerGrantBarrier<C>>,
733    },
734    Control,
735    ResolutionActivation,
736}
737
738type RemovedGrants<'a, C> = (
739    &'a BTreeSet<MembershipGrantId>,
740    &'a BTreeMap<MembershipGrantId, OwnerGrantBarrier<C>>,
741);
742
743impl<C: CausalCoordinate, A: CausalAssignment> CausalChange<C, A> {
744    fn removed(&self) -> Option<RemovedGrants<'_, C>> {
745        match self {
746            Self::SetMember {
747                replaces,
748                owner_barriers,
749                ..
750            } => Some((replaces, owner_barriers)),
751            Self::RemoveMember {
752                removes,
753                owner_barriers,
754                ..
755            } => Some((removes, owner_barriers)),
756            Self::Founder { .. } | Self::Control | Self::ResolutionActivation => None,
757        }
758    }
759
760    fn removes_grant(&self, grant: &MembershipGrantId) -> bool {
761        self.removed()
762            .is_some_and(|(removed, _)| removed.contains(grant))
763    }
764}
765
766#[derive(Debug, Clone)]
767pub(crate) struct CausalEntry<C: CausalCoordinate, A: CausalAssignment> {
768    pub coord: C,
769    pub previous_hash: Option<ObjectHash>,
770    pub dependencies: BTreeMap<C::StreamKey, C>,
771    pub change: CausalChange<C, A>,
772}
773
774#[derive(Debug, Clone, PartialEq, Eq)]
775pub(crate) struct GrantRecord<C: CausalCoordinate, A: CausalAssignment> {
776    pub member_pubkey: String,
777    pub assignment: A,
778    pub creation: CausalGrantCreation<C>,
779}
780
781#[derive(Debug, Clone, PartialEq, Eq)]
782pub(crate) enum CausalGrantCreation<C: CausalCoordinate> {
783    Entry(C),
784    Checkpoint,
785}
786
787#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
788pub(crate) enum CausalGrantRetirement<C: CausalCoordinate> {
789    Entry {
790        coord: C,
791        owner_barrier: Option<OwnerGrantBarrier<C>>,
792    },
793    Checkpoint,
794}
795
796#[derive(Debug, Clone, PartialEq, Eq)]
797pub(crate) struct CausalSeedGrant<A: CausalAssignment> {
798    pub member_pubkey: String,
799    pub assignment: A,
800}
801
802#[derive(Debug, Clone, PartialEq, Eq)]
803pub(crate) struct ReducedGrants<C: CausalCoordinate, A: CausalAssignment> {
804    pub grants:
805        BTreeMap<MembershipGrantId, GrantState<GrantRecord<C, A>, CausalGrantRetirement<C>>>,
806    pub included: BTreeSet<C>,
807}
808
809#[derive(Debug, Clone, PartialEq, Eq)]
810pub(crate) struct CausalGrantBranch<C: CausalCoordinate, A: CausalAssignment> {
811    pub raw_heads: Vec<C>,
812    pub effective_frontier: Vec<C>,
813    pub reduced: ReducedGrants<C, A>,
814}
815
816#[derive(Debug, Clone, PartialEq, Eq)]
817pub(crate) enum CausalGrantConflict<C: CausalCoordinate, A: CausalAssignment> {
818    ConcurrentMemberAssignments {
819        raw_heads: Vec<C>,
820        effective_frontier: Vec<C>,
821        member_pubkey: String,
822        conflicting_grants: BTreeMap<MembershipGrantId, GrantRecord<C, A>>,
823        uncontested_grants: BTreeMap<MembershipGrantId, GrantRecord<C, A>>,
824        reduced: ReducedGrants<C, A>,
825    },
826    RevocationCycle {
827        raw_heads: Vec<C>,
828        cyclic_sources: Vec<C>,
829        involved_owner_grants: BTreeSet<MembershipGrantId>,
830        maximal_valid_branches: Vec<CausalGrantBranch<C, A>>,
831    },
832}
833
834#[derive(Debug, Clone, PartialEq, Eq)]
835pub(crate) enum CausalGrantStatus<C: CausalCoordinate, A: CausalAssignment> {
836    Resolved(ReducedGrants<C, A>),
837    Conflict(CausalGrantConflict<C, A>),
838}
839
840impl<C: CausalCoordinate, A: CausalAssignment> ReducedGrants<C, A> {
841    pub(crate) fn active_grant(&self, grant: &MembershipGrantId) -> Option<&GrantRecord<C, A>> {
842        self.grants.get(grant).and_then(GrantState::active)
843    }
844
845    pub(crate) fn includes_coord(&self, coord: &C) -> bool {
846        self.included.contains(coord)
847    }
848}
849
850#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
851pub(crate) enum CausalGrantError<C: CausalCoordinate> {
852    #[error("causal assignment history is empty")]
853    Empty,
854    #[error("stream {stream:?} has conflicting entries at sequence {seq}")]
855    ConflictingSequence { stream: C::StreamKey, seq: u64 },
856    #[error("stream {stream:?} is missing sequence {seq}")]
857    MissingSequence { stream: C::StreamKey, seq: u64 },
858    #[error("entry {index} has predecessor {actual:?}, expected {expected:?}")]
859    BrokenStreamLink {
860        index: usize,
861        expected: Option<ObjectHash>,
862        actual: Option<ObjectHash>,
863    },
864    #[error("entry {index} does not carry its exact own-stream dependency")]
865    MissingOwnDependency { index: usize },
866    #[error("entry {index} has a dependency under the wrong stream key")]
867    DependencyStreamMismatch { index: usize },
868    #[error("entry {index} depends on missing coordinate {dependency:?}")]
869    MissingDependency { index: usize, dependency: C },
870    #[error("causal assignment dependency graph contains a cycle")]
871    DependencyCycle,
872    #[error("causal assignment founder is invalid")]
873    InvalidFounder,
874    #[error("entry {index} author is not active under Owner grant {grant}")]
875    AuthorGrantInactive {
876        index: usize,
877        grant: MembershipGrantId,
878    },
879    #[error("entry {index} creates already-defined grant {grant}")]
880    DuplicateGrant {
881        index: usize,
882        grant: MembershipGrantId,
883    },
884    #[error("entry {index} replaces or removes grant {grant} owned by another member")]
885    GrantOwnerMismatch {
886        index: usize,
887        grant: MembershipGrantId,
888    },
889    #[error("entry {index} does not name the exact active grants for member {member_pubkey}")]
890    GrantSetMismatch { index: usize, member_pubkey: String },
891    #[error("entry {index} removes no exact grants")]
892    EmptyRemoval { index: usize },
893    #[error("entry {index} removes Owner grant {grant} without its exact observed frontier")]
894    MissingOwnerRevocationBarrier {
895        index: usize,
896        grant: MembershipGrantId,
897    },
898    #[error("entry {index} carries an invalid frontier for Owner grant {grant}")]
899    InvalidOwnerRevocationBarrier {
900        index: usize,
901        grant: MembershipGrantId,
902    },
903    #[error("causal assignment history leaves no active Owner")]
904    NoActiveOwner,
905    #[error(
906        "causal assignment revocation cycle has {sources} sources, exceeding the protocol limit of {maximum}"
907    )]
908    RevocationCycleTooWide { sources: usize, maximum: usize },
909}
910
911#[cfg(test)]
912mod tests;
913
914/// The head references matching `coords` exactly — every coordinate has one
915/// reference and nothing else is included — in canonical order. `None` when a
916/// coordinate is missing or an extra reference remains.
917pub(crate) fn exact_head_refs<H, C>(
918    head_refs: &[H],
919    coords: &[C],
920    coord_of: impl Fn(&H) -> &C,
921) -> Option<Vec<H>>
922where
923    H: Clone + Ord,
924    C: Clone + Ord,
925{
926    let expected = coords
927        .iter()
928        .cloned()
929        .collect::<std::collections::BTreeSet<_>>();
930    let mut references = head_refs
931        .iter()
932        .filter(|reference| expected.contains(coord_of(reference)))
933        .cloned()
934        .collect::<Vec<_>>();
935    let actual = references
936        .iter()
937        .map(|reference| coord_of(reference).clone())
938        .collect::<std::collections::BTreeSet<_>>();
939    if expected != actual || references.len() != expected.len() {
940        return None;
941    }
942    references.sort();
943    Some(references)
944}