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}
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 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}