Skip to main content

coven_protocol/store_commit/batch_commit/
validation.rs

1use super::*;
2
3#[allow(clippy::too_many_arguments)]
4pub(super) fn validate_commit_envelope(
5    store_root_hash: ObjectHash,
6    coord: &StoreCommitCoord,
7    author_registration: &StoreDeviceRegistrationRef,
8    author: &StoreDeviceRegistration,
9    order: &StoreCommitOrder,
10    membership_state: &StoreMembershipStateRef,
11    device_state: &StoreDeviceStateRef,
12    membership_authority: Option<&MembershipGrantCreationAuthority>,
13    signer: &UserKeypair,
14) -> Result<(), StoreProtocolError> {
15    author_registration.verify_registration(author)?;
16    if keys::public_key_hex(signer) != author.device_signing_pubkey {
17        return Err(StoreProtocolError::InvalidSignature);
18    }
19    if order.seq() == 0 {
20        return Err(StoreProtocolError::InvalidSequence(0));
21    }
22    validate_commit_order(order)?;
23    validate_commit_predecessor_states(order, membership_state, device_state)?;
24    if coord.sequence() != order.seq() {
25        return Err(StoreProtocolError::Malformed(
26            "Store commit coordinate disagrees with its order".to_string(),
27        ));
28    }
29    if let Some(authority) = membership_authority {
30        validate_membership_authority(authority)?;
31    }
32    crate::objects::verify_store_root(store_root_hash, author.store_root.store_root_hash)?;
33    Ok(())
34}
35
36pub(super) fn validate_commit_body(
37    store_root_hash: ObjectHash,
38    body: &StoreCommitBody,
39    author: &StoreDeviceRegistrationRef,
40) -> Result<(), StoreProtocolError> {
41    match body {
42        StoreCommitBody::Operations(operations) => {
43            if operations.is_empty() {
44                return Err(StoreProtocolError::EmptyBatch);
45            }
46            validate_circle_control_refs(&operations.circle_controls)?;
47            validate_commit_acknowledgement(&operations.acknowledgement, author)?;
48            validate_commit_circle_acknowledgements(&operations.circle_acknowledgements, author)?;
49            validate_device_join_attempt_decision_refs(&operations.device_join_attempt_decisions)?;
50            validate_provider_access_refs(&operations.provider_access_grants)?;
51            validate_device_registration_refs(&operations.device_registrations)?;
52            validate_device_exclusion_refs(
53                &operations.device_exclusion_proposals,
54                &operations.device_exclusion_outcomes,
55            )?;
56            validate_stream_activations(
57                store_root_hash,
58                author,
59                operations.control.as_ref(),
60                &operations.stream_activations,
61            )?;
62        }
63        StoreCommitBody::ReclaimAuthorization { .. } => {}
64        StoreCommitBody::ReclaimReceipt { .. } => {}
65        StoreCommitBody::OwnerPromotionRequest { request } => {
66            if request.store_root_hash != store_root_hash
67                || request.promoter_registration != *author
68            {
69                return Err(StoreProtocolError::OwnerPromotionMismatch);
70            }
71        }
72        StoreCommitBody::AbandonCandidates { manifests } => {
73            if manifests.is_empty() {
74                return Err(StoreProtocolError::Malformed(
75                    "candidate abandonment has no candidates".to_string(),
76                ));
77            }
78        }
79    }
80    Ok(())
81}
82
83pub(super) fn validate_owner_promotion_request_for_commit(
84    request: &OwnerPromotionRequest,
85    store_root_hash: ObjectHash,
86    author_registration: &StoreDeviceRegistrationRef,
87    author: &StoreDeviceRegistration,
88    membership_state: &StoreMembershipStateRef,
89    device_state: &StoreDeviceStateRef,
90) -> Result<(), StoreProtocolError> {
91    request.verify(&author.store_root, author)?;
92    if request.store_root_hash != store_root_hash
93        || request.promoter_registration != *author_registration
94        || request.predecessor_membership != *membership_state
95        || request.predecessor_devices != *device_state
96    {
97        return Err(StoreProtocolError::OwnerPromotionMismatch);
98    }
99    Ok(())
100}
101
102pub(crate) fn validate_stream_activations(
103    store_root_hash: ObjectHash,
104    author: &StoreDeviceRegistrationRef,
105    control: Option<&StoreControl>,
106    activations: &[StreamActivation],
107) -> Result<(), StoreProtocolError> {
108    if activations.windows(2).any(|pair| pair[0] >= pair[1]) {
109        return Err(StoreProtocolError::Malformed(
110            "stream activations are not strictly sorted and unique".to_string(),
111        ));
112    }
113    let mut activation_ids = BTreeSet::new();
114    let mut stream_ids = BTreeSet::new();
115    let mut first_slots = BTreeSet::new();
116    for activation in activations {
117        crate::objects::verify_store_root(store_root_hash, activation.store_root_hash())?;
118        let owner_promotion = control.is_some();
119        if activation.author_registration() != author && !owner_promotion {
120            return Err(StoreProtocolError::Malformed(
121                "stream activation registration differs from its commit author".to_string(),
122            ));
123        }
124        let allowed_anchor = matches!(
125            (control, activation),
126            (
127                Some(StoreControl { .. }),
128                StreamActivation::GrantAuthorized {
129                    anchor: GrantStreamAnchor::StoreMembership { .. }
130                        | GrantStreamAnchor::OwnerRecovery { .. },
131                    ..
132                }
133            ) | (
134                _,
135                StreamActivation::GrantAuthorized {
136                    anchor: GrantStreamAnchor::CircleControl { .. }
137                        | GrantStreamAnchor::CircleRoster { .. }
138                        | GrantStreamAnchor::CircleMetadata { .. },
139                    ..
140                }
141            )
142        );
143        if !allowed_anchor {
144            return Err(StoreProtocolError::Malformed(
145                "Store commit contains a root- or registration-authorized stream anchor"
146                    .to_string(),
147            ));
148        }
149        if !activation_ids.insert(activation.activation_id())
150            || !stream_ids.insert(activation.author_stream_id())
151            || !first_slots.insert(activation.first_slot().clone())
152        {
153            return Err(StoreProtocolError::Malformed(
154                "stream activations repeat an activation, author stream, or first slot".to_string(),
155            ));
156        }
157    }
158    Ok(())
159}
160
161pub(super) fn validate_candidate_abandonment(
162    manifests: &[CandidateCleanupManifest],
163    store_root_hash: ObjectHash,
164    author_registration: &StoreDeviceRegistrationRef,
165    coord: &StoreCommitCoord,
166    order: &StoreCommitOrder,
167    author: &StoreDeviceRegistration,
168) -> Result<(), StoreProtocolError> {
169    if manifests.is_empty() {
170        return Err(StoreProtocolError::Malformed(
171            "candidate abandonment has no candidates".to_string(),
172        ));
173    }
174    if manifests.windows(2).any(|pair| pair[0] >= pair[1]) {
175        return Err(StoreProtocolError::Malformed(
176            "candidate abandonment manifests are not strictly sorted and unique".to_string(),
177        ));
178    }
179    for manifest in manifests {
180        if &manifest.candidate.coord != coord {
181            return Err(StoreProtocolError::Malformed(
182                "abandoned candidate occupies a different competition point".to_string(),
183            ));
184        }
185        let candidate = manifest
186            .candidate
187            .verify_candidate(store_root_hash, author)?;
188        if &candidate.author_registration != author_registration {
189            return Err(StoreProtocolError::Malformed(
190                "abandoned candidate has a different author registration".to_string(),
191            ));
192        }
193        let shares_predecessor = candidate.order.predecessor == order.predecessor;
194        if !shares_predecessor {
195            return Err(StoreProtocolError::Malformed(
196                "abandoned candidate has a different predecessor".to_string(),
197            ));
198        }
199    }
200    Ok(())
201}
202
203pub(crate) fn candidate_manifest(
204    family: CandidateFamilyId,
205    body: &StoreCommitBody,
206) -> Result<CandidateObjectManifest, StoreProtocolError> {
207    let mut objects = Vec::new();
208    match body {
209        StoreCommitBody::Operations(operations) => {
210            objects.extend(
211                operations
212                    .store_package
213                    .iter()
214                    .cloned()
215                    .map(CandidateExclusiveObjectRef::StorePackage),
216            );
217            objects.extend(
218                operations
219                    .circle_packages
220                    .iter()
221                    .cloned()
222                    .map(CandidateExclusiveObjectRef::CirclePackage),
223            );
224            for control in &operations.circle_controls {
225                let circle_id = control.circle_id();
226                if let Some(reference) = &control.objects().close_intent {
227                    objects.push(CandidateExclusiveObjectRef::CircleEpochCloseIntent {
228                        circle_id,
229                        reference: reference.clone(),
230                    });
231                }
232                if let Some(reference) = &control.objects().close_outcome {
233                    objects.push(CandidateExclusiveObjectRef::CircleEpochCloseOutcome {
234                        circle_id,
235                        reference: reference.clone(),
236                    });
237                }
238                if let Some(reference) = &control.objects().close_cancellation {
239                    objects.push(CandidateExclusiveObjectRef::CircleEpochCloseCancellation {
240                        circle_id,
241                        reference: reference.clone(),
242                    });
243                }
244                if control
245                    .objects()
246                    .access
247                    .iter()
248                    .any(|access| access.envelope.control_hash != control.control().control_hash())
249                {
250                    return Err(StoreProtocolError::Malformed(
251                        "Circle access envelope differs from its activating control".to_string(),
252                    ));
253                }
254                objects.extend(
255                    control.objects().access.iter().cloned().map(|access| {
256                        CandidateExclusiveObjectRef::CircleAccess { circle_id, access }
257                    }),
258                );
259            }
260        }
261        StoreCommitBody::ReclaimAuthorization { .. } => {}
262        StoreCommitBody::ReclaimReceipt { .. } => {}
263        StoreCommitBody::OwnerPromotionRequest { .. }
264        | StoreCommitBody::AbandonCandidates { .. } => {}
265    }
266    objects.sort_by_cached_key(|object| {
267        serde_json::to_vec(object).expect("candidate object serialization cannot fail")
268    });
269    let mut exact_refs = BTreeSet::new();
270    let mut access_keys = BTreeSet::new();
271    for object in &objects {
272        validate_candidate_object_path(family, object)?;
273        match object {
274            CandidateExclusiveObjectRef::CircleAccess { circle_id, access } => {
275                let key = (
276                    *circle_id,
277                    access.leaf.owner_pubkey.clone(),
278                    access.leaf.recipient_slot.clone(),
279                    access.envelope.control_hash,
280                );
281                if !access_keys.insert(key) {
282                    return Err(StoreProtocolError::Malformed(
283                        "candidate object manifest repeats a Circle access semantic key"
284                            .to_string(),
285                    ));
286                }
287                insert_candidate_exact_ref(&mut exact_refs, &access.leaf.object)?;
288                insert_candidate_exact_ref(&mut exact_refs, &access.envelope.object)?;
289            }
290            CandidateExclusiveObjectRef::CircleEpochCloseIntent { reference, .. } => {
291                insert_candidate_exact_ref(&mut exact_refs, &reference.object)?;
292            }
293            CandidateExclusiveObjectRef::CircleEpochCloseOutcome { reference, .. } => {
294                insert_candidate_exact_ref(&mut exact_refs, &reference.object)?;
295            }
296            CandidateExclusiveObjectRef::CircleEpochCloseCancellation { reference, .. } => {
297                insert_candidate_exact_ref(&mut exact_refs, &reference.object)?;
298            }
299            CandidateExclusiveObjectRef::StorePackage(reference) => {
300                insert_candidate_exact_ref(&mut exact_refs, &reference.object)?;
301            }
302            CandidateExclusiveObjectRef::CirclePackage(reference) => {
303                insert_candidate_exact_ref(&mut exact_refs, &reference.package.object)?;
304            }
305        }
306    }
307    Ok(CandidateObjectManifest { family, objects })
308}
309
310pub(super) fn insert_candidate_exact_ref<'a>(
311    exact_refs: &mut BTreeSet<&'a ExactObjectRef>,
312    object: &'a ExactObjectRef,
313) -> Result<(), StoreProtocolError> {
314    if !exact_refs.insert(object) {
315        return Err(StoreProtocolError::Malformed(
316            "candidate object manifest repeats an exact object reference".to_string(),
317        ));
318    }
319    Ok(())
320}
321
322pub(super) fn validate_candidate_object_path(
323    family: CandidateFamilyId,
324    candidate: &CandidateExclusiveObjectRef,
325) -> Result<(), StoreProtocolError> {
326    match candidate {
327        CandidateExclusiveObjectRef::StorePackage(reference) => {
328            if reference.candidate_family != family {
329                return Err(StoreProtocolError::Malformed(
330                    "Store package candidate family differs from its manifest".to_string(),
331                ));
332            }
333            Ok(())
334        }
335        CandidateExclusiveObjectRef::CirclePackage(reference) => {
336            if reference.package.candidate_family != family {
337                return Err(StoreProtocolError::Malformed(
338                    "Circle package candidate family differs from its manifest".to_string(),
339                ));
340            }
341            Ok(())
342        }
343        CandidateExclusiveObjectRef::CircleAccess { circle_id, access } => {
344            validate_circle_access_ref(*circle_id, family, access)?;
345            Ok(())
346        }
347        CandidateExclusiveObjectRef::CircleEpochCloseIntent {
348            circle_id,
349            reference,
350        } => {
351            let expected = format!(
352                "{}.json",
353                crate::circle::circle_epoch_close_intent_semantic_prefix(
354                    *circle_id,
355                    reference.close_id,
356                    reference.intent_hash,
357                )
358            );
359            if reference.object.slot().logical_key() != expected {
360                return Err(StoreProtocolError::RelocatedCandidateObject {
361                    expected,
362                    actual: reference.object.slot().logical_key().to_string(),
363                });
364            }
365            Ok(())
366        }
367        CandidateExclusiveObjectRef::CircleEpochCloseOutcome {
368            circle_id,
369            reference,
370        } => {
371            let expected = format!(
372                "{}.json",
373                crate::circle::circle_epoch_close_outcome_semantic_prefix(
374                    *circle_id,
375                    reference.close_id,
376                )
377            );
378            if reference.object.slot().logical_key() != expected {
379                return Err(StoreProtocolError::RelocatedCandidateObject {
380                    expected,
381                    actual: reference.object.slot().logical_key().to_string(),
382                });
383            }
384            Ok(())
385        }
386        CandidateExclusiveObjectRef::CircleEpochCloseCancellation {
387            circle_id,
388            reference,
389        } => {
390            let expected = format!(
391                "{}.json",
392                crate::circle::circle_epoch_close_outcome_semantic_prefix(
393                    *circle_id,
394                    reference.close_id,
395                )
396            );
397            if reference.object.slot().logical_key() != expected {
398                return Err(StoreProtocolError::RelocatedCandidateObject {
399                    expected,
400                    actual: reference.object.slot().logical_key().to_string(),
401                });
402            }
403            Ok(())
404        }
405    }
406}
407
408pub(super) fn validate_circle_access_ref(
409    circle_id: CircleId,
410    family: CandidateFamilyId,
411    access: &CircleAccessObjectRef,
412) -> Result<(), StoreProtocolError> {
413    if access.leaf.owner_pubkey != access.envelope.owner_pubkey
414        || access.leaf.recipient_slot != access.envelope.recipient_slot
415        || access.leaf.leaf_id != access.envelope.leaf_id
416        || access.leaf.leaf_hash != access.envelope.leaf_hash
417        || access.leaf.leaf_hash != access.leaf.object.stored_hash()
418    {
419        return Err(StoreProtocolError::Malformed(
420            "paired Circle access leaf and envelope references differ".to_string(),
421        ));
422    }
423    let leaf_expected = circle_access_leaf_semantic_prefix(
424        circle_id,
425        family,
426        &access.leaf.owner_pubkey,
427        access.leaf.epoch_id,
428        &access.leaf.recipient_slot,
429        access.leaf.leaf_id,
430    );
431    if access.leaf.object.slot().logical_key() != leaf_expected {
432        return Err(StoreProtocolError::RelocatedCandidateObject {
433            expected: leaf_expected,
434            actual: access.leaf.object.slot().logical_key().to_string(),
435        });
436    }
437    let envelope_expected = format!(
438        "{}.json",
439        circle_access_envelope_semantic_prefix(
440            circle_id,
441            family,
442            &access.envelope.owner_pubkey,
443            &access.envelope.recipient_slot,
444            access.envelope.control_hash,
445        )
446    );
447    if access.envelope.object.slot().logical_key() != envelope_expected {
448        return Err(StoreProtocolError::RelocatedCandidateObject {
449            expected: envelope_expected,
450            actual: access.envelope.object.slot().logical_key().to_string(),
451        });
452    }
453    Ok(())
454}
455
456pub(super) fn package_ref(
457    semantic_prefix: &str,
458    input: &StorePackageInput<'_>,
459) -> Result<StorePackageRef, StoreProtocolError> {
460    let package_bytes = input.bytes;
461    let changeset_size =
462        u64::try_from(package_bytes.len()).map_err(|_| StoreProtocolError::PackageTooLarge)?;
463    let content_hash = ObjectHash::digest(package_bytes);
464    let expected_key = format!("{semantic_prefix}.pkg");
465    if input.object.slot().logical_key() != expected_key {
466        return Err(StoreProtocolError::RelocatedPackage {
467            expected: expected_key,
468            actual: input.object.slot().logical_key().to_string(),
469        });
470    }
471    Ok(StorePackageRef {
472        candidate_family: input.candidate_family,
473        content_hash,
474        schema_version: input.schema_version,
475        changeset_size,
476        object: input.object.clone(),
477    })
478}
479
480pub(super) fn verify_package_ref(
481    package: &StorePackageRef,
482    package_bytes: &[u8],
483) -> Result<(), StoreProtocolError> {
484    let length =
485        u64::try_from(package_bytes.len()).map_err(|_| StoreProtocolError::PackageTooLarge)?;
486    if length != package.changeset_size {
487        return Err(StoreProtocolError::PackageLengthMismatch {
488            expected: package.changeset_size,
489            actual: length,
490        });
491    }
492    let actual = ObjectHash::digest(package_bytes);
493    if actual != package.content_hash {
494        return Err(StoreProtocolError::PackageHashMismatch {
495            expected: package.content_hash,
496            actual,
497        });
498    }
499    Ok(())
500}
501
502pub(super) fn validate_control(
503    author_registration: &StoreDeviceRegistrationRef,
504    author_pubkey: &str,
505    _membership_state: &StoreMembershipStateRef,
506    control: Option<&StoreControl>,
507) -> Result<(), StoreProtocolError> {
508    let Some(control) = control else {
509        return Ok(());
510    };
511    let transition = &control.transition;
512    if transition.body.author_registration != *author_registration
513        || transition.body.entry.coord.author_pubkey != author_pubkey
514        || transition.body.entry.coord.seq == 0
515    {
516        return Err(StoreProtocolError::InvalidMergeMembershipControl);
517    }
518    Ok(())
519}
520
521pub(super) fn validate_parsed_control(
522    commit: &StoreBatchCommit,
523    author: &StoreDeviceRegistration,
524) -> Result<(), StoreProtocolError> {
525    validate_control(
526        &commit.author_registration,
527        &author.author_pubkey,
528        &commit.membership_state,
529        commit.control(),
530    )
531}
532
533pub(super) fn validate_circle_control_coord(
534    coord: &CircleControlCoord,
535) -> Result<(), StoreProtocolError> {
536    coord
537        .validate()
538        .map_err(|_| StoreProtocolError::InvalidCircleControlCoord)?;
539    Ok(())
540}
541
542pub(super) fn validate_circle_control_refs(
543    controls: &[CircleControlRef],
544) -> Result<(), StoreProtocolError> {
545    let mut seen = BTreeSet::new();
546    for control_ref in controls {
547        if !seen.insert(control_ref.circle_id()) {
548            return Err(StoreProtocolError::DuplicateCircleControl(
549                control_ref.circle_id(),
550            ));
551        }
552        validate_circle_control_coord(control_ref.control())?;
553    }
554    Ok(())
555}
556
557impl StoreBatchCommit {
558    pub fn commit_hash(&self) -> ObjectHash {
559        self.hash()
560    }
561
562    pub fn verify_at(
563        &self,
564        expected_store_root_hash: ObjectHash,
565        expected_coord: &StoreCommitCoord,
566        author: &StoreDeviceRegistration,
567    ) -> Result<(), StoreProtocolError> {
568        self.require_version()?;
569        crate::objects::verify_store_root(expected_store_root_hash, self.store_root_hash)?;
570        self.publication_base
571            .validate_for_store(expected_store_root_hash)?;
572        let stream_id = commit_stream_id(expected_coord);
573        if self.order.seq() != expected_coord.sequence() {
574            return Err(StoreProtocolError::RelocatedSlot {
575                expected: commit_slot_prefix(&stream_id, expected_coord.sequence()),
576                actual: commit_slot_prefix(&stream_id, self.order.seq()),
577            });
578        }
579        self.author_registration.verify_registration(author)?;
580        let family = self.candidate_family();
581        if let Some(package) = self.store_package() {
582            if package.candidate_family != self.candidate_family() {
583                return Err(StoreProtocolError::Malformed(
584                    "Store package candidate family differs from its commit".to_string(),
585                ));
586            }
587            let expected =
588                package_semantic_prefix(family, &stream_id, self.order.seq(), package.content_hash);
589            if package.object.slot().logical_key() != format!("{expected}.pkg") {
590                return Err(StoreProtocolError::RelocatedPackage {
591                    expected,
592                    actual: package.object.slot().logical_key().to_string(),
593                });
594            }
595        }
596        let mut seen_circles = BTreeSet::new();
597        for circle_package in self.circle_packages() {
598            if circle_package.package.candidate_family != self.candidate_family() {
599                return Err(StoreProtocolError::Malformed(
600                    "Circle package candidate family differs from its commit".to_string(),
601                ));
602            }
603            if !seen_circles.insert(circle_package.circle_id) {
604                return Err(StoreProtocolError::DuplicateCirclePackage(
605                    circle_package.circle_id,
606                ));
607            }
608            validate_circle_control_coord(&circle_package.control)?;
609            let expected = circle_package_semantic_prefix(
610                circle_package.circle_id,
611                family,
612                &stream_id,
613                self.seq(),
614                circle_package.package.content_hash,
615            );
616            if circle_package.package.object.slot().logical_key() != format!("{expected}.pkg") {
617                return Err(StoreProtocolError::RelocatedCirclePackage {
618                    circle_id: circle_package.circle_id,
619                    expected,
620                    actual: circle_package
621                        .package
622                        .object
623                        .slot()
624                        .logical_key()
625                        .to_string(),
626                });
627            }
628        }
629        validate_commit_body(self.store_root_hash, &self.body, &self.author_registration)?;
630        if matches!(self.body, StoreCommitBody::Operations(_)) {
631            validate_operation_membership_authority(
632                self.membership_authority.as_ref().ok_or_else(|| {
633                    StoreProtocolError::Malformed(
634                        "operations commit omits membership authority".to_string(),
635                    )
636                })?,
637            )?;
638        }
639        if let StoreCommitBody::AbandonCandidates { manifests } = &self.body {
640            validate_candidate_abandonment(
641                manifests,
642                self.store_root_hash,
643                &self.author_registration,
644                expected_coord,
645                &self.order,
646                author,
647            )?;
648        }
649        if let StoreCommitBody::OwnerPromotionRequest { request } = &self.body {
650            validate_owner_promotion_request_for_commit(
651                request,
652                self.store_root_hash,
653                &self.author_registration,
654                author,
655                &self.membership_state,
656                &self.device_state,
657            )?;
658        }
659        self.verified_candidate_objects()?;
660        validate_commit_order(&self.order)?;
661        validate_commit_predecessor_states(
662            &self.order,
663            &self.membership_state,
664            &self.device_state,
665        )?;
666        if let Some(authority) = self.membership_authority.as_ref() {
667            validate_membership_authority(authority)?;
668        }
669        validate_parsed_control(self, author)?;
670        self.verify_by(&author.device_signing_pubkey)?;
671        Ok(())
672    }
673
674    pub fn verify_store_package(&self, package_bytes: &[u8]) -> Result<(), StoreProtocolError> {
675        let package = self
676            .store_package()
677            .ok_or(StoreProtocolError::MissingStorePackage)?;
678        verify_package_ref(package, package_bytes)
679    }
680
681    pub fn verify_circle_package(
682        &self,
683        circle_id: CircleId,
684        package_bytes: &[u8],
685    ) -> Result<(), StoreProtocolError> {
686        let package = self
687            .circle_packages()
688            .iter()
689            .find(|package| package.circle_id == circle_id)
690            .ok_or(StoreProtocolError::MissingCirclePackage(circle_id))?;
691        verify_package_ref(&package.package, package_bytes)
692    }
693
694    #[cfg(any(test, feature = "test-utils"))]
695    pub fn operations_membership_authority(
696        &self,
697    ) -> Result<StoreOperationMembershipAuthority, StoreProtocolError> {
698        if self.operations().is_none() {
699            return Err(StoreProtocolError::Malformed(
700                "Store commit does not carry operations".to_string(),
701            ));
702        }
703        let predecessor = self.membership_authority.clone().ok_or_else(|| {
704            StoreProtocolError::Malformed(
705                "operations commit omits its predecessor membership grant authority".to_string(),
706            )
707        })?;
708        validate_operation_membership_authority(&predecessor)?;
709        Ok(StoreOperationMembershipAuthority { predecessor })
710    }
711}