1use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::DomainError;
12use crate::value_objects::{
13 CeremonyContext, CeremonyDefinitionDigest, CeremonyDescription, CeremonyGuard,
14 CeremonyInputDefinition, CeremonyName, CeremonyOutputDefinition, CeremonyRole, CeremonyState,
15 CeremonyStep, CeremonyTransition, CeremonyValidationReport, CeremonyVersion, GuardName,
16 InputName, MaxBounces, MaxParallel, MaxTransitions, OutputName, RoleAction, RoleId, StateId,
17 StepExecutionRecord, StepId, TransitionTrigger,
18};
19
20use super::ceremony_definition_analysis::CeremonyDefinitionParts;
21
22mod collections;
23mod guards;
24
25use collections::{
26 collect_guards, collect_inputs, collect_outputs, collect_roles, collect_states, collect_steps,
27};
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct CeremonyDefinition {
31 name: CeremonyName,
32 version: CeremonyVersion,
33 description: Option<CeremonyDescription>,
34 inputs: BTreeMap<InputName, CeremonyInputDefinition>,
35 outputs: BTreeMap<OutputName, CeremonyOutputDefinition>,
36 states: BTreeMap<StateId, CeremonyState>,
37 transitions: Vec<CeremonyTransition>,
38 steps: BTreeMap<StepId, CeremonyStep>,
39 step_order: Vec<StepId>,
40 guards: BTreeMap<GuardName, CeremonyGuard>,
41 roles: BTreeMap<RoleId, CeremonyRole>,
42 #[serde(default, skip_serializing_if = "MaxParallel::is_default")]
43 max_parallel: MaxParallel,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 max_transitions: Option<MaxTransitions>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 max_bounces: Option<MaxBounces>,
48}
49
50impl CeremonyDefinition {
51 pub fn new(
52 name: CeremonyName,
53 version: CeremonyVersion,
54 description: Option<CeremonyDescription>,
55 inputs: impl IntoIterator<Item = CeremonyInputDefinition>,
56 outputs: impl IntoIterator<Item = CeremonyOutputDefinition>,
57 states: impl IntoIterator<Item = CeremonyState>,
58 transitions: impl IntoIterator<Item = CeremonyTransition>,
59 steps: impl IntoIterator<Item = CeremonyStep>,
60 guards: impl IntoIterator<Item = CeremonyGuard>,
61 roles: impl IntoIterator<Item = CeremonyRole>,
62 ) -> Result<Self, DomainError> {
63 Self::new_with_transition_budgets(
64 name,
65 version,
66 description,
67 inputs,
68 outputs,
69 states,
70 transitions,
71 steps,
72 guards,
73 roles,
74 None,
75 None,
76 )
77 }
78
79 #[allow(clippy::too_many_arguments)]
84 pub fn new_with_transition_budgets(
85 name: CeremonyName,
86 version: CeremonyVersion,
87 description: Option<CeremonyDescription>,
88 inputs: impl IntoIterator<Item = CeremonyInputDefinition>,
89 outputs: impl IntoIterator<Item = CeremonyOutputDefinition>,
90 states: impl IntoIterator<Item = CeremonyState>,
91 transitions: impl IntoIterator<Item = CeremonyTransition>,
92 steps: impl IntoIterator<Item = CeremonyStep>,
93 guards: impl IntoIterator<Item = CeremonyGuard>,
94 roles: impl IntoIterator<Item = CeremonyRole>,
95 max_transitions: Option<MaxTransitions>,
96 max_bounces: Option<MaxBounces>,
97 ) -> Result<Self, DomainError> {
98 let inputs = collect_inputs(inputs)?;
99 let outputs = collect_outputs(outputs)?;
100 let states = collect_states(states)?;
101 let transitions = transitions.into_iter().collect::<Vec<_>>();
102 let (steps, step_order) = collect_steps(steps)?;
103 let guards = collect_guards(guards)?;
104 let roles = collect_roles(roles)?;
105
106 let definition = Self {
107 name,
108 version,
109 description,
110 inputs,
111 outputs,
112 states,
113 transitions,
114 steps,
115 step_order,
116 guards,
117 roles,
118 max_parallel: MaxParallel::default(),
119 max_transitions,
120 max_bounces,
121 };
122 definition.validate()?;
123 Ok(definition)
124 }
125
126 #[must_use]
127 pub fn name(&self) -> &CeremonyName {
128 &self.name
129 }
130
131 #[must_use]
132 pub fn version(&self) -> &CeremonyVersion {
133 &self.version
134 }
135
136 #[must_use]
137 pub fn description(&self) -> Option<&CeremonyDescription> {
138 self.description.as_ref()
139 }
140
141 #[must_use]
142 pub fn inputs(&self) -> &BTreeMap<InputName, CeremonyInputDefinition> {
143 &self.inputs
144 }
145
146 #[must_use]
147 pub fn outputs(&self) -> &BTreeMap<OutputName, CeremonyOutputDefinition> {
148 &self.outputs
149 }
150
151 #[must_use]
152 pub fn states(&self) -> &BTreeMap<StateId, CeremonyState> {
153 &self.states
154 }
155
156 #[must_use]
157 pub fn transitions(&self) -> &[CeremonyTransition] {
158 &self.transitions
159 }
160
161 #[must_use]
162 pub fn steps(&self) -> &BTreeMap<StepId, CeremonyStep> {
163 &self.steps
164 }
165
166 pub fn steps_in_declaration_order(&self) -> impl Iterator<Item = &CeremonyStep> + '_ {
171 self.step_order.iter().map(|step_id| {
172 self.steps
173 .get(step_id)
174 .expect("ceremony step order must reference an indexed step")
175 })
176 }
177
178 #[must_use]
179 pub fn guards(&self) -> &BTreeMap<GuardName, CeremonyGuard> {
180 &self.guards
181 }
182
183 #[must_use]
184 pub fn roles(&self) -> &BTreeMap<RoleId, CeremonyRole> {
185 &self.roles
186 }
187
188 #[must_use]
189 pub fn with_max_parallel(mut self, max_parallel: MaxParallel) -> Self {
190 self.max_parallel = max_parallel;
191 self
192 }
193
194 #[must_use]
195 pub fn max_parallel(&self) -> MaxParallel {
196 self.max_parallel
197 }
198
199 #[must_use]
200 pub const fn max_transitions(&self) -> Option<MaxTransitions> {
201 self.max_transitions
202 }
203
204 #[must_use]
205 pub const fn max_bounces(&self) -> Option<MaxBounces> {
206 self.max_bounces
207 }
208
209 #[must_use]
210 pub fn initial_state_id(&self) -> &StateId {
211 self.states
212 .values()
213 .find(|state| state.is_initial())
214 .map(CeremonyState::id)
215 .expect("ceremony definition invariant requires one initial state")
216 }
217
218 #[must_use]
219 pub fn state(&self, state_id: &StateId) -> Option<&CeremonyState> {
220 self.states.get(state_id)
221 }
222
223 #[must_use]
224 pub fn step(&self, step_id: &StepId) -> Option<&CeremonyStep> {
225 self.steps.get(step_id)
226 }
227
228 #[must_use]
229 pub fn role(&self, role_id: &RoleId) -> Option<&CeremonyRole> {
230 self.roles.get(role_id)
231 }
232
233 pub fn steps_for_state(&self, state_id: &StateId) -> impl Iterator<Item = &CeremonyStep> + '_ {
234 let state_id = state_id.clone();
235 self.steps_in_declaration_order()
236 .filter(move |step| step.state_id() == &state_id)
237 }
238
239 #[must_use]
240 pub fn is_terminal_state(&self, state_id: &StateId) -> bool {
241 self.states
242 .get(state_id)
243 .is_some_and(CeremonyState::is_terminal)
244 }
245
246 #[must_use]
247 pub fn transition_for_trigger(
248 &self,
249 state_id: &StateId,
250 trigger: &TransitionTrigger,
251 ) -> Option<&CeremonyTransition> {
252 self.transitions
253 .iter()
254 .find(|transition| transition.from() == state_id && transition.trigger() == trigger)
255 }
256
257 pub fn available_transitions(
258 &self,
259 state_id: &StateId,
260 ) -> impl Iterator<Item = &CeremonyTransition> + '_ {
261 let state_id = state_id.clone();
262 self.transitions
263 .iter()
264 .filter(move |transition| transition.from() == &state_id)
265 }
266
267 #[must_use]
268 pub fn role_allows(&self, role_id: &RoleId, action: &RoleAction) -> bool {
269 self.roles
270 .get(role_id)
271 .is_some_and(|role| role.allows(action))
272 }
273
274 #[must_use]
280 pub fn role_for_action(&self, action: &RoleAction) -> Option<&CeremonyRole> {
281 self.roles.values().find(|role| role.allows(action))
282 }
283
284 pub fn role_id_for_step(&self, step_id: &StepId) -> Result<RoleId, DomainError> {
290 self.role_for_action(&RoleAction::step(step_id.clone()))
291 .map(|role| role.id().clone())
292 .ok_or(DomainError::InvariantViolated {
293 reason: "no ceremony role can execute step",
294 })
295 }
296
297 pub fn role_id_for_transition(
304 &self,
305 trigger: &TransitionTrigger,
306 ) -> Result<RoleId, DomainError> {
307 self.role_for_action(&RoleAction::transition(trigger.clone()))
308 .map(|role| role.id().clone())
309 .ok_or(DomainError::InvariantViolated {
310 reason: "no ceremony role can apply transition",
311 })
312 }
313
314 #[must_use]
323 pub fn next_satisfied_transition(
324 &self,
325 state_id: &StateId,
326 records: &BTreeMap<StepId, StepExecutionRecord>,
327 context: &CeremonyContext,
328 ) -> Option<&CeremonyTransition> {
329 self.available_transitions(state_id)
330 .find(|transition| self.guards_are_satisfied(transition, records, context))
331 }
332
333 pub fn digest(&self) -> Result<CeremonyDefinitionDigest, DomainError> {
351 let canonical = self.canonical_form()?;
352 Ok(CeremonyDefinitionDigest::of_canonical_form(&canonical))
353 }
354
355 fn canonical_form(&self) -> Result<Vec<u8>, DomainError> {
356 serde_json::to_vec(self).map_err(|_| DomainError::InvariantViolated {
357 reason: "ceremony definition cannot be rendered canonically",
358 })
359 }
360
361 #[must_use]
371 pub fn analyze(&self) -> CeremonyValidationReport {
372 let mut findings = Vec::new();
373 self.parts().collect_findings(&mut findings);
374 CeremonyValidationReport::new(findings)
375 }
376
377 fn parts(&self) -> CeremonyDefinitionParts<'_> {
378 CeremonyDefinitionParts {
379 states: &self.states,
380 transitions: &self.transitions,
381 steps: &self.steps,
382 guards: &self.guards,
383 roles: &self.roles,
384 max_transitions: self.max_transitions,
385 max_bounces: self.max_bounces,
386 }
387 }
388
389 fn validate(&self) -> Result<(), DomainError> {
390 match self.analyze().first_error() {
391 Some(finding) => Err(finding.defect().clone()),
392 None => Ok(()),
393 }
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::value_objects::{
401 CeremonyStateKind, CeremonyValidationLocus, GuardCondition, RetryPolicy, StepHandlerConfig,
402 StepHandlerKind, StepStatus,
403 };
404
405 fn name() -> CeremonyName {
406 CeremonyName::new("planning_ceremony").unwrap()
407 }
408
409 fn state_id(raw: &str) -> StateId {
410 StateId::new(raw).unwrap()
411 }
412
413 fn step_id(raw: &str) -> StepId {
414 StepId::new(raw).unwrap()
415 }
416
417 fn guard_name(raw: &str) -> GuardName {
418 GuardName::new(raw).unwrap()
419 }
420
421 fn trigger(raw: &str) -> TransitionTrigger {
422 TransitionTrigger::new(raw).unwrap()
423 }
424
425 fn handler_kind() -> StepHandlerKind {
426 StepHandlerKind::new("manual_review").unwrap()
427 }
428
429 fn step(raw_step_id: &str, raw_state_id: &str) -> CeremonyStep {
430 CeremonyStep::new(
431 step_id(raw_step_id),
432 state_id(raw_state_id),
433 handler_kind(),
434 StepHandlerConfig::empty(),
435 RetryPolicy::single_attempt(),
436 None,
437 )
438 }
439
440 fn role(actions: Vec<RoleAction>) -> CeremonyRole {
441 CeremonyRole::new(RoleId::new("facilitator").unwrap(), actions).unwrap()
442 }
443
444 fn definition(
445 states: Vec<CeremonyState>,
446 transitions: Vec<CeremonyTransition>,
447 steps: Vec<CeremonyStep>,
448 guards: Vec<CeremonyGuard>,
449 roles: Vec<CeremonyRole>,
450 ) -> Result<CeremonyDefinition, DomainError> {
451 definition_with_budgets(states, transitions, steps, guards, roles, None, None)
452 }
453
454 fn definition_with_budgets(
455 states: Vec<CeremonyState>,
456 transitions: Vec<CeremonyTransition>,
457 steps: Vec<CeremonyStep>,
458 guards: Vec<CeremonyGuard>,
459 roles: Vec<CeremonyRole>,
460 max_transitions: Option<MaxTransitions>,
461 max_bounces: Option<MaxBounces>,
462 ) -> Result<CeremonyDefinition, DomainError> {
463 CeremonyDefinition::new_with_transition_budgets(
464 name(),
465 CeremonyVersion::v1(),
466 None,
467 Vec::new(),
468 Vec::new(),
469 states,
470 transitions,
471 steps,
472 guards,
473 roles,
474 max_transitions,
475 max_bounces,
476 )
477 }
478
479 fn valid_definition() -> CeremonyDefinition {
480 let plan_step = step("plan", "drafting");
481 let guard = CeremonyGuard::new(
482 guard_name("plan_done"),
483 GuardCondition::StepStatus {
484 step_id: plan_step.id().clone(),
485 status: StepStatus::Completed,
486 },
487 );
488 let transition = CeremonyTransition::new(
489 state_id("drafting"),
490 state_id("done"),
491 trigger("finish"),
492 vec![guard.name().clone()],
493 )
494 .unwrap();
495 let role = role(vec![
496 RoleAction::step(plan_step.id().clone()),
497 RoleAction::transition(transition.trigger().clone()),
498 ]);
499
500 definition(
501 vec![
502 CeremonyState::initial(state_id("drafting")),
503 CeremonyState::terminal(state_id("done")),
504 ],
505 vec![transition],
506 vec![plan_step],
507 vec![guard],
508 vec![role],
509 )
510 .unwrap()
511 }
512
513 #[test]
514 fn accepts_valid_declarative_state_machine() {
515 let definition = valid_definition();
516
517 assert_eq!(definition.initial_state_id(), &state_id("drafting"));
518 assert_eq!(definition.steps_for_state(&state_id("drafting")).count(), 1);
519 assert!(definition.role_allows(
520 &RoleId::new("facilitator").unwrap(),
521 &RoleAction::transition(trigger("finish"))
522 ));
523 }
524
525 #[test]
526 fn preserves_step_declaration_order_within_a_state() {
527 let definition = definition(
528 vec![
529 CeremonyState::initial(state_id("drafting")),
530 CeremonyState::terminal(state_id("done")),
531 ],
532 Vec::new(),
533 vec![
534 step("write_plan", "drafting"),
535 step("challenge_plan", "drafting"),
536 step("archive_plan", "drafting"),
537 ],
538 Vec::new(),
539 Vec::new(),
540 )
541 .unwrap();
542
543 let step_ids = definition
544 .steps_for_state(&state_id("drafting"))
545 .map(CeremonyStep::id)
546 .cloned()
547 .collect::<Vec<_>>();
548
549 assert_eq!(
550 step_ids,
551 vec![
552 step_id("write_plan"),
553 step_id("challenge_plan"),
554 step_id("archive_plan"),
555 ]
556 );
557 }
558
559 #[test]
560 fn rejects_definitions_without_exactly_one_initial_state() {
561 let err = definition(
562 vec![
563 CeremonyState::new(state_id("one"), CeremonyStateKind::Initial),
564 CeremonyState::new(state_id("two"), CeremonyStateKind::Initial),
565 CeremonyState::terminal(state_id("done")),
566 ],
567 Vec::new(),
568 Vec::new(),
569 Vec::new(),
570 Vec::new(),
571 )
572 .unwrap_err();
573
574 assert!(matches!(err, DomainError::InvariantViolated { .. }));
575 }
576
577 #[test]
578 fn rejects_terminal_states_with_outgoing_transitions() {
579 let transition = CeremonyTransition::new(
580 state_id("done"),
581 state_id("drafting"),
582 trigger("restart"),
583 Vec::new(),
584 )
585 .unwrap();
586
587 let err = definition(
588 vec![
589 CeremonyState::initial(state_id("drafting")),
590 CeremonyState::terminal(state_id("done")),
591 ],
592 vec![transition],
593 Vec::new(),
594 Vec::new(),
595 Vec::new(),
596 )
597 .unwrap_err();
598
599 assert!(matches!(err, DomainError::InvariantViolated { .. }));
600 }
601
602 #[test]
603 fn rejects_roles_that_reference_unknown_steps() {
604 let role = role(vec![RoleAction::step(step_id("missing"))]);
605
606 let err = definition(
607 vec![
608 CeremonyState::initial(state_id("drafting")),
609 CeremonyState::terminal(state_id("done")),
610 ],
611 Vec::new(),
612 Vec::new(),
613 Vec::new(),
614 vec![role],
615 )
616 .unwrap_err();
617
618 assert!(matches!(
619 err,
620 DomainError::NotFound {
621 what: "ceremony_role.step_action"
622 }
623 ));
624 }
625
626 #[test]
627 fn rejects_empty_states_collection() {
628 let err =
629 definition(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()).unwrap_err();
630
631 assert!(matches!(
632 err,
633 DomainError::EmptyCollection {
634 field: "ceremony_definition.states"
635 }
636 ));
637 }
638
639 #[test]
640 fn rejects_transition_referencing_unknown_from_state() {
641 let transition = CeremonyTransition::new(
642 state_id("ghost"),
643 state_id("done"),
644 trigger("finish"),
645 Vec::new(),
646 )
647 .unwrap();
648
649 let err = definition(
650 vec![
651 CeremonyState::initial(state_id("drafting")),
652 CeremonyState::terminal(state_id("done")),
653 ],
654 vec![transition],
655 Vec::new(),
656 Vec::new(),
657 Vec::new(),
658 )
659 .unwrap_err();
660
661 assert!(matches!(
662 err,
663 DomainError::NotFound {
664 what: "ceremony_transition.from_state"
665 }
666 ));
667 }
668
669 #[test]
670 fn rejects_transition_referencing_unknown_to_state() {
671 let transition = CeremonyTransition::new(
672 state_id("drafting"),
673 state_id("ghost"),
674 trigger("finish"),
675 Vec::new(),
676 )
677 .unwrap();
678
679 let err = definition(
680 vec![
681 CeremonyState::initial(state_id("drafting")),
682 CeremonyState::terminal(state_id("done")),
683 ],
684 vec![transition],
685 Vec::new(),
686 Vec::new(),
687 Vec::new(),
688 )
689 .unwrap_err();
690
691 assert!(matches!(
692 err,
693 DomainError::NotFound {
694 what: "ceremony_transition.to_state"
695 }
696 ));
697 }
698
699 #[test]
700 fn rejects_duplicate_state_trigger_pairs() {
701 let first = CeremonyTransition::new(
702 state_id("drafting"),
703 state_id("done"),
704 trigger("finish"),
705 Vec::new(),
706 )
707 .unwrap();
708 let duplicate = CeremonyTransition::new(
709 state_id("drafting"),
710 state_id("done"),
711 trigger("finish"),
712 Vec::new(),
713 )
714 .unwrap();
715
716 let err = definition(
717 vec![
718 CeremonyState::initial(state_id("drafting")),
719 CeremonyState::terminal(state_id("done")),
720 ],
721 vec![first, duplicate],
722 Vec::new(),
723 Vec::new(),
724 Vec::new(),
725 )
726 .unwrap_err();
727
728 assert!(matches!(
729 err,
730 DomainError::AlreadyExists {
731 what: "ceremony_transition.state_trigger"
732 }
733 ));
734 }
735
736 #[test]
737 fn rejects_transition_referencing_unknown_guard() {
738 let transition = CeremonyTransition::new(
739 state_id("drafting"),
740 state_id("done"),
741 trigger("finish"),
742 vec![guard_name("absent")],
743 )
744 .unwrap();
745
746 let err = definition(
747 vec![
748 CeremonyState::initial(state_id("drafting")),
749 CeremonyState::terminal(state_id("done")),
750 ],
751 vec![transition],
752 Vec::new(),
753 Vec::new(),
754 Vec::new(),
755 )
756 .unwrap_err();
757
758 assert!(matches!(
759 err,
760 DomainError::NotFound {
761 what: "ceremony_transition.guard"
762 }
763 ));
764 }
765
766 #[test]
767 fn rejects_step_referencing_unknown_state() {
768 let err = definition(
769 vec![
770 CeremonyState::initial(state_id("drafting")),
771 CeremonyState::terminal(state_id("done")),
772 ],
773 Vec::new(),
774 vec![step("plan", "ghost")],
775 Vec::new(),
776 Vec::new(),
777 )
778 .unwrap_err();
779
780 assert!(matches!(
781 err,
782 DomainError::NotFound {
783 what: "ceremony_step.state"
784 }
785 ));
786 }
787
788 #[test]
789 fn rejects_role_that_references_unknown_transition_trigger() {
790 let role = role(vec![RoleAction::transition(trigger("ghost"))]);
791
792 let err = definition(
793 vec![
794 CeremonyState::initial(state_id("drafting")),
795 CeremonyState::terminal(state_id("done")),
796 ],
797 Vec::new(),
798 Vec::new(),
799 Vec::new(),
800 vec![role],
801 )
802 .unwrap_err();
803
804 assert!(matches!(
805 err,
806 DomainError::NotFound {
807 what: "ceremony_role.transition_action"
808 }
809 ));
810 }
811
812 #[test]
813 fn resolves_role_authorised_for_a_step() {
814 let definition = valid_definition();
815
816 assert_eq!(
817 definition.role_id_for_step(&step_id("plan")).unwrap(),
818 RoleId::new("facilitator").unwrap()
819 );
820 }
821
822 #[test]
823 fn rejects_step_with_no_authorised_role() {
824 let definition = valid_definition();
825
826 let err = definition
827 .role_id_for_step(&step_id("unowned"))
828 .unwrap_err();
829
830 assert!(matches!(
831 err,
832 DomainError::InvariantViolated {
833 reason: "no ceremony role can execute step"
834 }
835 ));
836 }
837
838 #[test]
839 fn resolves_role_authorised_for_a_transition() {
840 let definition = valid_definition();
841
842 assert_eq!(
843 definition
844 .role_id_for_transition(&trigger("finish"))
845 .unwrap(),
846 RoleId::new("facilitator").unwrap()
847 );
848 }
849
850 #[test]
851 fn rejects_transition_with_no_authorised_role() {
852 let definition = valid_definition();
853
854 let err = definition
855 .role_id_for_transition(&trigger("unowned"))
856 .unwrap_err();
857
858 assert!(matches!(
859 err,
860 DomainError::InvariantViolated {
861 reason: "no ceremony role can apply transition"
862 }
863 ));
864 }
865
866 #[test]
867 fn selects_guardless_transition_as_immediately_enabled() {
868 let transition = CeremonyTransition::new(
869 state_id("open"),
870 state_id("closed"),
871 trigger("go"),
872 Vec::new(),
873 )
874 .unwrap();
875 let definition = definition_with_budgets(
876 vec![
877 CeremonyState::initial(state_id("open")),
878 CeremonyState::terminal(state_id("closed")),
879 ],
880 vec![transition],
881 Vec::new(),
882 Vec::new(),
883 Vec::new(),
884 None,
885 Some(MaxBounces::new(1).unwrap()),
886 )
887 .unwrap();
888
889 let selected = definition
890 .next_satisfied_transition(
891 &state_id("open"),
892 &BTreeMap::new(),
893 &CeremonyContext::empty(),
894 )
895 .expect("guardless transition is always enabled");
896
897 assert_eq!(selected.trigger(), &trigger("go"));
898 }
899
900 #[test]
901 fn skips_transition_whose_guards_are_unsatisfied() {
902 let definition = valid_definition();
903
904 assert!(definition
905 .next_satisfied_transition(
906 &state_id("drafting"),
907 &BTreeMap::new(),
908 &CeremonyContext::empty(),
909 )
910 .is_none());
911 }
912
913 #[test]
914 fn yields_no_transition_out_of_a_terminal_state() {
915 let definition = valid_definition();
916
917 assert!(definition
918 .next_satisfied_transition(
919 &state_id("done"),
920 &BTreeMap::new(),
921 &CeremonyContext::empty(),
922 )
923 .is_none());
924 }
925
926 #[test]
927 fn rejects_guards_that_reference_unknown_steps() {
928 let guard = CeremonyGuard::new(
929 guard_name("unknown_step_done"),
930 GuardCondition::StepStatus {
931 step_id: step_id("missing"),
932 status: StepStatus::Completed,
933 },
934 );
935
936 let err = definition(
937 vec![
938 CeremonyState::initial(state_id("drafting")),
939 CeremonyState::terminal(state_id("done")),
940 ],
941 Vec::new(),
942 Vec::new(),
943 vec![guard],
944 Vec::new(),
945 )
946 .unwrap_err();
947
948 assert!(matches!(
949 err,
950 DomainError::NotFound {
951 what: "ceremony_guard.step"
952 }
953 ));
954 }
955
956 #[test]
962 fn serde_json_emits_sorted_keys() {
963 let mut out_of_order = serde_json::Map::new();
964 out_of_order.insert("zulu".to_owned(), serde_json::Value::from(1));
965 out_of_order.insert("alpha".to_owned(), serde_json::Value::from(2));
966
967 assert_eq!(
968 serde_json::to_string(&serde_json::Value::Object(out_of_order)).unwrap(),
969 r#"{"alpha":2,"zulu":1}"#,
970 "serde_json is no longer emitting sorted keys, so the definition digest is not canonical"
971 );
972 }
973
974 #[test]
975 fn the_same_definition_always_digests_the_same() {
976 assert_eq!(
977 valid_definition().digest().unwrap(),
978 valid_definition().digest().unwrap()
979 );
980 }
981
982 #[test]
983 fn a_material_difference_changes_the_digest() {
984 let baseline = valid_definition().digest().unwrap();
985 let renamed = definition(
986 vec![
987 CeremonyState::initial(state_id("drafting")),
988 CeremonyState::terminal(state_id("finished")),
989 ],
990 vec![CeremonyTransition::new(
991 state_id("drafting"),
992 state_id("finished"),
993 trigger("finish"),
994 Vec::new(),
995 )
996 .unwrap()],
997 Vec::new(),
998 Vec::new(),
999 Vec::new(),
1000 )
1001 .unwrap()
1002 .digest()
1003 .unwrap();
1004
1005 assert_ne!(baseline, renamed);
1006 }
1007
1008 #[test]
1009 fn transition_order_is_material_to_the_digest() {
1010 let states = || {
1014 vec![
1015 CeremonyState::initial(state_id("drafting")),
1016 CeremonyState::terminal(state_id("done")),
1017 CeremonyState::terminal(state_id("cancelled")),
1018 ]
1019 };
1020 let finish = || {
1021 CeremonyTransition::new(
1022 state_id("drafting"),
1023 state_id("done"),
1024 trigger("finish"),
1025 Vec::new(),
1026 )
1027 .unwrap()
1028 };
1029 let cancel = || {
1030 CeremonyTransition::new(
1031 state_id("drafting"),
1032 state_id("cancelled"),
1033 trigger("cancel"),
1034 Vec::new(),
1035 )
1036 .unwrap()
1037 };
1038
1039 let first = definition(
1040 states(),
1041 vec![finish(), cancel()],
1042 Vec::new(),
1043 Vec::new(),
1044 Vec::new(),
1045 )
1046 .unwrap();
1047 let swapped = definition(
1048 states(),
1049 vec![cancel(), finish()],
1050 Vec::new(),
1051 Vec::new(),
1052 Vec::new(),
1053 )
1054 .unwrap();
1055
1056 assert_ne!(first.digest().unwrap(), swapped.digest().unwrap());
1057 }
1058
1059 #[test]
1060 fn a_valid_definition_reports_no_findings_at_all() {
1061 let report = valid_definition().analyze();
1062
1063 assert!(report.is_valid());
1064 assert!(report.findings().is_empty());
1065 }
1066
1067 #[test]
1068 fn an_unreachable_state_is_warned_about_without_blocking_construction() {
1069 let definition = definition(
1070 vec![
1071 CeremonyState::initial(state_id("drafting")),
1072 CeremonyState::intermediate(state_id("orphan")),
1073 CeremonyState::terminal(state_id("done")),
1074 ],
1075 vec![
1076 CeremonyTransition::new(
1077 state_id("drafting"),
1078 state_id("done"),
1079 trigger("finish"),
1080 Vec::new(),
1081 )
1082 .unwrap(),
1083 CeremonyTransition::new(
1084 state_id("orphan"),
1085 state_id("done"),
1086 trigger("rescue"),
1087 Vec::new(),
1088 )
1089 .unwrap(),
1090 ],
1091 Vec::new(),
1092 Vec::new(),
1093 Vec::new(),
1094 )
1095 .unwrap();
1096
1097 let report = definition.analyze();
1098 let warnings = report.warnings().collect::<Vec<_>>();
1099
1100 assert!(report.is_valid());
1101 assert_eq!(warnings.len(), 1);
1102 assert_eq!(
1103 warnings[0].locus(),
1104 &CeremonyValidationLocus::state(state_id("orphan"))
1105 );
1106 }
1107
1108 #[test]
1109 fn a_state_that_cannot_reach_a_terminal_is_warned_about() {
1110 let definition = definition_with_budgets(
1111 vec![
1112 CeremonyState::initial(state_id("drafting")),
1113 CeremonyState::intermediate(state_id("stuck")),
1114 CeremonyState::terminal(state_id("done")),
1115 ],
1116 vec![
1117 CeremonyTransition::new(
1118 state_id("drafting"),
1119 state_id("done"),
1120 trigger("finish"),
1121 Vec::new(),
1122 )
1123 .unwrap(),
1124 CeremonyTransition::new(
1125 state_id("drafting"),
1126 state_id("stuck"),
1127 trigger("stall"),
1128 Vec::new(),
1129 )
1130 .unwrap(),
1131 CeremonyTransition::new(
1132 state_id("stuck"),
1133 state_id("stuck"),
1134 trigger("spin"),
1135 Vec::new(),
1136 )
1137 .unwrap(),
1138 ],
1139 Vec::new(),
1140 Vec::new(),
1141 Vec::new(),
1142 None,
1143 Some(MaxBounces::new(1).unwrap()),
1144 )
1145 .unwrap();
1146
1147 let report = definition.analyze();
1148 let warnings = report.warnings().collect::<Vec<_>>();
1149
1150 assert!(warnings.iter().any(|warning| {
1151 warning.locus() == &CeremonyValidationLocus::state(state_id("stuck"))
1152 && matches!(
1153 warning.defect(),
1154 DomainError::InvariantViolated {
1155 reason: "no terminal state is reachable from this ceremony state"
1156 }
1157 )
1158 }));
1159 }
1160
1161 #[test]
1162 fn a_definition_without_any_terminal_state_is_warned_about() {
1163 let definition = definition(
1164 vec![CeremonyState::initial(state_id("drafting"))],
1165 Vec::new(),
1166 Vec::new(),
1167 Vec::new(),
1168 Vec::new(),
1169 )
1170 .unwrap();
1171
1172 let report = definition.analyze();
1173 let warnings = report.warnings().collect::<Vec<_>>();
1174
1175 assert_eq!(warnings.len(), 1);
1176 assert_eq!(warnings[0].locus(), &CeremonyValidationLocus::Definition);
1177 }
1178
1179 #[test]
1180 fn structural_errors_suppress_reachability_noise() {
1181 let report = definition(
1182 vec![
1183 CeremonyState::initial(state_id("drafting")),
1184 CeremonyState::initial(state_id("also_drafting")),
1185 CeremonyState::terminal(state_id("done")),
1186 ],
1187 Vec::new(),
1188 Vec::new(),
1189 Vec::new(),
1190 Vec::new(),
1191 )
1192 .unwrap_err();
1193
1194 assert!(matches!(
1195 report,
1196 DomainError::InvariantViolated {
1197 reason: "ceremony definition must have exactly one initial state"
1198 }
1199 ));
1200 }
1201}