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, GuardCondition};
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            && self.guards_are_satisfied(definition, transition)
34            && self
35                .require_interventions_resolved_before_entering(definition, transition.to())
36                .is_ok()
37    }
38
39    #[must_use]
40    pub fn guard_is_satisfied_for_transition(
41        &self,
42        definition: &CeremonyDefinition,
43        transition: &CeremonyTransition,
44        guard: &crate::value_objects::CeremonyGuard,
45    ) -> bool {
46        match guard.condition() {
47            GuardCondition::ChildrenCompleted(condition) => {
48                definition
49                    .step(condition.step_id())
50                    .is_some_and(|step| step.state_id() == transition.from())
51                    && self.children_completed_guard_is_satisfied(condition)
52            }
53            _ => definition.guard_is_satisfied_for_transition(
54                guard,
55                transition,
56                &self.step_records,
57                &self.context,
58            ),
59        }
60    }
61
62    pub(super) fn guards_are_satisfied(
63        &self,
64        definition: &CeremonyDefinition,
65        transition: &CeremonyTransition,
66    ) -> bool {
67        definition.repeat_requirements_are_satisfied_for_transition(transition, &self.step_records)
68            && transition.required_guards().iter().all(|name| {
69                definition.guards().get(name).is_some_and(|guard| {
70                    self.guard_is_satisfied_for_transition(definition, transition, guard)
71                })
72            })
73    }
74
75    pub(super) fn require_transition_budget(
76        &self,
77        definition: &CeremonyDefinition,
78        transition: &CeremonyTransition,
79    ) -> Result<(), DomainError> {
80        if definition.max_transitions().is_some_and(|limit| {
81            u64::try_from(self.transitions.len()).unwrap_or(u64::MAX) >= u64::from(limit.get())
82        }) {
83            return Err(DomainError::InvariantViolated {
84                reason: "ceremony transition limit exhausted",
85            });
86        }
87        if definition.max_bounces().is_some_and(|limit| {
88            u64::try_from(self.exact_edge_count(transition)).unwrap_or(u64::MAX)
89                >= u64::from(limit.get())
90        }) {
91            return Err(DomainError::InvariantViolated {
92                reason: "ceremony transition bounce limit exhausted",
93            });
94        }
95        Ok(())
96    }
97
98    fn transition_budget_allows(
99        &self,
100        definition: &CeremonyDefinition,
101        transition: &CeremonyTransition,
102    ) -> bool {
103        self.require_transition_budget(definition, transition)
104            .is_ok()
105    }
106
107    fn exact_edge_count(&self, transition: &CeremonyTransition) -> usize {
108        self.transitions
109            .iter()
110            .filter(|record| {
111                record.from_state() == transition.from()
112                    && record.trigger() == transition.trigger()
113                    && record.to_state() == transition.to()
114            })
115            .count()
116    }
117
118    #[must_use]
119    pub fn transition_is_enabled_at(
120        &self,
121        definition: &CeremonyDefinition,
122        transition: &CeremonyTransition,
123        now: OffsetDateTime,
124    ) -> bool {
125        !self.has_live_step_leases_at(definition, now)
126            && self.transition_is_enabled(definition, transition)
127    }
128
129    pub(super) fn require_interventions_resolved_before_entering(
130        &self,
131        definition: &CeremonyDefinition,
132        state_id: &StateId,
133    ) -> Result<(), DomainError> {
134        if definition.is_terminal_state(state_id)
135            && self
136                .interventions
137                .iter()
138                .any(|item| item.status().is_open())
139        {
140            return Err(DomainError::InvariantViolated {
141                reason: "ceremony cannot enter a terminal state with open interventions",
142            });
143        }
144        Ok(())
145    }
146
147    pub fn apply_transition_as(
148        &mut self,
149        definition: &CeremonyDefinition,
150        role_id: &RoleId,
151        trigger: &TransitionTrigger,
152        now: OffsetDateTime,
153    ) -> Result<StateId, DomainError> {
154        self.move_on(definition, trigger, Some(role_id.clone()), now)
155    }
156
157    pub fn apply_transition(
158        &mut self,
159        definition: &CeremonyDefinition,
160        trigger: &TransitionTrigger,
161        now: OffsetDateTime,
162    ) -> Result<StateId, DomainError> {
163        self.move_on(definition, trigger, None, now)
164    }
165
166    /// `applied_by` is absent when the engine took the move itself,
167    /// and naming somebody would be inventing them.
168    fn move_on(
169        &mut self,
170        definition: &CeremonyDefinition,
171        trigger: &TransitionTrigger,
172        applied_by: Option<RoleId>,
173        now: OffsetDateTime,
174    ) -> Result<StateId, DomainError> {
175        let command = CeremonyCommand::ApplyTransition(ApplyTransition {
176            role_id: applied_by,
177            trigger: trigger.clone(),
178            now,
179        });
180        let events = self.decide(&command, definition)?;
181        let to_state = events
182            .iter()
183            .find_map(|event| match event {
184                CeremonyEvent::TransitionApplied(applied) => {
185                    Some(applied.transition.to_state().clone())
186                }
187                _ => None,
188            })
189            .ok_or(DomainError::InvariantViolated {
190                reason: "moving decides a transition",
191            })?;
192        self.apply_all(&events);
193        Ok(to_state)
194    }
195}