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