Skip to main content

coven_protocol/store_commit/
membership_rollup.rs

1use super::*;
2
3/// The exact coordinate of one published membership rollup.
4///
5/// `rollup_hash` is the digest of the rollup's canonical bytes — the same
6/// identity a snapshot image reference carries, and for the same reason: a
7/// reader that fetches the object this names can tell whether it got the bytes
8/// the snapshot meant before it looks at anything inside.
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct MembershipRollupRef {
12    pub rollup_hash: ObjectHash,
13    pub object: ExactObjectRef,
14}
15
16/// One membership head and the entry it selects, carried by value.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct MembershipRollupHead {
20    pub head: MembershipHeadRef,
21    pub head_value: AuthorHead,
22    pub entry: MembershipEntryRef,
23    pub entry_value: MembershipEntry,
24}
25
26/// One conflict resolution the carried heads depend on.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct MembershipRollupResolution {
30    pub resolution: StoreMembershipConflictResolutionRef,
31    pub resolution_value: StoreMembershipConflictResolution,
32}
33
34/// One author stream's heads, in sequence order from the stream's anchor.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct MembershipRollupStream {
38    pub author_pubkey: String,
39    pub author_owner_grant: MembershipGrantId,
40    pub stream_id: AuthorStreamId,
41    pub heads: Vec<MembershipRollupHead>,
42}
43
44/// Every membership object a reader needs to reach one membership frontier,
45/// carried in one object.
46///
47/// The membership chain is hash-linked per author stream, so a reader has to
48/// verify it in order — but it does not have to *fetch* it in order, and it
49/// does not have to fetch it one object at a time. A device joining a Store
50/// with a few dozen membership changes spent two provider round trips per
51/// change discovering and reading objects that had not moved in months, which
52/// on a live store was about eighty percent of the whole join.
53///
54/// This carries all of them. Nothing in it is believed: a reader takes the
55/// bytes, keys them by the slot and the content address they claim, and then
56/// runs the identical anchored-chain walk it would have run over its own
57/// reads — same signature checks, same predecessor linkage, same Store-commit
58/// activation for authority changes, same conflict-resolution layering. A
59/// rollup that is stale costs the reader the tail it does not cover; a rollup
60/// that is wrong is refused here and the reader walks the provider exactly as
61/// it did before.
62///
63/// It is published beside a snapshot and named by the signed snapshot metadata,
64/// which is what makes it discoverable before a joining device has opened the
65/// Store keyring — the membership chain is what *opens* that keyring, so
66/// nothing a joiner needs to read it can live behind it.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct MembershipRollupBody {
70    pub store_root_hash: ObjectHash,
71    pub author_registration: StoreDeviceRegistrationRef,
72    pub streams: Vec<MembershipRollupStream>,
73    pub resolutions: Vec<MembershipRollupResolution>,
74}
75
76impl SignedBody for MembershipRollupBody {
77    const DOMAIN: &'static [u8] = MEMBERSHIP_ROLLUP_DOMAIN;
78}
79
80pub type MembershipRollup = Signed<MembershipRollupBody>;
81
82impl MembershipRollup {
83    pub fn signed(
84        store_root_hash: ObjectHash,
85        author_registration: StoreDeviceRegistrationRef,
86        streams: Vec<MembershipRollupStream>,
87        resolutions: Vec<MembershipRollupResolution>,
88        device_signer: &UserKeypair,
89    ) -> Result<Self, StoreProtocolError> {
90        let rollup = Signed::sign(
91            MembershipRollupBody {
92                store_root_hash,
93                author_registration,
94                streams,
95                resolutions,
96            },
97            device_signer,
98        );
99        rollup.validate_shape()?;
100        Ok(rollup)
101    }
102
103    /// Everything about a rollup that can be checked without the chain: each
104    /// carried object hashes to the reference that names it, carries its own
105    /// author's signature, and sits at the coordinate its stream claims.
106    ///
107    /// This is deliberately not the whole of membership verification — grant
108    /// authority, predecessor linkage across a conflict layer, and Store-commit
109    /// activation are decided by the walk that consumes these bytes, over the
110    /// same code path that decides them for bytes read off the provider. What
111    /// this establishes is that the rollup is a faithful carrier: every object
112    /// in it is the object its reference names.
113    pub fn validate_shape(&self) -> Result<(), StoreProtocolError> {
114        if self
115            .streams
116            .windows(2)
117            .any(|pair| stream_key(&pair[0]) >= stream_key(&pair[1]))
118        {
119            return Err(StoreProtocolError::Malformed(
120                "membership rollup streams are not canonical".to_string(),
121            ));
122        }
123        for stream in &self.streams {
124            if stream.heads.is_empty() {
125                return Err(StoreProtocolError::Malformed(
126                    "membership rollup carries an empty author stream".to_string(),
127                ));
128            }
129            for (index, carried) in stream.heads.iter().enumerate() {
130                let sequence = u64::try_from(index)
131                    .ok()
132                    .and_then(|index| index.checked_add(1))
133                    .ok_or_else(|| {
134                        StoreProtocolError::Malformed(
135                            "membership rollup sequence overflow".to_string(),
136                        )
137                    })?;
138                carried.validate_at(stream, sequence)?;
139            }
140        }
141        for carried in &self.resolutions {
142            let value = &carried.resolution_value;
143            if value.store_root_hash != self.store_root_hash
144                || !value.verify_signature()
145                || value.resolution_ref(carried.resolution.object.clone()) != carried.resolution
146            {
147                return Err(StoreProtocolError::Malformed(
148                    "membership rollup carries an unauthentic conflict resolution".to_string(),
149                ));
150            }
151            carried
152                .resolution
153                .object
154                .verify(&serde_json::to_vec(value)?)?;
155        }
156        Ok(())
157    }
158
159    pub fn parse_at(
160        bytes: &[u8],
161        expected_store_root_hash: ObjectHash,
162        expected: &MembershipRollupRef,
163        author: &StoreDeviceRegistration,
164    ) -> Result<Self, StoreProtocolError> {
165        let rollup: Self = crate::objects::decode_protocol_object(bytes)?;
166        rollup.require_version()?;
167        crate::objects::verify_store_root(expected_store_root_hash, rollup.store_root_hash)?;
168        rollup.author_registration.verify_registration(author)?;
169        rollup.validate_shape()?;
170        rollup.verify_by(&author.device_signing_pubkey)?;
171        let actual = ObjectHash::digest(bytes);
172        if actual != expected.rollup_hash {
173            return Err(StoreProtocolError::ObjectHashMismatch {
174                expected: expected.rollup_hash,
175                actual,
176            });
177        }
178        Ok(rollup)
179    }
180}
181
182impl MembershipRollupHead {
183    fn validate_at(
184        &self,
185        stream: &MembershipRollupStream,
186        sequence: u64,
187    ) -> Result<(), StoreProtocolError> {
188        let coord = self.head_value.entry_coord();
189        if coord != self.head.coord
190            || coord.author_pubkey != stream.author_pubkey
191            || coord.author_owner_grant != stream.author_owner_grant
192            || coord.stream_id != stream.stream_id
193            || coord.seq != sequence
194            || self.head.head_hash != self.head_value.head_hash()
195            || self.head_value.body.entry != self.entry
196            || self.entry.coord != self.entry_value.coord()
197            || !verify_membership_entry(&self.entry_value)
198        {
199            return Err(StoreProtocolError::Malformed(format!(
200                "membership rollup head {}/{}/{sequence} does not match its own reference",
201                stream.author_pubkey, stream.stream_id
202            )));
203        }
204        self.head
205            .object
206            .verify(&serde_json::to_vec(&self.head_value)?)?;
207        self.entry
208            .object
209            .verify(&serde_json::to_vec(&self.entry_value)?)?;
210        Ok(())
211    }
212}
213
214fn stream_key(stream: &MembershipRollupStream) -> (&str, &MembershipGrantId, AuthorStreamId) {
215    (
216        &stream.author_pubkey,
217        &stream.author_owner_grant,
218        stream.stream_id,
219    )
220}