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