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