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    #[error("Owner promotion journal protocol: {0}")]
21    Protocol(#[from] crate::store_commit::StoreProtocolError),
22}
23
24const TARGET_PREFIX: &str = "owner_promotion_target/";
25
26pub fn target_key(
27    target: &StoreDeviceRegistrationRef,
28) -> Result<String, OwnerPromotionJournalError> {
29    let bytes = serde_json::to_vec(target)?;
30    Ok(format!("{TARGET_PREFIX}{}", ObjectHash::digest(&bytes)))
31}
32
33use serde::{Deserialize, Serialize};
34
35use crate::circle_control::StoreMembershipStateRef;
36use crate::membership::StoreMembershipRoleGrant;
37use crate::membership_mutation::PreparedMembershipPublication;
38use crate::prepared_commit::PreparedStoreOperationCommit;
39use crate::store_commit::{
40    membership_head_slot_prefix, owner_recovery_semantic_prefix, GrantStreamAnchor,
41    OwnerPromotionAcceptance, OwnerPromotionAnchors, OwnerPromotionFinalization, OwnerPromotionId,
42    OwnerPromotionRequest, OwnerPromotionRequestActivation, OwnerPromotionStaleReason,
43    RetainedOwnerPromotionRequestPublication, StoreDeviceRegistrationRef, StreamActivation,
44    StreamAnchorDomain,
45};
46use crate::wrapped_store_key::PreparedWrappedStoreKey;
47
48#[cfg_attr(any(test, feature = "test-utils"), derive(Clone))]
49#[derive(Debug, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct OwnerPromotionJournal {
52    pub promotion_id: OwnerPromotionId,
53    pub target: StoreDeviceRegistrationRef,
54    pub state: OwnerPromotionJournalState,
55}
56
57#[cfg_attr(any(test, feature = "test-utils"), derive(Clone))]
58#[derive(Debug, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case", deny_unknown_fields)]
60pub enum OwnerPromotionJournalState {
61    Allocated,
62    RequestPrepared {
63        request: OwnerPromotionRequest,
64        candidate: Box<PreparedStoreOperationCommit>,
65    },
66    RequestAccepted {
67        request: OwnerPromotionRequest,
68        candidate: Box<PreparedStoreOperationCommit>,
69        publication: RetainedOwnerPromotionRequestPublication,
70    },
71    AwaitingAcceptance {
72        request: OwnerPromotionRequest,
73        activation: OwnerPromotionRequestActivation,
74    },
75    AcceptanceReady {
76        acceptance: OwnerPromotionAcceptance,
77    },
78    MergeHeadPrepared {
79        acceptance: OwnerPromotionAcceptance,
80        wrapped_key: PreparedWrappedStoreKey,
81        candidate: Box<PreparedStoreOperationCommit>,
82    },
83    Finalized {
84        acceptance: OwnerPromotionAcceptance,
85        membership: StoreMembershipStateRef,
86        candidate: Box<PreparedStoreOperationCommit>,
87    },
88    Nonactivated {
89        request: OwnerPromotionRequest,
90        nonactivation: crate::remote_object::CandidateNonactivation,
91    },
92    Stale {
93        acceptance: OwnerPromotionAcceptance,
94        reason: OwnerPromotionStaleReason,
95        evidence: Box<OwnerPromotionStaleEvidence>,
96    },
97}
98
99#[cfg_attr(any(test, feature = "test-utils"), derive(Clone))]
100#[derive(Debug, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case", deny_unknown_fields)]
102pub enum OwnerPromotionStaleEvidence {
103    BeforePublication,
104    Candidate {
105        nonactivation: crate::remote_object::CandidateNonactivation,
106        candidate: Box<PreparedStoreOperationCommit>,
107    },
108}
109
110fn prepared_candidate_is_exact_request(
111    candidate: &PreparedStoreOperationCommit,
112    request: &OwnerPromotionRequest,
113) -> bool {
114    candidate.validate_closed_shape().is_ok()
115        && candidate.commit.owner_promotion_request() == Some(request)
116        && candidate.commit.author_registration == request.promoter_registration
117        && candidate.commit.membership_state == request.predecessor_membership
118        && candidate.commit.device_state == request.predecessor_devices
119}
120
121fn same_prepared_candidate(
122    previous: &PreparedStoreOperationCommit,
123    next: &PreparedStoreOperationCommit,
124) -> bool {
125    previous.reference == next.reference && previous.commit.to_bytes() == next.commit.to_bytes()
126}
127
128fn request_activation_matches_candidate(
129    request: &OwnerPromotionRequest,
130    candidate: &PreparedStoreOperationCommit,
131    activation: &OwnerPromotionRequestActivation,
132) -> bool {
133    prepared_candidate_is_exact_request(candidate, request)
134        && activation.commit == candidate.reference
135        && activation.publication.store_root_hash == request.store_root_hash
136}
137
138fn nonactivation_matches_candidate(
139    candidate: &PreparedStoreOperationCommit,
140    nonactivation: &crate::remote_object::CandidateNonactivation,
141) -> bool {
142    if nonactivation.validate().is_err() {
143        return false;
144    }
145    let Ok(reference) = nonactivation.reference() else {
146        return false;
147    };
148    reference == candidate.reference
149        && nonactivation.candidate().canonical_signed_bytes == candidate.commit.to_bytes()
150}
151
152fn nonactivation_commit(
153    nonactivation: &crate::remote_object::CandidateNonactivation,
154) -> Result<crate::store_commit::StoreBatchCommit, OwnerPromotionJournalError> {
155    nonactivation.validate()?;
156    serde_json::from_slice(&nonactivation.candidate().canonical_signed_bytes)
157        .map_err(OwnerPromotionJournalError::from)
158}
159
160fn nonactivation_matches_request(
161    nonactivation: &crate::remote_object::CandidateNonactivation,
162    request: &OwnerPromotionRequest,
163) -> bool {
164    nonactivation_commit(nonactivation).is_ok_and(|commit| {
165        commit.owner_promotion_request() == Some(request)
166            && commit.author_registration == request.promoter_registration
167            && commit.membership_state == request.predecessor_membership
168            && commit.device_state == request.predecessor_devices
169    })
170}
171
172fn wrapped_key_matches_acceptance(
173    wrapped_key: &PreparedWrappedStoreKey,
174    acceptance: &OwnerPromotionAcceptance,
175) -> bool {
176    wrapped_key.validate().is_ok()
177        && wrapped_key.reference.recipient_pubkey == acceptance.request.member_pubkey
178}
179
180fn publication_matches_acceptance(
181    publication: &PreparedMembershipPublication,
182    acceptance: &OwnerPromotionAcceptance,
183) -> bool {
184    let OwnerPromotionFinalization {
185        author_stream,
186        seq,
187        previous_hash,
188    } = &acceptance.request.finalization;
189    let entry = &publication.entry;
190    let expected_replacements =
191        std::collections::BTreeSet::from([acceptance.request.member_grant.clone()]);
192    publication.head.body.author_registration == acceptance.request.promoter_registration
193        && entry.author_owner_grant == acceptance.request.promoter_owner_grant
194        && entry.stream_id == *author_stream
195        && entry.seq == *seq
196        && entry.previous_hash == *previous_hash
197        && matches!(
198            &entry.change,
199            crate::membership::StoreAuthorityChange::SetMember {
200                user_pubkey,
201                role: StoreMembershipRoleGrant::Owner {
202                    recovery: crate::membership::OwnerRecoveryAnchorRef::Promotion {
203                        acceptance: entry_acceptance,
204                    },
205                },
206                grant_id,
207                membership: Some(membership),
208                replaces,
209                ..
210            } if user_pubkey == &acceptance.request.member_pubkey
211                && entry_acceptance.as_ref() == acceptance
212                && grant_id == &acceptance.request.intended_owner_grant
213                && membership == &acceptance.anchors.membership
214                && replaces == &expected_replacements
215        )
216}
217
218fn finalization_publication(
219    candidate: &PreparedStoreOperationCommit,
220    acceptance: &OwnerPromotionAcceptance,
221) -> Result<PreparedMembershipPublication, OwnerPromotionJournalError> {
222    let publication = candidate.prepared_membership_publication()?;
223    let OwnerPromotionAnchors {
224        membership,
225        recovery,
226    } = &acceptance.anchors;
227    let mut expected_activations = vec![
228        StreamActivation::grant_authorized(
229            acceptance.request.store_root_hash,
230            acceptance.request.member_registration.clone(),
231            acceptance.request.intended_owner_grant.clone(),
232            membership.clone(),
233        ),
234        StreamActivation::grant_authorized(
235            acceptance.request.store_root_hash,
236            acceptance.request.member_registration.clone(),
237            acceptance.request.intended_owner_grant.clone(),
238            recovery.clone(),
239        ),
240    ];
241    expected_activations.sort();
242    let expected_operations = crate::store_commit::StoreCommitOperations {
243        acknowledgement: None,
244        circle_acknowledgements: Vec::new(),
245        control: Some(crate::store_commit::StoreControl {
246            transition: publication.transition().transition,
247        }),
248        device_join_attempt_decisions: Vec::new(),
249        provider_access_grants: Vec::new(),
250        device_registrations: Vec::new(),
251        device_exclusion_proposals: Vec::new(),
252        device_exclusion_outcomes: Vec::new(),
253        stream_activations: expected_activations,
254        circle_controls: Vec::new(),
255        store_package: None,
256        circle_packages: Vec::new(),
257    };
258    if !publication_matches_acceptance(&publication, acceptance)
259        || candidate.commit.author_registration != acceptance.request.promoter_registration
260        || candidate.commit.operations() != Some(&expected_operations)
261    {
262        return Err(OwnerPromotionJournalError::Invariant(
263            "promotion candidate differs from its retained acceptance".into(),
264        ));
265    }
266    Ok(publication)
267}
268
269fn same_prepared_membership_candidate(
270    previous: &PreparedStoreOperationCommit,
271    next: &PreparedStoreOperationCommit,
272) -> bool {
273    same_prepared_candidate(previous, next)
274        && match (
275            previous.prepared_membership_publication(),
276            next.prepared_membership_publication(),
277        ) {
278            (Ok(previous), Ok(next)) => previous.head_ref == next.head_ref,
279            _ => false,
280        }
281}
282
283impl OwnerPromotionJournal {
284    fn request_has_closed_shape(&self, request: &OwnerPromotionRequest) -> bool {
285        request.require_version().is_ok()
286            && request.promotion_id == self.promotion_id
287            && request.member_registration == self.target
288            && !request.member_pubkey.is_empty()
289            && request.intended_owner_grant
290                == crate::store_commit::derive_owner_promotion_grant(
291                    request.store_root_hash,
292                    request.promotion_id,
293                    &request.member_pubkey,
294                )
295            && request.finalization.seq != 0
296    }
297
298    fn acceptance_has_closed_shape(&self, acceptance: &OwnerPromotionAcceptance) -> bool {
299        let request = acceptance.request.as_ref();
300        if !self.request_has_closed_shape(request)
301            || !matches!(
302                acceptance.anchors.recovery(),
303                GrantStreamAnchor::OwnerRecovery { .. }
304            )
305        {
306            return false;
307        }
308        let GrantStreamAnchor::StoreMembership { first_slot } = &acceptance.anchors.membership
309        else {
310            return false;
311        };
312        let GrantStreamAnchor::OwnerRecovery {
313            first_slot: recovery_slot,
314        } = &acceptance.anchors.recovery
315        else {
316            return false;
317        };
318        let membership_stream = StreamActivation::grant_authorized_stream_id(
319            request.store_root_hash,
320            &request.member_registration,
321            &request.intended_owner_grant,
322            StreamAnchorDomain::StoreMembership,
323        );
324        first_slot.logical_key()
325            == format!(
326                "{}.json",
327                membership_head_slot_prefix(
328                    &request.member_pubkey,
329                    &request.intended_owner_grant,
330                    membership_stream,
331                    1,
332                )
333            )
334            && recovery_slot.logical_key()
335                == format!(
336                    "{}.json",
337                    owner_recovery_semantic_prefix(
338                        &request.member_pubkey,
339                        request.intended_owner_grant.clone(),
340                        1,
341                    )
342                )
343    }
344
345    pub fn promotion_id(&self) -> OwnerPromotionId {
346        self.promotion_id
347    }
348
349    pub fn target_state_key(&self) -> Result<String, OwnerPromotionJournalError> {
350        target_key(&self.target)
351    }
352
353    pub fn validate_id(
354        &self,
355        expected: OwnerPromotionId,
356    ) -> Result<(), OwnerPromotionJournalError> {
357        self.validate_contents()?;
358        if self.promotion_id != expected {
359            return Err(OwnerPromotionJournalError::Invariant(
360                "promotion journal is stored under another identity".to_string(),
361            ));
362        }
363        Ok(())
364    }
365
366    pub fn validate_target_key(&self, expected: &str) -> Result<(), OwnerPromotionJournalError> {
367        self.validate_contents()?;
368        if self.target_state_key()? != expected {
369            return Err(OwnerPromotionJournalError::Invariant(
370                "promotion journal is stored under another target".to_string(),
371            ));
372        }
373        Ok(())
374    }
375
376    pub fn into_predecessor(
377        self,
378    ) -> Result<
379        (OwnerPromotionJournalPredecessor, OwnerPromotionJournalState),
380        OwnerPromotionJournalError,
381    > {
382        self.validate_contents()?;
383        let previous_value = serde_json::to_string(&self)?;
384        let Self {
385            promotion_id,
386            target,
387            state,
388        } = self;
389        Ok((
390            OwnerPromotionJournalPredecessor {
391                promotion_id,
392                target,
393                previous_value,
394            },
395            state,
396        ))
397    }
398
399    fn validate_contents(&self) -> Result<(), OwnerPromotionJournalError> {
400        let valid = match &self.state {
401            OwnerPromotionJournalState::Allocated => true,
402            OwnerPromotionJournalState::RequestPrepared { request, candidate } => {
403                self.request_has_closed_shape(request)
404                    && prepared_candidate_is_exact_request(candidate, request)
405            }
406            OwnerPromotionJournalState::RequestAccepted {
407                request,
408                candidate,
409                publication,
410            } => {
411                publication.validate_for(&candidate.commit)?;
412                self.request_has_closed_shape(request)
413                    && request_activation_matches_candidate(
414                        request,
415                        candidate,
416                        publication.value.body(),
417                    )
418            }
419            OwnerPromotionJournalState::AwaitingAcceptance {
420                request,
421                activation,
422            } => {
423                self.request_has_closed_shape(request) && activation.commit.coord.validate().is_ok()
424            }
425            OwnerPromotionJournalState::AcceptanceReady { acceptance } => {
426                self.acceptance_has_closed_shape(acceptance)
427            }
428            OwnerPromotionJournalState::MergeHeadPrepared {
429                acceptance,
430                wrapped_key,
431                candidate,
432            } => {
433                let publication = finalization_publication(candidate, acceptance)?;
434                self.acceptance_has_closed_shape(acceptance)
435                    && wrapped_key_matches_acceptance(wrapped_key, acceptance)
436                    && matches!(&publication.entry.change,
437                        crate::membership::StoreAuthorityChange::SetMember { wrapped_key: expected, .. }
438                            if expected == &wrapped_key.reference)
439            }
440            OwnerPromotionJournalState::Finalized {
441                acceptance,
442                membership,
443                candidate,
444            } => {
445                let publication = finalization_publication(candidate, acceptance)?;
446                self.acceptance_has_closed_shape(acceptance)
447                    && membership
448                        .heads
449                        .binary_search(&publication.head_ref)
450                        .is_ok()
451            }
452            OwnerPromotionJournalState::Nonactivated {
453                request,
454                nonactivation,
455            } => {
456                self.request_has_closed_shape(request)
457                    && nonactivation_matches_request(nonactivation, request)
458            }
459            OwnerPromotionJournalState::Stale {
460                acceptance,
461                reason,
462                evidence,
463            } => {
464                self.acceptance_has_closed_shape(acceptance)
465                    && match (reason, evidence.as_ref()) {
466                        (
467                            OwnerPromotionStaleReason::MergeFinalizationPointOccupied { winner },
468                            OwnerPromotionStaleEvidence::BeforePublication,
469                        ) => {
470                            winner.coord.author_owner_grant
471                                == acceptance.request.promoter_owner_grant
472                                && winner.coord.stream_id
473                                    == acceptance.request.finalization.author_stream
474                                && winner.coord.seq >= acceptance.request.finalization.seq
475                        }
476                        (
477                            OwnerPromotionStaleReason::MergeActivationRejected,
478                            OwnerPromotionStaleEvidence::Candidate {
479                                nonactivation,
480                                candidate,
481                            },
482                        ) => {
483                            finalization_publication(candidate, acceptance).is_ok()
484                                && nonactivation_matches_candidate(candidate, nonactivation)
485                        }
486                        _ => false,
487                    }
488            }
489        };
490        if !valid {
491            return Err(OwnerPromotionJournalError::Invariant(
492                "promotion journal state violates its closed protocol invariants".to_string(),
493            ));
494        }
495        Ok(())
496    }
497
498    pub fn validate_begin(&self) -> Result<(), OwnerPromotionJournalError> {
499        self.validate_contents()?;
500        if !matches!(self.state, OwnerPromotionJournalState::Allocated) {
501            return Err(OwnerPromotionJournalError::Invariant(
502                "promotion journal begins in a non-initial state".to_string(),
503            ));
504        }
505        Ok(())
506    }
507
508    pub fn validate_acceptance_begin(&self) -> Result<(), OwnerPromotionJournalError> {
509        self.validate_contents()?;
510        if !matches!(
511            self.state,
512            OwnerPromotionJournalState::AcceptanceReady { .. }
513        ) {
514            return Err(OwnerPromotionJournalError::Invariant(
515                "candidate promotion journal must begin with its signed acceptance".to_string(),
516            ));
517        }
518        Ok(())
519    }
520
521    pub fn validate_transition(
522        &self,
523        next: &OwnerPromotionJournal,
524    ) -> Result<(), OwnerPromotionJournalError> {
525        self.validate_contents()?;
526        next.validate_contents()?;
527        if self.promotion_id != next.promotion_id || self.target != next.target {
528            return Err(OwnerPromotionJournalError::Invariant(
529                "promotion journal transition changes its identity".to_string(),
530            ));
531        }
532        let valid = match (&self.state, &next.state) {
533            (
534                OwnerPromotionJournalState::Allocated,
535                OwnerPromotionJournalState::RequestPrepared { request, candidate },
536            ) => prepared_candidate_is_exact_request(candidate, request),
537            (
538                OwnerPromotionJournalState::RequestPrepared { request, candidate },
539                OwnerPromotionJournalState::RequestPrepared {
540                    request: successor,
541                    candidate: successor_candidate,
542                },
543            ) => {
544                request == successor
545                    && same_prepared_candidate(candidate, successor_candidate)
546                    && prepared_candidate_is_exact_request(successor_candidate, successor)
547            }
548            (
549                OwnerPromotionJournalState::RequestPrepared { request, candidate },
550                OwnerPromotionJournalState::RequestAccepted {
551                    request: successor,
552                    candidate: successor_candidate,
553                    publication,
554                },
555            ) => {
556                request == successor
557                    && same_prepared_candidate(candidate, successor_candidate)
558                    && request_activation_matches_candidate(
559                        request,
560                        candidate,
561                        publication.value.body(),
562                    )
563            }
564            (
565                OwnerPromotionJournalState::RequestAccepted {
566                    request,
567                    publication,
568                    ..
569                },
570                OwnerPromotionJournalState::AwaitingAcceptance {
571                    request: successor,
572                    activation,
573                },
574            ) => request == successor && publication.value.body() == activation,
575            (
576                OwnerPromotionJournalState::RequestPrepared { request, candidate },
577                OwnerPromotionJournalState::Nonactivated {
578                    request: successor,
579                    nonactivation,
580                },
581            ) => request == successor && nonactivation_matches_candidate(candidate, nonactivation),
582            (
583                OwnerPromotionJournalState::AwaitingAcceptance {
584                    request,
585                    activation,
586                },
587                OwnerPromotionJournalState::AcceptanceReady { acceptance },
588            ) => request == acceptance.request.as_ref() && activation == &acceptance.activation,
589            (
590                OwnerPromotionJournalState::AcceptanceReady { acceptance },
591                OwnerPromotionJournalState::MergeHeadPrepared {
592                    acceptance: successor,
593                    ..
594                },
595            ) => acceptance == successor,
596            (
597                OwnerPromotionJournalState::AcceptanceReady { acceptance },
598                OwnerPromotionJournalState::Stale {
599                    acceptance: successor,
600                    reason,
601                    evidence,
602                },
603            ) => {
604                acceptance == successor
605                    && matches!(
606                        evidence.as_ref(),
607                        OwnerPromotionStaleEvidence::BeforePublication
608                    )
609                    && matches!(
610                        reason,
611                        OwnerPromotionStaleReason::MergeFinalizationPointOccupied { .. }
612                    )
613            }
614            (
615                OwnerPromotionJournalState::MergeHeadPrepared {
616                    acceptance,
617                    wrapped_key,
618                    candidate,
619                },
620                OwnerPromotionJournalState::MergeHeadPrepared {
621                    acceptance: successor,
622                    wrapped_key: successor_key,
623                    candidate: successor_candidate,
624                },
625            ) => {
626                acceptance == successor
627                    && wrapped_key.reference == successor_key.reference
628                    && same_prepared_membership_candidate(candidate, successor_candidate)
629            }
630            (
631                OwnerPromotionJournalState::MergeHeadPrepared {
632                    acceptance,
633                    candidate,
634                    ..
635                },
636                OwnerPromotionJournalState::Finalized {
637                    acceptance: successor,
638                    candidate: successor_candidate,
639                    ..
640                },
641            ) => {
642                acceptance == successor
643                    && same_prepared_membership_candidate(candidate, successor_candidate)
644            }
645            (
646                OwnerPromotionJournalState::MergeHeadPrepared {
647                    acceptance,
648                    candidate,
649                    ..
650                },
651                OwnerPromotionJournalState::Stale {
652                    acceptance: successor,
653                    reason,
654                    evidence,
655                },
656            ) => {
657                acceptance == successor
658                    && matches!(reason, OwnerPromotionStaleReason::MergeActivationRejected)
659                    && matches!(evidence.as_ref(),
660                    OwnerPromotionStaleEvidence::Candidate {
661                        nonactivation,
662                        candidate: successor_candidate,
663                    } if nonactivation_matches_candidate(candidate, nonactivation)
664                        && same_prepared_membership_candidate(candidate, successor_candidate))
665            }
666            _ => false,
667        };
668        if !valid {
669            return Err(OwnerPromotionJournalError::Invariant(
670                "promotion journal transition skips or reverses protocol state".to_string(),
671            ));
672        }
673        Ok(())
674    }
675
676    pub fn validate_failed_attempt_replacement(
677        &self,
678        replacement: &OwnerPromotionJournal,
679    ) -> Result<(), OwnerPromotionJournalError> {
680        self.validate_contents()?;
681        replacement.validate_begin()?;
682        if self.target != replacement.target || self.promotion_id == replacement.promotion_id {
683            return Err(OwnerPromotionJournalError::Invariant(
684                "promotion retry must retain its target and use a fresh identity".to_string(),
685            ));
686        }
687        if !matches!(
688            self.state,
689            OwnerPromotionJournalState::Nonactivated { .. }
690                | OwnerPromotionJournalState::Stale { .. }
691        ) {
692            return Err(OwnerPromotionJournalError::Invariant(
693                "only a failed promotion attempt can be replaced".to_string(),
694            ));
695        }
696        Ok(())
697    }
698}
699
700pub struct OwnerPromotionJournalPredecessor {
701    pub promotion_id: OwnerPromotionId,
702    pub target: StoreDeviceRegistrationRef,
703    previous_value: String,
704}
705
706impl OwnerPromotionJournalPredecessor {
707    pub fn transition_to(
708        &self,
709        next: &OwnerPromotionJournal,
710    ) -> Result<OwnerPromotionJournalTransition, OwnerPromotionJournalError> {
711        let previous: OwnerPromotionJournal = serde_json::from_str(&self.previous_value)?;
712        previous.validate_transition(next)?;
713        let remote_objects = match &next.state {
714            OwnerPromotionJournalState::RequestPrepared { candidate, .. } => {
715                vec![candidate.candidate_remote_object()?]
716            }
717            OwnerPromotionJournalState::RequestAccepted {
718                candidate,
719                publication,
720                ..
721            } => {
722                vec![crate::remote_object::RemoteObjectRecord::prepared_owner_promotion_request_publication(
723                    publication,
724                    &candidate.commit,
725                )?]
726            }
727            OwnerPromotionJournalState::MergeHeadPrepared {
728                wrapped_key,
729                candidate,
730                ..
731            } => candidate
732                .merge_membership_activation_remote_objects(std::slice::from_ref(wrapped_key))?,
733            _ => Vec::new(),
734        };
735        let next_value = serde_json::to_string(next)?;
736        Ok(OwnerPromotionJournalTransition {
737            journal_key: format!("owner_promotion/{}", self.promotion_id),
738            target_key: target_key(&self.target)?,
739            previous_value: self.previous_value.clone(),
740            next_value,
741            remote_objects,
742        })
743    }
744}
745
746pub struct OwnerPromotionJournalTransition {
747    journal_key: String,
748    target_key: String,
749    previous_value: String,
750    next_value: String,
751    remote_objects: Vec<crate::remote_object::ClosedRemoteObject>,
752}
753
754impl OwnerPromotionJournalTransition {
755    pub fn into_values(
756        self,
757    ) -> (
758        String,
759        String,
760        String,
761        String,
762        Vec<crate::remote_object::ClosedRemoteObject>,
763    ) {
764        (
765            self.journal_key,
766            self.target_key,
767            self.previous_value,
768            self.next_value,
769            self.remote_objects,
770        )
771    }
772}