Skip to main content

coven_core/sync/
circle.rs

1//! Circle identities, audience routing, and control coordinates.
2
3use std::fmt;
4use std::str::FromStr;
5
6use hkdf::Hkdf;
7use hmac::{Hmac, Mac};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use sha2::Sha256;
10
11use super::membership::MembershipGrantId;
12use super::store_commit::ObjectHash;
13use crate::encryption::EncryptionService;
14
15pub use super::circle_control::*;
16pub use super::circle_roster::*;
17
18const CIRCLE_ID_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
19const CIRCLE_ID_LENGTH: usize = 26;
20const ROW_ROUTING_KEY_DOMAIN: &[u8] = b"coven.row-routing.v1";
21const ROW_ROUTING_ID_DOMAIN: &[u8] = b"coven.row-routing-id.v1\0";
22const CIRCLE_ID_FOUNDER_DOMAIN: &str = "coven.circle-id-founder.v1";
23const CIRCLE_EPOCH_ID_GENERATION_DOMAIN: &[u8] = b"coven.circle-epoch-id-generation.v1\0";
24const ACCESS_LEAF_ID_GENERATION_DOMAIN: &[u8] = b"coven.circle-access-leaf-id-generation.v1\0";
25
26pub const CIRCLE_CONTROL_PREFIX: &str = "circle-control/";
27pub const CIRCLE_ROSTER_PREFIX: &str = "circles/";
28pub const CIRCLE_METADATA_PREFIX: &str = "circles/";
29pub const CIRCLE_ACCESS_LEAF_PREFIX: &str = "circles/";
30pub const CIRCLE_ACCESS_ENVELOPE_PREFIX: &str = "circles/";
31
32/// A self-certifying 128-bit circle identity encoded as canonical lowercase base32.
33#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub struct CircleId([u8; 16]);
35
36impl CircleId {
37    pub(crate) fn founder(
38        store_root_hash: ObjectHash,
39        author_pubkey: &str,
40        owner_grant: &MembershipGrantId,
41    ) -> Self {
42        #[derive(Serialize)]
43        struct Founder<'a> {
44            domain: &'static str,
45            store_root_hash: ObjectHash,
46            author_pubkey: &'a str,
47            owner_grant: &'a MembershipGrantId,
48        }
49        let digest = ObjectHash::digest(
50            &serde_json::to_vec(&Founder {
51                domain: CIRCLE_ID_FOUNDER_DOMAIN,
52                store_root_hash,
53                author_pubkey,
54                owner_grant,
55            })
56            .expect("Circle ID founder serialization cannot fail"),
57        );
58        let mut bytes = [0_u8; 16];
59        bytes.copy_from_slice(&digest.as_bytes()[..16]);
60        Self(bytes)
61    }
62
63    pub fn from_bytes(bytes: [u8; 16]) -> Self {
64        Self(bytes)
65    }
66
67    pub fn as_bytes(&self) -> &[u8; 16] {
68        &self.0
69    }
70}
71
72impl fmt::Debug for CircleId {
73    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74        fmt::Display::fmt(self, formatter)
75    }
76}
77
78impl fmt::Display for CircleId {
79    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80        formatter.write_str(&encode_base32(&self.0))
81    }
82}
83
84impl FromStr for CircleId {
85    type Err = CircleIdError;
86
87    fn from_str(value: &str) -> Result<Self, Self::Err> {
88        let bytes = decode_base32(value)?;
89        let id = Self(bytes);
90        if id.to_string() != value || value == "local" {
91            return Err(CircleIdError(value.to_string()));
92        }
93        Ok(id)
94    }
95}
96
97impl Serialize for CircleId {
98    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99    where
100        S: Serializer,
101    {
102        serializer.serialize_str(&self.to_string())
103    }
104}
105
106impl<'de> Deserialize<'de> for CircleId {
107    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108    where
109        D: Deserializer<'de>,
110    {
111        String::deserialize(deserializer)?
112            .parse()
113            .map_err(serde::de::Error::custom)
114    }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
118#[error("circle id must be canonical 128-bit lowercase base32: {0:?}")]
119pub struct CircleIdError(String);
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum CircleRole {
124    Owner,
125    Member,
126}
127
128/// One Circle as the local application sees it. A Circle with a single resolved
129/// control is `Active`; a Circle whose control history forked into concurrent
130/// valid successors is `Conflicted` and carries no name, role, or key until an
131/// Owner resolves it. A conflicted Circle refuses authoring and package
132/// publication, so it has no single resolved roster or metadata to report.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum CircleInfo {
135    Active {
136        id: CircleId,
137        name: String,
138        role: CircleRole,
139        /// The resolved roster names a Store identity that is no longer an
140        /// active Store member. Publishing new Circle content is blocked until
141        /// an Owner closes the epoch and activates a successor roster without
142        /// that identity.
143        rotation_required: bool,
144    },
145    Conflicted {
146        id: CircleId,
147        /// Every retained concurrent control successor, in canonical order. The
148        /// Owner resolves the conflict by naming this complete set and a chosen
149        /// successor state.
150        branches: Vec<CircleControlCoord>,
151    },
152    /// The Circle's control history terminated in an Owner-signed deletion. Its
153    /// rows and access are gone locally; only the authority spine remains.
154    Deleted { id: CircleId },
155}
156
157impl CircleInfo {
158    pub fn id(&self) -> CircleId {
159        match self {
160            Self::Active { id, .. } | Self::Conflicted { id, .. } | Self::Deleted { id } => *id,
161        }
162    }
163
164    /// The Circle's display name, or `None` while its control is conflicted and
165    /// has no single resolved metadata.
166    pub fn name(&self) -> Option<&str> {
167        match self {
168            Self::Active { name, .. } => Some(name),
169            Self::Conflicted { .. } | Self::Deleted { .. } => None,
170        }
171    }
172
173    /// Whether publishing new content is blocked because the resolved roster
174    /// names a removed Store member. Always `false` for a conflicted Circle,
175    /// which blocks all authoring until it is resolved.
176    pub fn rotation_required(&self) -> bool {
177        matches!(
178            self,
179            Self::Active {
180                rotation_required: true,
181                ..
182            }
183        )
184    }
185}
186
187/// The public derived state of one Circle. Mapped once from the internal current
188/// state; `Circles::list` reports it per Circle.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum CircleState {
191    /// A single resolved active-epoch control whose roster names only current
192    /// Store members.
193    Active,
194    /// The local identity holds no active access — never granted, or revoked by a
195    /// removal it has not re-joined past.
196    Inactive,
197    /// An epoch close is in flight; new-content authoring under the old epoch is
198    /// frozen until the successor activates.
199    Closing,
200    /// The resolved roster names Store identities that are no longer active Store
201    /// members. New Circle content is refused until an Owner closes the epoch and
202    /// activates a successor roster without them.
203    RotationRequired { removed_members: Vec<String> },
204    /// The control history forked into concurrent valid successors awaiting Owner
205    /// resolution. Carries the complete retained branch set.
206    ControlConflict { branches: Vec<CircleControlCoord> },
207    /// The control history terminated in an Owner-signed deletion.
208    Deleted,
209}
210
211/// One Circle as `Circles::list` reports it: its id, display name, the local
212/// identity's role when it holds active access, and the derived state. The name
213/// is absent for a Circle with no resolved metadata (inactive, conflicted, or
214/// deleted); the role is present only when the local identity holds active roster
215/// membership.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Circle {
218    pub id: CircleId,
219    pub name: Option<String>,
220    pub role: Option<CircleRole>,
221    pub state: CircleState,
222}
223
224/// The settlement of one participant device's create-once epoch-close response
225/// slot: it published its own applied frontier, an Owner excluded it, or the slot
226/// is still empty.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum CircleCloseSettlement {
229    Responded,
230    Excluded,
231    Pending,
232}
233
234/// One participant in an in-flight epoch close and its slot settlement.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct CircleCloseParticipant {
237    pub device_id: crate::sync::store_commit::StoreDeviceId,
238    pub settlement: CircleCloseSettlement,
239}
240
241/// The read-only status of a Circle's in-flight epoch close: which participant
242/// slots hold responses, exclusions, or nothing.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct CircleCloseStatus {
245    pub circle_id: CircleId,
246    pub close_id: CircleEpochCloseId,
247    pub participants: Vec<CircleCloseParticipant>,
248}
249
250/// Why publishing new content into a Circle is refused. Carried as the durable
251/// typed reason on a blocked host write and on a refused Circle lifecycle
252/// operation.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum CirclePublicationBlocked {
255    RotationRequired {
256        circle_id: CircleId,
257        removed_members: Vec<String>,
258    },
259}
260
261impl std::fmt::Display for CirclePublicationBlocked {
262    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        match self {
264            Self::RotationRequired {
265                circle_id,
266                removed_members,
267            } => write!(
268                formatter,
269                "Circle {circle_id} requires rotation: its roster names removed Store members {removed_members:?}"
270            ),
271        }
272    }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct CircleMemberInfo {
277    pub pubkey: String,
278    pub role: CircleRole,
279    pub is_self: bool,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
283#[serde(transparent)]
284pub struct CircleOperationId(crate::WriteId);
285
286impl CircleOperationId {
287    pub(crate) fn from_write_id(write_id: crate::WriteId) -> Self {
288        Self(write_id)
289    }
290
291    /// A well-formed operation id that names no real operation, for API dispatch
292    /// tests that only need a value to send through the command channel.
293    #[cfg(any(test, feature = "test-utils"))]
294    pub fn placeholder(seed: &str) -> Self {
295        Self::from_write_id(crate::WriteId::from_generated(seed.to_string()))
296    }
297
298    pub fn as_str(&self) -> &str {
299        self.0.as_str()
300    }
301
302    pub(crate) fn finalization_write_id(&self) -> crate::WriteId {
303        crate::WriteId::from_generated(
304            crate::sync::store_commit::ObjectHash::digest(
305                &[
306                    b"coven.circle-epoch-close-finalization-write.v1\0".as_slice(),
307                    self.as_str().as_bytes(),
308                ]
309                .concat(),
310            )
311            .to_string(),
312        )
313    }
314
315    pub(crate) fn cancellation_write_id(&self) -> crate::WriteId {
316        crate::WriteId::from_generated(
317            crate::sync::store_commit::ObjectHash::digest(
318                &[
319                    b"coven.circle-epoch-close-cancellation-write.v1\0".as_slice(),
320                    self.as_str().as_bytes(),
321                ]
322                .concat(),
323            )
324            .to_string(),
325        )
326    }
327}
328
329impl fmt::Display for CircleOperationId {
330    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
331        formatter.write_str(self.as_str())
332    }
333}
334
335/// The stable identity of one Circle epoch close, derived from the durable
336/// operation that opened it. It names the close a `Circles::close_status` inspects
337/// and the reserved response and outcome slots that settle it; a close's identity
338/// is fixed for its lifetime and survives cancellation and retry.
339#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
340#[serde(transparent)]
341pub struct CircleEpochCloseId(ObjectHash);
342
343impl CircleEpochCloseId {
344    pub(crate) fn from_operation_id(operation_id: &CircleOperationId) -> Self {
345        Self(ObjectHash::digest(
346            &[
347                b"coven.circle-epoch-close-id.v1\0".as_slice(),
348                operation_id.as_str().as_bytes(),
349            ]
350            .concat(),
351        ))
352    }
353}
354
355impl fmt::Display for CircleEpochCloseId {
356    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
357        fmt::Display::fmt(&self.0, formatter)
358    }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum CircleOperationKind {
363    Create,
364    Rename,
365    AddMember,
366    RemoveMember,
367    ResolveControl,
368    Delete,
369}
370
371/// Why a durable Circle operation cannot currently publish. One variant per
372/// production block site; each future block site adds its own.
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case", deny_unknown_fields)]
375pub enum CircleOperationBlock {
376    /// The author's exact grant no longer holds current Store write authority.
377    AuthorityLost {
378        grant_id: crate::sync::membership::MembershipGrantId,
379    },
380}
381
382impl std::fmt::Display for CircleOperationBlock {
383    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        match self {
385            Self::AuthorityLost { grant_id } => write!(
386                formatter,
387                "author grant {grant_id} no longer has current Store write authority"
388            ),
389        }
390    }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "snake_case", deny_unknown_fields)]
395pub enum CircleOperationState {
396    Pending,
397    WaitingForCloseResponses,
398    Finalizing,
399    Blocked {
400        block: CircleOperationBlock,
401    },
402    /// A verified nonactivation proof was accepted; the candidate's exclusive
403    /// objects are being exact-deleted and the durable row cleared. Restart
404    /// resumes the same cleanup from this state.
405    Discarding,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct CircleOperationInfo {
410    pub operation_id: CircleOperationId,
411    pub circle_id: CircleId,
412    pub kind: CircleOperationKind,
413    pub state: CircleOperationState,
414}
415
416/// The one audience a synced row belongs to.
417#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
418pub enum Audience {
419    Store,
420    Circle(CircleId),
421    Local,
422}
423
424impl Audience {
425    pub fn from_column(value: Option<&str>) -> Result<Self, CircleIdError> {
426        match value {
427            None => Ok(Self::Store),
428            Some("local") => Ok(Self::Local),
429            Some(circle) => circle.parse().map(Self::Circle),
430        }
431    }
432
433    pub fn column_value(&self) -> Option<String> {
434        match self {
435            Self::Store => None,
436            Self::Circle(circle) => Some(circle.to_string()),
437            Self::Local => Some("local".to_string()),
438        }
439    }
440}
441
442macro_rules! generated_hex_id {
443    ($name:ident, $domain:ident) => {
444        #[derive(
445            Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
446        )]
447        #[serde(transparent)]
448        pub struct $name([u8; 16]);
449
450        impl $name {
451            pub(crate) fn generate(ids: &dyn crate::id_provider::IdProvider) -> Self {
452                Self(generated_id_bytes(ids, $domain))
453            }
454        }
455
456        impl fmt::Display for $name {
457            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
458                formatter.write_str(&hex::encode(self.0))
459            }
460        }
461    };
462}
463
464generated_hex_id!(CircleEpochId, CIRCLE_EPOCH_ID_GENERATION_DOMAIN);
465generated_hex_id!(AccessLeafId, ACCESS_LEAF_ID_GENERATION_DOMAIN);
466
467pub(crate) fn generated_id_digest(
468    ids: &dyn crate::id_provider::IdProvider,
469    domain: &[u8],
470) -> ObjectHash {
471    let id = ids.new_id();
472    let mut material = Vec::with_capacity(domain.len() + id.len());
473    material.extend_from_slice(domain);
474    material.extend_from_slice(id.as_bytes());
475    ObjectHash::digest(&material)
476}
477
478fn generated_id_bytes(ids: &dyn crate::id_provider::IdProvider, domain: &[u8]) -> [u8; 16] {
479    generated_id_digest(ids, domain).as_bytes()[..16]
480        .try_into()
481        .expect("SHA-256 digest prefix has fixed length")
482}
483
484/// HMAC identity of one scoped row. It is stable across audience moves and
485/// Store-key rotations because it derives from the unique generation-1 key.
486#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
487pub struct RowRoutingId([u8; 32]);
488
489impl fmt::Debug for RowRoutingId {
490    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
491        fmt::Display::fmt(self, formatter)
492    }
493}
494
495impl fmt::Display for RowRoutingId {
496    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
497        formatter.write_str(&hex::encode(self.0))
498    }
499}
500
501impl FromStr for RowRoutingId {
502    type Err = RowRoutingIdError;
503
504    fn from_str(value: &str) -> Result<Self, Self::Err> {
505        if value.len() != 64
506            || value
507                .bytes()
508                .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
509        {
510            return Err(RowRoutingIdError(value.to_string()));
511        }
512        let bytes: [u8; 32] = hex::decode(value)
513            .map_err(|_| RowRoutingIdError(value.to_string()))?
514            .try_into()
515            .map_err(|_| RowRoutingIdError(value.to_string()))?;
516        Ok(Self(bytes))
517    }
518}
519
520impl Serialize for RowRoutingId {
521    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
522    where
523        S: Serializer,
524    {
525        serializer.serialize_str(&self.to_string())
526    }
527}
528
529impl<'de> Deserialize<'de> for RowRoutingId {
530    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
531    where
532        D: Deserializer<'de>,
533    {
534        String::deserialize(deserializer)?
535            .parse()
536            .map_err(serde::de::Error::custom)
537    }
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
541#[error("row routing id must be exactly 64 lowercase hexadecimal characters: {0:?}")]
542pub struct RowRoutingIdError(String);
543
544#[derive(Clone)]
545pub(crate) struct RowRoutingKey([u8; 32]);
546
547#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
548pub enum RowRoutingKeyError {
549    #[error("Store keyring has no generation-1 key")]
550    MissingGenerationOne,
551    #[error("Store keyring has more than one generation-1 key")]
552    AmbiguousGenerationOne,
553}
554
555pub(crate) fn derive_row_routing_key(
556    encryption: &EncryptionService,
557    store_root_hash: ObjectHash,
558) -> Result<RowRoutingKey, RowRoutingKeyError> {
559    let mut generation_one = encryption
560        .keyring_entries()
561        .into_iter()
562        .filter_map(|(generation, key)| (generation == 1).then_some(key));
563    let key = generation_one
564        .next()
565        .ok_or(RowRoutingKeyError::MissingGenerationOne)?;
566    if generation_one.next().is_some() {
567        return Err(RowRoutingKeyError::AmbiguousGenerationOne);
568    }
569    let hkdf = Hkdf::<Sha256>::new(Some(ROW_ROUTING_KEY_DOMAIN), &key);
570    let mut derived = [0u8; 32];
571    hkdf.expand(store_root_hash.as_bytes(), &mut derived)
572        .expect("32 bytes is a valid HKDF output length");
573    Ok(RowRoutingKey(derived))
574}
575
576pub(crate) fn row_routing_id(key: &RowRoutingKey, table: &str, row_id: &str) -> RowRoutingId {
577    let mut mac = Hmac::<Sha256>::new_from_slice(&key.0).expect("HMAC accepts a 32-byte key");
578    mac.update(ROW_ROUTING_ID_DOMAIN);
579    mac.update(&(table.len() as u64).to_be_bytes());
580    mac.update(table.as_bytes());
581    mac.update(&(row_id.len() as u64).to_be_bytes());
582    mac.update(row_id.as_bytes());
583    RowRoutingId(mac.finalize().into_bytes().into())
584}
585
586fn encode_base32(bytes: &[u8; 16]) -> String {
587    let mut output = String::with_capacity(CIRCLE_ID_LENGTH);
588    let mut buffer = 0u32;
589    let mut bits = 0u8;
590    for byte in bytes {
591        buffer = (buffer << 8) | u32::from(*byte);
592        bits += 8;
593        while bits >= 5 {
594            bits -= 5;
595            output.push(CIRCLE_ID_ALPHABET[((buffer >> bits) & 0x1f) as usize] as char);
596            buffer &= (1u32 << bits).wrapping_sub(1);
597        }
598    }
599    if bits != 0 {
600        output.push(CIRCLE_ID_ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char);
601    }
602    output
603}
604
605fn decode_base32(value: &str) -> Result<[u8; 16], CircleIdError> {
606    if value.len() != CIRCLE_ID_LENGTH {
607        return Err(CircleIdError(value.to_string()));
608    }
609    let mut output = Vec::with_capacity(16);
610    let mut buffer = 0u32;
611    let mut bits = 0u8;
612    for byte in value.bytes() {
613        let digit = CIRCLE_ID_ALPHABET
614            .iter()
615            .position(|candidate| *candidate == byte)
616            .ok_or_else(|| CircleIdError(value.to_string()))? as u32;
617        buffer = (buffer << 5) | digit;
618        bits += 5;
619        while bits >= 8 {
620            bits -= 8;
621            output.push(((buffer >> bits) & 0xff) as u8);
622            buffer &= (1u32 << bits).wrapping_sub(1);
623        }
624    }
625    if output.len() != 16 || buffer != 0 {
626        return Err(CircleIdError(value.to_string()));
627    }
628    output
629        .try_into()
630        .map_err(|_| CircleIdError(value.to_string()))
631}
632
633#[cfg(test)]
634mod tests {
635    use std::collections::BTreeMap;
636
637    use super::super::circle_control::{merkle_root_and_proofs, verify_merkle_proof};
638    use super::*;
639    use crate::keys::{self, UserKeypair};
640
641    fn candidate_family(label: &str) -> super::super::store_commit::CandidateFamilyId {
642        super::super::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
643            label.as_bytes(),
644        ))
645    }
646
647    fn exact_object(label: &str, bytes: &[u8]) -> super::super::storage::ExactObjectRef {
648        super::super::storage::ExactObjectRef::new(
649            crate::storage::cloud::ObjectSlot::logical(format!("store-v1/test/{label}.json"))
650                .unwrap(),
651            bytes.len() as u64,
652            ObjectHash::digest(bytes),
653        )
654    }
655
656    fn exact_logical_object(
657        logical_key: String,
658        bytes: &[u8],
659    ) -> super::super::storage::ExactObjectRef {
660        super::super::storage::ExactObjectRef::new(
661            crate::storage::cloud::ObjectSlot::logical(logical_key).unwrap(),
662            bytes.len() as u64,
663            ObjectHash::digest(bytes),
664        )
665    }
666
667    fn test_founder_entry(
668        label: &str,
669        owner: &UserKeypair,
670        membership: super::super::store_commit::GrantStreamAnchor,
671    ) -> super::super::membership::MembershipEntry {
672        super::super::membership::founder_entry(
673            label,
674            owner,
675            crate::sync::test_helpers::test_membership_grant_id(label),
676            "founder",
677            membership,
678            crate::sync::test_helpers::test_founder_provider_admin(label),
679        )
680    }
681
682    fn merge_membership_ref(
683        owner: &UserKeypair,
684        members: &[(String, super::super::membership::MemberRole)],
685        label: &str,
686    ) -> (
687        StoreMembershipStateRef,
688        super::super::membership::MembershipGrantCreationAuthority,
689    ) {
690        let founder = test_founder_entry(
691            label,
692            owner,
693            super::super::store_commit::GrantStreamAnchor::StoreMembership {
694                first_slot: crate::storage::cloud::ObjectSlot::logical(format!(
695                    "store-v1/test/{label}/membership/1.json"
696                ))
697                .unwrap(),
698            },
699        );
700        let founder_coord = founder.coord();
701        let mut chain = super::super::membership::MembershipChain::from_entries(vec![founder])
702            .expect("found merge-concurrent membership");
703        for (index, (pubkey, role)) in members.iter().enumerate() {
704            if pubkey == &keys::public_key_hex(owner) {
705                continue;
706            }
707            if role == &super::super::membership::MemberRole::Owner {
708                chain
709                    .add_owner_for_test(
710                        owner,
711                        founder_coord.stream_id,
712                        pubkey.clone(),
713                        format!("member-{index}"),
714                    )
715                    .expect("promote merge-concurrent Owner");
716                continue;
717            }
718            let entry = chain
719                .signed_set_member_in_stream(
720                    owner,
721                    founder_coord.stream_id,
722                    pubkey.clone(),
723                    None,
724                    role.clone(),
725                    format!("member-{index}"),
726                )
727                .expect("sign merge-concurrent member");
728            chain
729                .add_entry(entry)
730                .expect("apply merge-concurrent member");
731        }
732        let resolved = match chain.status() {
733            super::super::membership::MembershipStatus::Resolved(resolved) => resolved,
734            super::super::membership::MembershipStatus::Conflict(_) => {
735                panic!("membership fixture must resolve")
736            }
737        };
738        let tip = chain.entries().last().expect("membership tip").coord();
739        let head = super::super::membership::MembershipHeadRef {
740            coord: tip,
741            head_hash: ObjectHash::digest(format!("{label} head").as_bytes()),
742            object: exact_object(&format!("{label}/membership-head"), b"membership head"),
743        };
744        (
745            StoreMembershipStateRef::from_parts(
746                vec![head],
747                Vec::new(),
748                Vec::new(),
749                resolved.state_hash,
750            )
751            .expect("valid merge-concurrent membership reference"),
752            super::super::membership::MembershipGrantCreationAuthority::Entry(founder_coord),
753        )
754    }
755
756    struct MergeDeviceAuthority {
757        registration: super::super::store_commit::StoreDeviceRegistration,
758        reference: super::super::store_commit::StoreDeviceRegistrationRef,
759        device_signer: UserKeypair,
760        stream_id: super::super::membership::AuthorStreamId,
761    }
762
763    fn merge_device_authority(
764        identity: &UserKeypair,
765        store_root_hash: ObjectHash,
766        label: &str,
767    ) -> MergeDeviceAuthority {
768        let root = super::super::store_commit::StoreRootRef {
769            store_root_id: ObjectHash::digest(format!("{label} identity").as_bytes()),
770            store_root_hash,
771            object: exact_object(&format!("{label}/root"), label.as_bytes()),
772        };
773        let slot = |stream: &str| {
774            crate::storage::cloud::ObjectSlot::logical(format!(
775                "store-v1/test/{label}/{stream}/1.json"
776            ))
777            .unwrap()
778        };
779        let registration = super::super::store_commit::StoreDeviceRegistration::signed(
780            root.clone(),
781            super::super::store_commit::StoreDeviceRegistrationOrigin::Founder {
782                creation_id: super::super::store_commit::StoreCreationId::from_nonce(label),
783            },
784            super::super::storage::ProviderDeviceBinding {
785                principal: super::super::storage::ProviderPrincipalId::CustomS3Credential {
786                    access_key_id_hash: ObjectHash::digest(label.as_bytes()),
787                },
788            },
789            super::super::store_commit::DeviceStreamAnchor::StoreAnnouncements {
790                first_slot: slot("announcements"),
791            },
792            super::super::store_commit::DeviceStreamAnchor::StoreAcknowledgements {
793                first_slot: slot("acknowledgements"),
794            },
795            super::super::store_commit::DeviceStreamAnchor::StoreSnapshots {
796                first_slot: slot("snapshots"),
797            },
798            identity,
799        )
800        .expect("sign test device registration");
801        let bytes = registration.to_bytes();
802        let reference = super::super::store_commit::StoreDeviceRegistrationRef::from_registration(
803            &registration,
804            exact_object(&format!("{label}/registration"), &bytes),
805        );
806        let device_signer = registration
807            .device_signer(identity)
808            .expect("derive registered device signer");
809        let stream_id = super::super::store_commit::StreamActivation::device_authorized_stream_id(
810            root.store_root_hash,
811            &reference,
812            super::super::store_commit::StreamAnchorDomain::StoreAnnouncements,
813        );
814        MergeDeviceAuthority {
815            registration,
816            reference,
817            device_signer,
818            stream_id,
819        }
820    }
821
822    fn merge_control_reference(
823        control: &PreparedCircleControl,
824        device: &MergeDeviceAuthority,
825        label: &str,
826    ) -> super::super::store_commit::CircleControlRef {
827        let control_object = exact_object(&format!("{label}/control"), &control.bytes);
828        let head_slot = crate::storage::cloud::ObjectSlot::logical(format!(
829            "store-v1/test/{label}/control-head/1.json"
830        ))
831        .expect("valid test Circle control-head slot");
832        let activation = super::super::store_commit::StreamActivation::grant_authorized(
833            control.value.store_root_hash,
834            device.reference.clone(),
835            control.value.author_grant_id(),
836            super::super::store_commit::GrantStreamAnchor::CircleControl {
837                circle_id: control.value.circle_id,
838                first_slot: head_slot.clone(),
839            },
840        );
841        let head = CircleControlHead::signed(
842            &control.value,
843            control_object.clone(),
844            super::super::store_commit::SuccessorLink {
845                activation: activation.activation_id(),
846                predecessor: None,
847                next_slot: crate::storage::cloud::ObjectSlot::logical(format!(
848                    "store-v1/test/{label}/control-head/2.json"
849                ))
850                .expect("valid next test Circle control-head slot"),
851            },
852            &device.device_signer,
853        );
854        let head_bytes = serde_json::to_vec(&head).expect("serialize test Circle control head");
855        let head_object = super::super::storage::ExactObjectRef::new(
856            head_slot,
857            head_bytes.len() as u64,
858            ObjectHash::digest(&head_bytes),
859        );
860        let objects = super::super::store_commit::CircleActivationObjects {
861            control: control_object,
862            close_intent: None,
863            close_outcome: None,
864            close_cancellation: None,
865            roster_entries: BTreeMap::new(),
866            roster_heads: Vec::new(),
867            roster_resolutions: BTreeMap::new(),
868            metadata_entries: BTreeMap::new(),
869            metadata_heads: Vec::new(),
870            access: Vec::new(),
871        };
872        super::super::store_commit::CircleControlRef {
873            circle_id: control.value.circle_id,
874            control: control.coord.clone(),
875            head_hash: head.head_hash(),
876            head_object,
877            objects,
878        }
879    }
880
881    #[test]
882    fn merkle_proofs_verify_for_every_leaf_in_even_and_odd_layers() {
883        for leaf_count in 1..=9 {
884            let leaves = (0..leaf_count)
885                .map(|index| ObjectHash::digest(format!("leaf-{index}").as_bytes()))
886                .collect::<Vec<_>>();
887            let (root, proofs) = merkle_root_and_proofs(&leaves);
888            assert_eq!(proofs.len(), leaves.len());
889            for (index, (leaf, proof)) in leaves.iter().zip(&proofs).enumerate() {
890                assert!(
891                    verify_merkle_proof(*leaf, proof, root),
892                    "leaf {index} of {leaf_count} failed its canonical proof"
893                );
894            }
895        }
896    }
897
898    #[test]
899    fn founder_payload_is_complete_and_acyclic() {
900        let owner = crate::keys::UserKeypair::generate();
901        let peer = crate::keys::UserKeypair::generate();
902        let owner_pubkey = crate::keys::public_key_hex(&owner);
903        let peer_pubkey = crate::keys::public_key_hex(&peer);
904        let members = vec![
905            (
906                owner_pubkey.clone(),
907                super::super::membership::MemberRole::Owner,
908            ),
909            (
910                peer_pubkey.clone(),
911                super::super::membership::MemberRole::Member,
912            ),
913        ];
914
915        let (membership, membership_authority) =
916            merge_membership_ref(&owner, &members, "founder-circle-merge");
917        let ids = crate::id_provider::SequentialIdProvider::new("founder-circle");
918        let candidate_family = candidate_family("founder-circle");
919        let creation = CircleTransitionDraft::founder(
920            ObjectHash::digest(b"store-root"),
921            candidate_family,
922            "device-a",
923            "Household",
924            "0000000001000-0000-device-a",
925            membership,
926            membership_authority,
927            members.clone(),
928            &ids,
929            &owner,
930        )
931        .expect("construct founder circle");
932
933        assert!(creation.control.verify());
934        assert!(creation.metadata.verify());
935        assert!(creation.roster.verify());
936        assert_eq!(creation.access.len(), 2);
937        for access in &creation.access {
938            assert!(access.leaf.verify(&creation.control, candidate_family));
939            assert!(access.envelope.verify(&creation.control, candidate_family));
940            assert!(access.leaf.verify_envelope(
941                &creation.control,
942                &access.envelope,
943                candidate_family
944            ));
945            assert!(!access.leaf.bytes.windows(64).any(|window| {
946                window == creation.control.coord.control_hash().to_string().as_bytes()
947            }));
948        }
949        assert!(matches!(
950            creation
951                .access
952                .iter()
953                .find(|access| access.leaf.value.recipient_pubkey == owner_pubkey)
954                .unwrap()
955                .leaf
956                .value
957                .disposition,
958            CircleAccessDisposition::Active { .. }
959        ));
960        assert!(matches!(
961            creation
962                .access
963                .iter()
964                .find(|access| access.leaf.value.recipient_pubkey == peer_pubkey)
965                .unwrap()
966                .leaf
967                .value
968                .disposition,
969            CircleAccessDisposition::Inactive
970        ));
971
972        let mut seized = creation.control.value.clone();
973        seized.circle_id = CircleId::from_bytes([0x5a; 16]);
974        seized.signature = keys::sign_hex(&owner, &seized.canonical_bytes()).1;
975        assert!(
976            !seized.verify(),
977            "a founder control must not choose an arbitrary Circle ID"
978        );
979
980        let mut discontinuous = creation.control.value.clone();
981        discontinuous.value.order.seq = 2;
982        discontinuous.signature = keys::sign_hex(&owner, &discontinuous.canonical_bytes()).1;
983        assert!(
984            !discontinuous.verify(),
985            "a control without a predecessor must be genesis"
986        );
987    }
988
989    #[test]
990    fn access_verification_rejects_signed_context_and_proof_substitution() {
991        let owner = crate::keys::UserKeypair::generate();
992        let peer = crate::keys::UserKeypair::generate();
993        let owner_pubkey = crate::keys::public_key_hex(&owner);
994        let peer_pubkey = crate::keys::public_key_hex(&peer);
995        let members = vec![
996            (
997                owner_pubkey.clone(),
998                super::super::membership::MemberRole::Owner,
999            ),
1000            (
1001                peer_pubkey.clone(),
1002                super::super::membership::MemberRole::Member,
1003            ),
1004        ];
1005        let (membership, authority) = merge_membership_ref(&owner, &members, "access-verification");
1006        let ids = crate::id_provider::SequentialIdProvider::new("access-verification");
1007        let candidate_family = candidate_family("access-verification");
1008        let creation = CircleTransitionDraft::founder(
1009            ObjectHash::digest(b"store-root"),
1010            candidate_family,
1011            "device-a",
1012            "Household",
1013            "0000000001000-0000-device-a",
1014            membership,
1015            authority,
1016            members.clone(),
1017            &ids,
1018            &owner,
1019        )
1020        .expect("construct founder circle");
1021
1022        let mut wrong_store = creation.access[0].envelope.clone();
1023        wrong_store.store_root_hash = ObjectHash::digest(b"other-store");
1024
1025        wrong_store.signature = keys::sign_hex(&owner, &wrong_store.canonical_bytes()).1;
1026        assert!(!wrong_store.verify(&creation.control, candidate_family));
1027
1028        let mut wrong_family_envelope = creation.access[0].envelope.clone();
1029        wrong_family_envelope.candidate_family =
1030            super::super::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
1031                b"other access family",
1032            ));
1033        wrong_family_envelope.signature =
1034            keys::sign_hex(&owner, &wrong_family_envelope.canonical_bytes()).1;
1035        assert!(!wrong_family_envelope.verify(&creation.control, candidate_family));
1036
1037        let mut wrong_family_leaf = creation.access[0].leaf.clone();
1038        wrong_family_leaf.value.candidate_family =
1039            super::super::store_commit::CandidateFamilyId::from_hash(ObjectHash::digest(
1040                b"other leaf family",
1041            ));
1042        wrong_family_leaf.value.signature =
1043            keys::sign_hex(&owner, &wrong_family_leaf.value.canonical_bytes()).1;
1044        assert!(!wrong_family_leaf.verify(&creation.control, candidate_family));
1045
1046        let mut non_owner = creation.access[0].envelope.clone();
1047        non_owner.owner_pubkey = peer_pubkey;
1048        non_owner.signature = keys::sign_hex(&peer, &non_owner.canonical_bytes()).1;
1049        assert!(!non_owner.verify(&creation.control, candidate_family));
1050
1051        let mut substituted_proof = creation.access[0].envelope.clone();
1052        substituted_proof.proof = creation.access[1].envelope.proof.clone();
1053        substituted_proof.signature =
1054            keys::sign_hex(&owner, &substituted_proof.canonical_bytes()).1;
1055        assert!(!substituted_proof.verify(&creation.control, candidate_family));
1056
1057        let mut substituted_leaf_id = creation.access[0].envelope.clone();
1058        substituted_leaf_id.leaf_id = creation.access[1].leaf.value.leaf_id;
1059        substituted_leaf_id.signature =
1060            keys::sign_hex(&owner, &substituted_leaf_id.canonical_bytes()).1;
1061        assert!(substituted_leaf_id.verify(&creation.control, candidate_family));
1062        assert!(!creation.access[0].leaf.verify_envelope(
1063            &creation.control,
1064            &substituted_leaf_id,
1065            candidate_family,
1066        ));
1067
1068        let mut wrong_membership_leaf = creation.access[0].leaf.value.clone();
1069        wrong_membership_leaf.store_membership =
1070            merge_membership_ref(&owner, &members, "wrong-membership-leaf").0;
1071        wrong_membership_leaf.signature =
1072            keys::sign_hex(&owner, &wrong_membership_leaf.canonical_bytes()).1;
1073        let recipient_key =
1074            keys::ed25519_to_x25519_public_key(&owner.public_key()).expect("convert recipient key");
1075        let bytes = keys::seal_box_encrypt(
1076            &serde_json::to_vec(&wrong_membership_leaf).expect("serialize forged leaf"),
1077            &recipient_key,
1078        );
1079        let wrong_membership_leaf = PreparedAccessLeaf {
1080            leaf_hash: ObjectHash::digest(&bytes),
1081            bytes,
1082            value: wrong_membership_leaf,
1083        };
1084        assert!(!wrong_membership_leaf.verify(&creation.control, candidate_family));
1085
1086        let mut wrong_keyring_leaf = creation
1087            .access
1088            .iter()
1089            .find(|access| {
1090                matches!(
1091                    &access.leaf.value.disposition,
1092                    CircleAccessDisposition::Active { .. }
1093                )
1094            })
1095            .expect("founder access")
1096            .leaf
1097            .value
1098            .clone();
1099        let CircleAccessDisposition::Active { keyring, .. } = &mut wrong_keyring_leaf.disposition
1100        else {
1101            panic!("founder access must be active")
1102        };
1103        *keyring = crate::encryption::MasterKeyring::generate().to_serialized();
1104        wrong_keyring_leaf.signature =
1105            keys::sign_hex(&owner, &wrong_keyring_leaf.canonical_bytes()).1;
1106        let bytes = keys::seal_box_encrypt(
1107            &serde_json::to_vec(&wrong_keyring_leaf).expect("serialize wrong-keyring leaf"),
1108            &recipient_key,
1109        );
1110        let wrong_keyring_leaf = PreparedAccessLeaf {
1111            leaf_hash: ObjectHash::digest(&bytes),
1112            bytes,
1113            value: wrong_keyring_leaf,
1114        };
1115        assert!(!wrong_keyring_leaf.verify(&creation.control, candidate_family));
1116    }
1117
1118    #[test]
1119    fn circle_id_round_trips_only_its_canonical_lowercase_base32() {
1120        let id = CircleId::from_bytes([0; 16]);
1121        let encoded = id.to_string();
1122        assert_eq!(encoded.len(), CIRCLE_ID_LENGTH);
1123        assert_eq!(encoded.parse::<CircleId>().unwrap(), id);
1124        assert!(encoded.to_uppercase().parse::<CircleId>().is_err());
1125        assert!("local".parse::<CircleId>().is_err());
1126        assert!(format!("{}b", &encoded[..25]).parse::<CircleId>().is_err());
1127    }
1128
1129    #[test]
1130    fn recipient_slot_rejects_the_ed25519_identity_point() {
1131        let local = UserKeypair::generate();
1132        let mut identity = [0; keys::SIGN_PUBLICKEYBYTES];
1133        identity[0] = 1;
1134        let recipient = hex::encode(identity);
1135
1136        assert_eq!(
1137            recipient_slot_with_peer(&local, &recipient, CircleId::from_bytes([9; 16])),
1138            Err(CircleTransitionError::InvalidRecipient(recipient))
1139        );
1140    }
1141
1142    #[test]
1143    fn row_routing_id_is_stable_across_store_key_rotation() {
1144        let root = ObjectHash::digest(b"store-root");
1145        let before = EncryptionService::from_key([1u8; 32]);
1146        let after = before
1147            .with_appended_generation(2, [2u8; 32])
1148            .expect("append generation");
1149        let before_id = row_routing_id(
1150            &derive_row_routing_key(&before, root).unwrap(),
1151            "accounts",
1152            "row-1",
1153        );
1154        let after_id = row_routing_id(
1155            &derive_row_routing_key(&after, root).unwrap(),
1156            "accounts",
1157            "row-1",
1158        );
1159        assert_eq!(before_id, after_id);
1160        assert_ne!(
1161            before_id,
1162            row_routing_id(
1163                &derive_row_routing_key(&after, root).unwrap(),
1164                "accounts",
1165                "row-2",
1166            )
1167        );
1168    }
1169
1170    #[test]
1171    fn row_routing_key_requires_exactly_one_generation_one_key() {
1172        let root = ObjectHash::digest(b"store-root");
1173        let missing = EncryptionService::from_key_at_generation(2, [2u8; 32]);
1174        assert!(matches!(
1175            derive_row_routing_key(&missing, root),
1176            Err(RowRoutingKeyError::MissingGenerationOne)
1177        ));
1178
1179        let ambiguous = EncryptionService::from_keyring([(1, [1u8; 32]), (1, [2u8; 32])])
1180            .expect("build forked generation one");
1181        assert!(matches!(
1182            derive_row_routing_key(&ambiguous, root),
1183            Err(RowRoutingKeyError::AmbiguousGenerationOne)
1184        ));
1185    }
1186
1187    #[tokio::test]
1188    async fn control_history_caches_the_verified_access_owner_and_rejects_second_genesis() {
1189        let author = crate::keys::UserKeypair::generate();
1190        let author_pubkey = crate::keys::public_key_hex(&author);
1191        let earlier_owner = loop {
1192            let candidate = crate::keys::UserKeypair::generate();
1193            if crate::keys::public_key_hex(&candidate) < author_pubkey {
1194                break candidate;
1195            }
1196        };
1197        let earlier_owner_pubkey = crate::keys::public_key_hex(&earlier_owner);
1198        let members = vec![
1199            (
1200                author_pubkey.clone(),
1201                super::super::membership::MemberRole::Owner,
1202            ),
1203            (
1204                earlier_owner_pubkey.clone(),
1205                super::super::membership::MemberRole::Owner,
1206            ),
1207        ];
1208        let store_root_hash = ObjectHash::digest(b"multi-owner-store-root");
1209        let (membership, membership_authority) =
1210            merge_membership_ref(&author, &members, "multi-owner-control");
1211        let device = merge_device_authority(&author, store_root_hash, "multi-owner-device");
1212        let ids = crate::id_provider::SequentialIdProvider::new("multi-owner-control");
1213        let operation_id = crate::WriteId::from_generated("multi-owner-control-commit".to_string());
1214        let order = super::super::store_commit::StoreCommitOrder {
1215            seq: 1,
1216            predecessor: None,
1217            dependencies: BTreeMap::new(),
1218        };
1219        let candidate_family = super::super::store_commit::CandidateFamilyId::derive(
1220            store_root_hash,
1221            &device.reference,
1222            &operation_id,
1223            &order,
1224        );
1225        let creation = CircleTransitionDraft::founder(
1226            store_root_hash,
1227            candidate_family,
1228            &device.reference.device_id.to_string(),
1229            "Household",
1230            "0000000001000-0000-device-a",
1231            membership.clone(),
1232            membership_authority.clone(),
1233            members,
1234            &ids,
1235            &author,
1236        )
1237        .expect("construct founder circle");
1238        let mut control = creation.control.value.clone();
1239        let active_epoch = control
1240            .value
1241            .state
1242            .active_epoch_mut()
1243            .expect("test control has an active epoch");
1244        active_epoch.common.owners = vec![earlier_owner_pubkey, author_pubkey.clone()];
1245        active_epoch.common.owners.sort();
1246        assert_ne!(active_epoch.common.owners[0], control.author_pubkey);
1247        control.signature = keys::sign_hex(&author, &control.canonical_bytes()).1;
1248        let control = PreparedCircleControl {
1249            coord: control.coord(),
1250            bytes: serde_json::to_vec(&control).expect("serialize control"),
1251            value: control,
1252        };
1253        let reference = merge_control_reference(&control, &device, "multi-owner");
1254        let first_coord = super::super::store_commit::StoreCommitCoord {
1255            stream_id: device.stream_id,
1256            sequence: 1,
1257        };
1258        let commit = super::super::store_commit::StoreBatchCommit::signed_operations(
1259            store_root_hash,
1260            operation_id,
1261            first_coord.clone(),
1262            device.reference.clone(),
1263            &device.registration,
1264            order,
1265            membership.clone(),
1266            super::super::store_commit::StoreDeviceStateRef::from_resolved(
1267                super::super::store_commit::CommitFrontier(BTreeMap::new()),
1268                &super::super::store_commit::ResolvedStoreDeviceState {
1269                    devices: BTreeMap::new(),
1270                    recovery: Vec::new(),
1271                    state_hash: ObjectHash::digest(b"multi-owner initial device state"),
1272                },
1273            )
1274            .expect("bind initial device state"),
1275            super::super::store_commit::StoreOperationMembershipAuthority {
1276                predecessor: membership_authority.clone(),
1277            },
1278            super::super::store_commit::StoreCommitOperationsInput {
1279                acknowledgement: None,
1280                circle_acknowledgements: Vec::new(),
1281                control: None,
1282                device_join_attempt_decisions: Vec::new(),
1283                device_join_outcomes: Vec::new(),
1284                device_join_cleanup_receipts: Vec::new(),
1285                provider_access_grants: Vec::new(),
1286                provider_access_withdrawals: Vec::new(),
1287                device_registrations: Vec::new(),
1288                device_exclusion_proposals: Vec::new(),
1289                device_exclusion_outcomes: Vec::new(),
1290                stream_activations: Vec::new(),
1291                circle_controls: vec![reference.clone()],
1292                store_package: None,
1293                circle_packages: &[],
1294            },
1295            &device.device_signer,
1296        )
1297        .expect("sign Store commit");
1298        let first_commit_path = format!(
1299            "{}.json",
1300            super::super::store_commit::commit_semantic_prefix(
1301                commit.candidate_family(),
1302                &device.stream_id.to_string(),
1303                1,
1304                commit.commit_hash(),
1305            )
1306        );
1307        let commit_ref = super::super::store_commit::StoreBatchCommitRef::from_commit(
1308            &commit,
1309            first_coord,
1310            exact_logical_object(first_commit_path, &commit.to_bytes()),
1311        )
1312        .expect("reference first Store commit");
1313        let own_access = creation
1314            .access
1315            .iter()
1316            .find(|access| access.leaf.value.recipient_pubkey == author_pubkey)
1317            .expect("author access");
1318        let verified = crate::sync::store::VerifiedCircleReference {
1319            reference,
1320            circle_id: creation.circle_id,
1321            control: control.clone(),
1322            local_access: Some(crate::sync::store::VerifiedCircleAccess {
1323                envelope: own_access.envelope.clone(),
1324                leaf: own_access.leaf.clone(),
1325                active: Some(crate::sync::store::VerifiedCircleActive {
1326                    roster: creation.roster.clone(),
1327                    metadata: creation.metadata.clone(),
1328                }),
1329            }),
1330        };
1331        let db = super::super::test_helpers::open_test_db();
1332        let first_commit = commit.clone();
1333        let first_commit_ref = commit_ref.clone();
1334        db.call(move |conn| {
1335            crate::sync::store::record_verified_circle_activations_for_test(
1336                conn,
1337                &first_commit,
1338                &first_commit_ref,
1339                &[verified],
1340            )
1341        })
1342        .await
1343        .expect("record multi-Owner control");
1344        let circle_id = creation.circle_id.to_string();
1345        let cached_owner = db
1346            .call(move |conn| {
1347                conn.query_row(
1348                    "SELECT owner_pubkey FROM circle_access_cache WHERE circle_id = ?1",
1349                    [circle_id],
1350                    |row| row.get::<_, String>(0),
1351                )
1352                .map_err(crate::database::DbError::from)
1353            })
1354            .await
1355            .expect("read cached access owner");
1356        assert_eq!(cached_owner, author_pubkey);
1357        db.call(|conn| {
1358            conn.execute("DELETE FROM circle_metadata_cache", [])
1359                .map_err(crate::database::DbError::from)?;
1360            conn.execute("DELETE FROM circle_roster_cache", [])
1361                .map_err(crate::database::DbError::from)?;
1362            conn.execute("DELETE FROM circle_access_cache", [])
1363                .map(|_| ())
1364                .map_err(crate::database::DbError::from)
1365        })
1366        .await
1367        .expect("remove historical Circle projections");
1368        let circles = crate::sync::store::StoreDatabase::new(&db)
1369            .get_circles(
1370                &author_pubkey,
1371                std::collections::BTreeSet::from([author_pubkey.clone()]),
1372            )
1373            .await
1374            .expect("list Circle from its derived current state");
1375        assert_eq!(
1376            circles,
1377            vec![CircleInfo::Active {
1378                id: creation.circle_id,
1379                name: creation.metadata.name.clone(),
1380                role: CircleRole::Owner,
1381                rotation_required: false,
1382            }]
1383        );
1384        let (publication_encryption, publication_fingerprint) =
1385            crate::sync::store::StoreDatabase::new(&db)
1386                .circle_publication_context(creation.circle_id, control.coord.clone())
1387                .await
1388                .expect("load publication authority from derived current state");
1389        assert_eq!(
1390            publication_encryption.seal_key_fingerprint(),
1391            publication_fingerprint
1392        );
1393        assert_eq!(publication_fingerprint, control.value.key_fingerprint());
1394
1395        let mut second_value = control.value.clone();
1396        let active_epoch = second_value
1397            .value
1398            .state
1399            .active_epoch_mut()
1400            .expect("test control has an active epoch");
1401        active_epoch.common.access_root = ObjectHash::digest(b"different founder access root");
1402        second_value.signature = keys::sign_hex(&author, &second_value.canonical_bytes()).1;
1403        let second_control = PreparedCircleControl {
1404            coord: second_value.coord(),
1405            bytes: serde_json::to_vec(&second_value).expect("serialize second founder control"),
1406            value: second_value,
1407        };
1408        let second_reference = merge_control_reference(&second_control, &device, "second-founder");
1409        let second_coord = super::super::store_commit::StoreCommitCoord {
1410            stream_id: device.stream_id,
1411            sequence: 2,
1412        };
1413        let second_commit = super::super::store_commit::StoreBatchCommit::signed_operations(
1414            store_root_hash,
1415            crate::WriteId::from_generated("second-founder-control-commit".to_string()),
1416            second_coord.clone(),
1417            device.reference,
1418            &device.registration,
1419            super::super::store_commit::StoreCommitOrder {
1420                seq: 2,
1421                predecessor: Some(commit_ref.clone()),
1422                dependencies: BTreeMap::new(),
1423            },
1424            membership,
1425            super::super::store_commit::StoreDeviceStateRef::from_resolved(
1426                super::super::store_commit::CommitFrontier(BTreeMap::from([(
1427                    device.stream_id,
1428                    commit_ref.clone(),
1429                )])),
1430                &super::super::store_commit::ResolvedStoreDeviceState {
1431                    devices: BTreeMap::new(),
1432                    recovery: Vec::new(),
1433                    state_hash: ObjectHash::digest(b"multi-owner second device state"),
1434                },
1435            )
1436            .expect("bind second device state"),
1437            super::super::store_commit::StoreOperationMembershipAuthority {
1438                predecessor: control.value.membership_authority().clone(),
1439            },
1440            super::super::store_commit::StoreCommitOperationsInput {
1441                acknowledgement: None,
1442                circle_acknowledgements: Vec::new(),
1443                control: None,
1444                device_join_attempt_decisions: Vec::new(),
1445                device_join_outcomes: Vec::new(),
1446                device_join_cleanup_receipts: Vec::new(),
1447                provider_access_grants: Vec::new(),
1448                provider_access_withdrawals: Vec::new(),
1449                device_registrations: Vec::new(),
1450                device_exclusion_proposals: Vec::new(),
1451                device_exclusion_outcomes: Vec::new(),
1452                stream_activations: Vec::new(),
1453                circle_controls: vec![second_reference.clone()],
1454                store_package: None,
1455                circle_packages: &[],
1456            },
1457            &device.device_signer,
1458        )
1459        .expect("sign second founder Store commit");
1460        let second_commit_path = format!(
1461            "{}.json",
1462            super::super::store_commit::commit_semantic_prefix(
1463                second_commit.candidate_family(),
1464                &device.stream_id.to_string(),
1465                2,
1466                second_commit.commit_hash(),
1467            )
1468        );
1469        let second_commit_ref = super::super::store_commit::StoreBatchCommitRef::from_commit(
1470            &second_commit,
1471            second_coord,
1472            exact_logical_object(second_commit_path, &second_commit.to_bytes()),
1473        )
1474        .expect("reference second Store commit");
1475        let error = db
1476            .call(move |conn| {
1477                crate::sync::store::record_verified_circle_activations_for_test(
1478                    conn,
1479                    &second_commit,
1480                    &second_commit_ref,
1481                    &[crate::sync::store::VerifiedCircleReference {
1482                        reference: second_reference,
1483                        circle_id: creation.circle_id,
1484                        control: second_control,
1485                        local_access: None,
1486                    }],
1487                )
1488            })
1489            .await
1490            .expect_err("a Circle cannot accept a second founder control");
1491        assert!(
1492            error.to_string().contains("already has a founder"),
1493            "{error}"
1494        );
1495    }
1496}