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