Skip to main content

made_core/entities/ceremony_instance/
transitions.rs

1use crate::entities::ceremony_commands::ApplyTransition;
2use crate::entities::CeremonyCommand;
3use crate::value_objects::CeremonyTransition;
4
5use super::{
6    CeremonyDefinition, CeremonyEvent, CeremonyInstance, DomainError, OffsetDateTime, RoleId,
7    StateId, TransitionTrigger,
8};
9
10impl CeremonyInstance {
11    /// A terminal move also requires every open intervention to be resolved.
12    #[must_use]
13    pub fn transition_is_enabled(
14        &self,
15        definition: &CeremonyDefinition,
16        transition: &CeremonyTransition,
17    ) -> bool {
18        self.transition_budget_allows(definition, transition)
19            && self.transition_requirements_are_satisfied(definition, transition)
20    }
21
22    /// Whether every transition requirement other than its declared
23    /// history budget is satisfied. A driver uses this only after no
24    /// budget-enabled edge exists, so the aggregate can issue the same
25    /// stable cap refusal as an explicit transition command.
26    #[must_use]
27    pub fn transition_requirements_are_satisfied(
28        &self,
29        definition: &CeremonyDefinition,
30        transition: &CeremonyTransition,
31    ) -> bool {
32        self.state_repeat_permits_transition(definition)
33            && definition.guards_are_satisfied(transition, &self.step_records, &self.context)
34            && self
35                .require_interventions_resolved_before_entering(definition, transition.to())
36                .is_ok()
37    }
38
39    pub(super) fn require_transition_budget(
40        &self,
41        definition: &CeremonyDefinition,
42        transition: &CeremonyTransition,
43    ) -> Result<(), DomainError> {
44        if definition.max_transitions().is_some_and(|limit| {
45            u64::try_from(self.transitions.len()).unwrap_or(u64::MAX) >= u64::from(limit.get())
46        }) {
47            return Err(DomainError::InvariantViolated {
48                reason: "ceremony transition limit exhausted",
49            });
50        }
51        if definition.max_bounces().is_some_and(|limit| {
52            u64::try_from(self.exact_edge_count(transition)).unwrap_or(u64::MAX)
53                >= u64::from(limit.get())
54        }) {
55            return Err(DomainError::InvariantViolated {
56                reason: "ceremony transition bounce limit exhausted",
57            });
58        }
59        Ok(())
60    }
61
62    fn transition_budget_allows(
63        &self,
64        definition: &CeremonyDefinition,
65        transition: &CeremonyTransition,
66    ) -> bool {
67        self.require_transition_budget(definition, transition)
68            .is_ok()
69    }
70
71    fn exact_edge_count(&self, transition: &CeremonyTransition) -> usize {
72        self.transitions
73            .iter()
74            .filter(|record| {
75                record.from_state() == transition.from()
76                    && record.trigger() == transition.trigger()
77                    && record.to_state() == transition.to()
78            })
79            .count()
80    }
81
82    #[must_use]
83    pub fn transition_is_enabled_at(
84        &self,
85        definition: &CeremonyDefinition,
86        transition: &CeremonyTransition,
87        now: OffsetDateTime,
88    ) -> bool {
89        !self.has_live_step_leases_at(definition, now)
90            && self.transition_is_enabled(definition, transition)
91    }
92
93    pub(super) fn require_interventions_resolved_before_entering(
94        &self,
95        definition: &CeremonyDefinition,
96        state_id: &StateId,
97    ) -> Result<(), DomainError> {
98        if definition.is_terminal_state(state_id)
99            && self
100                .interventions
101                .iter()
102                .any(|item| item.status().is_open())
103        {
104            return Err(DomainError::InvariantViolated {
105                reason: "ceremony cannot enter a terminal state with open interventions",
106            });
107        }
108        Ok(())
109    }
110
111    pub fn apply_transition_as(
112        &mut self,
113        definition: &CeremonyDefinition,
114        role_id: &RoleId,
115        trigger: &TransitionTrigger,
116        now: OffsetDateTime,
117    ) -> Result<StateId, DomainError> {
118        self.move_on(definition, trigger, Some(role_id.clone()), now)
119    }
120
121    pub fn apply_transition(
122        &mut self,
123        definition: &CeremonyDefinition,
124        trigger: &TransitionTrigger,
125        now: OffsetDateTime,
126    ) -> Result<StateId, DomainError> {
127        self.move_on(definition, trigger, None, now)
128    }
129
130    /// `applied_by` is absent when the engine took the move itself,
131    /// and naming somebody would be inventing them.
132    fn move_on(
133        &mut self,
134        definition: &CeremonyDefinition,
135        trigger: &TransitionTrigger,
136        applied_by: Option<RoleId>,
137        now: OffsetDateTime,
138    ) -> Result<StateId, DomainError> {
139        let command = CeremonyCommand::ApplyTransition(ApplyTransition {
140            role_id: applied_by,
141            trigger: trigger.clone(),
142            now,
143        });
144        let events = self.decide(&command, definition)?;
145        let to_state = events
146            .iter()
147            .find_map(|event| match event {
148                CeremonyEvent::TransitionApplied(applied) => {
149                    Some(applied.transition.to_state().clone())
150                }
151                _ => None,
152            })
153            .ok_or(DomainError::InvariantViolated {
154                reason: "moving decides a transition",
155            })?;
156        self.apply_all(&events);
157        Ok(to_state)
158    }
159}