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