Skip to main content

made_core/entities/
ceremony_definition.rs

1//! [`CeremonyDefinition`] aggregate.
2//!
3//! A ceremony definition is the declarative state machine extracted
4//! from the original laboratory ceremony engine. It is intentionally
5//! pure domain: no YAML, no transport, no handler registry.
6
7use 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    /// Construct with transition budgets installed before structural validation.
80    ///
81    /// A cyclic graph cannot be constructed uncapped and repaired afterward:
82    /// the missing bound is itself a blocking definition defect.
83    #[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    /// Iterate over every step in its declaration order.
167    ///
168    /// Step identifiers remain indexed separately for efficient lookup;
169    /// execution order is an explicit part of the ceremony definition.
170    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    /// Find the role authorised to perform `action`, if any.
275    ///
276    /// Roles are scanned in id order and the first whose action set
277    /// permits `action` is returned. Yields `None` when no declared role
278    /// is allowed to perform it.
279    #[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    /// Resolve the role authorised to execute `step_id`.
285    ///
286    /// Fails fast with [`DomainError::InvariantViolated`] when no role is
287    /// allowed to run the step — a ceremony cannot execute a step nobody
288    /// owns.
289    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    /// Resolve the role authorised to apply the transition fired by
298    /// `trigger`.
299    ///
300    /// Fails fast with [`DomainError::InvariantViolated`] when no role is
301    /// allowed to apply it — a ceremony cannot advance through a
302    /// transition nobody owns.
303    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    /// Select the next transition out of `state_id` whose guards are all
315    /// currently satisfied by `records` and `context`.
316    ///
317    /// Outgoing transitions are evaluated in declaration order and the
318    /// first one that is fully enabled is returned. Yields `None` when
319    /// the state has no outgoing transition whose guards hold — either
320    /// because the state is terminal or because the ceremony is not yet
321    /// ready to advance.
322    #[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    /// The identity of this definition's content.
334    ///
335    /// Computed over canonical JSON of the whole aggregate rather than
336    /// over the document it arrived in: two YAML files differing in
337    /// whitespace, key order or comments describe the same working
338    /// session and must agree, while any material difference must not.
339    ///
340    /// Encoding the aggregate through `serde` rather than by hand is
341    /// deliberate. A hand-written encoder that forgets a field produces
342    /// two materially different definitions with one digest, and
343    /// nothing would report it; here a field cannot be left out, and a
344    /// field added later changes the digest, which is correct because
345    /// it is material.
346    ///
347    /// Canonical because `serde_json` maps are ordered — see
348    /// `serde_json_emits_sorted_keys` for the guard that keeps that
349    /// assumption from being silently withdrawn.
350    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    /// Collect every defect in the definition instead of stopping at
362    /// the first one.
363    ///
364    /// A single error is enough to reject a definition but not enough
365    /// to correct one. An author — human or agent — needs the full set
366    /// to fix a draft in one pass.
367    ///
368    /// Findings are emitted in check order, so the first blocking one
369    /// is exactly the error [`Self::new`] raises.
370    #[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    /// The digest is canonical only because `serde_json` orders object
957    /// keys. Enabling `preserve_order` anywhere in the dependency graph
958    /// — including through feature unification by a crate nobody here
959    /// chose — would withdraw that silently and change every digest.
960    /// This is what makes it loud instead.
961    #[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        // Declaration order decides which transition fires first when
1011        // several are enabled, so two definitions that differ only in
1012        // that order are different working sessions.
1013        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}