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    /// Finished semantic iterations preceding each step's current record.
37    ///
38    /// Technical retries remain represented by their attempt number and the
39    /// audit journal. Semantic repetition needs its own durable history: one
40    /// successful iteration must not overwrite the output that made MADE run
41    /// the next one.
42    #[serde(default)]
43    step_record_history: BTreeMap<StepId, Vec<StepExecutionRecord>>,
44    #[serde(default)]
45    interventions: Vec<CeremonyIntervention>,
46    #[serde(default)]
47    guard_deferrals: Vec<CeremonyGuardDeferral>,
48    /// Who let each human guard through.
49    ///
50    /// Kept beside the context rather than inside it. The context is
51    /// what a guard is evaluated against and already holds "this one
52    /// is approved"; sessions written before this existed carry that
53    /// and nothing else, and moving approval out of the context would
54    /// have made every one of them unapproved on the next read. So the
55    /// context stays the state, and this is the event that produced
56    /// it.
57    #[serde(default)]
58    guard_approvals: Vec<CeremonyGuardApproval>,
59    /// Every move this session made, in order.
60    ///
61    /// The current state says where a session is; this says how it got
62    /// there. Without it nothing could point at a move, so nothing
63    /// could say why one happened — and "why did this resolve" is the
64    /// question the whole thing is for.
65    #[serde(default)]
66    transitions: Vec<CeremonyTransitionRecord>,
67    /// Why one thing here led to another.
68    ///
69    /// Kept apart from the records rather than inside them, because a
70    /// reason is an edge and not a field: it belongs to the pair, and
71    /// putting it on either end would make it readable but not
72    /// followable.
73    #[serde(default)]
74    reasons: Vec<CeremonyReason>,
75    /// Who sits in each seat for this session, where anyone was
76    /// seated. A role with no binding is played the way the definition
77    /// says, which is the usual case and not a lesser one.
78    #[serde(default)]
79    participant_bindings: BTreeMap<RoleId, CeremonyParticipantBinding>,
80    context: CeremonyContext,
81    idempotency_keys: BTreeSet<IdempotencyKey>,
82    #[serde(with = "time::serde::rfc3339")]
83    created_at: OffsetDateTime,
84    #[serde(with = "time::serde::rfc3339")]
85    updated_at: OffsetDateTime,
86    #[serde(with = "time::serde::rfc3339::option")]
87    completed_at: Option<OffsetDateTime>,
88    /// The published definition this instance is bound to, when it was
89    /// started from one.
90    ///
91    /// Absent for an instance started from a definition handed in at
92    /// the time — which is a real and useful way to work, and not the
93    /// same thing. Recording which of the two happened is the point: a
94    /// name and a version identify a published definition only while
95    /// publication is immutable, and an instance that also carries the
96    /// digest can be checked against the definition rather than trusted
97    /// to have run it.
98    #[serde(default)]
99    bound_definition: Option<CeremonyDefinitionDigest>,
100}
101
102impl CeremonyInstance {
103    /// Start from a definition supplied for this run.
104    ///
105    /// Nothing binds the instance to a definition that can be looked up
106    /// later; that is what [`Self::start_bound`] is for.
107    #[must_use]
108    pub fn start(
109        id: CeremonyId,
110        definition: &CeremonyDefinition,
111        context: CeremonyContext,
112        now: OffsetDateTime,
113    ) -> Self {
114        Self::open(id, definition, context, now, None)
115    }
116
117    /// Start from a published definition, recording its digest.
118    ///
119    /// The digest travels with the instance so a later reader can
120    /// verify which definition ran instead of taking the name and
121    /// version on trust.
122    #[must_use]
123    pub fn start_bound(
124        id: CeremonyId,
125        published: &PublishedCeremonyDefinition,
126        context: CeremonyContext,
127        now: OffsetDateTime,
128    ) -> Self {
129        Self::open(
130            id,
131            published.definition(),
132            context,
133            now,
134            Some(published.digest()),
135        )
136    }
137
138    fn open(
139        id: CeremonyId,
140        definition: &CeremonyDefinition,
141        context: CeremonyContext,
142        now: OffsetDateTime,
143        bound_definition: Option<CeremonyDefinitionDigest>,
144    ) -> Self {
145        let step_records = definition
146            .steps()
147            .keys()
148            .map(|step_id| (step_id.clone(), StepExecutionRecord::pending()))
149            .collect();
150
151        Self {
152            id,
153            definition_name: definition.name().clone(),
154            definition_version: definition.version().clone(),
155            current_state: definition.initial_state_id().clone(),
156            step_records,
157            step_record_history: BTreeMap::new(),
158            interventions: Vec::new(),
159            guard_deferrals: Vec::new(),
160            guard_approvals: Vec::new(),
161            transitions: Vec::new(),
162            reasons: Vec::new(),
163            participant_bindings: BTreeMap::new(),
164            context,
165            idempotency_keys: BTreeSet::new(),
166            created_at: now,
167            updated_at: now,
168            completed_at: None,
169            bound_definition,
170        }
171    }
172
173    /// The digest of the published definition this instance runs, if it
174    /// was started from one.
175    #[must_use]
176    pub fn bound_definition(&self) -> Option<CeremonyDefinitionDigest> {
177        self.bound_definition
178    }
179
180    /// Whether this instance runs a definition that can be looked up
181    /// and checked, rather than one supplied for the run.
182    #[must_use]
183    pub fn is_bound_to_a_published_definition(&self) -> bool {
184        self.bound_definition.is_some()
185    }
186
187    /// Replace a legacy publication identity after the same definition has
188    /// been verified under a successor digest scheme.
189    ///
190    /// This is deliberately narrower than a general rebind operation. A
191    /// running ceremony cannot be moved to different content, name or
192    /// version. Storage migrations may only replace the expected legacy
193    /// identity with the identity of the already verified publication.
194    pub fn migrate_definition_binding(
195        &mut self,
196        migration: &CeremonyDefinitionDigestMigration,
197    ) -> Result<bool, DomainError> {
198        if self.definition_name != *migration.definition_name()
199            || self.definition_version != *migration.definition_version()
200        {
201            return Err(DomainError::InvariantViolated {
202                reason: "a definition binding migration cannot change name or version",
203            });
204        }
205
206        match self.bound_definition {
207            Some(current) if current == migration.destination() => Ok(false),
208            Some(current) if current == migration.source() => {
209                self.bound_definition = Some(migration.destination());
210                Ok(true)
211            }
212            _ => Err(DomainError::InvariantViolated {
213                reason: "a definition binding migration did not match the stored identity",
214            }),
215        }
216    }
217
218    #[must_use]
219    pub fn id(&self) -> &CeremonyId {
220        &self.id
221    }
222
223    #[must_use]
224    pub fn definition_name(&self) -> &CeremonyName {
225        &self.definition_name
226    }
227
228    #[must_use]
229    pub fn definition_version(&self) -> &CeremonyVersion {
230        &self.definition_version
231    }
232
233    #[must_use]
234    pub fn current_state(&self) -> &StateId {
235        &self.current_state
236    }
237
238    #[must_use]
239    pub fn step_records(&self) -> &BTreeMap<StepId, StepExecutionRecord> {
240        &self.step_records
241    }
242
243    #[must_use]
244    pub fn step_record(&self, step_id: &StepId) -> Option<&StepExecutionRecord> {
245        self.step_records.get(step_id)
246    }
247
248    /// Finished iterations before the current record, in execution order.
249    #[must_use]
250    pub fn step_record_history(&self, step_id: &StepId) -> &[StepExecutionRecord] {
251        self.step_record_history
252            .get(step_id)
253            .map(Vec::as_slice)
254            .unwrap_or_default()
255    }
256
257    /// Whether a repeating step consumed its last permitted iteration without
258    /// satisfying its declared stop condition.
259    #[must_use]
260    pub fn step_repeat_limit_reached(
261        &self,
262        definition: &CeremonyDefinition,
263        step_id: &StepId,
264    ) -> bool {
265        let Some(step) = definition.step(step_id) else {
266            return false;
267        };
268        let Some(policy) = step.repeat_policy() else {
269            return false;
270        };
271        let Some(record) = self.step_record(step_id) else {
272            return false;
273        };
274        record.status().is_success()
275            && !policy.is_satisfied(record.output())
276            && !policy.permits_another_iteration(record.iteration())
277    }
278
279    #[must_use]
280    pub fn interventions(&self) -> &[CeremonyIntervention] {
281        &self.interventions
282    }
283
284    #[must_use]
285    pub fn guard_deferrals(&self) -> &[CeremonyGuardDeferral] {
286        &self.guard_deferrals
287    }
288
289    /// Who let each human guard through, in the order they did.
290    ///
291    /// Empty for a session written before approvals recorded an
292    /// approver, which is the truth about those sessions rather than a
293    /// gap to paper over.
294    #[must_use]
295    pub fn guard_approvals(&self) -> &[CeremonyGuardApproval] {
296        &self.guard_approvals
297    }
298
299    /// Every move this session made, in the order it made them.
300    #[must_use]
301    pub fn transitions(&self) -> &[CeremonyTransitionRecord] {
302        &self.transitions
303    }
304
305    /// Why one thing here led to another.
306    #[must_use]
307    pub fn reasons(&self) -> &[CeremonyReason] {
308        &self.reasons
309    }
310
311    #[must_use]
312    pub fn intervention(
313        &self,
314        intervention_id: &CeremonyInterventionId,
315    ) -> Option<&CeremonyIntervention> {
316        self.interventions
317            .iter()
318            .find(|intervention| intervention.id() == intervention_id)
319    }
320
321    #[must_use]
322    pub fn context(&self) -> &CeremonyContext {
323        &self.context
324    }
325
326    #[must_use]
327    pub fn idempotency_keys(&self) -> &BTreeSet<IdempotencyKey> {
328        &self.idempotency_keys
329    }
330
331    #[must_use]
332    pub fn created_at(&self) -> OffsetDateTime {
333        self.created_at
334    }
335
336    #[must_use]
337    pub fn updated_at(&self) -> OffsetDateTime {
338        self.updated_at
339    }
340
341    #[must_use]
342    pub fn completed_at(&self) -> Option<OffsetDateTime> {
343        self.completed_at
344    }
345
346    #[must_use]
347    pub fn is_terminal(&self, definition: &CeremonyDefinition) -> bool {
348        self.matches_definition(definition) && definition.is_terminal_state(&self.current_state)
349    }
350
351    #[must_use]
352    pub fn is_completed(&self, definition: &CeremonyDefinition) -> bool {
353        self.is_terminal(definition) && self.completed_at.is_some()
354    }
355
356    pub fn start_step_as(
357        &mut self,
358        definition: &CeremonyDefinition,
359        role_id: &RoleId,
360        step_id: &StepId,
361        lease: StepLease,
362        now: OffsetDateTime,
363    ) -> Result<StepAttempt, DomainError> {
364        self.require_role(definition, role_id, &RoleAction::step(step_id.clone()))?;
365        self.start_step(definition, step_id, lease, now)
366    }
367
368    pub fn start_step(
369        &mut self,
370        definition: &CeremonyDefinition,
371        step_id: &StepId,
372        lease: StepLease,
373        now: OffsetDateTime,
374    ) -> Result<StepAttempt, DomainError> {
375        self.require_definition(definition)?;
376        if self.is_terminal(definition) {
377            return Err(DomainError::InvariantViolated {
378                reason: "terminal ceremony instances cannot start steps",
379            });
380        }
381
382        let step = definition.step(step_id).ok_or(DomainError::NotFound {
383            what: "ceremony_instance.step",
384        })?;
385        if step.state_id() != &self.current_state {
386            return Err(DomainError::InvalidTransition {
387                from: "ceremony_instance.current_state",
388                to: "ceremony_step.state",
389            });
390        }
391
392        let record = self
393            .step_records
394            .get(step_id)
395            .cloned()
396            .ok_or(DomainError::NotFound {
397                what: "ceremony_instance.step_record",
398            })?;
399        if !record.can_be_started_at(now) {
400            return Err(DomainError::InvariantViolated {
401                reason: "step lease is still active",
402            });
403        }
404
405        let next_attempt = next_attempt_for_start(&record)?;
406        if !step.retry_policy().allows_attempt(next_attempt) {
407            return Err(DomainError::InvariantViolated {
408                reason: "step retry policy exhausted",
409            });
410        }
411        if !self
412            .idempotency_keys
413            .insert(lease.idempotency_key().clone())
414        {
415            return Err(DomainError::AlreadyExists {
416                what: "ceremony_instance.idempotency_key",
417            });
418        }
419
420        self.step_records
421            .insert(step_id.clone(), record.with_started(lease, next_attempt));
422        self.updated_at = now;
423        Ok(next_attempt)
424    }
425
426    pub fn apply_step_result(
427        &mut self,
428        definition: &CeremonyDefinition,
429        step_id: &StepId,
430        result: StepResult,
431        now: OffsetDateTime,
432    ) -> Result<(), DomainError> {
433        self.require_definition(definition)?;
434        let step = definition.step(step_id).ok_or(DomainError::NotFound {
435            what: "ceremony_instance.step",
436        })?;
437        if step.state_id() != &self.current_state {
438            return Err(DomainError::InvalidTransition {
439                from: "ceremony_instance.current_state",
440                to: "ceremony_step.state",
441            });
442        }
443
444        let record = self
445            .step_records
446            .get(step_id)
447            .cloned()
448            .ok_or(DomainError::NotFound {
449                what: "ceremony_instance.step_record",
450            })?;
451        if record.status() != StepStatus::InProgress {
452            return Err(DomainError::InvariantViolated {
453                reason: "step result requires an in-progress step",
454            });
455        }
456
457        let finished = record.with_result(result);
458        let repeat = step.repeat_policy().filter(|policy| {
459            finished.status().is_success() && !policy.is_satisfied(finished.output())
460        });
461        if repeat.is_some_and(|policy| policy.permits_another_iteration(finished.iteration())) {
462            let next_iteration = finished.iteration().next()?;
463            self.step_record_history
464                .entry(step_id.clone())
465                .or_default()
466                .push(finished);
467            self.step_records.insert(
468                step_id.clone(),
469                StepExecutionRecord::pending_iteration(next_iteration),
470            );
471        } else {
472            self.step_records.insert(step_id.clone(), finished);
473        }
474        self.updated_at = now;
475        Ok(())
476    }
477
478    /// Approving is checked the way deferring is. It used to take no
479    /// definition at all, so any name at all could be "approved" —
480    /// which wrote that name into the session context, told the caller
481    /// it had succeeded, and left a session that would never move.
482    ///
483    /// Approving ahead of time is still allowed: unlike a deferral,
484    /// which answers a decision being asked for now, a person may
485    /// settle a guard before the work leading up to it is finished.
486    pub fn approve_guard(
487        &mut self,
488        definition: &CeremonyDefinition,
489        guard_name: &GuardName,
490        approved_by: RoleId,
491        approved_by_kind: AuditActorKind,
492        now: OffsetDateTime,
493    ) -> Result<(), DomainError> {
494        self.require_active(
495            definition,
496            "terminal ceremony instances cannot approve guards",
497        )?;
498        let guard = definition
499            .guards()
500            .get(guard_name)
501            .ok_or(DomainError::NotFound {
502                what: "ceremony_guard",
503            })?;
504        if !matches!(guard.condition(), GuardCondition::HumanApproval) {
505            return Err(DomainError::InvariantViolated {
506                reason: "only human approval guards can be approved",
507            });
508        }
509        self.require_declared_role(definition, &approved_by)?;
510        self.context = self.context.clone().with_guard_approval(guard_name)?;
511        self.guard_approvals.push(CeremonyGuardApproval::record(
512            guard_name.clone(),
513            approved_by,
514            approved_by_kind,
515            now,
516        ));
517        self.updated_at = now;
518        Ok(())
519    }
520
521    /// Seat a role for this session.
522    ///
523    /// Rebinding is allowed and deliberate: a panel can become
524    /// unavailable halfway through a working session, and a ceremony
525    /// that could not be re-seated would have to be abandoned and
526    /// started again, losing everything already decided. What was
527    /// seated before stays in the journal; the instance carries who is
528    /// seated now, which is what the next step needs.
529    pub fn bind_participant(
530        &mut self,
531        definition: &CeremonyDefinition,
532        role_id: RoleId,
533        specialty: Specialty,
534        now: OffsetDateTime,
535    ) -> Result<(), DomainError> {
536        self.require_active(
537            definition,
538            "terminal ceremony instances cannot be re-seated",
539        )?;
540        // A seat that the ceremony never declared is not a seat.
541        if definition.role(&role_id).is_none() {
542            return Err(DomainError::NotFound {
543                what: "ceremony_role",
544            });
545        }
546        self.participant_bindings.insert(
547            role_id.clone(),
548            CeremonyParticipantBinding::record(role_id, specialty, now),
549        );
550        self.updated_at = now;
551        Ok(())
552    }
553
554    #[must_use]
555    pub fn participant_bindings(&self) -> &BTreeMap<RoleId, CeremonyParticipantBinding> {
556        &self.participant_bindings
557    }
558
559    /// The specialty a role's work should be put to, if this session
560    /// seated one. `None` means the definition decides, as usual.
561    #[must_use]
562    pub fn bound_specialty(&self, role_id: &RoleId) -> Option<&Specialty> {
563        self.participant_bindings
564            .get(role_id)
565            .map(CeremonyParticipantBinding::specialty)
566    }
567
568    pub fn defer_guard(
569        &mut self,
570        definition: &CeremonyDefinition,
571        guard_name: GuardName,
572        content: CeremonyGuardDeferralContent,
573        deferred_by: RoleId,
574        deferred_by_kind: AuditActorKind,
575        now: OffsetDateTime,
576    ) -> Result<(), DomainError> {
577        self.require_active(
578            definition,
579            "terminal ceremony instances cannot defer guard decisions",
580        )?;
581        let guard = definition
582            .guards()
583            .get(&guard_name)
584            .ok_or(DomainError::NotFound {
585                what: "ceremony_guard",
586            })?;
587        if !matches!(guard.condition(), GuardCondition::HumanApproval) {
588            return Err(DomainError::InvariantViolated {
589                reason: "only human approval guards can be deferred",
590            });
591        }
592        if self.context.is_guard_approved(&guard_name) {
593            return Err(DomainError::InvariantViolated {
594                reason: "approved human guards cannot be deferred",
595            });
596        }
597        let is_currently_required = definition
598            .available_transitions(&self.current_state)
599            .any(|transition| transition.required_guards().contains(&guard_name));
600        if !is_currently_required {
601            return Err(DomainError::InvariantViolated {
602                reason: "human guard is not required from the current state",
603            });
604        }
605
606        self.require_declared_role(definition, &deferred_by)?;
607        self.guard_deferrals.push(CeremonyGuardDeferral::record(
608            guard_name,
609            deferred_by,
610            deferred_by_kind,
611            content,
612            now,
613        ));
614        self.updated_at = now;
615        Ok(())
616    }
617
618    #[allow(clippy::too_many_arguments)]
619    pub fn request_intervention_as(
620        &mut self,
621        definition: &CeremonyDefinition,
622        intervention_id: CeremonyInterventionId,
623        role_id: RoleId,
624        kind: CeremonyInterventionKind,
625        target: CeremonyInterventionTarget,
626        content: CeremonyInterventionContent,
627        now: OffsetDateTime,
628    ) -> Result<(), DomainError> {
629        self.request_intervention_with_provenance_as(
630            definition,
631            intervention_id,
632            role_id,
633            kind,
634            target,
635            content,
636            None,
637            now,
638        )
639    }
640
641    #[allow(clippy::too_many_arguments)]
642    pub fn request_intervention_with_provenance_as(
643        &mut self,
644        definition: &CeremonyDefinition,
645        intervention_id: CeremonyInterventionId,
646        role_id: RoleId,
647        kind: CeremonyInterventionKind,
648        target: CeremonyInterventionTarget,
649        content: CeremonyInterventionContent,
650        provenance: Option<CeremonyInterventionProvenance>,
651        now: OffsetDateTime,
652    ) -> Result<(), DomainError> {
653        self.require_active(
654            definition,
655            "terminal ceremony instances cannot accept interventions",
656        )?;
657        self.require_role(definition, &role_id, &RoleAction::request_intervention())?;
658        Self::require_intervention_target(definition, &target)?;
659        if let Some(provenance) = provenance.as_ref() {
660            self.require_intervention_provenance(definition, &role_id, &target, provenance)?;
661        }
662        if self
663            .interventions
664            .iter()
665            .any(|intervention| intervention.id() == &intervention_id)
666        {
667            return Err(DomainError::AlreadyExists {
668                what: "ceremony_intervention",
669            });
670        }
671        let intervention = CeremonyIntervention::open_with_provenance(
672            intervention_id,
673            kind,
674            role_id,
675            target,
676            content,
677            provenance,
678            now,
679        );
680        self.interventions.push(intervention);
681        self.updated_at = now;
682        Ok(())
683    }
684
685    pub fn respond_to_intervention_as(
686        &mut self,
687        definition: &CeremonyDefinition,
688        intervention_id: &CeremonyInterventionId,
689        role_id: RoleId,
690        content: CeremonyInterventionContent,
691        now: OffsetDateTime,
692    ) -> Result<(), DomainError> {
693        self.require_active(
694            definition,
695            "terminal ceremony instances cannot receive intervention responses",
696        )?;
697        self.require_role(definition, &role_id, &RoleAction::respond_to_intervention())?;
698        self.interventions
699            .iter_mut()
700            .find(|intervention| intervention.id() == intervention_id)
701            .ok_or(DomainError::NotFound {
702                what: "ceremony_intervention",
703            })?
704            .respond(role_id, content, now)?;
705        self.record_that_it_answers(intervention_id, now);
706        self.updated_at = now;
707        Ok(())
708    }
709
710    pub fn prepare_evidence_request_as(
711        &self,
712        definition: &CeremonyDefinition,
713        intervention_id: CeremonyInterventionId,
714        role_id: RoleId,
715        source_id: CeremonyEvidenceSourceId,
716        query: CeremonyInterventionContent,
717    ) -> Result<CeremonyEvidenceRequest, DomainError> {
718        self.require_active(
719            definition,
720            "terminal ceremony instances cannot collect intervention evidence",
721        )?;
722        self.require_role(definition, &role_id, &RoleAction::respond_to_intervention())?;
723        self.intervention(&intervention_id)
724            .ok_or(DomainError::NotFound {
725                what: "ceremony_intervention",
726            })?
727            .ensure_can_respond(&role_id)?;
728        Ok(CeremonyEvidenceRequest::new(
729            self.id.clone(),
730            intervention_id,
731            role_id,
732            source_id,
733            query,
734            self.context.clone(),
735        ))
736    }
737
738    pub fn respond_to_intervention_with_evidence_as(
739        &mut self,
740        definition: &CeremonyDefinition,
741        intervention_id: &CeremonyInterventionId,
742        role_id: RoleId,
743        evidence_pack: super::CeremonyEvidencePack,
744        now: OffsetDateTime,
745    ) -> Result<(), DomainError> {
746        self.require_active(
747            definition,
748            "terminal ceremony instances cannot receive intervention evidence",
749        )?;
750        self.require_role(definition, &role_id, &RoleAction::respond_to_intervention())?;
751        self.interventions
752            .iter_mut()
753            .find(|intervention| intervention.id() == intervention_id)
754            .ok_or(DomainError::NotFound {
755                what: "ceremony_intervention",
756            })?
757            .respond_with_evidence(role_id, evidence_pack, now)?;
758        self.record_that_it_answers(intervention_id, now);
759        self.updated_at = now;
760        Ok(())
761    }
762
763    /// State why one thing here led to another.
764    ///
765    /// Its own act rather than a field on contributing, because a
766    /// reason is often known later — "in fact I did that because…" is
767    /// how people reason — and because a field gets filled in by
768    /// inertia while an act is chosen.
769    ///
770    /// What it refuses is the point:
771    ///
772    /// - a kind only the engine may assert, because a participant able
773    ///   to relabel the structure could rewrite the session's shape;
774    /// - a kind only an author may assert, claimed by anyone else,
775    ///   because nobody else has access to another's reasoning;
776    /// - either end naming something this session never produced.
777    #[allow(clippy::too_many_arguments)]
778    pub fn assert_reason_as(
779        &mut self,
780        definition: &CeremonyDefinition,
781        role_id: RoleId,
782        from: CeremonyRecordRef,
783        to: CeremonyRecordRef,
784        kind: CeremonyReasonKind,
785        why: impl Into<String>,
786        confidence: MemoryConfidence,
787        now: OffsetDateTime,
788    ) -> Result<(), DomainError> {
789        self.require_declared_role(definition, &role_id)?;
790        self.require_record(&from)?;
791        self.require_record(&to)?;
792
793        match kind.asserter() {
794            ReasonAsserter::TheEngine => {
795                return Err(DomainError::InvariantViolated {
796                    reason:
797                        "this kind of reason states the shape of the session, not a judgement, \
798                             and only the engine may assert it",
799                });
800            }
801            ReasonAsserter::ItsAuthor => {
802                if self.author_of(&from) != Some(&role_id) {
803                    return Err(DomainError::InvariantViolated {
804                        reason: "only whoever produced something may say why they decided it or \
805                                 how they did it",
806                    });
807                }
808            }
809            ReasonAsserter::AnySeat => {}
810        }
811
812        self.reasons.push(CeremonyReason::new(
813            from,
814            to,
815            kind,
816            why,
817            confidence,
818            Some(role_id),
819            now,
820        )?);
821        self.updated_at = now;
822        Ok(())
823    }
824
825    /// Who produced a record, where anyone did.
826    ///
827    /// A step has none: the engine ran it. A transition the engine
828    /// took has none either. Both are absences rather than gaps, and
829    /// they are what stops a reason of testimony being made about
830    /// something nobody can testify to.
831    fn author_of(&self, record: &CeremonyRecordRef) -> Option<&RoleId> {
832        match record {
833            CeremonyRecordRef::Step { .. } => None,
834            CeremonyRecordRef::AgendaItem { agenda_item } => self
835                .intervention(agenda_item)
836                .map(CeremonyIntervention::requested_by),
837            CeremonyRecordRef::Contribution {
838                agenda_item,
839                ordinal,
840            } => self
841                .intervention(agenda_item)
842                .and_then(|item| item.responses().get(*ordinal as usize))
843                .map(CeremonyInterventionResponse::role_id),
844            CeremonyRecordRef::GuardDecision { guard_name } => self
845                .guard_approvals
846                .iter()
847                .find(|approval| approval.guard_name() == guard_name)
848                .map(CeremonyGuardApproval::approved_by)
849                .or_else(|| {
850                    self.guard_deferrals
851                        .iter()
852                        .find(|deferral| deferral.guard_name() == guard_name)
853                        .map(CeremonyGuardDeferral::deferred_by)
854                }),
855            CeremonyRecordRef::Transition { ordinal } => self
856                .transitions
857                .get(ordinal.saturating_sub(1) as usize)
858                .and_then(CeremonyTransitionRecord::applied_by),
859        }
860    }
861
862    /// A record this session actually produced.
863    ///
864    /// Memory cannot check this — an edge there may reach something
865    /// written an hour ago — but a session knows everything it has
866    /// done, and letting a reason cite what never happened would be
867    /// declining to use the one advantage it has.
868    fn require_record(&self, record: &CeremonyRecordRef) -> Result<(), DomainError> {
869        let exists = match record {
870            CeremonyRecordRef::Step { step_id } => self.step_records.contains_key(step_id),
871            CeremonyRecordRef::AgendaItem { agenda_item } => {
872                self.intervention(agenda_item).is_some()
873            }
874            CeremonyRecordRef::Contribution {
875                agenda_item,
876                ordinal,
877            } => self
878                .intervention(agenda_item)
879                .is_some_and(|item| item.responses().len() > *ordinal as usize),
880            CeremonyRecordRef::GuardDecision { guard_name } => {
881                self.guard_approvals
882                    .iter()
883                    .any(|approval| approval.guard_name() == guard_name)
884                    || self
885                        .guard_deferrals
886                        .iter()
887                        .any(|deferral| deferral.guard_name() == guard_name)
888            }
889            CeremonyRecordRef::Transition { ordinal } => {
890                *ordinal >= 1 && (*ordinal as usize) <= self.transitions.len()
891            }
892        };
893        if exists {
894            Ok(())
895        } else {
896            Err(DomainError::NotFound {
897                what: "ceremony_record",
898            })
899        }
900    }
901
902    /// The reason the engine can see on its own: a contribution is the
903    /// reply to the item it was made against.
904    ///
905    /// The only kind it asserts. Everything explanatory comes from
906    /// whoever reasoned, because a session ending well after an action
907    /// is not the action having worked.
908    fn record_that_it_answers(
909        &mut self,
910        agenda_item: &CeremonyInterventionId,
911        now: OffsetDateTime,
912    ) {
913        let Some(ordinal) = self
914            .intervention(agenda_item)
915            .map(|item| item.responses().len())
916            .and_then(|count| u32::try_from(count.checked_sub(1)?).ok())
917        else {
918            return;
919        };
920        if let Ok(reason) = CeremonyReason::new(
921            CeremonyRecordRef::contribution(agenda_item.clone(), ordinal),
922            CeremonyRecordRef::agenda_item(agenda_item.clone()),
923            CeremonyReasonKind::Answers,
924            "a contribution made against this agenda item",
925            MemoryConfidence::High,
926            None,
927            now,
928        ) {
929            self.reasons.push(reason);
930        }
931    }
932
933    pub fn close_intervention_as(
934        &mut self,
935        definition: &CeremonyDefinition,
936        intervention_id: &CeremonyInterventionId,
937        role_id: &RoleId,
938        now: OffsetDateTime,
939    ) -> Result<(), DomainError> {
940        self.require_active(
941            definition,
942            "terminal ceremony instances cannot close interventions",
943        )?;
944        self.require_role(definition, role_id, &RoleAction::request_intervention())?;
945        self.interventions
946            .iter_mut()
947            .find(|intervention| intervention.id() == intervention_id)
948            .ok_or(DomainError::NotFound {
949                what: "ceremony_intervention",
950            })?
951            .close(role_id, now)?;
952        self.updated_at = now;
953        Ok(())
954    }
955
956    pub fn apply_transition_as(
957        &mut self,
958        definition: &CeremonyDefinition,
959        role_id: &RoleId,
960        trigger: &TransitionTrigger,
961        now: OffsetDateTime,
962    ) -> Result<StateId, DomainError> {
963        self.require_role(
964            definition,
965            role_id,
966            &RoleAction::transition(trigger.clone()),
967        )?;
968        self.move_on(definition, trigger, Some(role_id.clone()), now)
969    }
970
971    pub fn apply_transition(
972        &mut self,
973        definition: &CeremonyDefinition,
974        trigger: &TransitionTrigger,
975        now: OffsetDateTime,
976    ) -> Result<StateId, DomainError> {
977        self.move_on(definition, trigger, None, now)
978    }
979
980    /// The one place a session moves.
981    ///
982    /// `applied_by` is absent when the engine took the move itself,
983    /// and naming somebody would be inventing them.
984    fn move_on(
985        &mut self,
986        definition: &CeremonyDefinition,
987        trigger: &TransitionTrigger,
988        applied_by: Option<RoleId>,
989        now: OffsetDateTime,
990    ) -> Result<StateId, DomainError> {
991        self.require_definition(definition)?;
992        if self.is_terminal(definition) {
993            return Err(DomainError::InvariantViolated {
994                reason: "terminal ceremony instances cannot transition",
995            });
996        }
997
998        let transition = definition
999            .transition_for_trigger(&self.current_state, trigger)
1000            .ok_or(DomainError::InvalidTransition {
1001                from: "ceremony_instance.current_state",
1002                to: "transition_trigger",
1003            })?;
1004        if !definition.repeat_requirements_are_satisfied(&self.current_state, &self.step_records) {
1005            return Err(DomainError::InvariantViolated {
1006                reason: "ceremony step repeat condition is not satisfied",
1007            });
1008        }
1009        if !definition.guards_are_satisfied(transition, &self.step_records, &self.context) {
1010            return Err(DomainError::InvariantViolated {
1011                reason: "ceremony transition guards are not satisfied",
1012            });
1013        }
1014
1015        let from_state = self.current_state.clone();
1016        self.current_state = transition.to().clone();
1017        self.transitions.push(CeremonyTransitionRecord::record(
1018            trigger.clone(),
1019            from_state,
1020            self.current_state.clone(),
1021            applied_by,
1022            now,
1023        ));
1024        self.updated_at = now;
1025        if definition.is_terminal_state(&self.current_state) {
1026            self.completed_at = Some(now);
1027        }
1028        Ok(self.current_state.clone())
1029    }
1030
1031    fn matches_definition(&self, definition: &CeremonyDefinition) -> bool {
1032        self.definition_name == *definition.name()
1033            && self.definition_version == *definition.version()
1034    }
1035
1036    fn require_definition(&self, definition: &CeremonyDefinition) -> Result<(), DomainError> {
1037        if self.matches_definition(definition) {
1038            Ok(())
1039        } else {
1040            Err(DomainError::InvariantViolated {
1041                reason: "ceremony instance definition mismatch",
1042            })
1043        }
1044    }
1045
1046    fn require_active(
1047        &self,
1048        definition: &CeremonyDefinition,
1049        terminal_reason: &'static str,
1050    ) -> Result<(), DomainError> {
1051        self.require_definition(definition)?;
1052        if self.is_terminal(definition) {
1053            Err(DomainError::InvariantViolated {
1054                reason: terminal_reason,
1055            })
1056        } else {
1057            Ok(())
1058        }
1059    }
1060
1061    fn require_intervention_target(
1062        definition: &CeremonyDefinition,
1063        target: &CeremonyInterventionTarget,
1064    ) -> Result<(), DomainError> {
1065        let Some(role_ids) = target.role_ids() else {
1066            return Ok(());
1067        };
1068        for role_id in role_ids {
1069            if definition.role(role_id).is_none() {
1070                return Err(DomainError::NotFound {
1071                    what: "ceremony_intervention.target_role",
1072                });
1073            }
1074            if !definition.role_allows(role_id, &RoleAction::respond_to_intervention()) {
1075                return Err(DomainError::InvariantViolated {
1076                    reason: "target role cannot respond to ceremony interventions",
1077                });
1078            }
1079        }
1080        Ok(())
1081    }
1082
1083    fn require_intervention_provenance(
1084        &self,
1085        definition: &CeremonyDefinition,
1086        requested_by: &RoleId,
1087        target: &CeremonyInterventionTarget,
1088        provenance: &CeremonyInterventionProvenance,
1089    ) -> Result<(), DomainError> {
1090        let source = self
1091            .intervention(provenance.source_intervention_id())
1092            .ok_or(DomainError::NotFound {
1093                what: "ceremony_intervention.provenance_source",
1094            })?;
1095        if source.requested_by() != requested_by {
1096            return Err(DomainError::InvariantViolated {
1097                reason: "only the source requester can select an intervention response",
1098            });
1099        }
1100        if !source
1101            .responses()
1102            .iter()
1103            .any(|response| response.role_id() == provenance.source_response_role_id())
1104        {
1105            return Err(DomainError::NotFound {
1106                what: "ceremony_intervention.provenance_response",
1107            });
1108        }
1109        if definition.role(provenance.selected_role_id()).is_none() {
1110            return Err(DomainError::NotFound {
1111                what: "ceremony_intervention.provenance_selected_role",
1112            });
1113        }
1114        if !definition.role_allows(
1115            provenance.selected_role_id(),
1116            &RoleAction::respond_to_intervention(),
1117        ) {
1118            return Err(DomainError::InvariantViolated {
1119                reason: "selected intervention role cannot respond",
1120            });
1121        }
1122        if !target.accepts(provenance.selected_role_id()) {
1123            return Err(DomainError::InvariantViolated {
1124                reason: "intervention target does not include the selected role",
1125            });
1126        }
1127        Ok(())
1128    }
1129
1130    /// A seat this session's definition declares.
1131    ///
1132    /// Weaker on purpose than [`Self::require_role`]: a definition says
1133    /// which roles may run a step or fire a transition, and says
1134    /// nothing about who may approve a human guard. Demanding a
1135    /// capability that no definition grants would leave every existing
1136    /// ceremony with no one able to approve anything. Which seats may
1137    /// decide a guard is a question for whenever guards grow an
1138    /// authority model; until then, being a seat at this table is the
1139    /// check, and it is enough to make the receipt name someone.
1140    fn require_declared_role(
1141        &self,
1142        definition: &CeremonyDefinition,
1143        role_id: &RoleId,
1144    ) -> Result<(), DomainError> {
1145        self.require_definition(definition)?;
1146        if definition.role(role_id).is_some() {
1147            Ok(())
1148        } else {
1149            Err(DomainError::NotFound {
1150                what: "ceremony_role",
1151            })
1152        }
1153    }
1154
1155    fn require_role(
1156        &self,
1157        definition: &CeremonyDefinition,
1158        role_id: &RoleId,
1159        action: &RoleAction,
1160    ) -> Result<(), DomainError> {
1161        self.require_definition(definition)?;
1162        if definition.role_allows(role_id, action) {
1163            Ok(())
1164        } else {
1165            Err(DomainError::InvariantViolated {
1166                reason: "ceremony role is not allowed to perform action",
1167            })
1168        }
1169    }
1170}
1171
1172fn next_attempt_for_start(record: &StepExecutionRecord) -> Result<StepAttempt, DomainError> {
1173    if matches!(record.status(), StepStatus::Failed | StepStatus::InProgress) {
1174        record.attempt().next()
1175    } else {
1176        Ok(record.attempt())
1177    }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183    use crate::value_objects::{
1184        Attributes, CeremonyGuard, CeremonyState, CeremonyStep, CeremonyTransition, GuardCondition,
1185        GuardName, LeaseOwnerId, RepeatUntilCondition, RetryPolicy, StepHandlerConfig,
1186        StepHandlerKind, StepIteration, StepOutput, StepOutputField, StepRepeatPolicy,
1187    };
1188    use serde_json::json;
1189    use time::macros::datetime;
1190
1191    fn now() -> OffsetDateTime {
1192        datetime!(2026-06-06 12:00:00 UTC)
1193    }
1194
1195    fn state_id(raw: &str) -> StateId {
1196        StateId::new(raw).unwrap()
1197    }
1198
1199    fn step_id(raw: &str) -> StepId {
1200        StepId::new(raw).unwrap()
1201    }
1202
1203    fn trigger(raw: &str) -> TransitionTrigger {
1204        TransitionTrigger::new(raw).unwrap()
1205    }
1206
1207    fn role_id(raw: &str) -> RoleId {
1208        RoleId::new(raw).unwrap()
1209    }
1210
1211    fn guard_name(raw: &str) -> GuardName {
1212        GuardName::new(raw).unwrap()
1213    }
1214
1215    fn handler_kind() -> StepHandlerKind {
1216        StepHandlerKind::new("multiagent_round").unwrap()
1217    }
1218
1219    fn retrying_step(raw_step_id: &str, raw_state_id: &str) -> CeremonyStep {
1220        CeremonyStep::new(
1221            step_id(raw_step_id),
1222            state_id(raw_state_id),
1223            handler_kind(),
1224            StepHandlerConfig::empty(),
1225            RetryPolicy::new(
1226                StepAttempt::new(3).unwrap(),
1227                crate::value_objects::DurationMs::ZERO,
1228            ),
1229            None,
1230        )
1231    }
1232
1233    fn single_attempt_step(raw_step_id: &str, raw_state_id: &str) -> CeremonyStep {
1234        CeremonyStep::new(
1235            step_id(raw_step_id),
1236            state_id(raw_state_id),
1237            handler_kind(),
1238            StepHandlerConfig::empty(),
1239            RetryPolicy::single_attempt(),
1240            None,
1241        )
1242    }
1243
1244    fn repeating_plan(max_iterations: u32) -> CeremonyStep {
1245        retrying_step("plan", "drafting").with_repeat_policy(StepRepeatPolicy::new(
1246            RepeatUntilCondition::output_field_equals(
1247                StepOutputField::new("ready").unwrap(),
1248                json!(true),
1249            ),
1250            StepIteration::new(max_iterations).unwrap(),
1251        ))
1252    }
1253
1254    fn readiness_output(ready: bool) -> StepOutput {
1255        StepOutput::new(
1256            Attributes::new(std::collections::BTreeMap::from([(
1257                "ready".to_owned(),
1258                json!(ready),
1259            )]))
1260            .unwrap(),
1261        )
1262    }
1263
1264    fn lease(
1265        raw_owner_id: &str,
1266        raw_key: &str,
1267        acquired_at: OffsetDateTime,
1268        expires_at: OffsetDateTime,
1269    ) -> StepLease {
1270        StepLease::new(
1271            LeaseOwnerId::new(raw_owner_id).unwrap(),
1272            IdempotencyKey::new(raw_key).unwrap(),
1273            acquired_at,
1274            expires_at,
1275        )
1276        .unwrap()
1277    }
1278
1279    fn role(actions: Vec<RoleAction>) -> crate::value_objects::CeremonyRole {
1280        crate::value_objects::CeremonyRole::new(role_id("facilitator"), actions).unwrap()
1281    }
1282
1283    fn definition_with_steps(steps: Vec<CeremonyStep>) -> CeremonyDefinition {
1284        let plan_done = CeremonyGuard::new(
1285            guard_name("plan_done"),
1286            GuardCondition::StepStatus {
1287                step_id: step_id("plan"),
1288                status: StepStatus::Completed,
1289            },
1290        );
1291        let finish = CeremonyTransition::new(
1292            state_id("drafting"),
1293            state_id("done"),
1294            trigger("finish"),
1295            vec![plan_done.name().clone()],
1296        )
1297        .unwrap();
1298        let role = role(vec![
1299            RoleAction::step(step_id("plan")),
1300            RoleAction::transition(finish.trigger().clone()),
1301            RoleAction::request_intervention(),
1302        ]);
1303        let observer = crate::value_objects::CeremonyRole::new(
1304            role_id("observer"),
1305            vec![RoleAction::respond_to_intervention()],
1306        )
1307        .unwrap();
1308
1309        CeremonyDefinition::new(
1310            crate::value_objects::CeremonyName::new("planning_ceremony").unwrap(),
1311            CeremonyVersion::v1(),
1312            None,
1313            Vec::new(),
1314            Vec::new(),
1315            vec![
1316                CeremonyState::initial(state_id("drafting")),
1317                CeremonyState::intermediate(state_id("review")),
1318                CeremonyState::terminal(state_id("done")),
1319            ],
1320            vec![finish],
1321            steps,
1322            vec![plan_done],
1323            vec![role, observer],
1324        )
1325        .unwrap()
1326    }
1327
1328    fn definition() -> CeremonyDefinition {
1329        definition_with_steps(vec![
1330            retrying_step("plan", "drafting"),
1331            single_attempt_step("review_step", "review"),
1332        ])
1333    }
1334
1335    #[test]
1336    fn a_verified_digest_migration_rebinds_only_its_exact_definition() {
1337        let definition = definition();
1338        let published = PublishedCeremonyDefinition::seal(definition.clone()).unwrap();
1339        let migration = definition.choreographer_v1_digest_migration().unwrap();
1340        let mut value = serde_json::to_value(CeremonyInstance::start_bound(
1341            CeremonyId::new("legacy-bound").unwrap(),
1342            &published,
1343            CeremonyContext::empty(),
1344            now(),
1345        ))
1346        .unwrap();
1347        value["bound_definition"] = serde_json::to_value(migration.source()).unwrap();
1348        let mut instance: CeremonyInstance = serde_json::from_value(value).unwrap();
1349
1350        assert!(instance.migrate_definition_binding(&migration).unwrap());
1351        assert_eq!(instance.bound_definition(), Some(migration.destination()));
1352        assert!(!instance.migrate_definition_binding(&migration).unwrap());
1353    }
1354
1355    #[test]
1356    fn a_digest_migration_for_another_definition_is_rejected() {
1357        let definition = definition();
1358        let published = PublishedCeremonyDefinition::seal(definition.clone()).unwrap();
1359        let mut instance = CeremonyInstance::start_bound(
1360            CeremonyId::new("still-bound").unwrap(),
1361            &published,
1362            CeremonyContext::empty(),
1363            now(),
1364        );
1365        let other = CeremonyDefinition::new(
1366            CeremonyName::new("another_ceremony").unwrap(),
1367            CeremonyVersion::v1(),
1368            None,
1369            [],
1370            [],
1371            [CeremonyState::initial(state_id("OPEN"))],
1372            [],
1373            [],
1374            [],
1375            [],
1376        )
1377        .unwrap()
1378        .choreographer_v1_digest_migration()
1379        .unwrap();
1380
1381        assert!(instance.migrate_definition_binding(&other).is_err());
1382        assert_eq!(instance.bound_definition(), Some(published.digest()));
1383    }
1384
1385    /// The smallest ceremony that waits on a person: one guard, one
1386    /// transition it blocks, one seat allowed to fire it.
1387    fn definition_with_human_guard(approval: &CeremonyGuard) -> CeremonyDefinition {
1388        let finish = CeremonyTransition::new(
1389            state_id("drafting"),
1390            state_id("done"),
1391            trigger("approve"),
1392            vec![approval.name().clone()],
1393        )
1394        .unwrap();
1395        CeremonyDefinition::new(
1396            crate::value_objects::CeremonyName::new("approval_ceremony").unwrap(),
1397            CeremonyVersion::v1(),
1398            None,
1399            Vec::new(),
1400            Vec::new(),
1401            vec![
1402                CeremonyState::initial(state_id("drafting")),
1403                CeremonyState::terminal(state_id("done")),
1404            ],
1405            vec![finish.clone()],
1406            Vec::new(),
1407            vec![approval.clone()],
1408            vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1409        )
1410        .unwrap()
1411    }
1412
1413    fn instance(definition: &CeremonyDefinition) -> CeremonyInstance {
1414        CeremonyInstance::start(
1415            CeremonyId::new("ceremony-1").unwrap(),
1416            definition,
1417            CeremonyContext::empty(),
1418            now(),
1419        )
1420    }
1421
1422    #[test]
1423    fn starts_in_initial_state_with_pending_records() {
1424        let definition = definition();
1425        let instance = instance(&definition);
1426
1427        assert_eq!(instance.current_state(), &state_id("drafting"));
1428        assert_eq!(
1429            instance.step_record(&step_id("plan")).unwrap().status(),
1430            StepStatus::Pending
1431        );
1432        assert_eq!(
1433            instance
1434                .step_record(&step_id("review_step"))
1435                .unwrap()
1436                .status(),
1437            StepStatus::Pending
1438        );
1439    }
1440
1441    #[test]
1442    fn instances_without_iteration_fields_load_as_the_first_iteration() {
1443        let definition = definition();
1444        let mut value = serde_json::to_value(instance(&definition)).unwrap();
1445        value.as_object_mut().unwrap().remove("step_record_history");
1446        for record in value["step_records"].as_object_mut().unwrap().values_mut() {
1447            record.as_object_mut().unwrap().remove("iteration");
1448        }
1449
1450        let restored: CeremonyInstance = serde_json::from_value(value).unwrap();
1451
1452        assert!(restored.step_record_history(&step_id("plan")).is_empty());
1453        assert_eq!(
1454            restored.step_record(&step_id("plan")).unwrap().iteration(),
1455            StepIteration::FIRST
1456        );
1457    }
1458
1459    #[test]
1460    fn dynamic_intervention_collects_role_scoped_response_and_requester_closes_it() {
1461        let definition = definition();
1462        let mut instance = instance(&definition);
1463        let intervention_id = CeremonyInterventionId::new("queue-check").unwrap();
1464        let facilitator = role_id("facilitator");
1465        let observer = role_id("observer");
1466
1467        instance
1468            .request_intervention_as(
1469                &definition,
1470                intervention_id.clone(),
1471                facilitator.clone(),
1472                CeremonyInterventionKind::Investigation,
1473                CeremonyInterventionTarget::roles([observer.clone()]).unwrap(),
1474                CeremonyInterventionContent::new(
1475                    "Inspect the queue without consuming messages.",
1476                    Attributes::empty(),
1477                )
1478                .unwrap(),
1479                now(),
1480            )
1481            .unwrap();
1482        instance
1483            .respond_to_intervention_as(
1484                &definition,
1485                &intervention_id,
1486                observer.clone(),
1487                CeremonyInterventionContent::new("Queue depth is stable.", Attributes::empty())
1488                    .unwrap(),
1489                now(),
1490            )
1491            .unwrap();
1492        let selected_intervention_id = CeremonyInterventionId::new("selected-check").unwrap();
1493        instance
1494            .request_intervention_with_provenance_as(
1495                &definition,
1496                selected_intervention_id.clone(),
1497                facilitator.clone(),
1498                CeremonyInterventionKind::Investigation,
1499                CeremonyInterventionTarget::roles([observer.clone()]).unwrap(),
1500                CeremonyInterventionContent::new(
1501                    "Inspect the proposed signal.",
1502                    Attributes::empty(),
1503                )
1504                .unwrap(),
1505                Some(CeremonyInterventionProvenance::selected_from(
1506                    intervention_id.clone(),
1507                    observer.clone(),
1508                    observer.clone(),
1509                )),
1510                now(),
1511            )
1512            .unwrap();
1513        instance
1514            .close_intervention_as(&definition, &intervention_id, &facilitator, now())
1515            .unwrap();
1516
1517        let intervention = instance.intervention(&intervention_id).unwrap();
1518        assert_eq!(intervention.responses().len(), 1);
1519        assert_eq!(
1520            intervention.status(),
1521            crate::value_objects::CeremonyInterventionStatus::Closed
1522        );
1523        let provenance = instance
1524            .intervention(&selected_intervention_id)
1525            .unwrap()
1526            .provenance()
1527            .unwrap();
1528        assert_eq!(provenance.source_intervention_id(), &intervention_id);
1529        assert_eq!(provenance.selected_role_id(), &observer);
1530    }
1531
1532    #[test]
1533    fn intervention_rejects_roles_without_the_required_capability() {
1534        let definition = definition();
1535        let mut instance = instance(&definition);
1536
1537        let error = instance
1538            .request_intervention_as(
1539                &definition,
1540                CeremonyInterventionId::new("not-allowed").unwrap(),
1541                role_id("observer"),
1542                CeremonyInterventionKind::Opinion,
1543                CeremonyInterventionTarget::table(),
1544                CeremonyInterventionContent::new("What do you think?", Attributes::empty())
1545                    .unwrap(),
1546                now(),
1547            )
1548            .unwrap_err();
1549
1550        assert!(matches!(error, DomainError::InvariantViolated { .. }));
1551    }
1552
1553    #[test]
1554    fn rejects_step_execution_outside_current_state() {
1555        let definition = definition();
1556        let mut instance = instance(&definition);
1557
1558        let err = instance
1559            .start_step(
1560                &definition,
1561                &step_id("review_step"),
1562                lease(
1563                    "runner-1",
1564                    "key-1",
1565                    now(),
1566                    datetime!(2026-06-06 12:05:00 UTC),
1567                ),
1568                now(),
1569            )
1570            .unwrap_err();
1571
1572        assert!(matches!(err, DomainError::InvalidTransition { .. }));
1573    }
1574
1575    #[test]
1576    fn completed_step_unlocks_guarded_transition() {
1577        let definition = definition();
1578        let mut instance = instance(&definition);
1579
1580        instance
1581            .start_step_as(
1582                &definition,
1583                &role_id("facilitator"),
1584                &step_id("plan"),
1585                lease(
1586                    "runner-1",
1587                    "key-1",
1588                    now(),
1589                    datetime!(2026-06-06 12:05:00 UTC),
1590                ),
1591                now(),
1592            )
1593            .unwrap();
1594        instance
1595            .apply_step_result(
1596                &definition,
1597                &step_id("plan"),
1598                StepResult::completed(StepOutput::empty()).unwrap(),
1599                datetime!(2026-06-06 12:01:00 UTC),
1600            )
1601            .unwrap();
1602        let state = instance
1603            .apply_transition_as(
1604                &definition,
1605                &role_id("facilitator"),
1606                &trigger("finish"),
1607                datetime!(2026-06-06 12:02:00 UTC),
1608            )
1609            .unwrap();
1610
1611        assert_eq!(state, state_id("done"));
1612        assert!(instance.is_completed(&definition));
1613    }
1614
1615    #[test]
1616    fn false_repeat_condition_archives_iteration_and_schedules_the_next() {
1617        let definition = definition_with_steps(vec![repeating_plan(3)]);
1618        let mut instance = instance(&definition);
1619
1620        instance
1621            .start_step(
1622                &definition,
1623                &step_id("plan"),
1624                lease(
1625                    "runner-1",
1626                    "repeat-1",
1627                    now(),
1628                    datetime!(2026-06-06 12:05:00 UTC),
1629                ),
1630                now(),
1631            )
1632            .unwrap();
1633        instance
1634            .apply_step_result(
1635                &definition,
1636                &step_id("plan"),
1637                StepResult::completed(readiness_output(false)).unwrap(),
1638                datetime!(2026-06-06 12:01:00 UTC),
1639            )
1640            .unwrap();
1641
1642        let current = instance.step_record(&step_id("plan")).unwrap();
1643        assert_eq!(current.status(), StepStatus::Pending);
1644        assert_eq!(current.iteration().get(), 2);
1645        assert_eq!(current.attempt(), StepAttempt::FIRST);
1646        let history = instance.step_record_history(&step_id("plan"));
1647        assert_eq!(history.len(), 1);
1648        assert_eq!(history[0].iteration(), StepIteration::FIRST);
1649        assert_eq!(history[0].output(), &readiness_output(false));
1650        assert!(instance
1651            .apply_transition(&definition, &trigger("finish"), now())
1652            .is_err());
1653
1654        instance
1655            .start_step(
1656                &definition,
1657                &step_id("plan"),
1658                lease(
1659                    "runner-1",
1660                    "repeat-2",
1661                    datetime!(2026-06-06 12:02:00 UTC),
1662                    datetime!(2026-06-06 12:07:00 UTC),
1663                ),
1664                datetime!(2026-06-06 12:02:00 UTC),
1665            )
1666            .unwrap();
1667        instance
1668            .apply_step_result(
1669                &definition,
1670                &step_id("plan"),
1671                StepResult::completed(readiness_output(true)).unwrap(),
1672                datetime!(2026-06-06 12:03:00 UTC),
1673            )
1674            .unwrap();
1675
1676        let current = instance.step_record(&step_id("plan")).unwrap();
1677        assert_eq!(current.status(), StepStatus::Completed);
1678        assert_eq!(current.iteration().get(), 2);
1679        assert!(!instance.step_repeat_limit_reached(&definition, &step_id("plan")));
1680        assert_eq!(
1681            instance
1682                .apply_transition(&definition, &trigger("finish"), now())
1683                .unwrap(),
1684            state_id("done")
1685        );
1686    }
1687
1688    #[test]
1689    fn repeat_limit_is_terminal_for_the_step_and_blocks_transition() {
1690        let definition = definition_with_steps(vec![repeating_plan(2)]);
1691        let mut instance = instance(&definition);
1692
1693        for iteration in 1..=2 {
1694            instance
1695                .start_step(
1696                    &definition,
1697                    &step_id("plan"),
1698                    lease(
1699                        "runner-1",
1700                        &format!("limit-{iteration}"),
1701                        now(),
1702                        datetime!(2026-06-06 12:05:00 UTC),
1703                    ),
1704                    now(),
1705                )
1706                .unwrap();
1707            instance
1708                .apply_step_result(
1709                    &definition,
1710                    &step_id("plan"),
1711                    StepResult::completed(readiness_output(false)).unwrap(),
1712                    now(),
1713                )
1714                .unwrap();
1715        }
1716
1717        assert!(instance.step_repeat_limit_reached(&definition, &step_id("plan")));
1718        assert_eq!(
1719            instance
1720                .step_record(&step_id("plan"))
1721                .unwrap()
1722                .iteration()
1723                .get(),
1724            2
1725        );
1726        assert_eq!(instance.step_record_history(&step_id("plan")).len(), 1);
1727        assert!(instance
1728            .apply_transition(&definition, &trigger("finish"), now())
1729            .is_err());
1730        assert!(instance
1731            .start_step(
1732                &definition,
1733                &step_id("plan"),
1734                lease(
1735                    "runner-1",
1736                    "limit-3",
1737                    now(),
1738                    datetime!(2026-06-06 12:05:00 UTC),
1739                ),
1740                now(),
1741            )
1742            .is_err());
1743    }
1744
1745    #[test]
1746    fn active_lease_blocks_failover_takeover() {
1747        let definition = definition();
1748        let mut instance = instance(&definition);
1749
1750        instance
1751            .start_step(
1752                &definition,
1753                &step_id("plan"),
1754                lease(
1755                    "runner-1",
1756                    "key-1",
1757                    now(),
1758                    datetime!(2026-06-06 12:05:00 UTC),
1759                ),
1760                now(),
1761            )
1762            .unwrap();
1763        let err = instance
1764            .start_step(
1765                &definition,
1766                &step_id("plan"),
1767                lease(
1768                    "runner-2",
1769                    "key-2",
1770                    datetime!(2026-06-06 12:01:00 UTC),
1771                    datetime!(2026-06-06 12:06:00 UTC),
1772                ),
1773                datetime!(2026-06-06 12:01:00 UTC),
1774            )
1775            .unwrap_err();
1776
1777        assert!(matches!(err, DomainError::InvariantViolated { .. }));
1778        assert_eq!(
1779            instance
1780                .step_record(&step_id("plan"))
1781                .unwrap()
1782                .lease()
1783                .unwrap()
1784                .owner_id()
1785                .as_str(),
1786            "runner-1"
1787        );
1788    }
1789
1790    #[test]
1791    fn expired_lease_allows_failover_takeover_with_next_attempt() {
1792        let definition = definition();
1793        let mut instance = instance(&definition);
1794
1795        instance
1796            .start_step(
1797                &definition,
1798                &step_id("plan"),
1799                lease(
1800                    "runner-1",
1801                    "key-1",
1802                    now(),
1803                    datetime!(2026-06-06 12:05:00 UTC),
1804                ),
1805                now(),
1806            )
1807            .unwrap();
1808        let attempt = instance
1809            .start_step(
1810                &definition,
1811                &step_id("plan"),
1812                lease(
1813                    "runner-2",
1814                    "key-2",
1815                    datetime!(2026-06-06 12:06:00 UTC),
1816                    datetime!(2026-06-06 12:11:00 UTC),
1817                ),
1818                datetime!(2026-06-06 12:06:00 UTC),
1819            )
1820            .unwrap();
1821
1822        assert_eq!(attempt, StepAttempt::new(2).unwrap());
1823        let record = instance.step_record(&step_id("plan")).unwrap();
1824        assert_eq!(record.attempt(), StepAttempt::new(2).unwrap());
1825        assert_eq!(record.lease().unwrap().owner_id().as_str(), "runner-2");
1826    }
1827
1828    #[test]
1829    fn approving_a_guard_the_ceremony_never_declared_is_refused() {
1830        let approval =
1831            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1832        let finish = CeremonyTransition::new(
1833            state_id("drafting"),
1834            state_id("done"),
1835            trigger("approve"),
1836            vec![approval.name().clone()],
1837        )
1838        .unwrap();
1839        let definition = CeremonyDefinition::new(
1840            crate::value_objects::CeremonyName::new("approval_ceremony").unwrap(),
1841            CeremonyVersion::v1(),
1842            None,
1843            Vec::new(),
1844            Vec::new(),
1845            vec![
1846                CeremonyState::initial(state_id("drafting")),
1847                CeremonyState::terminal(state_id("done")),
1848            ],
1849            vec![finish.clone()],
1850            Vec::new(),
1851            vec![approval],
1852            vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1853        )
1854        .unwrap();
1855        let mut instance = instance(&definition);
1856
1857        // This used to succeed and write `not_a_guard: true` into the
1858        // session context: a caller could put any key at all there,
1859        // and a typo answered "approved" while leaving a session that
1860        // would never move.
1861        assert!(matches!(
1862            instance.approve_guard(
1863                &definition,
1864                &guard_name("not_a_guard"),
1865                role_id("facilitator"),
1866                AuditActorKind::Human,
1867                now()
1868            ),
1869            Err(DomainError::NotFound {
1870                what: "ceremony_guard"
1871            })
1872        ));
1873        assert!(!instance
1874            .context()
1875            .is_guard_approved(&guard_name("not_a_guard")));
1876    }
1877
1878    #[test]
1879    fn human_approval_guard_uses_typed_context() {
1880        let approval =
1881            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1882        let finish = CeremonyTransition::new(
1883            state_id("drafting"),
1884            state_id("done"),
1885            trigger("approve"),
1886            vec![approval.name().clone()],
1887        )
1888        .unwrap();
1889        let definition = CeremonyDefinition::new(
1890            crate::value_objects::CeremonyName::new("approval_ceremony").unwrap(),
1891            CeremonyVersion::v1(),
1892            None,
1893            Vec::new(),
1894            Vec::new(),
1895            vec![
1896                CeremonyState::initial(state_id("drafting")),
1897                CeremonyState::terminal(state_id("done")),
1898            ],
1899            vec![finish.clone()],
1900            Vec::new(),
1901            vec![approval.clone()],
1902            vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1903        )
1904        .unwrap();
1905        let mut instance = instance(&definition);
1906
1907        assert!(matches!(
1908            instance.apply_transition(&definition, &trigger("approve"), now()),
1909            Err(DomainError::InvariantViolated { .. })
1910        ));
1911        instance
1912            .approve_guard(
1913                &definition,
1914                approval.name(),
1915                role_id("facilitator"),
1916                AuditActorKind::Human,
1917                datetime!(2026-06-06 12:01:00 UTC),
1918            )
1919            .unwrap();
1920        instance
1921            .apply_transition(
1922                &definition,
1923                &trigger("approve"),
1924                datetime!(2026-06-06 12:02:00 UTC),
1925            )
1926            .unwrap();
1927
1928        assert!(instance.is_completed(&definition));
1929    }
1930
1931    #[test]
1932    fn human_guard_deferral_preserves_uncertainty_without_approving() {
1933        let approval =
1934            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1935        let finish = CeremonyTransition::new(
1936            state_id("drafting"),
1937            state_id("done"),
1938            trigger("approve"),
1939            vec![approval.name().clone()],
1940        )
1941        .unwrap();
1942        let definition = CeremonyDefinition::new(
1943            crate::value_objects::CeremonyName::new("deferral_ceremony").unwrap(),
1944            CeremonyVersion::v1(),
1945            None,
1946            Vec::new(),
1947            Vec::new(),
1948            vec![
1949                CeremonyState::initial(state_id("drafting")),
1950                CeremonyState::terminal(state_id("done")),
1951            ],
1952            vec![finish.clone()],
1953            Vec::new(),
1954            vec![approval.clone()],
1955            vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1956        )
1957        .unwrap();
1958        let mut instance = instance(&definition);
1959
1960        instance
1961            .defer_guard(
1962                &definition,
1963                approval.name().clone(),
1964                CeremonyGuardDeferralContent::new(
1965                    "I do not know.",
1966                    "I cannot explain how the issue was resolved.",
1967                    vec!["New evidence explains the resolution.".to_owned()],
1968                )
1969                .unwrap(),
1970                role_id("facilitator"),
1971                AuditActorKind::Human,
1972                datetime!(2026-06-06 12:01:00 UTC),
1973            )
1974            .unwrap();
1975
1976        assert!(!instance.context().is_guard_approved(approval.name()));
1977        assert!(instance
1978            .apply_transition(&definition, &trigger("approve"), now())
1979            .is_err());
1980        let deferral = &instance.guard_deferrals()[0];
1981        assert_eq!(deferral.guard_name(), approval.name());
1982        assert_eq!(deferral.content().statement(), "I do not know.");
1983    }
1984    /// An approval that names nobody is a receipt for a human decision
1985    /// nobody can be shown to have taken. This is that made checkable.
1986    #[test]
1987    fn approving_a_human_guard_records_the_seat_that_did_it() {
1988        let approval =
1989            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1990        let definition = definition_with_human_guard(&approval);
1991        let mut instance = instance(&definition);
1992
1993        instance
1994            .approve_guard(
1995                &definition,
1996                approval.name(),
1997                role_id("facilitator"),
1998                AuditActorKind::Human,
1999                datetime!(2026-06-06 12:01:00 UTC),
2000            )
2001            .unwrap();
2002
2003        let [recorded] = instance.guard_approvals() else {
2004            panic!(
2005                "expected one approval, got {:?}",
2006                instance.guard_approvals()
2007            );
2008        };
2009        assert_eq!(recorded.guard_name(), approval.name());
2010        assert_eq!(recorded.approved_by(), &role_id("facilitator"));
2011        assert_eq!(recorded.approved_at(), datetime!(2026-06-06 12:01:00 UTC));
2012        assert!(instance.context().is_guard_approved(approval.name()));
2013    }
2014
2015    /// A seat this session does not have cannot approve anything on it.
2016    /// Weaker than the capability check the other verbs use, and
2017    /// deliberately so — but not so weak that any string will do.
2018    #[test]
2019    fn a_seat_the_definition_does_not_declare_cannot_approve() {
2020        let approval =
2021            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
2022        let definition = definition_with_human_guard(&approval);
2023        let mut instance = instance(&definition);
2024
2025        let outcome = instance.approve_guard(
2026            &definition,
2027            approval.name(),
2028            role_id("someone-who-is-not-here"),
2029            AuditActorKind::Human,
2030            now(),
2031        );
2032
2033        assert!(matches!(
2034            outcome,
2035            Err(DomainError::NotFound {
2036                what: "ceremony_role"
2037            })
2038        ));
2039        assert!(instance.guard_approvals().is_empty());
2040        assert!(!instance.context().is_guard_approved(approval.name()));
2041    }
2042    /// A session with one agenda item and one contribution to it —
2043    /// the smallest thing that has something to explain.
2044    fn session_with_a_contribution(
2045        definition: &CeremonyDefinition,
2046    ) -> (CeremonyInstance, CeremonyInterventionId) {
2047        let mut instance = instance(definition);
2048        let agenda_item = CeremonyInterventionId::new("queue-check").unwrap();
2049        instance
2050            .request_intervention_as(
2051                definition,
2052                agenda_item.clone(),
2053                role_id("facilitator"),
2054                CeremonyInterventionKind::Investigation,
2055                CeremonyInterventionTarget::roles([role_id("observer")]).unwrap(),
2056                CeremonyInterventionContent::new("Inspect the queue.", Attributes::empty())
2057                    .unwrap(),
2058                now(),
2059            )
2060            .unwrap();
2061        instance
2062            .respond_to_intervention_as(
2063                definition,
2064                &agenda_item,
2065                role_id("observer"),
2066                CeremonyInterventionContent::new("Queue depth is stable.", Attributes::empty())
2067                    .unwrap(),
2068                now(),
2069            )
2070            .unwrap();
2071        (instance, agenda_item)
2072    }
2073
2074    /// The one reason the engine sees on its own, and it records it
2075    /// without being asked.
2076    #[test]
2077    fn a_contribution_is_recorded_as_answering_its_agenda_item() {
2078        let definition = definition();
2079        let (instance, agenda_item) = session_with_a_contribution(&definition);
2080
2081        let [answered] = instance.reasons() else {
2082            panic!("expected exactly one reason, got {:?}", instance.reasons());
2083        };
2084        assert_eq!(answered.kind(), CeremonyReasonKind::Answers);
2085        assert_eq!(
2086            answered.from(),
2087            &CeremonyRecordRef::contribution(agenda_item.clone(), 0)
2088        );
2089        assert_eq!(answered.to(), &CeremonyRecordRef::agenda_item(agenda_item));
2090        assert_eq!(
2091            answered.asserted_by(),
2092            None,
2093            "the engine observed it; naming a seat would be inventing one"
2094        );
2095    }
2096
2097    /// Structure is not a judgement. A seat able to assert it could
2098    /// rewrite the shape of the session by relabelling it.
2099    #[test]
2100    fn a_seat_cannot_assert_what_only_the_engine_observes() {
2101        let definition = definition();
2102        let (mut instance, agenda_item) = session_with_a_contribution(&definition);
2103
2104        let outcome = instance.assert_reason_as(
2105            &definition,
2106            role_id("observer"),
2107            CeremonyRecordRef::contribution(agenda_item.clone(), 0),
2108            CeremonyRecordRef::agenda_item(agenda_item),
2109            CeremonyReasonKind::Answers,
2110            "because I say it does",
2111            MemoryConfidence::High,
2112            now(),
2113        );
2114
2115        assert!(matches!(
2116            outcome,
2117            Err(DomainError::InvariantViolated { .. })
2118        ));
2119    }
2120
2121    /// Testimony about one's own reasoning. Nobody else has access to
2122    /// it, so nobody else may claim it.
2123    #[test]
2124    fn only_whoever_contributed_may_say_why_they_did() {
2125        let definition = definition();
2126        let (mut instance, agenda_item) = session_with_a_contribution(&definition);
2127        let contribution = CeremonyRecordRef::contribution(agenda_item.clone(), 0);
2128        let item = CeremonyRecordRef::agenda_item(agenda_item);
2129
2130        let by_someone_else = instance.assert_reason_as(
2131            &definition,
2132            role_id("facilitator"),
2133            contribution.clone(),
2134            item.clone(),
2135            CeremonyReasonKind::ChosenBecause,
2136            "they must have thought the queue mattered",
2137            MemoryConfidence::Low,
2138            now(),
2139        );
2140        assert!(matches!(
2141            by_someone_else,
2142            Err(DomainError::InvariantViolated { .. })
2143        ));
2144
2145        instance
2146            .assert_reason_as(
2147                &definition,
2148                role_id("observer"),
2149                contribution,
2150                item,
2151                CeremonyReasonKind::ChosenBecause,
2152                "the depth graph had been flat for an hour",
2153                MemoryConfidence::High,
2154                now(),
2155            )
2156            .expect("its author may say why");
2157        assert_eq!(instance.reasons().len(), 2);
2158    }
2159
2160    /// A claim about the world, not about a mind. Anyone may make one
2161    /// and everyone may weigh it.
2162    #[test]
2163    fn any_seat_may_claim_that_one_thing_came_from_another() {
2164        let definition = definition();
2165        let (mut instance, agenda_item) = session_with_a_contribution(&definition);
2166
2167        instance
2168            .assert_reason_as(
2169                &definition,
2170                role_id("facilitator"),
2171                CeremonyRecordRef::agenda_item(agenda_item.clone()),
2172                CeremonyRecordRef::contribution(agenda_item, 0),
2173                CeremonyReasonKind::FollowsFrom,
2174                "the item stayed open because the answer raised a new question",
2175                MemoryConfidence::Medium,
2176                now(),
2177            )
2178            .expect("a claim about the world is open to any seat");
2179
2180        let asserted = instance.reasons().last().unwrap();
2181        assert_eq!(asserted.confidence(), MemoryConfidence::Medium);
2182        assert_eq!(asserted.asserted_by(), Some(&role_id("facilitator")));
2183    }
2184
2185    /// A session knows everything it has done, so a reason may not
2186    /// cite something it never produced.
2187    #[test]
2188    fn a_reason_cannot_cite_something_that_never_happened() {
2189        let definition = definition();
2190        let (mut instance, agenda_item) = session_with_a_contribution(&definition);
2191
2192        let outcome = instance.assert_reason_as(
2193            &definition,
2194            role_id("observer"),
2195            CeremonyRecordRef::contribution(agenda_item.clone(), 7),
2196            CeremonyRecordRef::agenda_item(agenda_item),
2197            CeremonyReasonKind::FollowsFrom,
2198            "a contribution nobody made",
2199            MemoryConfidence::Low,
2200            now(),
2201        );
2202
2203        assert!(matches!(
2204            outcome,
2205            Err(DomainError::NotFound {
2206                what: "ceremony_record"
2207            })
2208        ));
2209    }
2210
2211    /// A move is recorded with the seat that fired it, so "the session
2212    /// resolved because…" has something to point at.
2213    #[test]
2214    fn a_move_is_recorded_with_whoever_made_it() {
2215        let approval =
2216            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
2217        let definition = definition_with_human_guard(&approval);
2218        let mut instance = instance(&definition);
2219        instance
2220            .approve_guard(
2221                &definition,
2222                approval.name(),
2223                role_id("facilitator"),
2224                AuditActorKind::Human,
2225                now(),
2226            )
2227            .unwrap();
2228
2229        instance
2230            .apply_transition_as(
2231                &definition,
2232                &role_id("facilitator"),
2233                &trigger("approve"),
2234                datetime!(2026-06-06 12:05:00 UTC),
2235            )
2236            .unwrap();
2237
2238        let [moved] = instance.transitions() else {
2239            panic!("expected one move, got {:?}", instance.transitions());
2240        };
2241        assert_eq!(moved.trigger(), &trigger("approve"));
2242        assert_eq!(moved.from_state(), &state_id("drafting"));
2243        assert_eq!(moved.to_state(), &state_id("done"));
2244        assert_eq!(moved.applied_by(), Some(&role_id("facilitator")));
2245    }
2246
2247    /// And without one when the engine took the move itself. An
2248    /// absence, not a gap — and it is what stops testimony being
2249    /// claimed about something nobody can testify to.
2250    #[test]
2251    fn a_move_the_engine_took_names_nobody() {
2252        let approval =
2253            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
2254        let definition = definition_with_human_guard(&approval);
2255        let mut instance = instance(&definition);
2256        instance
2257            .approve_guard(
2258                &definition,
2259                approval.name(),
2260                role_id("facilitator"),
2261                AuditActorKind::Human,
2262                now(),
2263            )
2264            .unwrap();
2265
2266        instance
2267            .apply_transition(&definition, &trigger("approve"), now())
2268            .unwrap();
2269
2270        assert_eq!(instance.transitions()[0].applied_by(), None);
2271    }
2272    /// What kind of party filled the seat is recorded as declared and
2273    /// never inferred.
2274    ///
2275    /// The engine knows this guard demands a human. That says one was
2276    /// required, not that one turned up — and a receipt that read
2277    /// compliance off its own requirement would assert exactly what
2278    /// nobody can demonstrate. So an agent approving a human-approval
2279    /// guard is recorded as an agent, and whether that is acceptable
2280    /// is a question for whoever reads it.
2281    #[test]
2282    fn an_approval_records_the_kind_it_was_told_not_the_one_the_guard_wanted() {
2283        let approval =
2284            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
2285        let definition = definition_with_human_guard(&approval);
2286        let mut instance = instance(&definition);
2287
2288        instance
2289            .approve_guard(
2290                &definition,
2291                approval.name(),
2292                role_id("facilitator"),
2293                AuditActorKind::Agent,
2294                now(),
2295            )
2296            .unwrap();
2297
2298        let [recorded] = instance.guard_approvals() else {
2299            panic!("expected one approval");
2300        };
2301        assert_eq!(
2302            recorded.approved_by_kind(),
2303            AuditActorKind::Agent,
2304            "the guard asked for a human and an agent answered; saying otherwise \
2305             would be the engine vouching for something it cannot see"
2306        );
2307        assert!(instance.context().is_guard_approved(approval.name()));
2308    }
2309}