1use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10use time::OffsetDateTime;
11
12use super::{
13 ceremony_definition::CeremonyDefinition, CeremonyIntervention, PublishedCeremonyDefinition,
14};
15use crate::error::DomainError;
16use crate::ports::CeremonyEvidenceRequest;
17use crate::value_objects::{
18 AuditActorKind, CeremonyContext, CeremonyDefinitionDigest, CeremonyDefinitionDigestMigration,
19 CeremonyEvidenceSourceId, CeremonyGuardApproval, CeremonyGuardDeferral,
20 CeremonyGuardDeferralContent, CeremonyId, CeremonyInterventionContent, CeremonyInterventionId,
21 CeremonyInterventionKind, CeremonyInterventionProvenance, CeremonyInterventionResponse,
22 CeremonyInterventionTarget, CeremonyName, CeremonyParticipantBinding, CeremonyReason,
23 CeremonyReasonKind, CeremonyRecordRef, CeremonyTransitionRecord, CeremonyVersion,
24 GuardCondition, GuardName, IdempotencyKey, MemoryConfidence, ReasonAsserter, RoleAction,
25 RoleId, Specialty, StateId, StepAttempt, StepExecutionRecord, StepId, StepLease, StepResult,
26 StepStatus, TransitionTrigger,
27};
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct CeremonyInstance {
31 id: CeremonyId,
32 definition_name: CeremonyName,
33 definition_version: CeremonyVersion,
34 current_state: StateId,
35 step_records: BTreeMap<StepId, StepExecutionRecord>,
36 #[serde(default)]
37 interventions: Vec<CeremonyIntervention>,
38 #[serde(default)]
39 guard_deferrals: Vec<CeremonyGuardDeferral>,
40 #[serde(default)]
50 guard_approvals: Vec<CeremonyGuardApproval>,
51 #[serde(default)]
58 transitions: Vec<CeremonyTransitionRecord>,
59 #[serde(default)]
66 reasons: Vec<CeremonyReason>,
67 #[serde(default)]
71 participant_bindings: BTreeMap<RoleId, CeremonyParticipantBinding>,
72 context: CeremonyContext,
73 idempotency_keys: BTreeSet<IdempotencyKey>,
74 #[serde(with = "time::serde::rfc3339")]
75 created_at: OffsetDateTime,
76 #[serde(with = "time::serde::rfc3339")]
77 updated_at: OffsetDateTime,
78 #[serde(with = "time::serde::rfc3339::option")]
79 completed_at: Option<OffsetDateTime>,
80 #[serde(default)]
91 bound_definition: Option<CeremonyDefinitionDigest>,
92}
93
94impl CeremonyInstance {
95 #[must_use]
100 pub fn start(
101 id: CeremonyId,
102 definition: &CeremonyDefinition,
103 context: CeremonyContext,
104 now: OffsetDateTime,
105 ) -> Self {
106 Self::open(id, definition, context, now, None)
107 }
108
109 #[must_use]
115 pub fn start_bound(
116 id: CeremonyId,
117 published: &PublishedCeremonyDefinition,
118 context: CeremonyContext,
119 now: OffsetDateTime,
120 ) -> Self {
121 Self::open(
122 id,
123 published.definition(),
124 context,
125 now,
126 Some(published.digest()),
127 )
128 }
129
130 fn open(
131 id: CeremonyId,
132 definition: &CeremonyDefinition,
133 context: CeremonyContext,
134 now: OffsetDateTime,
135 bound_definition: Option<CeremonyDefinitionDigest>,
136 ) -> Self {
137 let step_records = definition
138 .steps()
139 .keys()
140 .map(|step_id| (step_id.clone(), StepExecutionRecord::pending()))
141 .collect();
142
143 Self {
144 id,
145 definition_name: definition.name().clone(),
146 definition_version: definition.version().clone(),
147 current_state: definition.initial_state_id().clone(),
148 step_records,
149 interventions: Vec::new(),
150 guard_deferrals: Vec::new(),
151 guard_approvals: Vec::new(),
152 transitions: Vec::new(),
153 reasons: Vec::new(),
154 participant_bindings: BTreeMap::new(),
155 context,
156 idempotency_keys: BTreeSet::new(),
157 created_at: now,
158 updated_at: now,
159 completed_at: None,
160 bound_definition,
161 }
162 }
163
164 #[must_use]
167 pub fn bound_definition(&self) -> Option<CeremonyDefinitionDigest> {
168 self.bound_definition
169 }
170
171 #[must_use]
174 pub fn is_bound_to_a_published_definition(&self) -> bool {
175 self.bound_definition.is_some()
176 }
177
178 pub fn migrate_definition_binding(
186 &mut self,
187 migration: &CeremonyDefinitionDigestMigration,
188 ) -> Result<bool, DomainError> {
189 if self.definition_name != *migration.definition_name()
190 || self.definition_version != *migration.definition_version()
191 {
192 return Err(DomainError::InvariantViolated {
193 reason: "a definition binding migration cannot change name or version",
194 });
195 }
196
197 match self.bound_definition {
198 Some(current) if current == migration.destination() => Ok(false),
199 Some(current) if current == migration.source() => {
200 self.bound_definition = Some(migration.destination());
201 Ok(true)
202 }
203 _ => Err(DomainError::InvariantViolated {
204 reason: "a definition binding migration did not match the stored identity",
205 }),
206 }
207 }
208
209 #[must_use]
210 pub fn id(&self) -> &CeremonyId {
211 &self.id
212 }
213
214 #[must_use]
215 pub fn definition_name(&self) -> &CeremonyName {
216 &self.definition_name
217 }
218
219 #[must_use]
220 pub fn definition_version(&self) -> &CeremonyVersion {
221 &self.definition_version
222 }
223
224 #[must_use]
225 pub fn current_state(&self) -> &StateId {
226 &self.current_state
227 }
228
229 #[must_use]
230 pub fn step_records(&self) -> &BTreeMap<StepId, StepExecutionRecord> {
231 &self.step_records
232 }
233
234 #[must_use]
235 pub fn step_record(&self, step_id: &StepId) -> Option<&StepExecutionRecord> {
236 self.step_records.get(step_id)
237 }
238
239 #[must_use]
240 pub fn interventions(&self) -> &[CeremonyIntervention] {
241 &self.interventions
242 }
243
244 #[must_use]
245 pub fn guard_deferrals(&self) -> &[CeremonyGuardDeferral] {
246 &self.guard_deferrals
247 }
248
249 #[must_use]
255 pub fn guard_approvals(&self) -> &[CeremonyGuardApproval] {
256 &self.guard_approvals
257 }
258
259 #[must_use]
261 pub fn transitions(&self) -> &[CeremonyTransitionRecord] {
262 &self.transitions
263 }
264
265 #[must_use]
267 pub fn reasons(&self) -> &[CeremonyReason] {
268 &self.reasons
269 }
270
271 #[must_use]
272 pub fn intervention(
273 &self,
274 intervention_id: &CeremonyInterventionId,
275 ) -> Option<&CeremonyIntervention> {
276 self.interventions
277 .iter()
278 .find(|intervention| intervention.id() == intervention_id)
279 }
280
281 #[must_use]
282 pub fn context(&self) -> &CeremonyContext {
283 &self.context
284 }
285
286 #[must_use]
287 pub fn idempotency_keys(&self) -> &BTreeSet<IdempotencyKey> {
288 &self.idempotency_keys
289 }
290
291 #[must_use]
292 pub fn created_at(&self) -> OffsetDateTime {
293 self.created_at
294 }
295
296 #[must_use]
297 pub fn updated_at(&self) -> OffsetDateTime {
298 self.updated_at
299 }
300
301 #[must_use]
302 pub fn completed_at(&self) -> Option<OffsetDateTime> {
303 self.completed_at
304 }
305
306 #[must_use]
307 pub fn is_terminal(&self, definition: &CeremonyDefinition) -> bool {
308 self.matches_definition(definition) && definition.is_terminal_state(&self.current_state)
309 }
310
311 #[must_use]
312 pub fn is_completed(&self, definition: &CeremonyDefinition) -> bool {
313 self.is_terminal(definition) && self.completed_at.is_some()
314 }
315
316 pub fn start_step_as(
317 &mut self,
318 definition: &CeremonyDefinition,
319 role_id: &RoleId,
320 step_id: &StepId,
321 lease: StepLease,
322 now: OffsetDateTime,
323 ) -> Result<StepAttempt, DomainError> {
324 self.require_role(definition, role_id, &RoleAction::step(step_id.clone()))?;
325 self.start_step(definition, step_id, lease, now)
326 }
327
328 pub fn start_step(
329 &mut self,
330 definition: &CeremonyDefinition,
331 step_id: &StepId,
332 lease: StepLease,
333 now: OffsetDateTime,
334 ) -> Result<StepAttempt, DomainError> {
335 self.require_definition(definition)?;
336 if self.is_terminal(definition) {
337 return Err(DomainError::InvariantViolated {
338 reason: "terminal ceremony instances cannot start steps",
339 });
340 }
341
342 let step = definition.step(step_id).ok_or(DomainError::NotFound {
343 what: "ceremony_instance.step",
344 })?;
345 if step.state_id() != &self.current_state {
346 return Err(DomainError::InvalidTransition {
347 from: "ceremony_instance.current_state",
348 to: "ceremony_step.state",
349 });
350 }
351
352 let record = self
353 .step_records
354 .get(step_id)
355 .cloned()
356 .ok_or(DomainError::NotFound {
357 what: "ceremony_instance.step_record",
358 })?;
359 if !record.can_be_started_at(now) {
360 return Err(DomainError::InvariantViolated {
361 reason: "step lease is still active",
362 });
363 }
364
365 let next_attempt = next_attempt_for_start(&record)?;
366 if !step.retry_policy().allows_attempt(next_attempt) {
367 return Err(DomainError::InvariantViolated {
368 reason: "step retry policy exhausted",
369 });
370 }
371 if !self
372 .idempotency_keys
373 .insert(lease.idempotency_key().clone())
374 {
375 return Err(DomainError::AlreadyExists {
376 what: "ceremony_instance.idempotency_key",
377 });
378 }
379
380 self.step_records
381 .insert(step_id.clone(), record.with_started(lease, next_attempt));
382 self.updated_at = now;
383 Ok(next_attempt)
384 }
385
386 pub fn apply_step_result(
387 &mut self,
388 definition: &CeremonyDefinition,
389 step_id: &StepId,
390 result: StepResult,
391 now: OffsetDateTime,
392 ) -> Result<(), DomainError> {
393 self.require_definition(definition)?;
394 let step = definition.step(step_id).ok_or(DomainError::NotFound {
395 what: "ceremony_instance.step",
396 })?;
397 if step.state_id() != &self.current_state {
398 return Err(DomainError::InvalidTransition {
399 from: "ceremony_instance.current_state",
400 to: "ceremony_step.state",
401 });
402 }
403
404 let record = self
405 .step_records
406 .get(step_id)
407 .cloned()
408 .ok_or(DomainError::NotFound {
409 what: "ceremony_instance.step_record",
410 })?;
411 if record.status() != StepStatus::InProgress {
412 return Err(DomainError::InvariantViolated {
413 reason: "step result requires an in-progress step",
414 });
415 }
416
417 self.step_records
418 .insert(step_id.clone(), record.with_result(result));
419 self.updated_at = now;
420 Ok(())
421 }
422
423 pub fn approve_guard(
432 &mut self,
433 definition: &CeremonyDefinition,
434 guard_name: &GuardName,
435 approved_by: RoleId,
436 approved_by_kind: AuditActorKind,
437 now: OffsetDateTime,
438 ) -> Result<(), DomainError> {
439 self.require_active(
440 definition,
441 "terminal ceremony instances cannot approve guards",
442 )?;
443 let guard = definition
444 .guards()
445 .get(guard_name)
446 .ok_or(DomainError::NotFound {
447 what: "ceremony_guard",
448 })?;
449 if !matches!(guard.condition(), GuardCondition::HumanApproval) {
450 return Err(DomainError::InvariantViolated {
451 reason: "only human approval guards can be approved",
452 });
453 }
454 self.require_declared_role(definition, &approved_by)?;
455 self.context = self.context.clone().with_guard_approval(guard_name)?;
456 self.guard_approvals.push(CeremonyGuardApproval::record(
457 guard_name.clone(),
458 approved_by,
459 approved_by_kind,
460 now,
461 ));
462 self.updated_at = now;
463 Ok(())
464 }
465
466 pub fn bind_participant(
475 &mut self,
476 definition: &CeremonyDefinition,
477 role_id: RoleId,
478 specialty: Specialty,
479 now: OffsetDateTime,
480 ) -> Result<(), DomainError> {
481 self.require_active(
482 definition,
483 "terminal ceremony instances cannot be re-seated",
484 )?;
485 if definition.role(&role_id).is_none() {
487 return Err(DomainError::NotFound {
488 what: "ceremony_role",
489 });
490 }
491 self.participant_bindings.insert(
492 role_id.clone(),
493 CeremonyParticipantBinding::record(role_id, specialty, now),
494 );
495 self.updated_at = now;
496 Ok(())
497 }
498
499 #[must_use]
500 pub fn participant_bindings(&self) -> &BTreeMap<RoleId, CeremonyParticipantBinding> {
501 &self.participant_bindings
502 }
503
504 #[must_use]
507 pub fn bound_specialty(&self, role_id: &RoleId) -> Option<&Specialty> {
508 self.participant_bindings
509 .get(role_id)
510 .map(CeremonyParticipantBinding::specialty)
511 }
512
513 pub fn defer_guard(
514 &mut self,
515 definition: &CeremonyDefinition,
516 guard_name: GuardName,
517 content: CeremonyGuardDeferralContent,
518 deferred_by: RoleId,
519 deferred_by_kind: AuditActorKind,
520 now: OffsetDateTime,
521 ) -> Result<(), DomainError> {
522 self.require_active(
523 definition,
524 "terminal ceremony instances cannot defer guard decisions",
525 )?;
526 let guard = definition
527 .guards()
528 .get(&guard_name)
529 .ok_or(DomainError::NotFound {
530 what: "ceremony_guard",
531 })?;
532 if !matches!(guard.condition(), GuardCondition::HumanApproval) {
533 return Err(DomainError::InvariantViolated {
534 reason: "only human approval guards can be deferred",
535 });
536 }
537 if self.context.is_guard_approved(&guard_name) {
538 return Err(DomainError::InvariantViolated {
539 reason: "approved human guards cannot be deferred",
540 });
541 }
542 let is_currently_required = definition
543 .available_transitions(&self.current_state)
544 .any(|transition| transition.required_guards().contains(&guard_name));
545 if !is_currently_required {
546 return Err(DomainError::InvariantViolated {
547 reason: "human guard is not required from the current state",
548 });
549 }
550
551 self.require_declared_role(definition, &deferred_by)?;
552 self.guard_deferrals.push(CeremonyGuardDeferral::record(
553 guard_name,
554 deferred_by,
555 deferred_by_kind,
556 content,
557 now,
558 ));
559 self.updated_at = now;
560 Ok(())
561 }
562
563 #[allow(clippy::too_many_arguments)]
564 pub fn request_intervention_as(
565 &mut self,
566 definition: &CeremonyDefinition,
567 intervention_id: CeremonyInterventionId,
568 role_id: RoleId,
569 kind: CeremonyInterventionKind,
570 target: CeremonyInterventionTarget,
571 content: CeremonyInterventionContent,
572 now: OffsetDateTime,
573 ) -> Result<(), DomainError> {
574 self.request_intervention_with_provenance_as(
575 definition,
576 intervention_id,
577 role_id,
578 kind,
579 target,
580 content,
581 None,
582 now,
583 )
584 }
585
586 #[allow(clippy::too_many_arguments)]
587 pub fn request_intervention_with_provenance_as(
588 &mut self,
589 definition: &CeremonyDefinition,
590 intervention_id: CeremonyInterventionId,
591 role_id: RoleId,
592 kind: CeremonyInterventionKind,
593 target: CeremonyInterventionTarget,
594 content: CeremonyInterventionContent,
595 provenance: Option<CeremonyInterventionProvenance>,
596 now: OffsetDateTime,
597 ) -> Result<(), DomainError> {
598 self.require_active(
599 definition,
600 "terminal ceremony instances cannot accept interventions",
601 )?;
602 self.require_role(definition, &role_id, &RoleAction::request_intervention())?;
603 Self::require_intervention_target(definition, &target)?;
604 if let Some(provenance) = provenance.as_ref() {
605 self.require_intervention_provenance(definition, &role_id, &target, provenance)?;
606 }
607 if self
608 .interventions
609 .iter()
610 .any(|intervention| intervention.id() == &intervention_id)
611 {
612 return Err(DomainError::AlreadyExists {
613 what: "ceremony_intervention",
614 });
615 }
616 let intervention = CeremonyIntervention::open_with_provenance(
617 intervention_id,
618 kind,
619 role_id,
620 target,
621 content,
622 provenance,
623 now,
624 );
625 self.interventions.push(intervention);
626 self.updated_at = now;
627 Ok(())
628 }
629
630 pub fn respond_to_intervention_as(
631 &mut self,
632 definition: &CeremonyDefinition,
633 intervention_id: &CeremonyInterventionId,
634 role_id: RoleId,
635 content: CeremonyInterventionContent,
636 now: OffsetDateTime,
637 ) -> Result<(), DomainError> {
638 self.require_active(
639 definition,
640 "terminal ceremony instances cannot receive intervention responses",
641 )?;
642 self.require_role(definition, &role_id, &RoleAction::respond_to_intervention())?;
643 self.interventions
644 .iter_mut()
645 .find(|intervention| intervention.id() == intervention_id)
646 .ok_or(DomainError::NotFound {
647 what: "ceremony_intervention",
648 })?
649 .respond(role_id, content, now)?;
650 self.record_that_it_answers(intervention_id, now);
651 self.updated_at = now;
652 Ok(())
653 }
654
655 pub fn prepare_evidence_request_as(
656 &self,
657 definition: &CeremonyDefinition,
658 intervention_id: CeremonyInterventionId,
659 role_id: RoleId,
660 source_id: CeremonyEvidenceSourceId,
661 query: CeremonyInterventionContent,
662 ) -> Result<CeremonyEvidenceRequest, DomainError> {
663 self.require_active(
664 definition,
665 "terminal ceremony instances cannot collect intervention evidence",
666 )?;
667 self.require_role(definition, &role_id, &RoleAction::respond_to_intervention())?;
668 self.intervention(&intervention_id)
669 .ok_or(DomainError::NotFound {
670 what: "ceremony_intervention",
671 })?
672 .ensure_can_respond(&role_id)?;
673 Ok(CeremonyEvidenceRequest::new(
674 self.id.clone(),
675 intervention_id,
676 role_id,
677 source_id,
678 query,
679 self.context.clone(),
680 ))
681 }
682
683 pub fn respond_to_intervention_with_evidence_as(
684 &mut self,
685 definition: &CeremonyDefinition,
686 intervention_id: &CeremonyInterventionId,
687 role_id: RoleId,
688 evidence_pack: super::CeremonyEvidencePack,
689 now: OffsetDateTime,
690 ) -> Result<(), DomainError> {
691 self.require_active(
692 definition,
693 "terminal ceremony instances cannot receive intervention evidence",
694 )?;
695 self.require_role(definition, &role_id, &RoleAction::respond_to_intervention())?;
696 self.interventions
697 .iter_mut()
698 .find(|intervention| intervention.id() == intervention_id)
699 .ok_or(DomainError::NotFound {
700 what: "ceremony_intervention",
701 })?
702 .respond_with_evidence(role_id, evidence_pack, now)?;
703 self.record_that_it_answers(intervention_id, now);
704 self.updated_at = now;
705 Ok(())
706 }
707
708 #[allow(clippy::too_many_arguments)]
723 pub fn assert_reason_as(
724 &mut self,
725 definition: &CeremonyDefinition,
726 role_id: RoleId,
727 from: CeremonyRecordRef,
728 to: CeremonyRecordRef,
729 kind: CeremonyReasonKind,
730 why: impl Into<String>,
731 confidence: MemoryConfidence,
732 now: OffsetDateTime,
733 ) -> Result<(), DomainError> {
734 self.require_declared_role(definition, &role_id)?;
735 self.require_record(&from)?;
736 self.require_record(&to)?;
737
738 match kind.asserter() {
739 ReasonAsserter::TheEngine => {
740 return Err(DomainError::InvariantViolated {
741 reason:
742 "this kind of reason states the shape of the session, not a judgement, \
743 and only the engine may assert it",
744 });
745 }
746 ReasonAsserter::ItsAuthor => {
747 if self.author_of(&from) != Some(&role_id) {
748 return Err(DomainError::InvariantViolated {
749 reason: "only whoever produced something may say why they decided it or \
750 how they did it",
751 });
752 }
753 }
754 ReasonAsserter::AnySeat => {}
755 }
756
757 self.reasons.push(CeremonyReason::new(
758 from,
759 to,
760 kind,
761 why,
762 confidence,
763 Some(role_id),
764 now,
765 )?);
766 self.updated_at = now;
767 Ok(())
768 }
769
770 fn author_of(&self, record: &CeremonyRecordRef) -> Option<&RoleId> {
777 match record {
778 CeremonyRecordRef::Step { .. } => None,
779 CeremonyRecordRef::AgendaItem { agenda_item } => self
780 .intervention(agenda_item)
781 .map(CeremonyIntervention::requested_by),
782 CeremonyRecordRef::Contribution {
783 agenda_item,
784 ordinal,
785 } => self
786 .intervention(agenda_item)
787 .and_then(|item| item.responses().get(*ordinal as usize))
788 .map(CeremonyInterventionResponse::role_id),
789 CeremonyRecordRef::GuardDecision { guard_name } => self
790 .guard_approvals
791 .iter()
792 .find(|approval| approval.guard_name() == guard_name)
793 .map(CeremonyGuardApproval::approved_by)
794 .or_else(|| {
795 self.guard_deferrals
796 .iter()
797 .find(|deferral| deferral.guard_name() == guard_name)
798 .map(CeremonyGuardDeferral::deferred_by)
799 }),
800 CeremonyRecordRef::Transition { ordinal } => self
801 .transitions
802 .get(ordinal.saturating_sub(1) as usize)
803 .and_then(CeremonyTransitionRecord::applied_by),
804 }
805 }
806
807 fn require_record(&self, record: &CeremonyRecordRef) -> Result<(), DomainError> {
814 let exists = match record {
815 CeremonyRecordRef::Step { step_id } => self.step_records.contains_key(step_id),
816 CeremonyRecordRef::AgendaItem { agenda_item } => {
817 self.intervention(agenda_item).is_some()
818 }
819 CeremonyRecordRef::Contribution {
820 agenda_item,
821 ordinal,
822 } => self
823 .intervention(agenda_item)
824 .is_some_and(|item| item.responses().len() > *ordinal as usize),
825 CeremonyRecordRef::GuardDecision { guard_name } => {
826 self.guard_approvals
827 .iter()
828 .any(|approval| approval.guard_name() == guard_name)
829 || self
830 .guard_deferrals
831 .iter()
832 .any(|deferral| deferral.guard_name() == guard_name)
833 }
834 CeremonyRecordRef::Transition { ordinal } => {
835 *ordinal >= 1 && (*ordinal as usize) <= self.transitions.len()
836 }
837 };
838 if exists {
839 Ok(())
840 } else {
841 Err(DomainError::NotFound {
842 what: "ceremony_record",
843 })
844 }
845 }
846
847 fn record_that_it_answers(
854 &mut self,
855 agenda_item: &CeremonyInterventionId,
856 now: OffsetDateTime,
857 ) {
858 let Some(ordinal) = self
859 .intervention(agenda_item)
860 .map(|item| item.responses().len())
861 .and_then(|count| u32::try_from(count.checked_sub(1)?).ok())
862 else {
863 return;
864 };
865 if let Ok(reason) = CeremonyReason::new(
866 CeremonyRecordRef::contribution(agenda_item.clone(), ordinal),
867 CeremonyRecordRef::agenda_item(agenda_item.clone()),
868 CeremonyReasonKind::Answers,
869 "a contribution made against this agenda item",
870 MemoryConfidence::High,
871 None,
872 now,
873 ) {
874 self.reasons.push(reason);
875 }
876 }
877
878 pub fn close_intervention_as(
879 &mut self,
880 definition: &CeremonyDefinition,
881 intervention_id: &CeremonyInterventionId,
882 role_id: &RoleId,
883 now: OffsetDateTime,
884 ) -> Result<(), DomainError> {
885 self.require_active(
886 definition,
887 "terminal ceremony instances cannot close interventions",
888 )?;
889 self.require_role(definition, role_id, &RoleAction::request_intervention())?;
890 self.interventions
891 .iter_mut()
892 .find(|intervention| intervention.id() == intervention_id)
893 .ok_or(DomainError::NotFound {
894 what: "ceremony_intervention",
895 })?
896 .close(role_id, now)?;
897 self.updated_at = now;
898 Ok(())
899 }
900
901 pub fn apply_transition_as(
902 &mut self,
903 definition: &CeremonyDefinition,
904 role_id: &RoleId,
905 trigger: &TransitionTrigger,
906 now: OffsetDateTime,
907 ) -> Result<StateId, DomainError> {
908 self.require_role(
909 definition,
910 role_id,
911 &RoleAction::transition(trigger.clone()),
912 )?;
913 self.move_on(definition, trigger, Some(role_id.clone()), now)
914 }
915
916 pub fn apply_transition(
917 &mut self,
918 definition: &CeremonyDefinition,
919 trigger: &TransitionTrigger,
920 now: OffsetDateTime,
921 ) -> Result<StateId, DomainError> {
922 self.move_on(definition, trigger, None, now)
923 }
924
925 fn move_on(
930 &mut self,
931 definition: &CeremonyDefinition,
932 trigger: &TransitionTrigger,
933 applied_by: Option<RoleId>,
934 now: OffsetDateTime,
935 ) -> Result<StateId, DomainError> {
936 self.require_definition(definition)?;
937 if self.is_terminal(definition) {
938 return Err(DomainError::InvariantViolated {
939 reason: "terminal ceremony instances cannot transition",
940 });
941 }
942
943 let transition = definition
944 .transition_for_trigger(&self.current_state, trigger)
945 .ok_or(DomainError::InvalidTransition {
946 from: "ceremony_instance.current_state",
947 to: "transition_trigger",
948 })?;
949 if !definition.guards_are_satisfied(transition, &self.step_records, &self.context) {
950 return Err(DomainError::InvariantViolated {
951 reason: "ceremony transition guards are not satisfied",
952 });
953 }
954
955 let from_state = self.current_state.clone();
956 self.current_state = transition.to().clone();
957 self.transitions.push(CeremonyTransitionRecord::record(
958 trigger.clone(),
959 from_state,
960 self.current_state.clone(),
961 applied_by,
962 now,
963 ));
964 self.updated_at = now;
965 if definition.is_terminal_state(&self.current_state) {
966 self.completed_at = Some(now);
967 }
968 Ok(self.current_state.clone())
969 }
970
971 fn matches_definition(&self, definition: &CeremonyDefinition) -> bool {
972 self.definition_name == *definition.name()
973 && self.definition_version == *definition.version()
974 }
975
976 fn require_definition(&self, definition: &CeremonyDefinition) -> Result<(), DomainError> {
977 if self.matches_definition(definition) {
978 Ok(())
979 } else {
980 Err(DomainError::InvariantViolated {
981 reason: "ceremony instance definition mismatch",
982 })
983 }
984 }
985
986 fn require_active(
987 &self,
988 definition: &CeremonyDefinition,
989 terminal_reason: &'static str,
990 ) -> Result<(), DomainError> {
991 self.require_definition(definition)?;
992 if self.is_terminal(definition) {
993 Err(DomainError::InvariantViolated {
994 reason: terminal_reason,
995 })
996 } else {
997 Ok(())
998 }
999 }
1000
1001 fn require_intervention_target(
1002 definition: &CeremonyDefinition,
1003 target: &CeremonyInterventionTarget,
1004 ) -> Result<(), DomainError> {
1005 let Some(role_ids) = target.role_ids() else {
1006 return Ok(());
1007 };
1008 for role_id in role_ids {
1009 if definition.role(role_id).is_none() {
1010 return Err(DomainError::NotFound {
1011 what: "ceremony_intervention.target_role",
1012 });
1013 }
1014 if !definition.role_allows(role_id, &RoleAction::respond_to_intervention()) {
1015 return Err(DomainError::InvariantViolated {
1016 reason: "target role cannot respond to ceremony interventions",
1017 });
1018 }
1019 }
1020 Ok(())
1021 }
1022
1023 fn require_intervention_provenance(
1024 &self,
1025 definition: &CeremonyDefinition,
1026 requested_by: &RoleId,
1027 target: &CeremonyInterventionTarget,
1028 provenance: &CeremonyInterventionProvenance,
1029 ) -> Result<(), DomainError> {
1030 let source = self
1031 .intervention(provenance.source_intervention_id())
1032 .ok_or(DomainError::NotFound {
1033 what: "ceremony_intervention.provenance_source",
1034 })?;
1035 if source.requested_by() != requested_by {
1036 return Err(DomainError::InvariantViolated {
1037 reason: "only the source requester can select an intervention response",
1038 });
1039 }
1040 if !source
1041 .responses()
1042 .iter()
1043 .any(|response| response.role_id() == provenance.source_response_role_id())
1044 {
1045 return Err(DomainError::NotFound {
1046 what: "ceremony_intervention.provenance_response",
1047 });
1048 }
1049 if definition.role(provenance.selected_role_id()).is_none() {
1050 return Err(DomainError::NotFound {
1051 what: "ceremony_intervention.provenance_selected_role",
1052 });
1053 }
1054 if !definition.role_allows(
1055 provenance.selected_role_id(),
1056 &RoleAction::respond_to_intervention(),
1057 ) {
1058 return Err(DomainError::InvariantViolated {
1059 reason: "selected intervention role cannot respond",
1060 });
1061 }
1062 if !target.accepts(provenance.selected_role_id()) {
1063 return Err(DomainError::InvariantViolated {
1064 reason: "intervention target does not include the selected role",
1065 });
1066 }
1067 Ok(())
1068 }
1069
1070 fn require_declared_role(
1081 &self,
1082 definition: &CeremonyDefinition,
1083 role_id: &RoleId,
1084 ) -> Result<(), DomainError> {
1085 self.require_definition(definition)?;
1086 if definition.role(role_id).is_some() {
1087 Ok(())
1088 } else {
1089 Err(DomainError::NotFound {
1090 what: "ceremony_role",
1091 })
1092 }
1093 }
1094
1095 fn require_role(
1096 &self,
1097 definition: &CeremonyDefinition,
1098 role_id: &RoleId,
1099 action: &RoleAction,
1100 ) -> Result<(), DomainError> {
1101 self.require_definition(definition)?;
1102 if definition.role_allows(role_id, action) {
1103 Ok(())
1104 } else {
1105 Err(DomainError::InvariantViolated {
1106 reason: "ceremony role is not allowed to perform action",
1107 })
1108 }
1109 }
1110}
1111
1112fn next_attempt_for_start(record: &StepExecutionRecord) -> Result<StepAttempt, DomainError> {
1113 if matches!(record.status(), StepStatus::Failed | StepStatus::InProgress) {
1114 record.attempt().next()
1115 } else {
1116 Ok(record.attempt())
1117 }
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122 use super::*;
1123 use crate::value_objects::{
1124 Attributes, CeremonyGuard, CeremonyState, CeremonyStep, CeremonyTransition, GuardCondition,
1125 GuardName, LeaseOwnerId, RetryPolicy, StepHandlerConfig, StepHandlerKind, StepOutput,
1126 };
1127 use time::macros::datetime;
1128
1129 fn now() -> OffsetDateTime {
1130 datetime!(2026-06-06 12:00:00 UTC)
1131 }
1132
1133 fn state_id(raw: &str) -> StateId {
1134 StateId::new(raw).unwrap()
1135 }
1136
1137 fn step_id(raw: &str) -> StepId {
1138 StepId::new(raw).unwrap()
1139 }
1140
1141 fn trigger(raw: &str) -> TransitionTrigger {
1142 TransitionTrigger::new(raw).unwrap()
1143 }
1144
1145 fn role_id(raw: &str) -> RoleId {
1146 RoleId::new(raw).unwrap()
1147 }
1148
1149 fn guard_name(raw: &str) -> GuardName {
1150 GuardName::new(raw).unwrap()
1151 }
1152
1153 fn handler_kind() -> StepHandlerKind {
1154 StepHandlerKind::new("multiagent_round").unwrap()
1155 }
1156
1157 fn retrying_step(raw_step_id: &str, raw_state_id: &str) -> CeremonyStep {
1158 CeremonyStep::new(
1159 step_id(raw_step_id),
1160 state_id(raw_state_id),
1161 handler_kind(),
1162 StepHandlerConfig::empty(),
1163 RetryPolicy::new(
1164 StepAttempt::new(3).unwrap(),
1165 crate::value_objects::DurationMs::ZERO,
1166 ),
1167 None,
1168 )
1169 }
1170
1171 fn single_attempt_step(raw_step_id: &str, raw_state_id: &str) -> CeremonyStep {
1172 CeremonyStep::new(
1173 step_id(raw_step_id),
1174 state_id(raw_state_id),
1175 handler_kind(),
1176 StepHandlerConfig::empty(),
1177 RetryPolicy::single_attempt(),
1178 None,
1179 )
1180 }
1181
1182 fn lease(
1183 raw_owner_id: &str,
1184 raw_key: &str,
1185 acquired_at: OffsetDateTime,
1186 expires_at: OffsetDateTime,
1187 ) -> StepLease {
1188 StepLease::new(
1189 LeaseOwnerId::new(raw_owner_id).unwrap(),
1190 IdempotencyKey::new(raw_key).unwrap(),
1191 acquired_at,
1192 expires_at,
1193 )
1194 .unwrap()
1195 }
1196
1197 fn role(actions: Vec<RoleAction>) -> crate::value_objects::CeremonyRole {
1198 crate::value_objects::CeremonyRole::new(role_id("facilitator"), actions).unwrap()
1199 }
1200
1201 fn definition_with_steps(steps: Vec<CeremonyStep>) -> CeremonyDefinition {
1202 let plan_done = CeremonyGuard::new(
1203 guard_name("plan_done"),
1204 GuardCondition::StepStatus {
1205 step_id: step_id("plan"),
1206 status: StepStatus::Completed,
1207 },
1208 );
1209 let finish = CeremonyTransition::new(
1210 state_id("drafting"),
1211 state_id("done"),
1212 trigger("finish"),
1213 vec![plan_done.name().clone()],
1214 )
1215 .unwrap();
1216 let role = role(vec![
1217 RoleAction::step(step_id("plan")),
1218 RoleAction::transition(finish.trigger().clone()),
1219 RoleAction::request_intervention(),
1220 ]);
1221 let observer = crate::value_objects::CeremonyRole::new(
1222 role_id("observer"),
1223 vec![RoleAction::respond_to_intervention()],
1224 )
1225 .unwrap();
1226
1227 CeremonyDefinition::new(
1228 crate::value_objects::CeremonyName::new("planning_ceremony").unwrap(),
1229 CeremonyVersion::v1(),
1230 None,
1231 Vec::new(),
1232 Vec::new(),
1233 vec![
1234 CeremonyState::initial(state_id("drafting")),
1235 CeremonyState::intermediate(state_id("review")),
1236 CeremonyState::terminal(state_id("done")),
1237 ],
1238 vec![finish],
1239 steps,
1240 vec![plan_done],
1241 vec![role, observer],
1242 )
1243 .unwrap()
1244 }
1245
1246 fn definition() -> CeremonyDefinition {
1247 definition_with_steps(vec![
1248 retrying_step("plan", "drafting"),
1249 single_attempt_step("review_step", "review"),
1250 ])
1251 }
1252
1253 #[test]
1254 fn a_verified_digest_migration_rebinds_only_its_exact_definition() {
1255 let definition = definition();
1256 let published = PublishedCeremonyDefinition::seal(definition.clone()).unwrap();
1257 let migration = definition.choreographer_v1_digest_migration().unwrap();
1258 let mut value = serde_json::to_value(CeremonyInstance::start_bound(
1259 CeremonyId::new("legacy-bound").unwrap(),
1260 &published,
1261 CeremonyContext::empty(),
1262 now(),
1263 ))
1264 .unwrap();
1265 value["bound_definition"] = serde_json::to_value(migration.source()).unwrap();
1266 let mut instance: CeremonyInstance = serde_json::from_value(value).unwrap();
1267
1268 assert!(instance.migrate_definition_binding(&migration).unwrap());
1269 assert_eq!(instance.bound_definition(), Some(migration.destination()));
1270 assert!(!instance.migrate_definition_binding(&migration).unwrap());
1271 }
1272
1273 #[test]
1274 fn a_digest_migration_for_another_definition_is_rejected() {
1275 let definition = definition();
1276 let published = PublishedCeremonyDefinition::seal(definition.clone()).unwrap();
1277 let mut instance = CeremonyInstance::start_bound(
1278 CeremonyId::new("still-bound").unwrap(),
1279 &published,
1280 CeremonyContext::empty(),
1281 now(),
1282 );
1283 let other = CeremonyDefinition::new(
1284 CeremonyName::new("another_ceremony").unwrap(),
1285 CeremonyVersion::v1(),
1286 None,
1287 [],
1288 [],
1289 [CeremonyState::initial(state_id("OPEN"))],
1290 [],
1291 [],
1292 [],
1293 [],
1294 )
1295 .unwrap()
1296 .choreographer_v1_digest_migration()
1297 .unwrap();
1298
1299 assert!(instance.migrate_definition_binding(&other).is_err());
1300 assert_eq!(instance.bound_definition(), Some(published.digest()));
1301 }
1302
1303 fn definition_with_human_guard(approval: &CeremonyGuard) -> CeremonyDefinition {
1306 let finish = CeremonyTransition::new(
1307 state_id("drafting"),
1308 state_id("done"),
1309 trigger("approve"),
1310 vec![approval.name().clone()],
1311 )
1312 .unwrap();
1313 CeremonyDefinition::new(
1314 crate::value_objects::CeremonyName::new("approval_ceremony").unwrap(),
1315 CeremonyVersion::v1(),
1316 None,
1317 Vec::new(),
1318 Vec::new(),
1319 vec![
1320 CeremonyState::initial(state_id("drafting")),
1321 CeremonyState::terminal(state_id("done")),
1322 ],
1323 vec![finish.clone()],
1324 Vec::new(),
1325 vec![approval.clone()],
1326 vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1327 )
1328 .unwrap()
1329 }
1330
1331 fn instance(definition: &CeremonyDefinition) -> CeremonyInstance {
1332 CeremonyInstance::start(
1333 CeremonyId::new("ceremony-1").unwrap(),
1334 definition,
1335 CeremonyContext::empty(),
1336 now(),
1337 )
1338 }
1339
1340 #[test]
1341 fn starts_in_initial_state_with_pending_records() {
1342 let definition = definition();
1343 let instance = instance(&definition);
1344
1345 assert_eq!(instance.current_state(), &state_id("drafting"));
1346 assert_eq!(
1347 instance.step_record(&step_id("plan")).unwrap().status(),
1348 StepStatus::Pending
1349 );
1350 assert_eq!(
1351 instance
1352 .step_record(&step_id("review_step"))
1353 .unwrap()
1354 .status(),
1355 StepStatus::Pending
1356 );
1357 }
1358
1359 #[test]
1360 fn dynamic_intervention_collects_role_scoped_response_and_requester_closes_it() {
1361 let definition = definition();
1362 let mut instance = instance(&definition);
1363 let intervention_id = CeremonyInterventionId::new("queue-check").unwrap();
1364 let facilitator = role_id("facilitator");
1365 let observer = role_id("observer");
1366
1367 instance
1368 .request_intervention_as(
1369 &definition,
1370 intervention_id.clone(),
1371 facilitator.clone(),
1372 CeremonyInterventionKind::Investigation,
1373 CeremonyInterventionTarget::roles([observer.clone()]).unwrap(),
1374 CeremonyInterventionContent::new(
1375 "Inspect the queue without consuming messages.",
1376 Attributes::empty(),
1377 )
1378 .unwrap(),
1379 now(),
1380 )
1381 .unwrap();
1382 instance
1383 .respond_to_intervention_as(
1384 &definition,
1385 &intervention_id,
1386 observer.clone(),
1387 CeremonyInterventionContent::new("Queue depth is stable.", Attributes::empty())
1388 .unwrap(),
1389 now(),
1390 )
1391 .unwrap();
1392 let selected_intervention_id = CeremonyInterventionId::new("selected-check").unwrap();
1393 instance
1394 .request_intervention_with_provenance_as(
1395 &definition,
1396 selected_intervention_id.clone(),
1397 facilitator.clone(),
1398 CeremonyInterventionKind::Investigation,
1399 CeremonyInterventionTarget::roles([observer.clone()]).unwrap(),
1400 CeremonyInterventionContent::new(
1401 "Inspect the proposed signal.",
1402 Attributes::empty(),
1403 )
1404 .unwrap(),
1405 Some(CeremonyInterventionProvenance::selected_from(
1406 intervention_id.clone(),
1407 observer.clone(),
1408 observer.clone(),
1409 )),
1410 now(),
1411 )
1412 .unwrap();
1413 instance
1414 .close_intervention_as(&definition, &intervention_id, &facilitator, now())
1415 .unwrap();
1416
1417 let intervention = instance.intervention(&intervention_id).unwrap();
1418 assert_eq!(intervention.responses().len(), 1);
1419 assert_eq!(
1420 intervention.status(),
1421 crate::value_objects::CeremonyInterventionStatus::Closed
1422 );
1423 let provenance = instance
1424 .intervention(&selected_intervention_id)
1425 .unwrap()
1426 .provenance()
1427 .unwrap();
1428 assert_eq!(provenance.source_intervention_id(), &intervention_id);
1429 assert_eq!(provenance.selected_role_id(), &observer);
1430 }
1431
1432 #[test]
1433 fn intervention_rejects_roles_without_the_required_capability() {
1434 let definition = definition();
1435 let mut instance = instance(&definition);
1436
1437 let error = instance
1438 .request_intervention_as(
1439 &definition,
1440 CeremonyInterventionId::new("not-allowed").unwrap(),
1441 role_id("observer"),
1442 CeremonyInterventionKind::Opinion,
1443 CeremonyInterventionTarget::table(),
1444 CeremonyInterventionContent::new("What do you think?", Attributes::empty())
1445 .unwrap(),
1446 now(),
1447 )
1448 .unwrap_err();
1449
1450 assert!(matches!(error, DomainError::InvariantViolated { .. }));
1451 }
1452
1453 #[test]
1454 fn rejects_step_execution_outside_current_state() {
1455 let definition = definition();
1456 let mut instance = instance(&definition);
1457
1458 let err = instance
1459 .start_step(
1460 &definition,
1461 &step_id("review_step"),
1462 lease(
1463 "runner-1",
1464 "key-1",
1465 now(),
1466 datetime!(2026-06-06 12:05:00 UTC),
1467 ),
1468 now(),
1469 )
1470 .unwrap_err();
1471
1472 assert!(matches!(err, DomainError::InvalidTransition { .. }));
1473 }
1474
1475 #[test]
1476 fn completed_step_unlocks_guarded_transition() {
1477 let definition = definition();
1478 let mut instance = instance(&definition);
1479
1480 instance
1481 .start_step_as(
1482 &definition,
1483 &role_id("facilitator"),
1484 &step_id("plan"),
1485 lease(
1486 "runner-1",
1487 "key-1",
1488 now(),
1489 datetime!(2026-06-06 12:05:00 UTC),
1490 ),
1491 now(),
1492 )
1493 .unwrap();
1494 instance
1495 .apply_step_result(
1496 &definition,
1497 &step_id("plan"),
1498 StepResult::completed(StepOutput::empty()).unwrap(),
1499 datetime!(2026-06-06 12:01:00 UTC),
1500 )
1501 .unwrap();
1502 let state = instance
1503 .apply_transition_as(
1504 &definition,
1505 &role_id("facilitator"),
1506 &trigger("finish"),
1507 datetime!(2026-06-06 12:02:00 UTC),
1508 )
1509 .unwrap();
1510
1511 assert_eq!(state, state_id("done"));
1512 assert!(instance.is_completed(&definition));
1513 }
1514
1515 #[test]
1516 fn active_lease_blocks_failover_takeover() {
1517 let definition = definition();
1518 let mut instance = instance(&definition);
1519
1520 instance
1521 .start_step(
1522 &definition,
1523 &step_id("plan"),
1524 lease(
1525 "runner-1",
1526 "key-1",
1527 now(),
1528 datetime!(2026-06-06 12:05:00 UTC),
1529 ),
1530 now(),
1531 )
1532 .unwrap();
1533 let err = instance
1534 .start_step(
1535 &definition,
1536 &step_id("plan"),
1537 lease(
1538 "runner-2",
1539 "key-2",
1540 datetime!(2026-06-06 12:01:00 UTC),
1541 datetime!(2026-06-06 12:06:00 UTC),
1542 ),
1543 datetime!(2026-06-06 12:01:00 UTC),
1544 )
1545 .unwrap_err();
1546
1547 assert!(matches!(err, DomainError::InvariantViolated { .. }));
1548 assert_eq!(
1549 instance
1550 .step_record(&step_id("plan"))
1551 .unwrap()
1552 .lease()
1553 .unwrap()
1554 .owner_id()
1555 .as_str(),
1556 "runner-1"
1557 );
1558 }
1559
1560 #[test]
1561 fn expired_lease_allows_failover_takeover_with_next_attempt() {
1562 let definition = definition();
1563 let mut instance = instance(&definition);
1564
1565 instance
1566 .start_step(
1567 &definition,
1568 &step_id("plan"),
1569 lease(
1570 "runner-1",
1571 "key-1",
1572 now(),
1573 datetime!(2026-06-06 12:05:00 UTC),
1574 ),
1575 now(),
1576 )
1577 .unwrap();
1578 let attempt = instance
1579 .start_step(
1580 &definition,
1581 &step_id("plan"),
1582 lease(
1583 "runner-2",
1584 "key-2",
1585 datetime!(2026-06-06 12:06:00 UTC),
1586 datetime!(2026-06-06 12:11:00 UTC),
1587 ),
1588 datetime!(2026-06-06 12:06:00 UTC),
1589 )
1590 .unwrap();
1591
1592 assert_eq!(attempt, StepAttempt::new(2).unwrap());
1593 let record = instance.step_record(&step_id("plan")).unwrap();
1594 assert_eq!(record.attempt(), StepAttempt::new(2).unwrap());
1595 assert_eq!(record.lease().unwrap().owner_id().as_str(), "runner-2");
1596 }
1597
1598 #[test]
1599 fn approving_a_guard_the_ceremony_never_declared_is_refused() {
1600 let approval =
1601 CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1602 let finish = CeremonyTransition::new(
1603 state_id("drafting"),
1604 state_id("done"),
1605 trigger("approve"),
1606 vec![approval.name().clone()],
1607 )
1608 .unwrap();
1609 let definition = CeremonyDefinition::new(
1610 crate::value_objects::CeremonyName::new("approval_ceremony").unwrap(),
1611 CeremonyVersion::v1(),
1612 None,
1613 Vec::new(),
1614 Vec::new(),
1615 vec![
1616 CeremonyState::initial(state_id("drafting")),
1617 CeremonyState::terminal(state_id("done")),
1618 ],
1619 vec![finish.clone()],
1620 Vec::new(),
1621 vec![approval],
1622 vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1623 )
1624 .unwrap();
1625 let mut instance = instance(&definition);
1626
1627 assert!(matches!(
1632 instance.approve_guard(
1633 &definition,
1634 &guard_name("not_a_guard"),
1635 role_id("facilitator"),
1636 AuditActorKind::Human,
1637 now()
1638 ),
1639 Err(DomainError::NotFound {
1640 what: "ceremony_guard"
1641 })
1642 ));
1643 assert!(!instance
1644 .context()
1645 .is_guard_approved(&guard_name("not_a_guard")));
1646 }
1647
1648 #[test]
1649 fn human_approval_guard_uses_typed_context() {
1650 let approval =
1651 CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1652 let finish = CeremonyTransition::new(
1653 state_id("drafting"),
1654 state_id("done"),
1655 trigger("approve"),
1656 vec![approval.name().clone()],
1657 )
1658 .unwrap();
1659 let definition = CeremonyDefinition::new(
1660 crate::value_objects::CeremonyName::new("approval_ceremony").unwrap(),
1661 CeremonyVersion::v1(),
1662 None,
1663 Vec::new(),
1664 Vec::new(),
1665 vec![
1666 CeremonyState::initial(state_id("drafting")),
1667 CeremonyState::terminal(state_id("done")),
1668 ],
1669 vec![finish.clone()],
1670 Vec::new(),
1671 vec![approval.clone()],
1672 vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1673 )
1674 .unwrap();
1675 let mut instance = instance(&definition);
1676
1677 assert!(matches!(
1678 instance.apply_transition(&definition, &trigger("approve"), now()),
1679 Err(DomainError::InvariantViolated { .. })
1680 ));
1681 instance
1682 .approve_guard(
1683 &definition,
1684 approval.name(),
1685 role_id("facilitator"),
1686 AuditActorKind::Human,
1687 datetime!(2026-06-06 12:01:00 UTC),
1688 )
1689 .unwrap();
1690 instance
1691 .apply_transition(
1692 &definition,
1693 &trigger("approve"),
1694 datetime!(2026-06-06 12:02:00 UTC),
1695 )
1696 .unwrap();
1697
1698 assert!(instance.is_completed(&definition));
1699 }
1700
1701 #[test]
1702 fn human_guard_deferral_preserves_uncertainty_without_approving() {
1703 let approval =
1704 CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1705 let finish = CeremonyTransition::new(
1706 state_id("drafting"),
1707 state_id("done"),
1708 trigger("approve"),
1709 vec![approval.name().clone()],
1710 )
1711 .unwrap();
1712 let definition = CeremonyDefinition::new(
1713 crate::value_objects::CeremonyName::new("deferral_ceremony").unwrap(),
1714 CeremonyVersion::v1(),
1715 None,
1716 Vec::new(),
1717 Vec::new(),
1718 vec![
1719 CeremonyState::initial(state_id("drafting")),
1720 CeremonyState::terminal(state_id("done")),
1721 ],
1722 vec![finish.clone()],
1723 Vec::new(),
1724 vec![approval.clone()],
1725 vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1726 )
1727 .unwrap();
1728 let mut instance = instance(&definition);
1729
1730 instance
1731 .defer_guard(
1732 &definition,
1733 approval.name().clone(),
1734 CeremonyGuardDeferralContent::new(
1735 "I do not know.",
1736 "I cannot explain how the issue was resolved.",
1737 vec!["New evidence explains the resolution.".to_owned()],
1738 )
1739 .unwrap(),
1740 role_id("facilitator"),
1741 AuditActorKind::Human,
1742 datetime!(2026-06-06 12:01:00 UTC),
1743 )
1744 .unwrap();
1745
1746 assert!(!instance.context().is_guard_approved(approval.name()));
1747 assert!(instance
1748 .apply_transition(&definition, &trigger("approve"), now())
1749 .is_err());
1750 let deferral = &instance.guard_deferrals()[0];
1751 assert_eq!(deferral.guard_name(), approval.name());
1752 assert_eq!(deferral.content().statement(), "I do not know.");
1753 }
1754 #[test]
1757 fn approving_a_human_guard_records_the_seat_that_did_it() {
1758 let approval =
1759 CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1760 let definition = definition_with_human_guard(&approval);
1761 let mut instance = instance(&definition);
1762
1763 instance
1764 .approve_guard(
1765 &definition,
1766 approval.name(),
1767 role_id("facilitator"),
1768 AuditActorKind::Human,
1769 datetime!(2026-06-06 12:01:00 UTC),
1770 )
1771 .unwrap();
1772
1773 let [recorded] = instance.guard_approvals() else {
1774 panic!(
1775 "expected one approval, got {:?}",
1776 instance.guard_approvals()
1777 );
1778 };
1779 assert_eq!(recorded.guard_name(), approval.name());
1780 assert_eq!(recorded.approved_by(), &role_id("facilitator"));
1781 assert_eq!(recorded.approved_at(), datetime!(2026-06-06 12:01:00 UTC));
1782 assert!(instance.context().is_guard_approved(approval.name()));
1783 }
1784
1785 #[test]
1789 fn a_seat_the_definition_does_not_declare_cannot_approve() {
1790 let approval =
1791 CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1792 let definition = definition_with_human_guard(&approval);
1793 let mut instance = instance(&definition);
1794
1795 let outcome = instance.approve_guard(
1796 &definition,
1797 approval.name(),
1798 role_id("someone-who-is-not-here"),
1799 AuditActorKind::Human,
1800 now(),
1801 );
1802
1803 assert!(matches!(
1804 outcome,
1805 Err(DomainError::NotFound {
1806 what: "ceremony_role"
1807 })
1808 ));
1809 assert!(instance.guard_approvals().is_empty());
1810 assert!(!instance.context().is_guard_approved(approval.name()));
1811 }
1812 fn session_with_a_contribution(
1815 definition: &CeremonyDefinition,
1816 ) -> (CeremonyInstance, CeremonyInterventionId) {
1817 let mut instance = instance(definition);
1818 let agenda_item = CeremonyInterventionId::new("queue-check").unwrap();
1819 instance
1820 .request_intervention_as(
1821 definition,
1822 agenda_item.clone(),
1823 role_id("facilitator"),
1824 CeremonyInterventionKind::Investigation,
1825 CeremonyInterventionTarget::roles([role_id("observer")]).unwrap(),
1826 CeremonyInterventionContent::new("Inspect the queue.", Attributes::empty())
1827 .unwrap(),
1828 now(),
1829 )
1830 .unwrap();
1831 instance
1832 .respond_to_intervention_as(
1833 definition,
1834 &agenda_item,
1835 role_id("observer"),
1836 CeremonyInterventionContent::new("Queue depth is stable.", Attributes::empty())
1837 .unwrap(),
1838 now(),
1839 )
1840 .unwrap();
1841 (instance, agenda_item)
1842 }
1843
1844 #[test]
1847 fn a_contribution_is_recorded_as_answering_its_agenda_item() {
1848 let definition = definition();
1849 let (instance, agenda_item) = session_with_a_contribution(&definition);
1850
1851 let [answered] = instance.reasons() else {
1852 panic!("expected exactly one reason, got {:?}", instance.reasons());
1853 };
1854 assert_eq!(answered.kind(), CeremonyReasonKind::Answers);
1855 assert_eq!(
1856 answered.from(),
1857 &CeremonyRecordRef::contribution(agenda_item.clone(), 0)
1858 );
1859 assert_eq!(answered.to(), &CeremonyRecordRef::agenda_item(agenda_item));
1860 assert_eq!(
1861 answered.asserted_by(),
1862 None,
1863 "the engine observed it; naming a seat would be inventing one"
1864 );
1865 }
1866
1867 #[test]
1870 fn a_seat_cannot_assert_what_only_the_engine_observes() {
1871 let definition = definition();
1872 let (mut instance, agenda_item) = session_with_a_contribution(&definition);
1873
1874 let outcome = instance.assert_reason_as(
1875 &definition,
1876 role_id("observer"),
1877 CeremonyRecordRef::contribution(agenda_item.clone(), 0),
1878 CeremonyRecordRef::agenda_item(agenda_item),
1879 CeremonyReasonKind::Answers,
1880 "because I say it does",
1881 MemoryConfidence::High,
1882 now(),
1883 );
1884
1885 assert!(matches!(
1886 outcome,
1887 Err(DomainError::InvariantViolated { .. })
1888 ));
1889 }
1890
1891 #[test]
1894 fn only_whoever_contributed_may_say_why_they_did() {
1895 let definition = definition();
1896 let (mut instance, agenda_item) = session_with_a_contribution(&definition);
1897 let contribution = CeremonyRecordRef::contribution(agenda_item.clone(), 0);
1898 let item = CeremonyRecordRef::agenda_item(agenda_item);
1899
1900 let by_someone_else = instance.assert_reason_as(
1901 &definition,
1902 role_id("facilitator"),
1903 contribution.clone(),
1904 item.clone(),
1905 CeremonyReasonKind::ChosenBecause,
1906 "they must have thought the queue mattered",
1907 MemoryConfidence::Low,
1908 now(),
1909 );
1910 assert!(matches!(
1911 by_someone_else,
1912 Err(DomainError::InvariantViolated { .. })
1913 ));
1914
1915 instance
1916 .assert_reason_as(
1917 &definition,
1918 role_id("observer"),
1919 contribution,
1920 item,
1921 CeremonyReasonKind::ChosenBecause,
1922 "the depth graph had been flat for an hour",
1923 MemoryConfidence::High,
1924 now(),
1925 )
1926 .expect("its author may say why");
1927 assert_eq!(instance.reasons().len(), 2);
1928 }
1929
1930 #[test]
1933 fn any_seat_may_claim_that_one_thing_came_from_another() {
1934 let definition = definition();
1935 let (mut instance, agenda_item) = session_with_a_contribution(&definition);
1936
1937 instance
1938 .assert_reason_as(
1939 &definition,
1940 role_id("facilitator"),
1941 CeremonyRecordRef::agenda_item(agenda_item.clone()),
1942 CeremonyRecordRef::contribution(agenda_item, 0),
1943 CeremonyReasonKind::FollowsFrom,
1944 "the item stayed open because the answer raised a new question",
1945 MemoryConfidence::Medium,
1946 now(),
1947 )
1948 .expect("a claim about the world is open to any seat");
1949
1950 let asserted = instance.reasons().last().unwrap();
1951 assert_eq!(asserted.confidence(), MemoryConfidence::Medium);
1952 assert_eq!(asserted.asserted_by(), Some(&role_id("facilitator")));
1953 }
1954
1955 #[test]
1958 fn a_reason_cannot_cite_something_that_never_happened() {
1959 let definition = definition();
1960 let (mut instance, agenda_item) = session_with_a_contribution(&definition);
1961
1962 let outcome = instance.assert_reason_as(
1963 &definition,
1964 role_id("observer"),
1965 CeremonyRecordRef::contribution(agenda_item.clone(), 7),
1966 CeremonyRecordRef::agenda_item(agenda_item),
1967 CeremonyReasonKind::FollowsFrom,
1968 "a contribution nobody made",
1969 MemoryConfidence::Low,
1970 now(),
1971 );
1972
1973 assert!(matches!(
1974 outcome,
1975 Err(DomainError::NotFound {
1976 what: "ceremony_record"
1977 })
1978 ));
1979 }
1980
1981 #[test]
1984 fn a_move_is_recorded_with_whoever_made_it() {
1985 let approval =
1986 CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1987 let definition = definition_with_human_guard(&approval);
1988 let mut instance = instance(&definition);
1989 instance
1990 .approve_guard(
1991 &definition,
1992 approval.name(),
1993 role_id("facilitator"),
1994 AuditActorKind::Human,
1995 now(),
1996 )
1997 .unwrap();
1998
1999 instance
2000 .apply_transition_as(
2001 &definition,
2002 &role_id("facilitator"),
2003 &trigger("approve"),
2004 datetime!(2026-06-06 12:05:00 UTC),
2005 )
2006 .unwrap();
2007
2008 let [moved] = instance.transitions() else {
2009 panic!("expected one move, got {:?}", instance.transitions());
2010 };
2011 assert_eq!(moved.trigger(), &trigger("approve"));
2012 assert_eq!(moved.from_state(), &state_id("drafting"));
2013 assert_eq!(moved.to_state(), &state_id("done"));
2014 assert_eq!(moved.applied_by(), Some(&role_id("facilitator")));
2015 }
2016
2017 #[test]
2021 fn a_move_the_engine_took_names_nobody() {
2022 let approval =
2023 CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
2024 let definition = definition_with_human_guard(&approval);
2025 let mut instance = instance(&definition);
2026 instance
2027 .approve_guard(
2028 &definition,
2029 approval.name(),
2030 role_id("facilitator"),
2031 AuditActorKind::Human,
2032 now(),
2033 )
2034 .unwrap();
2035
2036 instance
2037 .apply_transition(&definition, &trigger("approve"), now())
2038 .unwrap();
2039
2040 assert_eq!(instance.transitions()[0].applied_by(), None);
2041 }
2042 #[test]
2052 fn an_approval_records_the_kind_it_was_told_not_the_one_the_guard_wanted() {
2053 let approval =
2054 CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
2055 let definition = definition_with_human_guard(&approval);
2056 let mut instance = instance(&definition);
2057
2058 instance
2059 .approve_guard(
2060 &definition,
2061 approval.name(),
2062 role_id("facilitator"),
2063 AuditActorKind::Agent,
2064 now(),
2065 )
2066 .unwrap();
2067
2068 let [recorded] = instance.guard_approvals() else {
2069 panic!("expected one approval");
2070 };
2071 assert_eq!(
2072 recorded.approved_by_kind(),
2073 AuditActorKind::Agent,
2074 "the guard asked for a human and an agent answered; saying otherwise \
2075 would be the engine vouching for something it cannot see"
2076 );
2077 assert!(instance.context().is_guard_approved(approval.name()));
2078 }
2079}