Skip to main content

coven_protocol/
membership.rs

1//! Store-bound causal membership protocol.
2//!
3//! Every causal author stream is identified by its author, the Owner grant that
4//! authorizes it, and an independently generated stream id. Entries carry the
5//! complete observed stream frontier; authorization is derived from that causal
6//! past, never from `created_at`.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use serde::{Deserialize, Serialize};
11
12use super::causal_grants::{
13    self, CausalAssignment, CausalChange, CausalCoordinate, CausalEntry, CausalGrantError,
14    CausalGrantStatus, GrantState, OwnerGrantBarrier,
15};
16pub use super::causal_grants::{AuthorStreamId, MembershipGrantId};
17use super::store_commit::{
18    GrantStreamAnchor, ObjectHash, OwnerPromotionAcceptance, OwnerPromotionFinalization,
19    OwnerRecoveryCursor, Signed, SignedBody, StoreBatchCommitRef, StoreCreationId,
20    StoreDeviceRegistration, StoreDeviceRegistrationRef, StoreDeviceStateRef, StoreRootRef,
21    SuccessorLink,
22};
23#[cfg(any(test, feature = "test-utils"))]
24use super::store_commit::{
25    OwnerPromotionAcceptanceBody, OwnerPromotionAnchors, OwnerPromotionId, OwnerPromotionRequest,
26    OwnerPromotionRequestActivation, OwnerPromotionRequestBody, OwnerRecoveryNodeRef,
27    OwnerRecoveryPosition,
28};
29use super::wrapped_store_key::WrappedStoreKeyRef;
30use crate::objects::ExactObjectRef;
31use coven_keys::keys::{self, UserKeypair};
32
33mod authoring;
34mod authority;
35mod chain;
36mod entry;
37mod head_acceptance;
38mod head_predecessor;
39pub use head_predecessor::MembershipHeadPredecessor;
40mod reduction;
41
42pub use head_acceptance::{
43    membership_head_acceptance_semantic_prefix, MembershipHeadAcceptance,
44    MembershipHeadAcceptanceBody, MembershipHeadAcceptanceIssuer,
45};
46
47pub use entry::{
48    derive_founder_stream_id, derive_grant_id, founder_entry_for_creation, verify_membership_entry,
49};
50#[cfg(any(test, feature = "test-utils"))]
51pub use entry::{founder_entry, test_wrapped_key_ref};
52use reduction::*;
53
54const MEMBERSHIP_ENTRY_DOMAIN: &[u8] = b"coven.store-membership-entry.v1\0";
55const MEMBERSHIP_HEAD_DOMAIN: &[u8] = b"coven.store-membership-head.v1\0";
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub enum MemberRole {
59    Owner,
60    Member,
61    Follower,
62}
63
64impl MemberRole {
65    pub fn can_write(&self) -> bool {
66        matches!(self, Self::Owner | Self::Member)
67    }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71#[serde(rename_all = "snake_case", deny_unknown_fields)]
72pub enum OwnerRecoveryAnchorRef {
73    Founder {
74        creation_id: StoreCreationId,
75    },
76    Promotion {
77        acceptance: Box<OwnerPromotionAcceptance>,
78    },
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "snake_case", deny_unknown_fields)]
83pub enum StoreMembershipRoleGrant {
84    Owner { recovery: OwnerRecoveryAnchorRef },
85    Member,
86    Follower,
87}
88
89impl StoreMembershipRoleGrant {
90    pub fn role(&self) -> MemberRole {
91        match self {
92            Self::Owner { .. } => MemberRole::Owner,
93            Self::Member => MemberRole::Member,
94            Self::Follower => MemberRole::Follower,
95        }
96    }
97
98    fn from_direct_assignment(role: MemberRole) -> Result<Self, MembershipError> {
99        match role {
100            MemberRole::Owner => Err(MembershipError::OwnerPromotionRequired),
101            MemberRole::Member => Ok(Self::Member),
102            MemberRole::Follower => Ok(Self::Follower),
103        }
104    }
105
106    fn can_write(&self) -> bool {
107        matches!(self, Self::Owner { .. } | Self::Member)
108    }
109
110    fn is_owner(&self) -> bool {
111        matches!(self, Self::Owner { .. })
112    }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct MemberInfo {
117    pub pubkey: String,
118    pub role: MemberRole,
119    pub is_self: bool,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(deny_unknown_fields)]
124pub enum StoreAuthorityChange {
125    Founder {
126        creation_id: StoreCreationId,
127        owner_pubkey: String,
128        owner_grant_id: MembershipGrantId,
129        membership: GrantStreamAnchor,
130        provider_admin: super::provider::FounderProviderAdminGrant,
131    },
132    SetMember {
133        user_pubkey: String,
134        #[serde(skip_serializing_if = "Option::is_none")]
135        provider_account_email: Option<String>,
136        role: StoreMembershipRoleGrant,
137        grant_id: MembershipGrantId,
138        membership: Option<GrantStreamAnchor>,
139        replaces: BTreeSet<MembershipGrantId>,
140        retirement_barriers: BTreeMap<MembershipGrantId, MergeMembershipGrantRetirementBarrier>,
141        #[serde(skip_serializing_if = "Option::is_none")]
142        retirement_device_state: Option<StoreDeviceStateRef>,
143        wrapped_key: WrappedStoreKeyRef,
144    },
145    RemoveMember {
146        user_pubkey: String,
147        removes: BTreeSet<MembershipGrantId>,
148        retirement_barriers: BTreeMap<MembershipGrantId, MergeMembershipGrantRetirementBarrier>,
149        #[serde(skip_serializing_if = "Option::is_none")]
150        retirement_device_state: Option<StoreDeviceStateRef>,
151        wrapped_keys: Vec<WrappedStoreKeyRef>,
152    },
153    DeviceRegistrationActivation {
154        registration: super::store_commit::ActivatedStoreDeviceRegistrationRef,
155    },
156    DeviceExclusionProposal {
157        proposal: super::store_commit::StoreDeviceExclusionProposalRef,
158    },
159    DeviceExclusionOutcome {
160        outcome: super::store_commit::StoreDeviceExclusionOutcomeRef,
161    },
162    ProviderAdmin,
163}
164
165impl StoreAuthorityChange {
166    pub fn membership_anchor(&self) -> Option<GrantStreamAnchor> {
167        match self {
168            Self::Founder { membership, .. } => Some(membership.clone()),
169            Self::SetMember { membership, .. } => membership.clone(),
170            Self::RemoveMember { .. }
171            | Self::DeviceRegistrationActivation { .. }
172            | Self::DeviceExclusionProposal { .. }
173            | Self::DeviceExclusionOutcome { .. }
174            | Self::ProviderAdmin => None,
175        }
176    }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct MembershipCoord {
182    pub author_pubkey: String,
183    pub author_owner_grant: MembershipGrantId,
184    pub stream_id: AuthorStreamId,
185    pub seq: u64,
186    pub entry_hash: ObjectHash,
187}
188
189impl MembershipCoord {
190    pub fn stream_key(&self) -> MembershipStreamKey {
191        MembershipStreamKey {
192            author_pubkey: self.author_pubkey.clone(),
193            author_owner_grant: self.author_owner_grant.clone(),
194            stream_id: self.stream_id,
195        }
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
200pub struct MembershipStreamKey {
201    pub author_pubkey: String,
202    pub author_owner_grant: MembershipGrantId,
203    pub stream_id: AuthorStreamId,
204}
205
206impl CausalCoordinate for MembershipCoord {
207    type StreamKey = MembershipStreamKey;
208
209    fn stream_key(&self) -> Self::StreamKey {
210        MembershipCoord::stream_key(self)
211    }
212
213    fn author_pubkey(&self) -> &str {
214        &self.author_pubkey
215    }
216
217    fn author_owner_grant(&self) -> &MembershipGrantId {
218        &self.author_owner_grant
219    }
220
221    fn seq(&self) -> u64 {
222        self.seq
223    }
224
225    fn entry_hash(&self) -> ObjectHash {
226        self.entry_hash
227    }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
231struct StoreAssignment {
232    role: StoreMembershipRoleGrant,
233    provider_account_email: Option<String>,
234}
235
236impl causal_grants::CausalHistoryEntry for MembershipEntry {
237    type Coord = MembershipCoord;
238
239    fn coord(&self) -> Self::Coord {
240        self.coord()
241    }
242
243    fn dependencies(&self) -> &[Self::Coord] {
244        &self.dependencies
245    }
246}
247
248impl CausalAssignment for StoreAssignment {
249    fn is_owner(&self) -> bool {
250        self.role.is_owner()
251    }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
255#[serde(deny_unknown_fields)]
256pub struct OwnerStreamBarrier {
257    pub observed_streams: Vec<MembershipCoord>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
261#[serde(deny_unknown_fields)]
262pub struct StoreGrantStreamBarrier {
263    pub observed_streams: Vec<MembershipCoord>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
267#[serde(deny_unknown_fields)]
268pub struct MergeStoreOwnerGrantBarrier {
269    pub author_streams: StoreGrantStreamBarrier,
270    pub recovery: OwnerRecoveryCursor,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
274#[serde(rename_all = "snake_case", deny_unknown_fields)]
275pub enum MergeMembershipGrantRetirementBarrier {
276    NonOwner {
277        author_streams: StoreGrantStreamBarrier,
278    },
279    Owner {
280        barrier: MergeStoreOwnerGrantBarrier,
281    },
282}
283
284impl MergeMembershipGrantRetirementBarrier {
285    fn author_streams(&self) -> &StoreGrantStreamBarrier {
286        match self {
287            Self::NonOwner { author_streams } => author_streams,
288            Self::Owner { barrier } => &barrier.author_streams,
289        }
290    }
291
292    fn owner_stream_barrier(&self) -> Option<OwnerGrantBarrier<MembershipCoord>> {
293        match self {
294            Self::NonOwner { .. } => None,
295            Self::Owner { barrier } => Some(shared_store_barrier(&barrier.author_streams)),
296        }
297    }
298}
299
300/// The wire body of one membership entry. Every field here is signed.
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
302#[serde(deny_unknown_fields)]
303pub struct MembershipEntryBody {
304    pub store_id: String,
305    pub author_pubkey: String,
306    pub author_owner_grant: MembershipGrantId,
307    pub stream_id: AuthorStreamId,
308    pub seq: u64,
309    pub previous_hash: Option<ObjectHash>,
310    pub dependencies: Vec<MembershipCoord>,
311    pub created_at: String,
312    pub change: StoreAuthorityChange,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub provider_admin: Option<super::provider::ProviderAdminMembershipChange>,
315}
316
317impl SignedBody for MembershipEntryBody {
318    const DOMAIN: &'static [u8] = MEMBERSHIP_ENTRY_DOMAIN;
319}
320
321pub type MembershipEntry = Signed<MembershipEntryBody>;
322
323#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
324#[serde(deny_unknown_fields)]
325pub struct MembershipEntryRef {
326    pub coord: MembershipCoord,
327    pub object: ExactObjectRef,
328}
329
330impl MembershipEntry {
331    pub fn coord(&self) -> MembershipCoord {
332        MembershipCoord {
333            author_pubkey: self.author_pubkey.clone(),
334            author_owner_grant: self.author_owner_grant.clone(),
335            stream_id: self.stream_id,
336            seq: self.seq,
337            entry_hash: self.hash(),
338        }
339    }
340}
341
342/// The wire body of one membership author head. Every field here is signed.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(deny_unknown_fields)]
345pub struct AuthorHeadBody {
346    pub store_id: String,
347    pub body: MembershipHeadBody,
348    pub activation: MembershipHeadActivation,
349}
350
351impl SignedBody for AuthorHeadBody {
352    const DOMAIN: &'static [u8] = MEMBERSHIP_HEAD_DOMAIN;
353}
354
355pub type AuthorHead = Signed<AuthorHeadBody>;
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(deny_unknown_fields)]
359pub struct MembershipHeadBody {
360    pub author_registration: StoreDeviceRegistrationRef,
361    pub entry: MembershipEntryRef,
362    pub predecessor: Option<MembershipHeadPredecessor>,
363    pub successor: SuccessorLink,
364}
365
366impl MembershipHeadBody {
367    pub fn predecessor_head(&self) -> Option<&MembershipHeadRef> {
368        self.predecessor
369            .as_ref()
370            .map(MembershipHeadPredecessor::head)
371    }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
376pub enum MembershipHeadActivation {
377    Direct,
378    StoreCommit {
379        commit: StoreBatchCommitRef,
380        acceptance_slot: crate::objects::ObjectSlot,
381    },
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(deny_unknown_fields)]
386pub struct MergeMembershipHeadTransition {
387    pub body: MembershipHeadBody,
388    pub head_slot: crate::objects::ObjectSlot,
389}
390
391impl MergeMembershipHeadTransition {
392    pub fn matches_head(&self, head: &AuthorHead, reference: &MembershipHeadRef) -> bool {
393        self.body == head.body
394            && self.head_slot == *reference.object.slot()
395            && self.body.entry.coord == reference.coord
396            && head.entry_coord() == reference.coord
397            && head.head_hash() == reference.head_hash
398    }
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
402#[serde(deny_unknown_fields)]
403pub struct MembershipHeadRef {
404    pub coord: MembershipCoord,
405    pub head_hash: ObjectHash,
406    pub object: ExactObjectRef,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
410pub enum MembershipFloorError {
411    #[error("membership floor is empty")]
412    Empty,
413    #[error("membership floor contains sequence zero")]
414    SequenceZero,
415    #[error("membership floor is not strictly ordered by author stream")]
416    NotStrictlyOrdered,
417    #[error("membership floor has conflicting exact heads at the same stream sequence")]
418    ConflictingHeads,
419}
420
421pub fn validate_membership_floor(floor: &[MembershipHeadRef]) -> Result<(), MembershipFloorError> {
422    if floor.is_empty() {
423        return Err(MembershipFloorError::Empty);
424    }
425    for (index, reference) in floor.iter().enumerate() {
426        if reference.coord.seq == 0 {
427            return Err(MembershipFloorError::SequenceZero);
428        }
429        if index > 0 && floor[index - 1].coord.stream_key() >= reference.coord.stream_key() {
430            return Err(MembershipFloorError::NotStrictlyOrdered);
431        }
432    }
433    Ok(())
434}
435
436#[derive(Debug, thiserror::Error, PartialEq, Eq)]
437pub enum MembershipError {
438    #[error("membership chain is empty")]
439    EmptyChain,
440    #[error("membership entry {0} has unsupported version")]
441    UnsupportedVersion(usize),
442    #[error("membership entry {index} belongs to store {actual:?}, expected {expected:?}")]
443    StoreMismatch {
444        index: usize,
445        expected: String,
446        actual: String,
447    },
448    #[error("membership entry {0} has an invalid signature")]
449    InvalidSignature(usize),
450    #[error("membership entry {index} is in coordinate {actual:?}, expected {expected:?}")]
451    CoordinateMismatch {
452        index: usize,
453        expected: Box<MembershipCoord>,
454        actual: Box<MembershipCoord>,
455    },
456    #[error("membership stream {author}/{grant} is missing sequence {seq}")]
457    MissingSequence {
458        author: String,
459        grant: MembershipGrantId,
460        seq: u64,
461    },
462    #[error("membership stream {author}/{grant} has conflicting entries at sequence {seq}")]
463    ConflictingSequence {
464        author: String,
465        grant: MembershipGrantId,
466        seq: u64,
467    },
468    #[error("membership entry at {coord:?} differs from the expected exact entry")]
469    ExactEntryMismatch { coord: Box<MembershipCoord> },
470    #[error("membership entry {index} has predecessor {actual:?}, expected {expected:?}")]
471    BrokenStreamLink {
472        index: usize,
473        expected: Option<ObjectHash>,
474        actual: Option<ObjectHash>,
475    },
476    #[error("membership entry {index} does not carry its complete own-stream dependency")]
477    MissingOwnDependency { index: usize },
478    #[error("membership entry {index} depends on missing coordinate {dependency:?}")]
479    MissingDependency {
480        index: usize,
481        dependency: Box<MembershipCoord>,
482    },
483    #[error(
484        "membership entry {index} dependency frontier is not strictly ordered by author stream"
485    )]
486    NonCanonicalDependencyFrontier { index: usize },
487    #[error("membership dependency graph contains a cycle")]
488    DependencyCycle,
489    #[error("membership founder entry is invalid")]
490    InvalidFounder,
491    #[error("membership entry {index} author is not active under Owner grant {grant}")]
492    AuthorGrantInactive {
493        index: usize,
494        grant: MembershipGrantId,
495    },
496    #[error("membership entry {index} creates an already-defined grant {grant}")]
497    DuplicateGrant {
498        index: usize,
499        grant: MembershipGrantId,
500    },
501    #[error("membership entry {index} replaces or removes grant {grant} owned by another member")]
502    GrantOwnerMismatch {
503        index: usize,
504        grant: MembershipGrantId,
505    },
506    #[error("membership entry {index} does not name the exact active grants for member {pubkey}")]
507    GrantSetMismatch { index: usize, pubkey: String },
508    #[error("membership entry {index} removes no exact grants")]
509    EmptyRemoval { index: usize },
510    #[error(
511        "membership entry {index} removes Owner grant {grant} without its exact observed-through coordinate"
512    )]
513    MissingOwnerRevocationBarrier {
514        index: usize,
515        grant: MembershipGrantId,
516    },
517    #[error(
518        "membership entry {index} carries an invalid revocation barrier for Owner grant {grant}"
519    )]
520    InvalidOwnerRevocationBarrier {
521        index: usize,
522        grant: MembershipGrantId,
523    },
524    #[error("membership change retiring Owner grants lacks the exact Merge device state")]
525    MissingOwnerRecoveryState,
526    #[error("membership change without an Owner retirement carries a device state")]
527    UnexpectedOwnerRecoveryState,
528    #[error("membership entry {0} carries an invalid Owner membership stream anchor")]
529    InvalidOwnerMembershipAnchor(usize),
530    #[error("membership entry {0} carries invalid wrapped Store-key authority")]
531    InvalidWrappedKeys(usize),
532    #[error("checkpoint lacks the exact record for membership grant {grant}")]
533    MissingCheckpointGrant { grant: MembershipGrantId },
534    #[error("checkpoint lacks retirement evidence for membership grant {grant}")]
535    MissingCheckpointRetirementEvidence { grant: MembershipGrantId },
536    #[error("membership grant {grant} retirement at {authority:?} lacks its exact signed barrier")]
537    MissingRetirementBarrier {
538        grant: MembershipGrantId,
539        authority: Box<MembershipCoord>,
540    },
541    #[error(
542        "current member {recipient_pubkey} lacks wrapped Store-key coverage for rotation {rotation:?}"
543    )]
544    MissingWrappedKeyCoverage {
545        recipient_pubkey: String,
546        rotation: Box<MembershipCoord>,
547    },
548    #[error("membership history leaves no active Owner")]
549    NoActiveOwner,
550    #[error(
551        "membership revocation cycle has {sources} sources, exceeding the protocol limit of {maximum}"
552    )]
553    RevocationCycleTooWide { sources: usize, maximum: usize },
554    #[error("signer {0} has no active Owner grant")]
555    SignerIsNotOwner(String),
556    #[error("member {0} has no active grants")]
557    NotAMember(String),
558    #[error("non-founder Owner grants require an accepted Owner promotion")]
559    OwnerPromotionRequired,
560    #[error("Owner promotion does not match the exact current membership state")]
561    InvalidOwnerPromotion,
562    #[error("membership author stream contains a pruned suffix and cannot be extended")]
563    PrunedAuthorStream,
564    #[error("membership author stream exhausted its sequence space")]
565    SequenceExhausted,
566    #[error("provider administrator control entry {0} is invalid")]
567    InvalidProviderAdminChange(usize),
568    #[error("membership graph contains conflicting authority")]
569    Conflict,
570    #[error("membership entry {coord:?} was prepared against different accepted authority")]
571    PublicationPredecessorChanged { coord: Box<MembershipCoord> },
572    #[error("membership is missing its exact signed heads")]
573    MissingExactHeads,
574    #[error("provider administrator history is invalid: {0}")]
575    ProviderAdmin(#[from] super::provider::ProviderAdminReducerError),
576}
577
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
579#[serde(deny_unknown_fields)]
580pub struct MembershipGrantRecord {
581    pub member_pubkey: String,
582    pub role: StoreMembershipRoleGrant,
583    #[serde(skip_serializing_if = "Option::is_none")]
584    pub provider_account_email: Option<String>,
585    pub creation_authority: MembershipCoord,
586}
587
588#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
589#[serde(deny_unknown_fields)]
590pub struct MembershipGrantRetirement {
591    pub authority: MembershipCoord,
592    pub barrier: MergeMembershipGrantRetirementBarrier,
593}
594
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
596#[serde(deny_unknown_fields)]
597pub struct ResolvedStoreMembership {
598    pub grants:
599        BTreeMap<MembershipGrantId, GrantState<MembershipGrantRecord, MembershipGrantRetirement>>,
600    pub provider_admin: super::provider::ProviderAdminResolution,
601    pub state_hash: ObjectHash,
602}
603
604impl ResolvedStoreMembership {
605    pub fn active_grants(
606        &self,
607    ) -> impl Iterator<Item = (&MembershipGrantId, &MembershipGrantRecord)> {
608        causal_grants::active_grants(&self.grants)
609    }
610
611    pub fn active_grant(&self, grant: &MembershipGrantId) -> Option<&MembershipGrantRecord> {
612        self.grants.get(grant).and_then(GrantState::active)
613    }
614}
615
616#[derive(Debug, Clone)]
617pub struct MembershipChain {
618    entries: Vec<MembershipEntry>,
619    coords: Vec<MembershipCoord>,
620    included: BTreeSet<MembershipCoord>,
621    resolved: ResolvedStoreMembership,
622    head_refs: Vec<MembershipHeadRef>,
623    provider_admin_genesis: super::provider::ProviderAdminState,
624}
625
626#[cfg(any(test, feature = "test-utils"))]
627fn test_provider_admin_genesis(
628    entries: &[MembershipEntry],
629) -> Result<super::provider::ProviderAdminState, MembershipError> {
630    let founder = entries
631        .iter()
632        .find_map(|entry| match &entry.change {
633            StoreAuthorityChange::Founder { provider_admin, .. } => Some((entry, provider_admin)),
634            _ => None,
635        })
636        .ok_or(MembershipError::InvalidFounder)?;
637    let root_bytes = founder.0.store_id.as_bytes();
638    let root = StoreRootRef {
639        store_root_id: ObjectHash::digest(
640            format!("{} test root id", founder.0.store_id).as_bytes(),
641        ),
642        store_root_hash: ObjectHash::digest(root_bytes),
643        object: ExactObjectRef::new(
644            crate::objects::ObjectSlot::logical(format!(
645                "store-v1/test/{}/root.json",
646                founder.0.store_id
647            ))
648            .expect("valid test root slot"),
649            root_bytes.len() as u64,
650            ObjectHash::digest(root_bytes),
651        ),
652    };
653    let registration: StoreDeviceRegistrationRef =
654        serde_json::from_value(serde_json::json!({
655            "device_id": ObjectHash::digest(format!("{} founder device", founder.0.store_id).as_bytes()),
656            "registration_hash": ObjectHash::digest(format!("{} founder registration", founder.0.store_id).as_bytes()),
657            "object": {
658                "slot": {"logical_key": format!("store-v1/test/{}/registration.json", founder.0.store_id), "physical": {"kind": "logical_key"}},
659                "stored_size": 1,
660                "stored_hash": ObjectHash::digest(format!("{} founder registration object", founder.0.store_id).as_bytes()),
661            }
662        }))
663        .expect("valid test founder registration reference");
664    Ok(super::provider::ProviderAdminState::founder_from_root(
665        root,
666        registration,
667        founder.1,
668    ))
669}
670
671impl MembershipChain {}
672
673impl AuthorHead {
674    pub fn signed(
675        store_id: String,
676        body: MembershipHeadBody,
677        activation: MembershipHeadActivation,
678        device_signer: &UserKeypair,
679    ) -> Self {
680        Signed::sign(
681            AuthorHeadBody {
682                store_id,
683                body,
684                activation,
685            },
686            device_signer,
687        )
688    }
689
690    pub fn verify(&self, registration: &StoreDeviceRegistration) -> bool {
691        self.body
692            .author_registration
693            .verify_registration(registration)
694            .is_ok()
695            && registration.author_pubkey == self.body.entry.coord.author_pubkey
696            && self.body.successor.predecessor
697                == self
698                    .body
699                    .predecessor_head()
700                    .map(|reference| reference.object.clone())
701            && self.verify_by(&registration.device_signing_pubkey).is_ok()
702    }
703
704    pub fn entry_coord(&self) -> MembershipCoord {
705        self.body.entry.coord.clone()
706    }
707
708    pub fn head_hash(&self) -> ObjectHash {
709        self.hash()
710    }
711}
712
713#[cfg(test)]
714mod floor_tests;
715#[cfg(test)]
716mod tests;
717
718pub const OWNER_PUBKEY_STATE_KEY: &str = "owner_pubkey";
719
720#[derive(Clone, Copy, Debug, PartialEq, Eq)]
721pub enum LocalStoreMembership {
722    Current,
723    NotYetMember,
724    Removed,
725    IdentityNotSupplied,
726}
727
728impl LocalStoreMembership {
729    pub fn from_membership(
730        membership: &MembershipChain,
731        identity: Option<&coven_keys::keys::UserKeypair>,
732    ) -> Self {
733        let Some(identity) = identity else {
734            return Self::IdentityNotSupplied;
735        };
736        let identity = coven_keys::keys::public_key_hex(identity);
737        if membership
738            .current_members()
739            .iter()
740            .any(|(member, _)| member == &identity)
741        {
742            Self::Current
743        } else if membership.contains_member_history(&identity) {
744            Self::Removed
745        } else {
746            Self::NotYetMember
747        }
748    }
749
750    pub fn allows_circle_access(self) -> bool {
751        matches!(self, Self::Current)
752    }
753
754    pub fn retains_circle_rows(self) -> bool {
755        !matches!(self, Self::Removed)
756    }
757}
758
759pub enum ApplyOutcome<R> {
760    Applied(Vec<coven_foundation::changeset::RowChange>),
761    Held(R),
762}
763
764#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
765#[serde(transparent)]
766pub struct MembershipFloor(pub Vec<MembershipHeadRef>);
767
768impl MembershipFloor {
769    /// Normalize head references by stream. Acceptance and hash-linked ancestry
770    /// remain the responsibility of the authority supplying these references.
771    pub fn from_heads(
772        references: impl IntoIterator<Item = MembershipHeadRef>,
773    ) -> Result<Self, MembershipFloorError> {
774        let mut heads = BTreeMap::<MembershipStreamKey, BTreeMap<u64, MembershipHeadRef>>::new();
775        for reference in references {
776            if reference.coord.seq == 0 {
777                return Err(MembershipFloorError::SequenceZero);
778            }
779            match heads
780                .entry(reference.coord.stream_key())
781                .or_default()
782                .entry(reference.coord.seq)
783            {
784                std::collections::btree_map::Entry::Vacant(entry) => {
785                    entry.insert(reference);
786                }
787                std::collections::btree_map::Entry::Occupied(entry) => {
788                    if reference != *entry.get() {
789                        return Err(MembershipFloorError::ConflictingHeads);
790                    }
791                }
792            }
793        }
794        let floor = Self(
795            heads
796                .into_values()
797                .map(|mut stream| {
798                    stream
799                        .pop_last()
800                        .expect("every recorded stream has a head")
801                        .1
802                })
803                .collect(),
804        );
805        floor.validate()?;
806        Ok(floor)
807    }
808
809    pub fn validate(&self) -> Result<(), MembershipFloorError> {
810        crate::membership::validate_membership_floor(&self.0)
811    }
812}