Skip to main content

coven_protocol/
owner_promotion_journal.rs

1//! The durable Owner-promotion journal: the request, acceptance, and
2//! finalization values one promotion binds, validated against the exact
3//! target and identities they retain.
4
5use crate::store_commit::ObjectHash;
6
7/// A promotion journal whose recorded state contradicts the request, target,
8/// or acceptance it retains. Workflow errors wrap it at the operation
9/// boundary.
10#[derive(Debug, thiserror::Error)]
11pub enum OwnerPromotionJournalError {
12    #[error("Owner promotion journal: {0}")]
13    Invariant(String),
14    #[error("Owner promotion journal JSON: {0}")]
15    Json(#[from] serde_json::Error),
16    #[error("Owner promotion journal prepared commit: {0}")]
17    PreparedCommit(#[from] crate::prepared_commit::PreparedCommitError),
18    #[error("Owner promotion journal candidate: {0}")]
19    Candidate(#[from] crate::remote_object::RemoteObjectRecordError),
20}
21
22const TARGET_PREFIX: &str = "owner_promotion_target/";
23
24pub fn target_key(
25    target: &StoreDeviceRegistrationRef,
26) -> Result<String, OwnerPromotionJournalError> {
27    let bytes = serde_json::to_vec(target)?;
28    Ok(format!("{TARGET_PREFIX}{}", ObjectHash::digest(&bytes)))
29}
30
31use serde::{Deserialize, Serialize};
32
33use crate::circle_control::StoreMembershipStateRef;
34use crate::membership::StoreMembershipRoleGrant;
35use crate::membership_mutation::{PreparedMembershipPublication, PreparedMembershipTransition};
36use crate::prepared_commit::PreparedStoreOperationCommit;
37use crate::store_commit::{
38    membership_head_slot_prefix, owner_recovery_semantic_prefix, GrantStreamAnchor,
39    OwnerPromotionAcceptance, OwnerPromotionAnchors, OwnerPromotionFinalization, OwnerPromotionId,
40    OwnerPromotionRequest, OwnerPromotionRequestActivation, OwnerPromotionStaleReason,
41    StoreDeviceRegistrationRef, StreamActivation, StreamAnchorDomain,
42};
43use crate::wrapped_store_key::PreparedWrappedStoreKey;
44
45#[cfg_attr(any(test, feature = "test-utils"), derive(Clone))]
46#[derive(Debug, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct OwnerPromotionJournal {
49    pub promotion_id: OwnerPromotionId,
50    pub target: StoreDeviceRegistrationRef,
51    pub state: OwnerPromotionJournalState,
52}
53
54#[cfg_attr(any(test, feature = "test-utils"), derive(Clone))]
55#[derive(Debug, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case", deny_unknown_fields)]
57pub enum OwnerPromotionJournalState {
58    Allocated,
59    RequestPrepared {
60        request: OwnerPromotionRequest,
61        candidate: Box<PreparedStoreOperationCommit>,
62    },
63    AwaitingAcceptance {
64        request: OwnerPromotionRequest,
65        activation: OwnerPromotionRequestActivation,
66    },
67    AcceptanceReady {
68        acceptance: OwnerPromotionAcceptance,
69    },
70    MergeMembershipPrepared {
71        acceptance: OwnerPromotionAcceptance,
72        wrapped_key: PreparedWrappedStoreKey,
73        transition: Box<PreparedMembershipTransition>,
74    },
75    MergeHeadPrepared {
76        acceptance: OwnerPromotionAcceptance,
77        wrapped_key: PreparedWrappedStoreKey,
78        transition: Box<PreparedMembershipTransition>,
79        publication: Box<PreparedMembershipPublication>,
80        candidate: Box<PreparedStoreOperationCommit>,
81    },
82    Finalized {
83        acceptance: OwnerPromotionAcceptance,
84        membership: StoreMembershipStateRef,
85        receipt: Box<OwnerPromotionFinalizationReceipt>,
86    },
87    Nonactivated {
88        request: OwnerPromotionRequest,
89        nonactivation: crate::remote_object::CandidateNonactivation,
90    },
91    Stale {
92        acceptance: OwnerPromotionAcceptance,
93        reason: OwnerPromotionStaleReason,
94        evidence: Box<OwnerPromotionStaleEvidence>,
95    },
96}
97
98#[cfg_attr(any(test, feature = "test-utils"), derive(Clone))]
99#[derive(Debug, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct OwnerPromotionFinalizationReceipt {
102    pub candidate: Box<PreparedStoreOperationCommit>,
103    pub publication: Box<PreparedMembershipPublication>,
104}
105
106#[cfg_attr(any(test, feature = "test-utils"), derive(Clone))]
107#[derive(Debug, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case", deny_unknown_fields)]
109pub enum OwnerPromotionStaleEvidence {
110    BeforePublication,
111    Candidate {
112        nonactivation: crate::remote_object::CandidateNonactivation,
113        receipt: Box<OwnerPromotionFinalizationReceipt>,
114        /// Every object the lost candidate owns, so the deletion this state owes
115        /// is stated rather than re-derived: whoever next reads this journal —
116        /// the retry of this attempt, or the attempt that replaces it — finishes
117        /// an interrupted cleanup from the list the transition validated.
118        published: Vec<crate::objects::ExactObjectRef>,
119    },
120}
121
122pub fn owner_promotion_published_objects(
123    candidate: &PreparedStoreOperationCommit,
124    transition: &PreparedMembershipTransition,
125    publication: &PreparedMembershipPublication,
126    wrapped_key: &PreparedWrappedStoreKey,
127) -> Result<Vec<crate::objects::ExactObjectRef>, OwnerPromotionJournalError> {
128    Ok(candidate
129        .merge_owner_promotion_remote_objects(transition, publication, wrapped_key)?
130        .iter()
131        .map(|remote| remote.record().object().clone())
132        .collect())
133}
134
135fn prepared_candidate_is_exact_request(
136    candidate: &PreparedStoreOperationCommit,
137    request: &OwnerPromotionRequest,
138) -> bool {
139    candidate.validate_closed_shape().is_ok()
140        && candidate.commit.owner_promotion_request() == Some(request)
141        && candidate.commit.author_registration == request.promoter_registration
142        && candidate.commit.membership_state == request.predecessor_membership
143        && candidate.commit.device_state == request.predecessor_devices
144}
145
146fn same_prepared_candidate(
147    previous: &PreparedStoreOperationCommit,
148    next: &PreparedStoreOperationCommit,
149) -> bool {
150    previous.reference == next.reference && previous.commit.to_bytes() == next.commit.to_bytes()
151}
152
153fn request_activation_matches_candidate(
154    request: &OwnerPromotionRequest,
155    candidate: &PreparedStoreOperationCommit,
156    activation: &OwnerPromotionRequestActivation,
157) -> bool {
158    prepared_candidate_is_exact_request(candidate, request)
159        && activation.commit == candidate.reference
160        && candidate.head_ref() == activation.head
161}
162
163fn nonactivation_matches_candidate(
164    candidate: &PreparedStoreOperationCommit,
165    nonactivation: &crate::remote_object::CandidateNonactivation,
166) -> bool {
167    if nonactivation.validate().is_err() {
168        return false;
169    }
170    let Ok(reference) = nonactivation.reference() else {
171        return false;
172    };
173    reference == candidate.reference
174        && nonactivation.candidate().canonical_signed_bytes == candidate.commit.to_bytes()
175}
176
177fn nonactivation_commit(
178    nonactivation: &crate::remote_object::CandidateNonactivation,
179) -> Result<crate::store_commit::StoreBatchCommit, OwnerPromotionJournalError> {
180    nonactivation.validate()?;
181    serde_json::from_slice(&nonactivation.candidate().canonical_signed_bytes)
182        .map_err(OwnerPromotionJournalError::from)
183}
184
185fn nonactivation_matches_request(
186    nonactivation: &crate::remote_object::CandidateNonactivation,
187    request: &OwnerPromotionRequest,
188) -> bool {
189    nonactivation_commit(nonactivation).is_ok_and(|commit| {
190        commit.owner_promotion_request() == Some(request)
191            && commit.author_registration == request.promoter_registration
192            && commit.membership_state == request.predecessor_membership
193            && commit.device_state == request.predecessor_devices
194    })
195}
196
197fn wrapped_key_matches_acceptance(
198    wrapped_key: &PreparedWrappedStoreKey,
199    acceptance: &OwnerPromotionAcceptance,
200) -> bool {
201    wrapped_key.validate().is_ok()
202        && wrapped_key.reference.recipient_pubkey == acceptance.request.member_pubkey
203}
204
205fn transition_matches_acceptance(
206    transition: &PreparedMembershipTransition,
207    wrapped_key: &crate::wrapped_store_key::WrappedStoreKeyRef,
208    acceptance: &OwnerPromotionAcceptance,
209) -> bool {
210    let OwnerPromotionFinalization {
211        author_stream,
212        seq,
213        previous_hash,
214    } = &acceptance.request.finalization;
215    let entry = &transition.entry;
216    let expected_replacements =
217        std::collections::BTreeSet::from([acceptance.request.member_grant.clone()]);
218    transition.validate().is_ok()
219        && transition.transition.body.author_registration
220            == acceptance.request.promoter_registration
221        && transition.transition.body.resolutions == entry.resolution_dependencies
222        && entry.author_owner_grant == acceptance.request.promoter_owner_grant
223        && entry.stream_id == *author_stream
224        && entry.seq == *seq
225        && entry.previous_hash == *previous_hash
226        && matches!(
227            &entry.change,
228            crate::membership::MembershipChange::SetMember {
229                user_pubkey,
230                role: StoreMembershipRoleGrant::Owner {
231                    recovery: crate::membership::OwnerRecoveryAnchorRef::Promotion {
232                        acceptance: entry_acceptance,
233                    },
234                },
235                grant_id,
236                membership: Some(membership),
237                replaces,
238                wrapped_key: entry_wrapped_key,
239                ..
240            } if user_pubkey == &acceptance.request.member_pubkey
241                && entry_acceptance.as_ref() == acceptance
242                && grant_id == &acceptance.request.intended_owner_grant
243                && membership == &acceptance.anchors.membership
244                && replaces == &expected_replacements
245                && entry_wrapped_key == wrapped_key
246        )
247}
248
249fn merge_candidate_matches_finalization(
250    candidate: &PreparedStoreOperationCommit,
251    transition: &PreparedMembershipTransition,
252    acceptance: &OwnerPromotionAcceptance,
253) -> bool {
254    let OwnerPromotionAnchors {
255        membership,
256        recovery,
257    } = &acceptance.anchors;
258    let mut expected_activations = vec![
259        StreamActivation::grant_authorized(
260            acceptance.request.store_root_hash,
261            acceptance.request.member_registration.clone(),
262            acceptance.request.intended_owner_grant.clone(),
263            membership.clone(),
264        ),
265        StreamActivation::grant_authorized(
266            acceptance.request.store_root_hash,
267            acceptance.request.member_registration.clone(),
268            acceptance.request.intended_owner_grant.clone(),
269            recovery.clone(),
270        ),
271    ];
272    expected_activations.sort();
273    let Some(operations) = candidate.commit.operations() else {
274        return false;
275    };
276    candidate.validate_closed_shape().is_ok()
277        && candidate.commit.author_registration == acceptance.request.promoter_registration
278        && candidate.commit.control()
279            == Some(&crate::store_commit::StoreControl {
280                transition: transition.transition.clone(),
281            })
282        && operations.acknowledgement.is_none()
283        && operations.device_join_attempt_decisions.is_empty()
284        && operations.provider_access_grants.is_empty()
285        && operations.device_registrations.is_empty()
286        && operations.device_exclusion_proposals.is_empty()
287        && operations.device_exclusion_outcomes.is_empty()
288        && operations.stream_activations == expected_activations
289        && operations.circle_controls.is_empty()
290        && operations.store_package.is_none()
291        && operations.circle_packages.is_empty()
292}
293
294fn finalization_receipt_matches_acceptance(
295    receipt: &OwnerPromotionFinalizationReceipt,
296    acceptance: &OwnerPromotionAcceptance,
297) -> bool {
298    {
299        let OwnerPromotionFinalizationReceipt {
300            candidate,
301            publication,
302        } = receipt;
303        let Some(crate::store_commit::StoreControl { transition }) = candidate.commit.control()
304        else {
305            return false;
306        };
307        let crate::membership::MembershipChange::SetMember { wrapped_key, .. } =
308            &publication.entry.change
309        else {
310            return false;
311        };
312        let prepared_transition = PreparedMembershipTransition {
313            entry: publication.entry.clone(),
314            entry_ref: publication.entry_ref.clone(),
315            transition: transition.clone(),
316        };
317        publication.validate().is_ok()
318            && transition_matches_acceptance(&prepared_transition, wrapped_key, acceptance)
319            && merge_candidate_matches_finalization(candidate, &prepared_transition, acceptance)
320            && prepared_transition
321                .transition
322                .matches_head(&publication.head, &publication.head_ref)
323            && matches!(
324                &publication.head.activation,
325                crate::membership::MembershipHeadActivation::StoreCommit { commit }
326                    if commit == &candidate.reference
327            )
328    }
329}
330
331fn finalization_receipt_candidate(
332    receipt: &OwnerPromotionFinalizationReceipt,
333) -> &PreparedStoreOperationCommit {
334    &receipt.candidate
335}
336
337fn finalization_receipt_matches_membership(
338    receipt: &OwnerPromotionFinalizationReceipt,
339    membership: &StoreMembershipStateRef,
340) -> bool {
341    membership
342        .heads
343        .binary_search(&receipt.publication.head_ref)
344        .is_ok()
345}
346
347fn stale_candidate_evidence_matches(
348    nonactivation: &crate::remote_object::CandidateNonactivation,
349    receipt: &OwnerPromotionFinalizationReceipt,
350    acceptance: &OwnerPromotionAcceptance,
351) -> bool {
352    let candidate = finalization_receipt_candidate(receipt);
353    finalization_receipt_matches_acceptance(receipt, acceptance)
354        && nonactivation_matches_candidate(candidate, nonactivation)
355}
356
357fn receipt_matches_merge_preparation(
358    receipt: &OwnerPromotionFinalizationReceipt,
359    candidate: &PreparedStoreOperationCommit,
360    publication: &PreparedMembershipPublication,
361) -> bool {
362    matches!(
363        receipt,
364        OwnerPromotionFinalizationReceipt {
365            candidate: receipt_candidate,
366            publication: receipt_publication,
367        } if same_prepared_candidate(candidate, receipt_candidate)
368            && publication.entry == receipt_publication.entry
369            && publication.entry_ref == receipt_publication.entry_ref
370            && publication.head == receipt_publication.head
371            && publication.head_ref == receipt_publication.head_ref
372    )
373}
374
375impl OwnerPromotionJournal {
376    fn request_has_closed_shape(&self, request: &OwnerPromotionRequest) -> bool {
377        request.require_version().is_ok()
378            && request.promotion_id == self.promotion_id
379            && request.member_registration == self.target
380            && !request.member_pubkey.is_empty()
381            && request.intended_owner_grant
382                == crate::store_commit::derive_owner_promotion_grant(
383                    request.store_root_hash,
384                    request.promotion_id,
385                    &request.member_pubkey,
386                )
387            && request.finalization.seq != 0
388    }
389
390    fn acceptance_has_closed_shape(&self, acceptance: &OwnerPromotionAcceptance) -> bool {
391        let request = acceptance.request.as_ref();
392        if !self.request_has_closed_shape(request)
393            || !matches!(
394                acceptance.anchors.recovery(),
395                GrantStreamAnchor::OwnerRecovery { .. }
396            )
397        {
398            return false;
399        }
400        let GrantStreamAnchor::StoreMembership { first_slot } = &acceptance.anchors.membership
401        else {
402            return false;
403        };
404        let GrantStreamAnchor::OwnerRecovery {
405            first_slot: recovery_slot,
406        } = &acceptance.anchors.recovery
407        else {
408            return false;
409        };
410        let membership_stream = StreamActivation::grant_authorized_stream_id(
411            request.store_root_hash,
412            &request.member_registration,
413            &request.intended_owner_grant,
414            StreamAnchorDomain::StoreMembership,
415        );
416        first_slot.logical_key()
417            == format!(
418                "{}.json",
419                membership_head_slot_prefix(
420                    &request.member_pubkey,
421                    &request.intended_owner_grant,
422                    membership_stream,
423                    1,
424                )
425            )
426            && recovery_slot.logical_key()
427                == format!(
428                    "{}.json",
429                    owner_recovery_semantic_prefix(
430                        &request.member_pubkey,
431                        request.intended_owner_grant.clone(),
432                        1,
433                    )
434                )
435    }
436
437    pub fn promotion_id(&self) -> OwnerPromotionId {
438        self.promotion_id
439    }
440
441    pub fn target_state_key(&self) -> Result<String, OwnerPromotionJournalError> {
442        target_key(&self.target)
443    }
444
445    pub fn validate_id(
446        &self,
447        expected: OwnerPromotionId,
448    ) -> Result<(), OwnerPromotionJournalError> {
449        self.validate_contents()?;
450        if self.promotion_id != expected {
451            return Err(OwnerPromotionJournalError::Invariant(
452                "promotion journal is stored under another identity".to_string(),
453            ));
454        }
455        Ok(())
456    }
457
458    pub fn validate_target_key(&self, expected: &str) -> Result<(), OwnerPromotionJournalError> {
459        self.validate_contents()?;
460        if self.target_state_key()? != expected {
461            return Err(OwnerPromotionJournalError::Invariant(
462                "promotion journal is stored under another target".to_string(),
463            ));
464        }
465        Ok(())
466    }
467
468    pub fn into_predecessor(
469        self,
470    ) -> Result<
471        (OwnerPromotionJournalPredecessor, OwnerPromotionJournalState),
472        OwnerPromotionJournalError,
473    > {
474        self.validate_contents()?;
475        let previous_value = serde_json::to_string(&self)?;
476        let Self {
477            promotion_id,
478            target,
479            state,
480        } = self;
481        Ok((
482            OwnerPromotionJournalPredecessor {
483                promotion_id,
484                target,
485                previous_value,
486            },
487            state,
488        ))
489    }
490
491    fn validate_contents(&self) -> Result<(), OwnerPromotionJournalError> {
492        let valid = match &self.state {
493            OwnerPromotionJournalState::Allocated => true,
494            OwnerPromotionJournalState::RequestPrepared { request, candidate } => {
495                self.request_has_closed_shape(request)
496                    && prepared_candidate_is_exact_request(candidate, request)
497            }
498            OwnerPromotionJournalState::AwaitingAcceptance {
499                request,
500                activation,
501            } => {
502                self.request_has_closed_shape(request) && activation.commit.coord.validate().is_ok()
503            }
504            OwnerPromotionJournalState::AcceptanceReady { acceptance } => {
505                self.acceptance_has_closed_shape(acceptance)
506            }
507            OwnerPromotionJournalState::MergeMembershipPrepared {
508                acceptance,
509                wrapped_key,
510                transition,
511            } => {
512                self.acceptance_has_closed_shape(acceptance)
513                    && wrapped_key_matches_acceptance(wrapped_key, acceptance)
514                    && transition_matches_acceptance(transition, &wrapped_key.reference, acceptance)
515            }
516            OwnerPromotionJournalState::MergeHeadPrepared {
517                acceptance,
518                wrapped_key,
519                transition,
520                publication,
521                candidate,
522            } => {
523                self.acceptance_has_closed_shape(acceptance)
524                    && wrapped_key_matches_acceptance(wrapped_key, acceptance)
525                    && transition_matches_acceptance(transition, &wrapped_key.reference, acceptance)
526                    && merge_candidate_matches_finalization(candidate, transition, acceptance)
527                    && publication.validate().is_ok()
528                    && transition.entry == publication.entry
529                    && transition.entry_ref == publication.entry_ref
530                    && transition
531                        .transition
532                        .matches_head(&publication.head, &publication.head_ref)
533                    && matches!(
534                        &publication.head.activation,
535                        crate::membership::MembershipHeadActivation::StoreCommit { commit }
536                            if commit == &candidate.reference
537                    )
538            }
539            OwnerPromotionJournalState::Finalized {
540                acceptance,
541                membership,
542                receipt,
543            } => {
544                self.acceptance_has_closed_shape(acceptance)
545                    && finalization_receipt_matches_acceptance(receipt, acceptance)
546                    && finalization_receipt_matches_membership(receipt, membership)
547            }
548            OwnerPromotionJournalState::Nonactivated {
549                request,
550                nonactivation,
551            } => {
552                self.request_has_closed_shape(request)
553                    && nonactivation_matches_request(nonactivation, request)
554            }
555            OwnerPromotionJournalState::Stale {
556                acceptance,
557                reason,
558                evidence,
559            } => {
560                self.acceptance_has_closed_shape(acceptance)
561                    && match (reason, evidence.as_ref()) {
562                        (
563                            OwnerPromotionStaleReason::MergeFinalizationPointOccupied { winner },
564                            OwnerPromotionStaleEvidence::BeforePublication,
565                        ) => {
566                            winner.coord.author_owner_grant
567                                == acceptance.request.promoter_owner_grant
568                                && winner.coord.stream_id
569                                    == acceptance.request.finalization.author_stream
570                                && winner.coord.seq >= acceptance.request.finalization.seq
571                        }
572                        (
573                            OwnerPromotionStaleReason::MergeActivationRejected,
574                            OwnerPromotionStaleEvidence::Candidate {
575                                nonactivation,
576                                receipt,
577                                ..
578                            },
579                        ) => stale_candidate_evidence_matches(nonactivation, receipt, acceptance),
580                        _ => false,
581                    }
582            }
583        };
584        if !valid {
585            return Err(OwnerPromotionJournalError::Invariant(
586                "promotion journal state violates its closed protocol invariants".to_string(),
587            ));
588        }
589        Ok(())
590    }
591
592    pub fn validate_begin(&self) -> Result<(), OwnerPromotionJournalError> {
593        self.validate_contents()?;
594        if !matches!(self.state, OwnerPromotionJournalState::Allocated) {
595            return Err(OwnerPromotionJournalError::Invariant(
596                "promotion journal begins in a non-initial state".to_string(),
597            ));
598        }
599        Ok(())
600    }
601
602    pub fn validate_acceptance_begin(&self) -> Result<(), OwnerPromotionJournalError> {
603        self.validate_contents()?;
604        if !matches!(
605            self.state,
606            OwnerPromotionJournalState::AcceptanceReady { .. }
607        ) {
608            return Err(OwnerPromotionJournalError::Invariant(
609                "candidate promotion journal must begin with its signed acceptance".to_string(),
610            ));
611        }
612        Ok(())
613    }
614
615    pub fn validate_transition(
616        &self,
617        next: &OwnerPromotionJournal,
618    ) -> Result<(), OwnerPromotionJournalError> {
619        self.validate_contents()?;
620        next.validate_contents()?;
621        if self.promotion_id != next.promotion_id || self.target != next.target {
622            return Err(OwnerPromotionJournalError::Invariant(
623                "promotion journal transition changes its identity".to_string(),
624            ));
625        }
626        let valid = match (&self.state, &next.state) {
627            (
628                OwnerPromotionJournalState::Allocated,
629                OwnerPromotionJournalState::RequestPrepared { request, candidate },
630            ) => prepared_candidate_is_exact_request(candidate, request),
631            (
632                OwnerPromotionJournalState::RequestPrepared { request, candidate },
633                OwnerPromotionJournalState::RequestPrepared {
634                    request: successor,
635                    candidate: successor_candidate,
636                },
637            ) => {
638                request == successor
639                    && same_prepared_candidate(candidate, successor_candidate)
640                    && prepared_candidate_is_exact_request(successor_candidate, successor)
641            }
642            (
643                OwnerPromotionJournalState::RequestPrepared { request, candidate },
644                OwnerPromotionJournalState::AwaitingAcceptance {
645                    request: successor,
646                    activation,
647                },
648            ) => {
649                request == successor
650                    && request_activation_matches_candidate(request, candidate, activation)
651            }
652            (
653                OwnerPromotionJournalState::RequestPrepared { request, candidate },
654                OwnerPromotionJournalState::Nonactivated {
655                    request: successor,
656                    nonactivation,
657                },
658            ) => request == successor && nonactivation_matches_candidate(candidate, nonactivation),
659            (
660                OwnerPromotionJournalState::AwaitingAcceptance {
661                    request,
662                    activation,
663                },
664                OwnerPromotionJournalState::AcceptanceReady { acceptance },
665            ) => request == acceptance.request.as_ref() && activation == &acceptance.activation,
666            (
667                OwnerPromotionJournalState::AcceptanceReady { acceptance },
668                OwnerPromotionJournalState::MergeMembershipPrepared {
669                    acceptance: successor,
670                    ..
671                },
672            ) => acceptance == successor,
673            (
674                OwnerPromotionJournalState::AcceptanceReady { acceptance },
675                OwnerPromotionJournalState::Stale {
676                    acceptance: successor,
677                    reason,
678                    evidence,
679                },
680            ) => {
681                acceptance == successor
682                    && matches!(
683                        evidence.as_ref(),
684                        OwnerPromotionStaleEvidence::BeforePublication
685                    )
686                    && matches!(
687                        reason,
688                        OwnerPromotionStaleReason::MergeFinalizationPointOccupied { .. }
689                    )
690            }
691            (
692                OwnerPromotionJournalState::MergeMembershipPrepared {
693                    acceptance,
694                    wrapped_key,
695                    transition,
696                },
697                OwnerPromotionJournalState::MergeHeadPrepared {
698                    acceptance: successor,
699                    wrapped_key: successor_key,
700                    transition: successor_transition,
701                    publication,
702                    candidate,
703                },
704            ) => {
705                acceptance == successor
706                    && wrapped_key.reference == successor_key.reference
707                    && transition.entry == publication.entry
708                    && transition.entry_ref == publication.entry_ref
709                    && transition.entry_ref == successor_transition.entry_ref
710                    && transition.transition == successor_transition.transition
711                    && merge_candidate_matches_finalization(candidate, transition, acceptance)
712                    && transition
713                        .transition
714                        .matches_head(&publication.head, &publication.head_ref)
715                    && matches!(
716                        &publication.head.activation,
717                        crate::membership::MembershipHeadActivation::StoreCommit { commit }
718                            if commit == &candidate.reference
719                    )
720            }
721            (
722                OwnerPromotionJournalState::MergeHeadPrepared {
723                    acceptance,
724                    wrapped_key,
725                    transition,
726                    publication,
727                    candidate,
728                },
729                OwnerPromotionJournalState::MergeHeadPrepared {
730                    acceptance: successor,
731                    wrapped_key: successor_key,
732                    transition: successor_transition,
733                    publication: successor_publication,
734                    candidate: successor_candidate,
735                },
736            ) => {
737                acceptance == successor
738                    && wrapped_key.reference == successor_key.reference
739                    && transition.entry_ref == successor_transition.entry_ref
740                    && transition.transition == successor_transition.transition
741                    && publication.entry_ref == successor_publication.entry_ref
742                    && publication.head_ref == successor_publication.head_ref
743                    && same_prepared_candidate(candidate, successor_candidate)
744                    && transition.entry == publication.entry
745                    && transition.entry == successor_publication.entry
746                    && transition
747                        .transition
748                        .matches_head(&successor_publication.head, &successor_publication.head_ref)
749                    && matches!(
750                        &successor_publication.head.activation,
751                        crate::membership::MembershipHeadActivation::StoreCommit { commit }
752                            if commit == &successor_candidate.reference
753                    )
754            }
755            (
756                OwnerPromotionJournalState::MergeHeadPrepared {
757                    acceptance,
758                    publication,
759                    candidate,
760                    ..
761                },
762                OwnerPromotionJournalState::Finalized {
763                    acceptance: successor,
764                    membership,
765                    receipt,
766                },
767            ) => {
768                acceptance == successor
769                    && receipt_matches_merge_preparation(receipt, candidate, publication)
770                    && membership
771                        .heads
772                        .binary_search(&publication.head_ref)
773                        .is_ok()
774                    && matches!(
775                        &publication.head.activation,
776                        crate::membership::MembershipHeadActivation::StoreCommit { commit }
777                            if commit == &candidate.reference
778                    )
779            }
780            (
781                OwnerPromotionJournalState::MergeHeadPrepared {
782                    acceptance,
783                    wrapped_key,
784                    transition,
785                    publication,
786                    candidate,
787                },
788                OwnerPromotionJournalState::Stale {
789                    acceptance: successor,
790                    reason,
791                    evidence,
792                },
793            ) => {
794                acceptance == successor
795                    && matches!(reason, OwnerPromotionStaleReason::MergeActivationRejected)
796                    && matches!(
797                        evidence.as_ref(),
798                        OwnerPromotionStaleEvidence::Candidate {
799                            nonactivation,
800                            receipt,
801                            published,
802                        } if nonactivation_matches_candidate(candidate, nonactivation)
803                            && receipt_matches_merge_preparation(
804                                receipt,
805                                candidate,
806                                publication,
807                            )
808                            && owner_promotion_published_objects(
809                                candidate,
810                                transition,
811                                publication,
812                                wrapped_key,
813                            )
814                            .is_ok_and(|expected| expected == *published)
815                    )
816            }
817            _ => false,
818        };
819        if !valid {
820            return Err(OwnerPromotionJournalError::Invariant(
821                "promotion journal transition skips or reverses protocol state".to_string(),
822            ));
823        }
824        Ok(())
825    }
826
827    pub fn validate_failed_attempt_replacement(
828        &self,
829        replacement: &OwnerPromotionJournal,
830    ) -> Result<(), OwnerPromotionJournalError> {
831        self.validate_contents()?;
832        replacement.validate_begin()?;
833        if self.target != replacement.target || self.promotion_id == replacement.promotion_id {
834            return Err(OwnerPromotionJournalError::Invariant(
835                "promotion retry must retain its target and use a fresh identity".to_string(),
836            ));
837        }
838        if !matches!(
839            self.state,
840            OwnerPromotionJournalState::Nonactivated { .. }
841                | OwnerPromotionJournalState::Stale { .. }
842        ) {
843            return Err(OwnerPromotionJournalError::Invariant(
844                "only a failed promotion attempt can be replaced".to_string(),
845            ));
846        }
847        Ok(())
848    }
849}
850
851pub struct OwnerPromotionJournalPredecessor {
852    pub promotion_id: OwnerPromotionId,
853    pub target: StoreDeviceRegistrationRef,
854    previous_value: String,
855}
856
857impl OwnerPromotionJournalPredecessor {
858    pub fn transition_to(
859        &self,
860        next: &OwnerPromotionJournal,
861        remote_objects: Vec<crate::remote_object::ClosedRemoteObject>,
862    ) -> Result<OwnerPromotionJournalTransition, OwnerPromotionJournalError> {
863        let previous: OwnerPromotionJournal = serde_json::from_str(&self.previous_value)?;
864        previous.validate_transition(next)?;
865        let next_value = serde_json::to_string(next)?;
866        Ok(OwnerPromotionJournalTransition {
867            journal_key: format!("owner_promotion/{}", self.promotion_id),
868            target_key: target_key(&self.target)?,
869            previous_value: self.previous_value.clone(),
870            next_value,
871            remote_objects,
872        })
873    }
874}
875
876pub struct OwnerPromotionJournalTransition {
877    journal_key: String,
878    target_key: String,
879    previous_value: String,
880    next_value: String,
881    remote_objects: Vec<crate::remote_object::ClosedRemoteObject>,
882}
883
884impl OwnerPromotionJournalTransition {
885    pub fn into_values(
886        self,
887    ) -> (
888        String,
889        String,
890        String,
891        String,
892        Vec<crate::remote_object::ClosedRemoteObject>,
893    ) {
894        (
895            self.journal_key,
896            self.target_key,
897            self.previous_value,
898            self.next_value,
899            self.remote_objects,
900        )
901    }
902}