Skip to main content

coven_protocol/
membership_mutation.rs

1//! Prepared membership mutations: the exact entry, head, and objects one
2//! membership publication or transition binds, validated as a unit before
3//! anything durable records them.
4
5use crate::membership::{
6    self, AuthorHead, MembershipEntry, MembershipEntryRef, MembershipHeadRef,
7    MergeMembershipHeadTransition,
8};
9use crate::objects::{ExactObjectRef, PreparedExactObject};
10use crate::store_commit::ObjectHash;
11
12/// One value prepared for upload: its canonical bytes under the exact object its
13/// reference names.
14///
15/// The bytes are not carried beside the value — they are what the value
16/// serializes to — so both the validators and the upload paths re-derive them
17/// here. [`PreparedExactObject::new`] checks them against the reference, so a
18/// value that does not serialize to what its reference names fails at this call
19/// rather than reaching storage.
20pub fn prepare_exact_object(
21    object: &ExactObjectRef,
22    value: &impl serde::Serialize,
23) -> Result<PreparedExactObject, MembershipPreparationError> {
24    let bytes = serde_json::to_vec(value).map_err(MembershipPreparationError::Json)?;
25    PreparedExactObject::new(object.clone(), bytes).map_err(MembershipPreparationError::ExactObject)
26}
27
28fn binds_exact_object(object: &ExactObjectRef, value: &impl serde::Serialize) -> bool {
29    prepare_exact_object(object, value).is_ok()
30}
31
32/// A prepared membership mutation whose parts do not bind one exact entry and
33/// head. Workflow errors wrap it at the operation boundary.
34#[derive(Debug, thiserror::Error)]
35pub enum MembershipPreparationError {
36    #[error("invalid prepared membership mutation: {0}")]
37    Invariant(String),
38    #[error("serialize prepared membership mutation: {0}")]
39    Json(#[source] serde_json::Error),
40    #[error("prepared membership exact object: {0}")]
41    ExactObject(#[source] crate::objects::StorageError),
42}
43
44/// One membership entry and the head that publishes it, each named by its exact
45/// reference.
46///
47/// The entry and head values are here, and `entry_ref.object` / `head_ref.object`
48/// name the objects they serialize to, so nothing carries their bytes a second
49/// time: the upload rebuilds them from the value and the reference re-checks
50/// them on the way out.
51#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct PreparedMembershipPublication {
54    pub entry: MembershipEntry,
55    pub entry_ref: MembershipEntryRef,
56    pub head: AuthorHead,
57    pub head_ref: MembershipHeadRef,
58}
59
60impl PreparedMembershipPublication {
61    pub fn validate(&self) -> Result<(), MembershipPreparationError> {
62        self.transition().validate()?;
63        let coord = self.entry.coord();
64        if self.entry_ref.coord != coord
65            || self.head.body.entry != self.entry_ref
66            || self.head.entry_coord() != coord
67            || self.head_ref.coord != coord
68            || self.head_ref.head_hash != self.head.head_hash()
69            || !binds_exact_object(&self.head_ref.object, &self.head)
70        {
71            return Err(MembershipPreparationError::Invariant(
72                "prepared membership publication does not bind one exact entry and head"
73                    .to_string(),
74            ));
75        }
76        Ok(())
77    }
78
79    pub fn transition(&self) -> PreparedMembershipTransition {
80        PreparedMembershipTransition {
81            entry: self.entry.clone(),
82            entry_ref: self.entry_ref.clone(),
83            transition: membership::MergeMembershipHeadTransition {
84                body: self.head.body.clone(),
85                head_slot: self.head_ref.object.slot().clone(),
86            },
87        }
88    }
89
90    pub fn candidate_remote_objects(
91        &self,
92        commit: &crate::store_commit::StoreBatchCommit,
93        reference: &crate::store_commit::StoreBatchCommitRef,
94    ) -> Result<
95        Vec<crate::remote_object::ClosedRemoteObject>,
96        crate::prepared_commit::PreparedCommitError,
97    > {
98        self.validate()?;
99        reference.verify_commit(commit)?;
100        if !commit
101            .control()
102            .is_some_and(|control| control.transition.matches_head(&self.head, &self.head_ref))
103            || !matches!(&self.head.activation, membership::MembershipHeadActivation::StoreCommit { commit, .. } if commit == reference)
104        {
105            return Err(crate::prepared_commit::PreparedCommitError::Invariant(
106                "membership objects differ from their activating Store commit".into(),
107            ));
108        }
109        let entry = self.prepared_entry()?;
110        let head = self.prepared_head()?;
111        Ok(vec![
112            crate::remote_object::RemoteObjectRecord::candidate_exclusive_merge_membership_entry(
113                commit.candidate_family(),
114                self.entry_ref.clone(),
115                entry.stored_bytes(),
116                entry.stored_bytes(),
117                reference.clone(),
118            )?,
119            crate::remote_object::RemoteObjectRecord::candidate_exclusive_merge_membership_head(
120                commit.candidate_family(),
121                self.head_ref.clone(),
122                head.stored_bytes(),
123                head.stored_bytes(),
124                reference.clone(),
125            )?,
126        ])
127    }
128
129    pub fn candidate_object_refs(
130        &self,
131        commit: &crate::store_commit::StoreBatchCommit,
132        reference: &crate::store_commit::StoreBatchCommitRef,
133    ) -> Result<Vec<ExactObjectRef>, crate::prepared_commit::PreparedCommitError> {
134        let mut objects = self
135            .candidate_remote_objects(commit, reference)?
136            .into_iter()
137            .map(|remote| remote.object().clone())
138            .collect::<Vec<_>>();
139        objects.push(reference.object.clone());
140        match &self.entry.change {
141            membership::StoreAuthorityChange::SetMember { wrapped_key, .. } => {
142                objects.push(wrapped_key.object.clone());
143            }
144            membership::StoreAuthorityChange::RemoveMember { wrapped_keys, .. } => {
145                objects.extend(wrapped_keys.iter().map(|key| key.object.clone()));
146            }
147            membership::StoreAuthorityChange::Founder { .. }
148            | membership::StoreAuthorityChange::DeviceRegistrationActivation { .. }
149            | membership::StoreAuthorityChange::DeviceExclusionProposal { .. }
150            | membership::StoreAuthorityChange::DeviceExclusionOutcome { .. }
151            | membership::StoreAuthorityChange::ProviderAdmin => {}
152        }
153        objects.sort();
154        if objects.windows(2).any(|pair| pair[0] == pair[1]) {
155            return Err(crate::prepared_commit::PreparedCommitError::Invariant(
156                "membership candidate repeats an exact object".into(),
157            ));
158        }
159        Ok(objects)
160    }
161
162    /// The entry object this publication uploads.
163    pub fn prepared_entry(&self) -> Result<PreparedExactObject, MembershipPreparationError> {
164        prepare_exact_object(&self.entry_ref.object, &self.entry)
165    }
166
167    /// The head object this publication uploads.
168    pub fn prepared_head(&self) -> Result<PreparedExactObject, MembershipPreparationError> {
169        prepare_exact_object(&self.head_ref.object, &self.head)
170    }
171}
172
173#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct PreparedMembershipTransition {
176    pub entry: MembershipEntry,
177    pub entry_ref: MembershipEntryRef,
178    pub transition: MergeMembershipHeadTransition,
179}
180
181impl PreparedMembershipTransition {
182    pub fn validate(&self) -> Result<(), MembershipPreparationError> {
183        let coord = self.entry.coord();
184        let next_sequence = coord.seq.checked_add(1).ok_or_else(|| {
185            MembershipPreparationError::Invariant("membership sequence is exhausted".to_string())
186        })?;
187        let entry_key = format!(
188            "{}.json",
189            crate::store_commit::membership_entry_semantic_prefix(
190                &coord.author_pubkey,
191                &coord.author_owner_grant,
192                coord.stream_id,
193                coord.seq,
194                coord.entry_hash,
195            )
196        );
197        let head_key = format!(
198            "{}.json",
199            crate::store_commit::membership_head_slot_prefix(
200                &coord.author_pubkey,
201                &coord.author_owner_grant,
202                coord.stream_id,
203                coord.seq,
204            )
205        );
206        let successor_key = format!(
207            "{}.json",
208            crate::store_commit::membership_head_slot_prefix(
209                &coord.author_pubkey,
210                &coord.author_owner_grant,
211                coord.stream_id,
212                next_sequence,
213            )
214        );
215        if self.entry_ref.coord != self.entry.coord()
216            || !binds_exact_object(&self.entry_ref.object, &self.entry)
217            || self.entry_ref.object.slot().logical_key() != entry_key
218            || self.transition.body.entry != self.entry_ref
219            || self.transition.head_slot.logical_key() != head_key
220            || self.transition.body.successor.next_slot.logical_key() != successor_key
221        {
222            return Err(MembershipPreparationError::Invariant(
223                "prepared membership transition does not bind its exact entry".to_string(),
224            ));
225        }
226        Ok(())
227    }
228}
229
230pub enum StoreMembershipJournalCompletion {
231    MembershipCandidateAbandoned {
232        intent_hash: ObjectHash,
233        original: Box<crate::prepared_commit::PreparedStoreOperationCommit>,
234        remote_objects: Vec<crate::remote_object::RemoteObjectRecord>,
235    },
236    DeviceJoin {
237        remote_objects: Vec<crate::remote_object::RemoteObjectRecord>,
238    },
239    DeviceExclusion {
240        operation: Box<crate::device_exclusion_journal::DurableStoreDeviceExclusionOperation>,
241        remote_objects: Vec<crate::remote_object::RemoteObjectRecord>,
242    },
243    Mutation {
244        intent_hash: ObjectHash,
245        progress_bytes: Vec<u8>,
246        remote_objects: Vec<crate::remote_object::RemoteObjectRecord>,
247    },
248    OwnerPromotion {
249        transition: crate::owner_promotion_journal::OwnerPromotionJournalTransition,
250        remote_objects: Vec<crate::remote_object::RemoteObjectRecord>,
251    },
252}
253
254impl StoreMembershipJournalCompletion {
255    pub fn retain_acceptance_result(&mut self, remote: crate::remote_object::RemoteObjectRecord) {
256        let remote_objects = match self {
257            Self::MembershipCandidateAbandoned { remote_objects, .. }
258            | Self::DeviceJoin { remote_objects }
259            | Self::DeviceExclusion { remote_objects, .. }
260            | Self::Mutation { remote_objects, .. }
261            | Self::OwnerPromotion { remote_objects, .. } => remote_objects,
262        };
263        remote_objects.push(remote);
264    }
265
266    pub fn object_refs(&self) -> Vec<ExactObjectRef> {
267        let remote_objects = match self {
268            Self::MembershipCandidateAbandoned { remote_objects, .. }
269            | Self::DeviceJoin { remote_objects }
270            | Self::DeviceExclusion { remote_objects, .. }
271            | Self::Mutation { remote_objects, .. }
272            | Self::OwnerPromotion { remote_objects, .. } => remote_objects,
273        };
274        remote_objects
275            .iter()
276            .map(|remote| remote.object().clone())
277            .collect()
278    }
279
280    pub fn remote_object(
281        &self,
282        object: &ExactObjectRef,
283    ) -> Result<crate::remote_object::RemoteObjectRecord, MembershipPreparationError> {
284        let remote_objects = match self {
285            Self::MembershipCandidateAbandoned { remote_objects, .. }
286            | Self::DeviceJoin { remote_objects }
287            | Self::DeviceExclusion { remote_objects, .. }
288            | Self::Mutation { remote_objects, .. }
289            | Self::OwnerPromotion { remote_objects, .. } => remote_objects,
290        };
291        remote_objects
292            .iter()
293            .find(|remote| remote.object() == object)
294            .cloned()
295            .ok_or_else(|| {
296                MembershipPreparationError::Invariant(
297                    "membership completion omits an exact activated object".to_string(),
298                )
299            })
300    }
301}