1use crate::store_commit::ObjectHash;
6
7#[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}