1use aws_lc_rs::digest;
4use lodestone_wire::{Digest, Identity, IndexResourceSpec, Locator, MappingMode, PrepareCreateIndexRequest, Resource};
5use serde::{Deserialize, Serialize};
6
7use crate::{
8 LocalOperatorWorkflowKind, ValidatedConnectionProfile, WorkflowJournalError, WorkflowJournalIntent,
9 WorkflowJournalTransition,
10};
11
12pub const PROVISION_RETAINED_APPEND_FORMAT_VERSION: u16 = 1;
13const MAX_NAME_BYTES: usize = 255;
14const MAX_ROLE_NAME_BYTES: usize = 128;
15
16#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
18#[serde(deny_unknown_fields)]
19pub struct ProvisionRetainedAppendIntent {
20 pub format_version: u16,
21 pub cluster_id: Identity,
22 pub collection_id: Identity,
23 pub admin_profile_sha256: [u8; 32],
24 pub consumer_profile_sha256: [u8; 32],
25 pub trust_sha256: [u8; 32],
26 pub definition_sha256: [u8; 32],
27 pub index_name: String,
28 pub role_name: String,
29}
30
31impl ProvisionRetainedAppendIntent {
32 pub fn new(
33 cluster_id: Identity,
34 collection_id: Identity,
35 admin_profile: &ValidatedConnectionProfile,
36 consumer_profile: &ValidatedConnectionProfile,
37 trust_sha256: [u8; 32],
38 definition: &PrepareCreateIndexRequest,
39 role_name: String,
40 ) -> Result<Self, WorkflowJournalError> {
41 let intent = Self {
42 format_version: PROVISION_RETAINED_APPEND_FORMAT_VERSION,
43 cluster_id,
44 collection_id,
45 admin_profile_sha256: profile_sha256(admin_profile)?,
46 consumer_profile_sha256: profile_sha256(consumer_profile)?,
47 trust_sha256,
48 definition_sha256: provision_definition_sha256(definition)?,
49 index_name: definition.name.clone(),
50 role_name,
51 };
52 intent.validate()?;
53 Ok(intent)
54 }
55}
56
57impl crate::workflow_journal::sealed::Sealed for ProvisionRetainedAppendIntent {}
58
59impl WorkflowJournalIntent for ProvisionRetainedAppendIntent {
60 type Transition = ProvisionRetainedAppendTransition;
61
62 const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::ProvisionRetainedAppend;
63
64 fn validate(&self) -> Result<(), WorkflowJournalError> {
65 if self.format_version != PROVISION_RETAINED_APPEND_FORMAT_VERSION
66 || !valid_identity(&self.cluster_id)
67 || !valid_identity(&self.collection_id)
68 || !valid_sha256(&self.admin_profile_sha256)
69 || !valid_sha256(&self.consumer_profile_sha256)
70 || !valid_sha256(&self.trust_sha256)
71 || !valid_sha256(&self.definition_sha256)
72 || !valid_index_name(&self.index_name)
73 || !valid_name(&self.role_name, MAX_ROLE_NAME_BYTES)
74 {
75 return Err(WorkflowJournalError::InvalidIntent);
76 }
77 Ok(())
78 }
79}
80
81#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum RoleAppendApplyResolution {
85 SubmitResponse,
86 Resolver,
87}
88
89#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
91#[serde(rename_all = "snake_case")]
92pub enum RoleAppendAttemptClosure {
93 ExpiredUnknown,
94 SupersededNoEffect,
95}
96
97#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
99#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
100pub enum ProvisionRetainedAppendTransition {
101 PreflightVerified,
102 CatalogPrepared {
103 locator: Locator,
104 semantic_digest: Digest,
105 expires_at: lodestone_wire::U64,
106 resource_spec: IndexResourceSpec,
107 },
108 CatalogApplied {
109 locator: Locator,
110 resource: Resource,
111 },
112 IndexActive {
113 resource: Resource,
114 index_revision: lodestone_wire::U64,
115 },
116 RolePrepared {
117 locator: Locator,
118 semantic_digest: Digest,
119 expires_at: lodestone_wire::U64,
120 predecessor_role_version: lodestone_wire::U64,
121 successor_role_version: lodestone_wire::U64,
122 },
123 RoleAttemptClosed {
124 locator: Locator,
125 closure: RoleAppendAttemptClosure,
126 },
127 RoleApplied {
128 locator: Locator,
129 resolution: RoleAppendApplyResolution,
130 target_policy_revision: lodestone_wire::U64,
131 },
132 RoleVerified {
133 resource: Resource,
134 role_version: lodestone_wire::U64,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 target_policy_revision: Option<lodestone_wire::U64>,
137 },
138 DiscoveryVerified {
139 resource: Resource,
140 observed_policy_revision: lodestone_wire::U64,
141 },
142 Completed {
143 resource: Resource,
144 role_version: lodestone_wire::U64,
145 observed_policy_revision: lodestone_wire::U64,
146 },
147}
148
149#[derive(Clone, Debug, PartialEq)]
150pub struct ProvisionCatalogPreparation {
151 pub locator: Locator,
152 pub semantic_digest: Digest,
153 pub expires_at: lodestone_wire::U64,
154 pub resource_spec: IndexResourceSpec,
155}
156
157#[derive(Clone, Debug, PartialEq)]
158pub struct ProvisionRolePreparation {
159 pub locator: Locator,
160 pub semantic_digest: Digest,
161 pub expires_at: lodestone_wire::U64,
162 pub predecessor_role_version: lodestone_wire::U64,
163 pub successor_role_version: lodestone_wire::U64,
164}
165
166impl crate::workflow_journal::sealed::Sealed for ProvisionRetainedAppendTransition {}
167
168impl WorkflowJournalTransition for ProvisionRetainedAppendTransition {
169 fn validate(&self) -> Result<(), WorkflowJournalError> {
170 let valid = match self {
171 Self::PreflightVerified => true,
172 Self::CatalogPrepared {
173 locator,
174 semantic_digest,
175 expires_at,
176 resource_spec,
177 } => {
178 valid_locator(locator)
179 && valid_digest(semantic_digest)
180 && valid_u64(expires_at)
181 && resource_spec.format_version == 1
182 }
183 Self::CatalogApplied { locator, resource } => valid_locator(locator) && valid_resource(resource),
184 Self::IndexActive {
185 resource,
186 index_revision,
187 } => valid_resource(resource) && valid_u64(index_revision),
188 Self::RolePrepared {
189 locator,
190 semantic_digest,
191 expires_at,
192 predecessor_role_version,
193 successor_role_version,
194 } => {
195 valid_locator(locator)
196 && valid_digest(semantic_digest)
197 && valid_u64(expires_at)
198 && ordered_successor(predecessor_role_version, successor_role_version)
199 }
200 Self::RoleAttemptClosed { locator, .. } => valid_locator(locator),
201 Self::RoleApplied {
202 locator,
203 target_policy_revision,
204 ..
205 } => valid_locator(locator) && valid_u64(target_policy_revision),
206 Self::RoleVerified {
207 resource,
208 role_version,
209 target_policy_revision,
210 } => {
211 valid_resource(resource)
212 && valid_u64(role_version)
213 && target_policy_revision.as_ref().is_none_or(valid_u64)
214 }
215 Self::DiscoveryVerified {
216 resource,
217 observed_policy_revision,
218 } => valid_resource(resource) && valid_u64(observed_policy_revision),
219 Self::Completed {
220 resource,
221 role_version,
222 observed_policy_revision,
223 } => valid_resource(resource) && valid_u64(role_version) && valid_u64(observed_policy_revision),
224 };
225 if valid {
226 Ok(())
227 } else {
228 Err(WorkflowJournalError::InvalidTransition)
229 }
230 }
231}
232
233#[derive(Clone, Debug, Default, PartialEq)]
235pub struct ProvisionRetainedAppendState {
236 preflight_verified: bool,
237 catalog_preparation: Option<ProvisionCatalogPreparation>,
238 catalog_resource: Option<Resource>,
239 index_active: bool,
240 role_preparation: Option<ProvisionRolePreparation>,
241 target_policy_revision: Option<lodestone_wire::U64>,
242 role_version: Option<lodestone_wire::U64>,
243 discovery_policy_revision: Option<lodestone_wire::U64>,
244 completed: bool,
245}
246
247impl ProvisionRetainedAppendState {
248 pub fn replay(transitions: &[ProvisionRetainedAppendTransition]) -> Result<Self, WorkflowJournalError> {
249 let mut state = Self::default();
250 for transition in transitions {
251 transition.validate()?;
252 state.apply(transition)?;
253 }
254 Ok(state)
255 }
256
257 fn apply(&mut self, transition: &ProvisionRetainedAppendTransition) -> Result<(), WorkflowJournalError> {
258 if self.completed {
259 return Err(WorkflowJournalError::Corrupt);
260 }
261 match transition {
262 ProvisionRetainedAppendTransition::PreflightVerified
263 if !self.preflight_verified && self.catalog_preparation.is_none() =>
264 {
265 self.preflight_verified = true;
266 }
267 ProvisionRetainedAppendTransition::CatalogPrepared {
268 locator,
269 semantic_digest,
270 expires_at,
271 resource_spec,
272 } if self.preflight_verified && self.catalog_preparation.is_none() && self.catalog_resource.is_none() => {
273 self.catalog_preparation = Some(ProvisionCatalogPreparation {
274 locator: locator.clone(),
275 semantic_digest: semantic_digest.clone(),
276 expires_at: expires_at.clone(),
277 resource_spec: resource_spec.clone(),
278 });
279 }
280 ProvisionRetainedAppendTransition::CatalogApplied { locator, resource }
281 if self.catalog_resource.is_none()
282 && self
283 .catalog_preparation
284 .as_ref()
285 .is_some_and(|prepared| &prepared.locator == locator) =>
286 {
287 self.catalog_resource = Some(resource.clone());
288 }
289 ProvisionRetainedAppendTransition::IndexActive { resource, .. }
290 if !self.index_active && self.catalog_resource.as_ref() == Some(resource) =>
291 {
292 self.index_active = true;
293 }
294 ProvisionRetainedAppendTransition::RolePrepared {
295 locator,
296 semantic_digest,
297 expires_at,
298 predecessor_role_version,
299 successor_role_version,
300 } if self.index_active && self.role_preparation.is_none() && self.role_version.is_none() => {
301 self.role_preparation = Some(ProvisionRolePreparation {
302 locator: locator.clone(),
303 semantic_digest: semantic_digest.clone(),
304 expires_at: expires_at.clone(),
305 predecessor_role_version: predecessor_role_version.clone(),
306 successor_role_version: successor_role_version.clone(),
307 });
308 }
309 ProvisionRetainedAppendTransition::RoleAttemptClosed { locator, .. }
310 if self
311 .role_preparation
312 .as_ref()
313 .is_some_and(|prepared| &prepared.locator == locator)
314 && self.target_policy_revision.is_none() =>
315 {
316 self.role_preparation = None;
317 }
318 ProvisionRetainedAppendTransition::RoleApplied {
319 locator,
320 target_policy_revision,
321 ..
322 } if self
323 .role_preparation
324 .as_ref()
325 .is_some_and(|prepared| &prepared.locator == locator)
326 && self.target_policy_revision.is_none() =>
327 {
328 self.target_policy_revision = Some(target_policy_revision.clone());
329 }
330 ProvisionRetainedAppendTransition::RoleVerified {
331 resource,
332 role_version,
333 target_policy_revision,
334 } if self.index_active
335 && self.catalog_resource.as_ref() == Some(resource)
336 && self.role_version.is_none()
337 && target_policy_revision == &self.target_policy_revision
338 && self.role_version_is_admissible(role_version) =>
339 {
340 self.role_version = Some(role_version.clone());
341 }
342 ProvisionRetainedAppendTransition::DiscoveryVerified {
343 resource,
344 observed_policy_revision,
345 } if self.catalog_resource.as_ref() == Some(resource)
346 && self.role_version.is_some()
347 && self.discovery_policy_revision.is_none()
348 && revision_at_least(observed_policy_revision, self.target_policy_revision.as_ref()) =>
349 {
350 self.discovery_policy_revision = Some(observed_policy_revision.clone());
351 }
352 ProvisionRetainedAppendTransition::Completed {
353 resource,
354 role_version,
355 observed_policy_revision,
356 } if self.catalog_resource.as_ref() == Some(resource)
357 && revision_at_least(role_version, self.role_version.as_ref())
358 && self.discovery_policy_revision.as_ref() == Some(observed_policy_revision) =>
359 {
360 self.role_version = Some(role_version.clone());
361 self.completed = true;
362 }
363 _ => return Err(WorkflowJournalError::Corrupt),
364 }
365 Ok(())
366 }
367
368 fn role_version_is_admissible(&self, observed: &lodestone_wire::U64) -> bool {
369 match &self.role_preparation {
370 Some(preparation) if self.target_policy_revision.is_some() => {
371 revision_at_least(observed, Some(&preparation.successor_role_version))
372 }
373 Some(_) => false,
374 None => self.target_policy_revision.is_none(),
375 }
376 }
377
378 #[must_use]
379 pub const fn preflight_verified(&self) -> bool {
380 self.preflight_verified
381 }
382
383 #[must_use]
384 pub fn catalog_preparation(&self) -> Option<&ProvisionCatalogPreparation> {
385 self.catalog_preparation.as_ref()
386 }
387
388 #[must_use]
389 pub fn resource(&self) -> Option<&Resource> {
390 self.catalog_resource.as_ref()
391 }
392
393 #[must_use]
394 pub const fn index_active(&self) -> bool {
395 self.index_active
396 }
397
398 #[must_use]
399 pub fn role_preparation(&self) -> Option<&ProvisionRolePreparation> {
400 self.role_preparation.as_ref()
401 }
402
403 #[must_use]
404 pub fn target_policy_revision(&self) -> Option<&lodestone_wire::U64> {
405 self.target_policy_revision.as_ref()
406 }
407
408 #[must_use]
409 pub fn role_version(&self) -> Option<&lodestone_wire::U64> {
410 self.role_version.as_ref()
411 }
412
413 #[must_use]
414 pub fn discovery_policy_revision(&self) -> Option<&lodestone_wire::U64> {
415 self.discovery_policy_revision.as_ref()
416 }
417
418 #[must_use]
419 pub const fn completed(&self) -> bool {
420 self.completed
421 }
422}
423
424pub fn profile_sha256(profile: &ValidatedConnectionProfile) -> Result<[u8; 32], WorkflowJournalError> {
425 let bytes = profile
426 .encode_current()
427 .map_err(|_| WorkflowJournalError::InvalidIntent)?;
428 Ok(sha256(&bytes))
429}
430
431pub fn provision_definition_sha256(definition: &PrepareCreateIndexRequest) -> Result<[u8; 32], WorkflowJournalError> {
432 if definition.mapping.mode != MappingMode::AppendLog || definition.retained_event_identity != Some(true) {
433 return Err(WorkflowJournalError::InvalidIntent);
434 }
435 let bytes = serde_json::to_vec(definition).map_err(|_| WorkflowJournalError::Codec)?;
436 Ok(sha256(&bytes))
437}
438
439fn sha256(bytes: &[u8]) -> [u8; 32] {
440 let observed = digest::digest(&digest::SHA256, bytes);
441 let mut output = [0; 32];
442 output.copy_from_slice(observed.as_ref());
443 output
444}
445
446fn valid_identity(value: &Identity) -> bool {
447 value.value.len() == 32
448 && value
449 .value
450 .bytes()
451 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
452 && u128::from_str_radix(&value.value, 16).is_ok_and(|value| value != 0)
453}
454
455fn valid_sha256(value: &[u8; 32]) -> bool {
456 value.iter().any(|byte| *byte != 0)
457}
458
459fn valid_name(value: &str, maximum: usize) -> bool {
460 !value.is_empty()
461 && value.len() <= maximum
462 && value
463 .bytes()
464 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
465}
466
467fn valid_index_name(value: &str) -> bool {
468 !value.is_empty()
469 && value.len() <= MAX_NAME_BYTES
470 && !value
471 .bytes()
472 .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
473}
474
475fn valid_locator(value: &Locator) -> bool {
476 !value.authority.is_empty()
477 && value.authority.len() <= 128
478 && !value.value.is_empty()
479 && value.value.len() <= 512
480 && !value.authority.chars().any(char::is_control)
481 && !value.value.chars().any(char::is_control)
482}
483
484fn valid_digest(value: &Digest) -> bool {
485 value.value.len() == 64
486 && value
487 .value
488 .bytes()
489 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
490 && value.value.bytes().any(|byte| byte != b'0')
491}
492
493fn valid_resource(value: &Resource) -> bool {
494 value.kind == "index" && valid_index_name(&value.name) && valid_identity(&value.incarnation)
495}
496
497fn valid_u64(value: &lodestone_wire::U64) -> bool {
498 value
499 .value
500 .parse::<u64>()
501 .is_ok_and(|parsed| parsed.to_string() == value.value)
502}
503
504fn ordered_successor(predecessor: &lodestone_wire::U64, successor: &lodestone_wire::U64) -> bool {
505 match (predecessor.value.parse::<u64>(), successor.value.parse::<u64>()) {
506 (Ok(predecessor), Ok(successor)) => predecessor.checked_add(1) == Some(successor),
507 _ => false,
508 }
509}
510
511fn revision_at_least(observed: &lodestone_wire::U64, minimum: Option<&lodestone_wire::U64>) -> bool {
512 match (
513 observed.value.parse::<u64>(),
514 minimum.map(|value| value.value.parse::<u64>()),
515 ) {
516 (Ok(_), None) => valid_u64(observed),
517 (Ok(observed), Some(Ok(minimum))) => observed >= minimum,
518 _ => false,
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use lodestone_wire::{ResourceDurabilityClass, ResourceStorageClass, U64};
526
527 fn id(value: char) -> Identity {
528 Identity {
529 value: value.to_string().repeat(32),
530 }
531 }
532
533 fn digest(value: char) -> Digest {
534 Digest {
535 value: value.to_string().repeat(64),
536 }
537 }
538
539 fn locator(value: &str) -> Locator {
540 Locator {
541 authority: "catalog".to_owned(),
542 value: value.to_owned(),
543 }
544 }
545
546 fn resource() -> Resource {
547 Resource {
548 kind: "index".to_owned(),
549 name: "events".to_owned(),
550 incarnation: id('a'),
551 }
552 }
553
554 fn resource_spec() -> IndexResourceSpec {
555 IndexResourceSpec {
556 format_version: 1,
557 revision: U64 { value: "1".to_owned() },
558 initial_shards: 1,
559 target_shard_bytes: U64 { value: "1".to_owned() },
560 durability: ResourceDurabilityClass::RegionalObject,
561 storage_class: ResourceStorageClass::Standard,
562 ram_admission_weight: 1,
563 ram_hard_max_bytes: U64 { value: "1".to_owned() },
564 nvme_admission_weight: 1,
565 nvme_hard_max_bytes: U64 { value: "1".to_owned() },
566 refresh_objective_millis: 1,
567 memtable_hard_max_bytes: U64 { value: "1".to_owned() },
568 seal_parallelism: 1,
569 merge_parallelism: 1,
570 lifecycle_binding: None,
571 encryption_required: true,
572 resolver_capability: None,
573 }
574 }
575
576 fn valid_chain() -> Vec<ProvisionRetainedAppendTransition> {
577 let resource = resource();
578 vec![
579 ProvisionRetainedAppendTransition::PreflightVerified,
580 ProvisionRetainedAppendTransition::CatalogPrepared {
581 locator: locator("catalog-1"),
582 semantic_digest: digest('b'),
583 expires_at: U64 { value: "10".to_owned() },
584 resource_spec: resource_spec(),
585 },
586 ProvisionRetainedAppendTransition::CatalogApplied {
587 locator: locator("catalog-1"),
588 resource: resource.clone(),
589 },
590 ProvisionRetainedAppendTransition::IndexActive {
591 resource: resource.clone(),
592 index_revision: U64 { value: "1".to_owned() },
593 },
594 ProvisionRetainedAppendTransition::RolePrepared {
595 locator: locator("role-1"),
596 semantic_digest: digest('c'),
597 expires_at: U64 { value: "20".to_owned() },
598 predecessor_role_version: U64 { value: "4".to_owned() },
599 successor_role_version: U64 { value: "5".to_owned() },
600 },
601 ProvisionRetainedAppendTransition::RoleApplied {
602 locator: locator("role-1"),
603 resolution: RoleAppendApplyResolution::Resolver,
604 target_policy_revision: U64 { value: "9".to_owned() },
605 },
606 ProvisionRetainedAppendTransition::RoleVerified {
607 resource: resource.clone(),
608 role_version: U64 { value: "6".to_owned() },
609 target_policy_revision: Some(U64 { value: "9".to_owned() }),
610 },
611 ProvisionRetainedAppendTransition::DiscoveryVerified {
612 resource: resource.clone(),
613 observed_policy_revision: U64 { value: "10".to_owned() },
614 },
615 ProvisionRetainedAppendTransition::Completed {
616 resource,
617 role_version: U64 { value: "6".to_owned() },
618 observed_policy_revision: U64 { value: "10".to_owned() },
619 },
620 ]
621 }
622
623 #[test]
624 fn reducer_accepts_monotonic_completed_chain() -> Result<(), WorkflowJournalError> {
625 let state = ProvisionRetainedAppendState::replay(&valid_chain())?;
626 assert!(state.completed());
627 assert_eq!(state.resource(), Some(&resource()));
628 Ok(())
629 }
630
631 #[test]
632 fn intent_and_transitions_reject_zero_authority_values() {
633 let intent = ProvisionRetainedAppendIntent {
634 format_version: PROVISION_RETAINED_APPEND_FORMAT_VERSION,
635 cluster_id: id('0'),
636 collection_id: id('a'),
637 admin_profile_sha256: [1; 32],
638 consumer_profile_sha256: [2; 32],
639 trust_sha256: [3; 32],
640 definition_sha256: [4; 32],
641 index_name: "events".to_owned(),
642 role_name: "consumer".to_owned(),
643 };
644 assert_eq!(intent.validate(), Err(WorkflowJournalError::InvalidIntent));
645
646 let mut intent = intent;
647 intent.cluster_id = id('a');
648 intent.definition_sha256 = [0; 32];
649 assert_eq!(intent.validate(), Err(WorkflowJournalError::InvalidIntent));
650
651 let transition = ProvisionRetainedAppendTransition::CatalogPrepared {
652 locator: locator("catalog-1"),
653 semantic_digest: digest('0'),
654 expires_at: U64 { value: "10".to_owned() },
655 resource_spec: resource_spec(),
656 };
657 assert_eq!(transition.validate(), Err(WorkflowJournalError::InvalidTransition));
658 }
659
660 #[test]
661 fn reducer_rejects_out_of_order_or_post_terminal_records() {
662 assert_eq!(
663 ProvisionRetainedAppendState::replay(&[
664 ProvisionRetainedAppendTransition::PreflightVerified,
665 ProvisionRetainedAppendTransition::IndexActive {
666 resource: resource(),
667 index_revision: U64 { value: "1".to_owned() },
668 }
669 ]),
670 Err(WorkflowJournalError::Corrupt)
671 );
672 let mut transitions = valid_chain();
673 transitions.push(ProvisionRetainedAppendTransition::PreflightVerified);
674 assert_eq!(
675 ProvisionRetainedAppendState::replay(&transitions),
676 Err(WorkflowJournalError::Corrupt)
677 );
678 }
679
680 #[test]
681 fn reducer_retires_only_the_exact_role_locator() {
682 let mut transitions = valid_chain();
683 transitions.truncate(5);
684 transitions.push(ProvisionRetainedAppendTransition::RoleAttemptClosed {
685 locator: locator("other"),
686 closure: RoleAppendAttemptClosure::ExpiredUnknown,
687 });
688 assert_eq!(
689 ProvisionRetainedAppendState::replay(&transitions),
690 Err(WorkflowJournalError::Corrupt)
691 );
692 }
693
694 #[test]
695 fn reducer_accepts_a_later_role_version_that_retains_the_scope() -> Result<(), WorkflowJournalError> {
696 let mut transitions = valid_chain();
697 let Some(ProvisionRetainedAppendTransition::Completed { role_version, .. }) = transitions.last_mut() else {
698 return Err(WorkflowJournalError::Corrupt);
699 };
700 *role_version = U64 { value: "7".to_owned() };
701 let state = ProvisionRetainedAppendState::replay(&transitions)?;
702 assert_eq!(state.role_version(), Some(&U64 { value: "7".to_owned() }));
703 Ok(())
704 }
705}