Skip to main content

made_core/entities/
ceremony_instance.rs

1//! [`CeremonyInstance`] aggregate.
2//!
3//! Runtime state for a single ceremony execution. The aggregate owns
4//! step leases, retry attempts, idempotency keys and state transitions,
5//! so failover remains a domain rule instead of adapter glue.
6
7use 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    /// Who let each human guard through.
41    ///
42    /// Kept beside the context rather than inside it. The context is
43    /// what a guard is evaluated against and already holds "this one
44    /// is approved"; sessions written before this existed carry that
45    /// and nothing else, and moving approval out of the context would
46    /// have made every one of them unapproved on the next read. So the
47    /// context stays the state, and this is the event that produced
48    /// it.
49    #[serde(default)]
50    guard_approvals: Vec<CeremonyGuardApproval>,
51    /// Every move this session made, in order.
52    ///
53    /// The current state says where a session is; this says how it got
54    /// there. Without it nothing could point at a move, so nothing
55    /// could say why one happened — and "why did this resolve" is the
56    /// question the whole thing is for.
57    #[serde(default)]
58    transitions: Vec<CeremonyTransitionRecord>,
59    /// Why one thing here led to another.
60    ///
61    /// Kept apart from the records rather than inside them, because a
62    /// reason is an edge and not a field: it belongs to the pair, and
63    /// putting it on either end would make it readable but not
64    /// followable.
65    #[serde(default)]
66    reasons: Vec<CeremonyReason>,
67    /// Who sits in each seat for this session, where anyone was
68    /// seated. A role with no binding is played the way the definition
69    /// says, which is the usual case and not a lesser one.
70    #[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    /// The published definition this instance is bound to, when it was
81    /// started from one.
82    ///
83    /// Absent for an instance started from a definition handed in at
84    /// the time — which is a real and useful way to work, and not the
85    /// same thing. Recording which of the two happened is the point: a
86    /// name and a version identify a published definition only while
87    /// publication is immutable, and an instance that also carries the
88    /// digest can be checked against the definition rather than trusted
89    /// to have run it.
90    #[serde(default)]
91    bound_definition: Option<CeremonyDefinitionDigest>,
92}
93
94impl CeremonyInstance {
95    /// Start from a definition supplied for this run.
96    ///
97    /// Nothing binds the instance to a definition that can be looked up
98    /// later; that is what [`Self::start_bound`] is for.
99    #[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    /// Start from a published definition, recording its digest.
110    ///
111    /// The digest travels with the instance so a later reader can
112    /// verify which definition ran instead of taking the name and
113    /// version on trust.
114    #[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    /// The digest of the published definition this instance runs, if it
165    /// was started from one.
166    #[must_use]
167    pub fn bound_definition(&self) -> Option<CeremonyDefinitionDigest> {
168        self.bound_definition
169    }
170
171    /// Whether this instance runs a definition that can be looked up
172    /// and checked, rather than one supplied for the run.
173    #[must_use]
174    pub fn is_bound_to_a_published_definition(&self) -> bool {
175        self.bound_definition.is_some()
176    }
177
178    /// Replace a legacy publication identity after the same definition has
179    /// been verified under a successor digest scheme.
180    ///
181    /// This is deliberately narrower than a general rebind operation. A
182    /// running ceremony cannot be moved to different content, name or
183    /// version. Storage migrations may only replace the expected legacy
184    /// identity with the identity of the already verified publication.
185    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    /// Who let each human guard through, in the order they did.
250    ///
251    /// Empty for a session written before approvals recorded an
252    /// approver, which is the truth about those sessions rather than a
253    /// gap to paper over.
254    #[must_use]
255    pub fn guard_approvals(&self) -> &[CeremonyGuardApproval] {
256        &self.guard_approvals
257    }
258
259    /// Every move this session made, in the order it made them.
260    #[must_use]
261    pub fn transitions(&self) -> &[CeremonyTransitionRecord] {
262        &self.transitions
263    }
264
265    /// Why one thing here led to another.
266    #[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    /// Approving is checked the way deferring is. It used to take no
424    /// definition at all, so any name at all could be "approved" —
425    /// which wrote that name into the session context, told the caller
426    /// it had succeeded, and left a session that would never move.
427    ///
428    /// Approving ahead of time is still allowed: unlike a deferral,
429    /// which answers a decision being asked for now, a person may
430    /// settle a guard before the work leading up to it is finished.
431    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    /// Seat a role for this session.
467    ///
468    /// Rebinding is allowed and deliberate: a panel can become
469    /// unavailable halfway through a working session, and a ceremony
470    /// that could not be re-seated would have to be abandoned and
471    /// started again, losing everything already decided. What was
472    /// seated before stays in the journal; the instance carries who is
473    /// seated now, which is what the next step needs.
474    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        // A seat that the ceremony never declared is not a seat.
486        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    /// The specialty a role's work should be put to, if this session
505    /// seated one. `None` means the definition decides, as usual.
506    #[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    /// State why one thing here led to another.
709    ///
710    /// Its own act rather than a field on contributing, because a
711    /// reason is often known later — "in fact I did that because…" is
712    /// how people reason — and because a field gets filled in by
713    /// inertia while an act is chosen.
714    ///
715    /// What it refuses is the point:
716    ///
717    /// - a kind only the engine may assert, because a participant able
718    ///   to relabel the structure could rewrite the session's shape;
719    /// - a kind only an author may assert, claimed by anyone else,
720    ///   because nobody else has access to another's reasoning;
721    /// - either end naming something this session never produced.
722    #[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    /// Who produced a record, where anyone did.
771    ///
772    /// A step has none: the engine ran it. A transition the engine
773    /// took has none either. Both are absences rather than gaps, and
774    /// they are what stops a reason of testimony being made about
775    /// something nobody can testify to.
776    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    /// A record this session actually produced.
808    ///
809    /// Memory cannot check this — an edge there may reach something
810    /// written an hour ago — but a session knows everything it has
811    /// done, and letting a reason cite what never happened would be
812    /// declining to use the one advantage it has.
813    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    /// The reason the engine can see on its own: a contribution is the
848    /// reply to the item it was made against.
849    ///
850    /// The only kind it asserts. Everything explanatory comes from
851    /// whoever reasoned, because a session ending well after an action
852    /// is not the action having worked.
853    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    /// The one place a session moves.
926    ///
927    /// `applied_by` is absent when the engine took the move itself,
928    /// and naming somebody would be inventing them.
929    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    /// A seat this session's definition declares.
1071    ///
1072    /// Weaker on purpose than [`Self::require_role`]: a definition says
1073    /// which roles may run a step or fire a transition, and says
1074    /// nothing about who may approve a human guard. Demanding a
1075    /// capability that no definition grants would leave every existing
1076    /// ceremony with no one able to approve anything. Which seats may
1077    /// decide a guard is a question for whenever guards grow an
1078    /// authority model; until then, being a seat at this table is the
1079    /// check, and it is enough to make the receipt name someone.
1080    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    /// The smallest ceremony that waits on a person: one guard, one
1304    /// transition it blocks, one seat allowed to fire it.
1305    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        // This used to succeed and write `not_a_guard: true` into the
1628        // session context: a caller could put any key at all there,
1629        // and a typo answered "approved" while leaving a session that
1630        // would never move.
1631        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    /// An approval that names nobody is a receipt for a human decision
1755    /// nobody can be shown to have taken. This is that made checkable.
1756    #[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    /// A seat this session does not have cannot approve anything on it.
1786    /// Weaker than the capability check the other verbs use, and
1787    /// deliberately so — but not so weak that any string will do.
1788    #[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    /// A session with one agenda item and one contribution to it —
1813    /// the smallest thing that has something to explain.
1814    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    /// The one reason the engine sees on its own, and it records it
1845    /// without being asked.
1846    #[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    /// Structure is not a judgement. A seat able to assert it could
1868    /// rewrite the shape of the session by relabelling it.
1869    #[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    /// Testimony about one's own reasoning. Nobody else has access to
1892    /// it, so nobody else may claim it.
1893    #[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    /// A claim about the world, not about a mind. Anyone may make one
1931    /// and everyone may weigh it.
1932    #[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    /// A session knows everything it has done, so a reason may not
1956    /// cite something it never produced.
1957    #[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    /// A move is recorded with the seat that fired it, so "the session
1982    /// resolved because…" has something to point at.
1983    #[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    /// And without one when the engine took the move itself. An
2018    /// absence, not a gap — and it is what stops testimony being
2019    /// claimed about something nobody can testify to.
2020    #[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    /// What kind of party filled the seat is recorded as declared and
2043    /// never inferred.
2044    ///
2045    /// The engine knows this guard demands a human. That says one was
2046    /// required, not that one turned up — and a receipt that read
2047    /// compliance off its own requirement would assert exactly what
2048    /// nobody can demonstrate. So an agent approving a human-approval
2049    /// guard is recorded as an agent, and whether that is acceptable
2050    /// is a question for whoever reads it.
2051    #[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}