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