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//!
7//! A ceremony is its event stream. Every change goes through two
8//! halves: [`CeremonyInstance::decide`] turns a
9//! [`CeremonyCommand`](super::CeremonyCommand) into the
10//! [`CeremonyEvent`]s it would produce, holding every rule and writing
11//! nothing; [`CeremonyInstance::apply`] writes one event and checks
12//! nothing. [`CeremonyInstance::rehydrate`] folds a whole stream. The
13//! mutators callers already use are thin wrappers over that pair. Opening
14//! constructors also return domain refusals when required context is absent.
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use serde::{Deserialize, Serialize};
19use time::OffsetDateTime;
20
21use super::{
22    ceremony_definition::CeremonyDefinition, CeremonyEvent, CeremonyEvidencePack,
23    CeremonyIntervention, PublishedCeremonyDefinition,
24};
25use crate::error::DomainError;
26use crate::ports::CeremonyEvidenceRequest;
27use crate::value_objects::{
28    AuditActorKind, BudgetAccountId, CeremonyContext, CeremonyDeadline, CeremonyDefinitionDigest,
29    CeremonyEvidenceSourceId, CeremonyGuardApproval, CeremonyGuardDeferral,
30    CeremonyGuardDeferralContent, CeremonyId, CeremonyInterventionContent, CeremonyInterventionId,
31    CeremonyInterventionKind, CeremonyInterventionProvenance, CeremonyInterventionTarget,
32    CeremonyLifecycle, CeremonyLineage, CeremonyName, CeremonyParticipantBinding, CeremonyReason,
33    CeremonyReasonKind, CeremonyRecordRef, CeremonySuccession, CeremonyTransitionRecord,
34    CeremonyVersion, ChildGroupId, ChildGroupState, ExecutionOperationId, ExecutionReceiptLink,
35    GuardName, IdempotencyKey, LateStepResult, MemoryConfidence, RoleAction, RoleId,
36    SessionRecollection, Specialty, StateDeadline, StateId, StateIteration, StateVisit,
37    StepAttempt, StepClaimFence, StepDeadline, StepExecutionRecord, StepId, StepLease, StepResult,
38    SuccessionPlan, TransitionTrigger,
39};
40
41mod children;
42mod decisions;
43mod fold;
44mod guard_decisions;
45mod interventions;
46mod invariants;
47mod participant_bindings;
48mod role_resolution;
49#[cfg(test)]
50mod role_resolution_tests;
51mod step_claims;
52mod step_execution;
53mod transitions;
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct CeremonyInstance {
57    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
58    lease_renewals: BTreeMap<IdempotencyKey, super::ceremony_events::StepLeaseRenewed>,
59    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
60    host_handoffs: BTreeMap<IdempotencyKey, super::ceremony_events::HostHandoffRecorded>,
61    id: CeremonyId,
62    definition_name: CeremonyName,
63    definition_version: CeremonyVersion,
64    current_state: StateId,
65    #[serde(default, skip_serializing_if = "StateIteration::is_first")]
66    current_state_iteration: StateIteration,
67    #[serde(default, skip_serializing_if = "StateVisit::is_first")]
68    current_state_visit: StateVisit,
69    step_records: BTreeMap<StepId, StepExecutionRecord>,
70    /// Prior executed records, including repeated work and superseded visits.
71    ///
72    /// Retries within one visit remain represented by their attempt number and
73    /// the audit journal. A new visit archives every executed destination record,
74    /// including failed or expired work; repetition archives successful work.
75    /// Neither operation overwrites the result that preceded the current record.
76    #[serde(default)]
77    step_record_history: BTreeMap<StepId, Vec<StepExecutionRecord>>,
78    #[serde(default)]
79    interventions: Vec<CeremonyIntervention>,
80    #[serde(default)]
81    guard_deferrals: Vec<CeremonyGuardDeferral>,
82    /// Who let each human guard through.
83    ///
84    /// Kept beside the context rather than inside it. The context is
85    /// what a guard is evaluated against and already holds "this one
86    /// is approved"; sessions written before this existed carry that
87    /// and nothing else, and moving approval out of the context would
88    /// have made every one of them unapproved on the next read. So the
89    /// context stays the state, and this is the event that produced
90    /// it.
91    #[serde(default)]
92    guard_approvals: Vec<CeremonyGuardApproval>,
93    /// Every move this session made, in order.
94    ///
95    /// The current state says where a session is; this says how it got
96    /// there. Without it nothing could point at a move, so nothing
97    /// could say why one happened — and "why did this resolve" is the
98    /// question the whole thing is for.
99    #[serde(default)]
100    transitions: Vec<CeremonyTransitionRecord>,
101    /// Why one thing here led to another.
102    ///
103    /// Kept apart from the records rather than inside them, because a
104    /// reason is an edge and not a field: it belongs to the pair, and
105    /// putting it on either end would make it readable but not
106    /// followable.
107    #[serde(default)]
108    reasons: Vec<CeremonyReason>,
109    /// Who sits in each seat for this session, where anyone was
110    /// seated. A role with no binding is played the way the definition
111    /// says, which is the usual case and not a lesser one.
112    #[serde(default)]
113    participant_bindings: BTreeMap<RoleId, CeremonyParticipantBinding>,
114    context: CeremonyContext,
115    /// What earlier sessions in this session's memory scope decided,
116    /// as this session was told at its opening.
117    ///
118    /// Absent for a session that recalled nothing, which is every
119    /// session that declares no scope of its own: the default scope is
120    /// the session's own id, and nobody else writes there. Kept as it
121    /// was read rather than re-read on demand — what a session was told
122    /// is part of what it did, and answering later with today's memory
123    /// would judge the session by knowledge it did not have.
124    #[serde(default)]
125    recollection: Option<SessionRecollection>,
126    idempotency_keys: BTreeSet<IdempotencyKey>,
127    #[serde(with = "time::serde::rfc3339")]
128    created_at: OffsetDateTime,
129    #[serde(with = "time::serde::rfc3339")]
130    updated_at: OffsetDateTime,
131    #[serde(with = "time::serde::rfc3339::option")]
132    completed_at: Option<OffsetDateTime>,
133    /// The published definition this instance is bound to, when it was
134    /// started from one.
135    ///
136    /// Absent for an instance started from a definition handed in at
137    /// the time — which is a real and useful way to work, and not the
138    /// same thing. Recording which of the two happened is the point: a
139    /// name and a version identify a published definition only while
140    /// publication is immutable, and an instance that also carries the
141    /// digest can be checked against the definition rather than trusted
142    /// to have run it.
143    #[serde(default)]
144    bound_definition: Option<CeremonyDefinitionDigest>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    lineage: Option<CeremonyLineage>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    budget_account_id: Option<BudgetAccountId>,
149    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
150    child_groups: BTreeMap<ChildGroupId, ChildGroupState>,
151    #[serde(default, skip_serializing_if = "CeremonyLifecycle::is_default")]
152    lifecycle: CeremonyLifecycle,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    ceremony_deadline: Option<CeremonyDeadline>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    state_deadline: Option<StateDeadline>,
157    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
158    step_deadlines: BTreeMap<StepId, StepDeadline>,
159    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
160    retired_deadline_claims: BTreeMap<StepClaimFence, StepDeadline>,
161    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
162    late_step_results: BTreeMap<StepClaimFence, LateStepResult>,
163    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
164    execution_receipt_links: BTreeMap<ExecutionOperationId, ExecutionReceiptLink>,
165    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
166    execution_receipt_adoptions:
167        BTreeMap<ExecutionOperationId, BTreeMap<StepClaimFence, ExecutionReceiptLink>>,
168    /// Which ceremony this one succeeds, when it was opened as a
169    /// successor. Held on the successor.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    succession: Option<Box<CeremonySuccession>>,
172    /// The handoff this ceremony sealed. Held on the predecessor, and
173    /// the reason it can no longer resume: a session that named its
174    /// successor has said which definition it believes in, and it is
175    /// not this one.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    successor_plan: Option<Box<SuccessionPlan>>,
178}
179
180impl CeremonyInstance {
181    /// Start from a definition supplied for this run.
182    ///
183    /// Nothing binds the instance to a definition that can be looked up
184    /// later; that is what [`Self::start_bound`] is for. The instance
185    /// is the fold of the opening event [`Self::decide_start`] yields.
186    pub fn start(
187        id: CeremonyId,
188        definition: &CeremonyDefinition,
189        context: CeremonyContext,
190        now: OffsetDateTime,
191    ) -> Result<Self, DomainError> {
192        Ok(Self::from_started(&Self::opening(
193            id, definition, context, now, None, None, None,
194        )?))
195    }
196
197    /// Start from a published definition, recording its digest.
198    ///
199    /// The digest travels with the instance so a later reader can
200    /// verify which definition ran instead of taking the name and
201    /// version on trust.
202    pub fn start_bound(
203        id: CeremonyId,
204        published: &PublishedCeremonyDefinition,
205        context: CeremonyContext,
206        now: OffsetDateTime,
207    ) -> Result<Self, DomainError> {
208        Ok(Self::from_started(&Self::opening(
209            id,
210            published.definition(),
211            context,
212            now,
213            Some(published.digest()),
214            None,
215            None,
216        )?))
217    }
218
219    /// The digest of the published definition this instance runs, if it
220    /// was started from one.
221    #[must_use]
222    pub fn bound_definition(&self) -> Option<CeremonyDefinitionDigest> {
223        self.bound_definition
224    }
225
226    #[must_use]
227    pub fn lineage(&self) -> Option<&CeremonyLineage> {
228        self.lineage.as_ref()
229    }
230
231    /// Which ceremony this one succeeds, if it succeeds one.
232    #[must_use]
233    pub fn succession(&self) -> Option<&CeremonySuccession> {
234        self.succession.as_deref()
235    }
236
237    /// The handoff this ceremony sealed, if it sealed one.
238    #[must_use]
239    pub fn successor_plan(&self) -> Option<&SuccessionPlan> {
240        self.successor_plan.as_deref()
241    }
242
243    /// Whether this ceremony has named the one that replaces it.
244    #[must_use]
245    pub fn is_superseded(&self) -> bool {
246        self.successor_plan.is_some()
247    }
248
249    #[must_use]
250    pub fn budget_account_id(&self) -> Option<&BudgetAccountId> {
251        self.budget_account_id.as_ref()
252    }
253
254    #[must_use]
255    pub fn child_groups(&self) -> &BTreeMap<ChildGroupId, ChildGroupState> {
256        &self.child_groups
257    }
258
259    #[must_use]
260    pub fn child_group(&self, group_id: &ChildGroupId) -> Option<&ChildGroupState> {
261        self.child_groups.get(group_id)
262    }
263
264    /// What earlier sessions in this session's scope decided, as this
265    /// session was told when it opened.
266    #[must_use]
267    pub fn recollection(&self) -> Option<&SessionRecollection> {
268        self.recollection.as_ref()
269    }
270
271    /// Whether this instance runs a definition that can be looked up
272    /// and checked, rather than one supplied for the run.
273    #[must_use]
274    pub fn is_bound_to_a_published_definition(&self) -> bool {
275        self.bound_definition.is_some()
276    }
277
278    #[must_use]
279    pub fn id(&self) -> &CeremonyId {
280        &self.id
281    }
282
283    #[must_use]
284    pub fn definition_name(&self) -> &CeremonyName {
285        &self.definition_name
286    }
287
288    #[must_use]
289    pub fn definition_version(&self) -> &CeremonyVersion {
290        &self.definition_version
291    }
292
293    #[must_use]
294    pub fn current_state(&self) -> &StateId {
295        &self.current_state
296    }
297
298    #[must_use]
299    pub fn current_state_visit(&self) -> StateVisit {
300        self.current_state_visit
301    }
302
303    #[must_use]
304    pub fn current_state_iteration(&self) -> StateIteration {
305        self.current_state_iteration
306    }
307
308    #[must_use]
309    pub fn state_work_is_complete(&self, definition: &CeremonyDefinition) -> bool {
310        definition.steps_for_state(&self.current_state).all(|step| {
311            self.step_records
312                .get(step.id())
313                .is_some_and(|record| record.status().is_success())
314        }) && definition.repeat_requirements_are_satisfied(&self.current_state, &self.step_records)
315    }
316
317    #[must_use]
318    pub fn state_repeat_condition_is_satisfied(&self, definition: &CeremonyDefinition) -> bool {
319        let Some(policy) = definition
320            .state(&self.current_state)
321            .and_then(|state| state.repeat_policy())
322        else {
323            return true;
324        };
325        self.step_records
326            .get(policy.until().step_id())
327            .is_some_and(|record| policy.is_satisfied(record))
328    }
329
330    #[must_use]
331    pub fn state_repeat_permits_transition(&self, definition: &CeremonyDefinition) -> bool {
332        definition
333            .state(&self.current_state)
334            .and_then(|state| state.repeat_policy())
335            .is_none()
336            || (self.state_work_is_complete(definition)
337                && self.state_repeat_condition_is_satisfied(definition))
338    }
339
340    #[must_use]
341    pub fn state_repeat_limit_reached(&self, definition: &CeremonyDefinition) -> bool {
342        let Some(policy) = definition
343            .state(&self.current_state)
344            .and_then(|state| state.repeat_policy())
345        else {
346            return false;
347        };
348        self.state_work_is_complete(definition)
349            && !self.state_repeat_condition_is_satisfied(definition)
350            && !policy.permits_another_iteration(self.current_state_iteration)
351    }
352
353    #[must_use]
354    pub fn step_records(&self) -> &BTreeMap<StepId, StepExecutionRecord> {
355        &self.step_records
356    }
357
358    #[must_use]
359    pub fn step_record(&self, step_id: &StepId) -> Option<&StepExecutionRecord> {
360        self.step_records.get(step_id)
361    }
362
363    #[must_use]
364    pub fn execution_receipt_links(&self) -> &BTreeMap<ExecutionOperationId, ExecutionReceiptLink> {
365        &self.execution_receipt_links
366    }
367
368    #[must_use]
369    pub fn execution_receipt_link(
370        &self,
371        operation_id: &ExecutionOperationId,
372    ) -> Option<&ExecutionReceiptLink> {
373        self.execution_receipt_links.get(operation_id)
374    }
375
376    #[must_use]
377    pub fn execution_receipt_adoptions(
378        &self,
379    ) -> &BTreeMap<ExecutionOperationId, BTreeMap<StepClaimFence, ExecutionReceiptLink>> {
380        &self.execution_receipt_adoptions
381    }
382
383    #[must_use]
384    pub fn execution_receipt_adoption(
385        &self,
386        operation_id: &ExecutionOperationId,
387        applied_claim_fence: &StepClaimFence,
388    ) -> Option<&ExecutionReceiptLink> {
389        self.execution_receipt_adoptions
390            .get(operation_id)
391            .and_then(|adoptions| adoptions.get(applied_claim_fence))
392    }
393
394    /// Finished iterations before the current record, in execution order.
395    #[must_use]
396    pub fn step_record_history(&self, step_id: &StepId) -> &[StepExecutionRecord] {
397        self.step_record_history
398            .get(step_id)
399            .map(Vec::as_slice)
400            .unwrap_or_default()
401    }
402
403    /// Whether a repeating step consumed its last permitted iteration without
404    /// satisfying its declared stop condition.
405    #[must_use]
406    pub fn step_repeat_limit_reached(
407        &self,
408        definition: &CeremonyDefinition,
409        step_id: &StepId,
410    ) -> bool {
411        let Some(step) = definition.step(step_id) else {
412            return false;
413        };
414        let Some(policy) = step.repeat_policy() else {
415            return false;
416        };
417        let Some(record) = self.step_record(step_id) else {
418            return false;
419        };
420        record.status().is_success()
421            && !policy.is_satisfied(record.output())
422            && !policy.permits_another_iteration(record.iteration())
423    }
424
425    #[must_use]
426    pub fn interventions(&self) -> &[CeremonyIntervention] {
427        &self.interventions
428    }
429
430    #[must_use]
431    pub fn guard_deferrals(&self) -> &[CeremonyGuardDeferral] {
432        &self.guard_deferrals
433    }
434
435    /// Who let each human guard through, in the order they did.
436    ///
437    /// Empty for a session written before approvals recorded an
438    /// approver, which is the truth about those sessions rather than a
439    /// gap to paper over.
440    #[must_use]
441    pub fn guard_approvals(&self) -> &[CeremonyGuardApproval] {
442        &self.guard_approvals
443    }
444
445    /// Every move this session made, in the order it made them.
446    #[must_use]
447    pub fn transitions(&self) -> &[CeremonyTransitionRecord] {
448        &self.transitions
449    }
450
451    /// Why one thing here led to another.
452    #[must_use]
453    pub fn reasons(&self) -> &[CeremonyReason] {
454        &self.reasons
455    }
456
457    #[must_use]
458    pub fn intervention(
459        &self,
460        intervention_id: &CeremonyInterventionId,
461    ) -> Option<&CeremonyIntervention> {
462        self.interventions
463            .iter()
464            .find(|intervention| intervention.id() == intervention_id)
465    }
466
467    #[must_use]
468    pub fn context(&self) -> &CeremonyContext {
469        &self.context
470    }
471
472    #[must_use]
473    pub fn idempotency_keys(&self) -> &BTreeSet<IdempotencyKey> {
474        &self.idempotency_keys
475    }
476
477    #[must_use]
478    pub fn created_at(&self) -> OffsetDateTime {
479        self.created_at
480    }
481
482    #[must_use]
483    pub fn updated_at(&self) -> OffsetDateTime {
484        self.updated_at
485    }
486
487    #[must_use]
488    pub fn completed_at(&self) -> Option<OffsetDateTime> {
489        self.completed_at
490    }
491
492    #[must_use]
493    pub fn lifecycle(&self) -> CeremonyLifecycle {
494        self.completed_at
495            .filter(|_| !self.lifecycle.is_explicit())
496            .map_or_else(|| self.lifecycle.clone(), CeremonyLifecycle::completed)
497    }
498    #[must_use]
499    pub fn ceremony_deadline(&self) -> Option<CeremonyDeadline> {
500        self.ceremony_deadline
501    }
502    #[must_use]
503    pub fn state_deadline(&self) -> Option<&StateDeadline> {
504        self.state_deadline.as_ref()
505    }
506    #[must_use]
507    pub fn step_deadlines(&self) -> &BTreeMap<StepId, StepDeadline> {
508        &self.step_deadlines
509    }
510    #[must_use]
511    pub fn late_step_results(&self) -> &BTreeMap<StepClaimFence, LateStepResult> {
512        &self.late_step_results
513    }
514    #[must_use]
515    pub fn is_paused(&self) -> bool {
516        self.lifecycle().is_paused()
517    }
518    #[must_use]
519    pub fn is_ended(&self) -> bool {
520        self.lifecycle().is_ended()
521    }
522    #[must_use]
523    pub fn admits_new_work(&self) -> bool {
524        !self.is_ended() && self.lifecycle.admits_new_work()
525    }
526
527    #[must_use]
528    pub fn is_terminal(&self, definition: &CeremonyDefinition) -> bool {
529        self.matches_definition(definition) && definition.is_terminal_state(&self.current_state)
530    }
531
532    #[must_use]
533    pub fn is_completed(&self, definition: &CeremonyDefinition) -> bool {
534        self.is_terminal(definition) && self.completed_at.is_some()
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use crate::value_objects::{
542        Attributes, CeremonyGuard, CeremonyState, CeremonyStep, CeremonyTransition, GuardCondition,
543        GuardName, LeaseOwnerId, RepeatUntilCondition, RetryPolicy, StepHandlerConfig,
544        StepHandlerKind, StepIteration, StepOutput, StepOutputField, StepRepeatPolicy, StepStatus,
545    };
546    use serde_json::json;
547    use time::macros::datetime;
548
549    fn now() -> OffsetDateTime {
550        datetime!(2026-06-06 12:00:00 UTC)
551    }
552
553    fn state_id(raw: &str) -> StateId {
554        StateId::new(raw).unwrap()
555    }
556
557    fn step_id(raw: &str) -> StepId {
558        StepId::new(raw).unwrap()
559    }
560
561    fn trigger(raw: &str) -> TransitionTrigger {
562        TransitionTrigger::new(raw).unwrap()
563    }
564
565    fn role_id(raw: &str) -> RoleId {
566        RoleId::new(raw).unwrap()
567    }
568
569    fn guard_name(raw: &str) -> GuardName {
570        GuardName::new(raw).unwrap()
571    }
572
573    fn handler_kind() -> StepHandlerKind {
574        StepHandlerKind::new("multiagent_round").unwrap()
575    }
576
577    fn retrying_step(raw_step_id: &str, raw_state_id: &str) -> CeremonyStep {
578        CeremonyStep::new(
579            step_id(raw_step_id),
580            state_id(raw_state_id),
581            handler_kind(),
582            StepHandlerConfig::empty(),
583            RetryPolicy::new(
584                StepAttempt::new(3).unwrap(),
585                crate::value_objects::DurationMs::ZERO,
586            ),
587            None,
588        )
589    }
590
591    fn single_attempt_step(raw_step_id: &str, raw_state_id: &str) -> CeremonyStep {
592        CeremonyStep::new(
593            step_id(raw_step_id),
594            state_id(raw_state_id),
595            handler_kind(),
596            StepHandlerConfig::empty(),
597            RetryPolicy::single_attempt(),
598            None,
599        )
600    }
601
602    fn repeating_plan(max_iterations: u32) -> CeremonyStep {
603        retrying_step("plan", "drafting").with_repeat_policy(StepRepeatPolicy::new(
604            RepeatUntilCondition::output_field_equals(
605                StepOutputField::new("ready").unwrap(),
606                json!(true),
607            ),
608            StepIteration::new(max_iterations).unwrap(),
609        ))
610    }
611
612    fn readiness_output(ready: bool) -> StepOutput {
613        StepOutput::new(
614            Attributes::new(std::collections::BTreeMap::from([(
615                "ready".to_owned(),
616                json!(ready),
617            )]))
618            .unwrap(),
619        )
620    }
621
622    fn lease(
623        raw_owner_id: &str,
624        raw_key: &str,
625        acquired_at: OffsetDateTime,
626        expires_at: OffsetDateTime,
627    ) -> StepLease {
628        StepLease::new(
629            LeaseOwnerId::new(raw_owner_id).unwrap(),
630            IdempotencyKey::new(raw_key).unwrap(),
631            acquired_at,
632            expires_at,
633        )
634        .unwrap()
635    }
636
637    fn role(actions: Vec<RoleAction>) -> crate::value_objects::CeremonyRole {
638        crate::value_objects::CeremonyRole::new(role_id("facilitator"), actions).unwrap()
639    }
640
641    fn definition_with_steps(steps: Vec<CeremonyStep>) -> CeremonyDefinition {
642        let plan_done = CeremonyGuard::new(
643            guard_name("plan_done"),
644            GuardCondition::StepStatus {
645                step_id: step_id("plan"),
646                status: StepStatus::Completed,
647            },
648        );
649        let finish = CeremonyTransition::new(
650            state_id("drafting"),
651            state_id("done"),
652            trigger("finish"),
653            vec![plan_done.name().clone()],
654        )
655        .unwrap();
656        let role = role(vec![
657            RoleAction::step(step_id("plan")),
658            RoleAction::transition(finish.trigger().clone()),
659            RoleAction::request_intervention(),
660        ]);
661        let observer = crate::value_objects::CeremonyRole::new(
662            role_id("observer"),
663            vec![RoleAction::respond_to_intervention()],
664        )
665        .unwrap();
666
667        CeremonyDefinition::new(
668            crate::value_objects::CeremonyName::new("planning_ceremony").unwrap(),
669            CeremonyVersion::v1(),
670            None,
671            Vec::new(),
672            Vec::new(),
673            vec![
674                CeremonyState::initial(state_id("drafting")),
675                CeremonyState::intermediate(state_id("review")),
676                CeremonyState::terminal(state_id("done")),
677            ],
678            vec![finish],
679            steps,
680            vec![plan_done],
681            vec![role, observer],
682        )
683        .unwrap()
684    }
685
686    fn definition() -> CeremonyDefinition {
687        definition_with_steps(vec![
688            retrying_step("plan", "drafting"),
689            single_attempt_step("review_step", "review"),
690        ])
691    }
692
693    /// The smallest ceremony that waits on a person: one guard, one
694    /// transition it blocks, one seat allowed to fire it.
695    fn definition_with_human_guard(approval: &CeremonyGuard) -> CeremonyDefinition {
696        let finish = CeremonyTransition::new(
697            state_id("drafting"),
698            state_id("done"),
699            trigger("approve"),
700            vec![approval.name().clone()],
701        )
702        .unwrap();
703        CeremonyDefinition::new(
704            crate::value_objects::CeremonyName::new("approval_ceremony").unwrap(),
705            CeremonyVersion::v1(),
706            None,
707            Vec::new(),
708            Vec::new(),
709            vec![
710                CeremonyState::initial(state_id("drafting")),
711                CeremonyState::terminal(state_id("done")),
712            ],
713            vec![finish.clone()],
714            Vec::new(),
715            vec![approval.clone()],
716            vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
717        )
718        .unwrap()
719    }
720
721    fn instance(definition: &CeremonyDefinition) -> CeremonyInstance {
722        CeremonyInstance::start(
723            CeremonyId::new("ceremony-1").unwrap(),
724            definition,
725            CeremonyContext::empty(),
726            now(),
727        )
728        .expect("required ceremony inputs")
729    }
730
731    #[test]
732    fn starts_in_initial_state_with_pending_records() {
733        let definition = definition();
734        let instance = instance(&definition);
735
736        assert_eq!(instance.current_state(), &state_id("drafting"));
737        assert_eq!(
738            instance.step_record(&step_id("plan")).unwrap().status(),
739            StepStatus::Pending
740        );
741        assert_eq!(
742            instance
743                .step_record(&step_id("review_step"))
744                .unwrap()
745                .status(),
746            StepStatus::Pending
747        );
748    }
749
750    #[test]
751    fn instances_without_iteration_fields_load_as_the_first_iteration() {
752        let definition = definition();
753        let mut value = serde_json::to_value(instance(&definition)).unwrap();
754        value.as_object_mut().unwrap().remove("step_record_history");
755        for record in value["step_records"].as_object_mut().unwrap().values_mut() {
756            record.as_object_mut().unwrap().remove("iteration");
757        }
758
759        let restored: CeremonyInstance = serde_json::from_value(value).unwrap();
760
761        assert!(restored.step_record_history(&step_id("plan")).is_empty());
762        assert_eq!(
763            restored.step_record(&step_id("plan")).unwrap().iteration(),
764            StepIteration::FIRST
765        );
766    }
767
768    #[test]
769    fn dynamic_intervention_collects_role_scoped_response_and_requester_closes_it() {
770        let definition = definition();
771        let mut instance = instance(&definition);
772        let intervention_id = CeremonyInterventionId::new("queue-check").unwrap();
773        let facilitator = role_id("facilitator");
774        let observer = role_id("observer");
775
776        instance
777            .request_intervention_as(
778                &definition,
779                intervention_id.clone(),
780                facilitator.clone(),
781                CeremonyInterventionKind::Investigation,
782                CeremonyInterventionTarget::roles([observer.clone()]).unwrap(),
783                CeremonyInterventionContent::new(
784                    "Inspect the queue without consuming messages.",
785                    Attributes::empty(),
786                )
787                .unwrap(),
788                now(),
789            )
790            .unwrap();
791        instance
792            .respond_to_intervention_as(
793                &definition,
794                &intervention_id,
795                observer.clone(),
796                CeremonyInterventionContent::new("Queue depth is stable.", Attributes::empty())
797                    .unwrap(),
798                now(),
799            )
800            .unwrap();
801        let selected_intervention_id = CeremonyInterventionId::new("selected-check").unwrap();
802        instance
803            .request_intervention_with_provenance_as(
804                &definition,
805                selected_intervention_id.clone(),
806                facilitator.clone(),
807                CeremonyInterventionKind::Investigation,
808                CeremonyInterventionTarget::roles([observer.clone()]).unwrap(),
809                CeremonyInterventionContent::new(
810                    "Inspect the proposed signal.",
811                    Attributes::empty(),
812                )
813                .unwrap(),
814                Some(CeremonyInterventionProvenance::selected_from(
815                    intervention_id.clone(),
816                    observer.clone(),
817                    observer.clone(),
818                )),
819                now(),
820            )
821            .unwrap();
822        instance
823            .close_intervention_as(&definition, &intervention_id, &facilitator, now())
824            .unwrap();
825
826        let intervention = instance.intervention(&intervention_id).unwrap();
827        assert_eq!(intervention.responses().len(), 1);
828        assert_eq!(
829            intervention.status(),
830            crate::value_objects::CeremonyInterventionStatus::Closed
831        );
832        let provenance = instance
833            .intervention(&selected_intervention_id)
834            .unwrap()
835            .provenance()
836            .unwrap();
837        assert_eq!(provenance.source_intervention_id(), &intervention_id);
838        assert_eq!(provenance.selected_role_id(), &observer);
839    }
840
841    #[test]
842    fn intervention_rejects_roles_without_the_required_capability() {
843        let definition = definition();
844        let mut instance = instance(&definition);
845
846        let error = instance
847            .request_intervention_as(
848                &definition,
849                CeremonyInterventionId::new("not-allowed").unwrap(),
850                role_id("observer"),
851                CeremonyInterventionKind::Opinion,
852                CeremonyInterventionTarget::table(),
853                CeremonyInterventionContent::new("What do you think?", Attributes::empty())
854                    .unwrap(),
855                now(),
856            )
857            .unwrap_err();
858
859        assert!(matches!(error, DomainError::InvariantViolated { .. }));
860    }
861
862    #[test]
863    fn rejects_step_execution_outside_current_state() {
864        let definition = definition();
865        let mut instance = instance(&definition);
866
867        let err = instance
868            .start_step(
869                &definition,
870                &step_id("review_step"),
871                lease(
872                    "runner-1",
873                    "key-1",
874                    now(),
875                    datetime!(2026-06-06 12:05:00 UTC),
876                ),
877                now(),
878            )
879            .unwrap_err();
880
881        assert!(matches!(err, DomainError::InvalidTransition { .. }));
882    }
883
884    #[test]
885    fn completed_step_unlocks_guarded_transition() {
886        let definition = definition();
887        let mut instance = instance(&definition);
888
889        instance
890            .start_step_as(
891                &definition,
892                &role_id("facilitator"),
893                &step_id("plan"),
894                lease(
895                    "runner-1",
896                    "key-1",
897                    now(),
898                    datetime!(2026-06-06 12:05:00 UTC),
899                ),
900                now(),
901            )
902            .unwrap();
903        instance
904            .apply_step_result(
905                &definition,
906                &step_id("plan"),
907                instance.step_claim_fence(&step_id("plan")).unwrap(),
908                StepResult::completed(StepOutput::empty()).unwrap(),
909                datetime!(2026-06-06 12:01:00 UTC),
910            )
911            .unwrap();
912        let state = instance
913            .apply_transition_as(
914                &definition,
915                &role_id("facilitator"),
916                &trigger("finish"),
917                datetime!(2026-06-06 12:02:00 UTC),
918            )
919            .unwrap();
920
921        assert_eq!(state, state_id("done"));
922        assert!(instance.is_completed(&definition));
923    }
924
925    #[test]
926    fn false_repeat_condition_archives_iteration_and_schedules_the_next() {
927        let definition = definition_with_steps(vec![repeating_plan(3)]);
928        let mut instance = instance(&definition);
929
930        instance
931            .start_step(
932                &definition,
933                &step_id("plan"),
934                lease(
935                    "runner-1",
936                    "repeat-1",
937                    now(),
938                    datetime!(2026-06-06 12:05:00 UTC),
939                ),
940                now(),
941            )
942            .unwrap();
943        instance
944            .apply_step_result(
945                &definition,
946                &step_id("plan"),
947                instance.step_claim_fence(&step_id("plan")).unwrap(),
948                StepResult::completed(readiness_output(false)).unwrap(),
949                datetime!(2026-06-06 12:01:00 UTC),
950            )
951            .unwrap();
952
953        let current = instance.step_record(&step_id("plan")).unwrap();
954        assert_eq!(current.status(), StepStatus::Pending);
955        assert_eq!(current.iteration().get(), 2);
956        assert_eq!(current.attempt(), StepAttempt::FIRST);
957        let history = instance.step_record_history(&step_id("plan"));
958        assert_eq!(history.len(), 1);
959        assert_eq!(history[0].iteration(), StepIteration::FIRST);
960        assert_eq!(history[0].output(), &readiness_output(false));
961        assert!(instance
962            .apply_transition(&definition, &trigger("finish"), now())
963            .is_err());
964
965        instance
966            .start_step(
967                &definition,
968                &step_id("plan"),
969                lease(
970                    "runner-1",
971                    "repeat-2",
972                    datetime!(2026-06-06 12:02:00 UTC),
973                    datetime!(2026-06-06 12:07:00 UTC),
974                ),
975                datetime!(2026-06-06 12:02:00 UTC),
976            )
977            .unwrap();
978        instance
979            .apply_step_result(
980                &definition,
981                &step_id("plan"),
982                instance.step_claim_fence(&step_id("plan")).unwrap(),
983                StepResult::completed(readiness_output(true)).unwrap(),
984                datetime!(2026-06-06 12:03:00 UTC),
985            )
986            .unwrap();
987
988        let current = instance.step_record(&step_id("plan")).unwrap();
989        assert_eq!(current.status(), StepStatus::Completed);
990        assert_eq!(current.iteration().get(), 2);
991        assert!(!instance.step_repeat_limit_reached(&definition, &step_id("plan")));
992        assert_eq!(
993            instance
994                .apply_transition(&definition, &trigger("finish"), now())
995                .unwrap(),
996            state_id("done")
997        );
998    }
999
1000    #[test]
1001    fn repeat_limit_is_terminal_for_the_step_and_blocks_transition() {
1002        let definition = definition_with_steps(vec![repeating_plan(2)]);
1003        let mut instance = instance(&definition);
1004
1005        for iteration in 1..=2 {
1006            instance
1007                .start_step(
1008                    &definition,
1009                    &step_id("plan"),
1010                    lease(
1011                        "runner-1",
1012                        &format!("limit-{iteration}"),
1013                        now(),
1014                        datetime!(2026-06-06 12:05:00 UTC),
1015                    ),
1016                    now(),
1017                )
1018                .unwrap();
1019            instance
1020                .apply_step_result(
1021                    &definition,
1022                    &step_id("plan"),
1023                    instance.step_claim_fence(&step_id("plan")).unwrap(),
1024                    StepResult::completed(readiness_output(false)).unwrap(),
1025                    now(),
1026                )
1027                .unwrap();
1028        }
1029
1030        assert!(instance.step_repeat_limit_reached(&definition, &step_id("plan")));
1031        assert_eq!(
1032            instance
1033                .step_record(&step_id("plan"))
1034                .unwrap()
1035                .iteration()
1036                .get(),
1037            2
1038        );
1039        assert_eq!(instance.step_record_history(&step_id("plan")).len(), 1);
1040        assert!(instance
1041            .apply_transition(&definition, &trigger("finish"), now())
1042            .is_err());
1043        assert!(instance
1044            .start_step(
1045                &definition,
1046                &step_id("plan"),
1047                lease(
1048                    "runner-1",
1049                    "limit-3",
1050                    now(),
1051                    datetime!(2026-06-06 12:05:00 UTC),
1052                ),
1053                now(),
1054            )
1055            .is_err());
1056    }
1057
1058    #[test]
1059    fn active_lease_blocks_failover_takeover() {
1060        let definition = definition();
1061        let mut instance = instance(&definition);
1062
1063        instance
1064            .start_step(
1065                &definition,
1066                &step_id("plan"),
1067                lease(
1068                    "runner-1",
1069                    "key-1",
1070                    now(),
1071                    datetime!(2026-06-06 12:05:00 UTC),
1072                ),
1073                now(),
1074            )
1075            .unwrap();
1076        let err = instance
1077            .start_step(
1078                &definition,
1079                &step_id("plan"),
1080                lease(
1081                    "runner-2",
1082                    "key-2",
1083                    datetime!(2026-06-06 12:01:00 UTC),
1084                    datetime!(2026-06-06 12:06:00 UTC),
1085                ),
1086                datetime!(2026-06-06 12:01:00 UTC),
1087            )
1088            .unwrap_err();
1089
1090        assert!(matches!(err, DomainError::InvariantViolated { .. }));
1091        assert_eq!(
1092            instance
1093                .step_record(&step_id("plan"))
1094                .unwrap()
1095                .lease()
1096                .unwrap()
1097                .owner_id()
1098                .as_str(),
1099            "runner-1"
1100        );
1101    }
1102
1103    #[test]
1104    fn expired_lease_allows_failover_takeover_with_next_attempt() {
1105        let definition = definition();
1106        let mut instance = instance(&definition);
1107
1108        instance
1109            .start_step(
1110                &definition,
1111                &step_id("plan"),
1112                lease(
1113                    "runner-1",
1114                    "key-1",
1115                    now(),
1116                    datetime!(2026-06-06 12:05:00 UTC),
1117                ),
1118                now(),
1119            )
1120            .unwrap();
1121        let attempt = instance
1122            .start_step(
1123                &definition,
1124                &step_id("plan"),
1125                lease(
1126                    "runner-2",
1127                    "key-2",
1128                    datetime!(2026-06-06 12:06:00 UTC),
1129                    datetime!(2026-06-06 12:11:00 UTC),
1130                ),
1131                datetime!(2026-06-06 12:06:00 UTC),
1132            )
1133            .unwrap();
1134
1135        assert_eq!(attempt, StepAttempt::new(2).unwrap());
1136        let record = instance.step_record(&step_id("plan")).unwrap();
1137        assert_eq!(record.attempt(), StepAttempt::new(2).unwrap());
1138        assert_eq!(record.lease().unwrap().owner_id().as_str(), "runner-2");
1139    }
1140
1141    #[test]
1142    fn approving_a_guard_the_ceremony_never_declared_is_refused() {
1143        let approval =
1144            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1145        let finish = CeremonyTransition::new(
1146            state_id("drafting"),
1147            state_id("done"),
1148            trigger("approve"),
1149            vec![approval.name().clone()],
1150        )
1151        .unwrap();
1152        let definition = CeremonyDefinition::new(
1153            crate::value_objects::CeremonyName::new("approval_ceremony").unwrap(),
1154            CeremonyVersion::v1(),
1155            None,
1156            Vec::new(),
1157            Vec::new(),
1158            vec![
1159                CeremonyState::initial(state_id("drafting")),
1160                CeremonyState::terminal(state_id("done")),
1161            ],
1162            vec![finish.clone()],
1163            Vec::new(),
1164            vec![approval],
1165            vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1166        )
1167        .unwrap();
1168        let mut instance = instance(&definition);
1169
1170        // This used to succeed and write `not_a_guard: true` into the
1171        // session context: a caller could put any key at all there,
1172        // and a typo answered "approved" while leaving a session that
1173        // would never move.
1174        assert!(matches!(
1175            instance.approve_guard(
1176                &definition,
1177                &guard_name("not_a_guard"),
1178                role_id("facilitator"),
1179                AuditActorKind::Human,
1180                now()
1181            ),
1182            Err(DomainError::NotFound {
1183                what: "ceremony_guard"
1184            })
1185        ));
1186        assert!(!instance
1187            .context()
1188            .is_guard_approved(&guard_name("not_a_guard")));
1189    }
1190
1191    #[test]
1192    fn human_approval_guard_uses_typed_context() {
1193        let approval =
1194            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1195        let finish = CeremonyTransition::new(
1196            state_id("drafting"),
1197            state_id("done"),
1198            trigger("approve"),
1199            vec![approval.name().clone()],
1200        )
1201        .unwrap();
1202        let definition = CeremonyDefinition::new(
1203            crate::value_objects::CeremonyName::new("approval_ceremony").unwrap(),
1204            CeremonyVersion::v1(),
1205            None,
1206            Vec::new(),
1207            Vec::new(),
1208            vec![
1209                CeremonyState::initial(state_id("drafting")),
1210                CeremonyState::terminal(state_id("done")),
1211            ],
1212            vec![finish.clone()],
1213            Vec::new(),
1214            vec![approval.clone()],
1215            vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1216        )
1217        .unwrap();
1218        let mut instance = instance(&definition);
1219
1220        assert!(matches!(
1221            instance.apply_transition(&definition, &trigger("approve"), now()),
1222            Err(DomainError::InvariantViolated { .. })
1223        ));
1224        instance
1225            .approve_guard(
1226                &definition,
1227                approval.name(),
1228                role_id("facilitator"),
1229                AuditActorKind::Human,
1230                datetime!(2026-06-06 12:01:00 UTC),
1231            )
1232            .unwrap();
1233        instance
1234            .apply_transition(
1235                &definition,
1236                &trigger("approve"),
1237                datetime!(2026-06-06 12:02:00 UTC),
1238            )
1239            .unwrap();
1240
1241        assert!(instance.is_completed(&definition));
1242    }
1243
1244    #[test]
1245    fn human_guard_deferral_preserves_uncertainty_without_approving() {
1246        let approval =
1247            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1248        let finish = CeremonyTransition::new(
1249            state_id("drafting"),
1250            state_id("done"),
1251            trigger("approve"),
1252            vec![approval.name().clone()],
1253        )
1254        .unwrap();
1255        let definition = CeremonyDefinition::new(
1256            crate::value_objects::CeremonyName::new("deferral_ceremony").unwrap(),
1257            CeremonyVersion::v1(),
1258            None,
1259            Vec::new(),
1260            Vec::new(),
1261            vec![
1262                CeremonyState::initial(state_id("drafting")),
1263                CeremonyState::terminal(state_id("done")),
1264            ],
1265            vec![finish.clone()],
1266            Vec::new(),
1267            vec![approval.clone()],
1268            vec![role(vec![RoleAction::transition(finish.trigger().clone())])],
1269        )
1270        .unwrap();
1271        let mut instance = instance(&definition);
1272
1273        instance
1274            .defer_guard(
1275                &definition,
1276                approval.name().clone(),
1277                CeremonyGuardDeferralContent::new(
1278                    "I do not know.",
1279                    "I cannot explain how the issue was resolved.",
1280                    vec!["New evidence explains the resolution.".to_owned()],
1281                )
1282                .unwrap(),
1283                role_id("facilitator"),
1284                AuditActorKind::Human,
1285                datetime!(2026-06-06 12:01:00 UTC),
1286            )
1287            .unwrap();
1288
1289        assert!(!instance.context().is_guard_approved(approval.name()));
1290        assert!(instance
1291            .apply_transition(&definition, &trigger("approve"), now())
1292            .is_err());
1293        let deferral = &instance.guard_deferrals()[0];
1294        assert_eq!(deferral.guard_name(), approval.name());
1295        assert_eq!(deferral.content().statement(), "I do not know.");
1296    }
1297    /// An approval that names nobody is a receipt for a human decision
1298    /// nobody can be shown to have taken. This is that made checkable.
1299    #[test]
1300    fn approving_a_human_guard_records_the_seat_that_did_it() {
1301        let approval =
1302            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1303        let definition = definition_with_human_guard(&approval);
1304        let mut instance = instance(&definition);
1305
1306        instance
1307            .approve_guard(
1308                &definition,
1309                approval.name(),
1310                role_id("facilitator"),
1311                AuditActorKind::Human,
1312                datetime!(2026-06-06 12:01:00 UTC),
1313            )
1314            .unwrap();
1315
1316        let [recorded] = instance.guard_approvals() else {
1317            panic!(
1318                "expected one approval, got {:?}",
1319                instance.guard_approvals()
1320            );
1321        };
1322        assert_eq!(recorded.guard_name(), approval.name());
1323        assert_eq!(recorded.approved_by(), &role_id("facilitator"));
1324        assert_eq!(recorded.approved_at(), datetime!(2026-06-06 12:01:00 UTC));
1325        assert!(instance.context().is_guard_approved(approval.name()));
1326    }
1327
1328    /// A seat this session does not have cannot approve anything on it.
1329    /// Weaker than the capability check the other verbs use, and
1330    /// deliberately so — but not so weak that any string will do.
1331    #[test]
1332    fn a_seat_the_definition_does_not_declare_cannot_approve() {
1333        let approval =
1334            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1335        let definition = definition_with_human_guard(&approval);
1336        let mut instance = instance(&definition);
1337
1338        let outcome = instance.approve_guard(
1339            &definition,
1340            approval.name(),
1341            role_id("someone-who-is-not-here"),
1342            AuditActorKind::Human,
1343            now(),
1344        );
1345
1346        assert!(matches!(
1347            outcome,
1348            Err(DomainError::NotFound {
1349                what: "ceremony_role"
1350            })
1351        ));
1352        assert!(instance.guard_approvals().is_empty());
1353        assert!(!instance.context().is_guard_approved(approval.name()));
1354    }
1355    /// A session with one agenda item and one contribution to it —
1356    /// the smallest thing that has something to explain.
1357    fn session_with_a_contribution(
1358        definition: &CeremonyDefinition,
1359    ) -> (CeremonyInstance, CeremonyInterventionId) {
1360        let mut instance = instance(definition);
1361        let agenda_item = CeremonyInterventionId::new("queue-check").unwrap();
1362        instance
1363            .request_intervention_as(
1364                definition,
1365                agenda_item.clone(),
1366                role_id("facilitator"),
1367                CeremonyInterventionKind::Investigation,
1368                CeremonyInterventionTarget::roles([role_id("observer")]).unwrap(),
1369                CeremonyInterventionContent::new("Inspect the queue.", Attributes::empty())
1370                    .unwrap(),
1371                now(),
1372            )
1373            .unwrap();
1374        instance
1375            .respond_to_intervention_as(
1376                definition,
1377                &agenda_item,
1378                role_id("observer"),
1379                CeremonyInterventionContent::new("Queue depth is stable.", Attributes::empty())
1380                    .unwrap(),
1381                now(),
1382            )
1383            .unwrap();
1384        (instance, agenda_item)
1385    }
1386
1387    /// The one reason the engine sees on its own, and it records it
1388    /// without being asked.
1389    #[test]
1390    fn a_contribution_is_recorded_as_answering_its_agenda_item() {
1391        let definition = definition();
1392        let (instance, agenda_item) = session_with_a_contribution(&definition);
1393
1394        let [answered] = instance.reasons() else {
1395            panic!("expected exactly one reason, got {:?}", instance.reasons());
1396        };
1397        assert_eq!(answered.kind(), CeremonyReasonKind::Answers);
1398        assert_eq!(
1399            answered.from(),
1400            &CeremonyRecordRef::contribution(agenda_item.clone(), 0)
1401        );
1402        assert_eq!(answered.to(), &CeremonyRecordRef::agenda_item(agenda_item));
1403        assert_eq!(
1404            answered.asserted_by(),
1405            None,
1406            "the engine observed it; naming a seat would be inventing one"
1407        );
1408    }
1409
1410    /// Structure is not a judgement. A seat able to assert it could
1411    /// rewrite the shape of the session by relabelling it.
1412    #[test]
1413    fn a_seat_cannot_assert_what_only_the_engine_observes() {
1414        let definition = definition();
1415        let (mut instance, agenda_item) = session_with_a_contribution(&definition);
1416
1417        let outcome = instance.assert_reason_as(
1418            &definition,
1419            role_id("observer"),
1420            CeremonyRecordRef::contribution(agenda_item.clone(), 0),
1421            CeremonyRecordRef::agenda_item(agenda_item),
1422            CeremonyReasonKind::Answers,
1423            "because I say it does",
1424            MemoryConfidence::High,
1425            now(),
1426        );
1427
1428        assert!(matches!(
1429            outcome,
1430            Err(DomainError::InvariantViolated { .. })
1431        ));
1432    }
1433
1434    /// Testimony about one's own reasoning. Nobody else has access to
1435    /// it, so nobody else may claim it.
1436    #[test]
1437    fn only_whoever_contributed_may_say_why_they_did() {
1438        let definition = definition();
1439        let (mut instance, agenda_item) = session_with_a_contribution(&definition);
1440        let contribution = CeremonyRecordRef::contribution(agenda_item.clone(), 0);
1441        let item = CeremonyRecordRef::agenda_item(agenda_item);
1442
1443        let by_someone_else = instance.assert_reason_as(
1444            &definition,
1445            role_id("facilitator"),
1446            contribution.clone(),
1447            item.clone(),
1448            CeremonyReasonKind::ChosenBecause,
1449            "they must have thought the queue mattered",
1450            MemoryConfidence::Low,
1451            now(),
1452        );
1453        assert!(matches!(
1454            by_someone_else,
1455            Err(DomainError::InvariantViolated { .. })
1456        ));
1457
1458        instance
1459            .assert_reason_as(
1460                &definition,
1461                role_id("observer"),
1462                contribution,
1463                item,
1464                CeremonyReasonKind::ChosenBecause,
1465                "the depth graph had been flat for an hour",
1466                MemoryConfidence::High,
1467                now(),
1468            )
1469            .expect("its author may say why");
1470        assert_eq!(instance.reasons().len(), 2);
1471    }
1472
1473    /// A claim about the world, not about a mind. Anyone may make one
1474    /// and everyone may weigh it.
1475    #[test]
1476    fn any_seat_may_claim_that_one_thing_came_from_another() {
1477        let definition = definition();
1478        let (mut instance, agenda_item) = session_with_a_contribution(&definition);
1479
1480        instance
1481            .assert_reason_as(
1482                &definition,
1483                role_id("facilitator"),
1484                CeremonyRecordRef::agenda_item(agenda_item.clone()),
1485                CeremonyRecordRef::contribution(agenda_item, 0),
1486                CeremonyReasonKind::FollowsFrom,
1487                "the item stayed open because the answer raised a new question",
1488                MemoryConfidence::Medium,
1489                now(),
1490            )
1491            .expect("a claim about the world is open to any seat");
1492
1493        let asserted = instance.reasons().last().unwrap();
1494        assert_eq!(asserted.confidence(), MemoryConfidence::Medium);
1495        assert_eq!(asserted.asserted_by(), Some(&role_id("facilitator")));
1496    }
1497
1498    /// A session knows everything it has done, so a reason may not
1499    /// cite something it never produced.
1500    #[test]
1501    fn a_reason_cannot_cite_something_that_never_happened() {
1502        let definition = definition();
1503        let (mut instance, agenda_item) = session_with_a_contribution(&definition);
1504
1505        let outcome = instance.assert_reason_as(
1506            &definition,
1507            role_id("observer"),
1508            CeremonyRecordRef::contribution(agenda_item.clone(), 7),
1509            CeremonyRecordRef::agenda_item(agenda_item),
1510            CeremonyReasonKind::FollowsFrom,
1511            "a contribution nobody made",
1512            MemoryConfidence::Low,
1513            now(),
1514        );
1515
1516        assert!(matches!(
1517            outcome,
1518            Err(DomainError::NotFound {
1519                what: "ceremony_record"
1520            })
1521        ));
1522    }
1523
1524    /// A move is recorded with the seat that fired it, so "the session
1525    /// resolved because…" has something to point at.
1526    #[test]
1527    fn a_move_is_recorded_with_whoever_made_it() {
1528        let approval =
1529            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1530        let definition = definition_with_human_guard(&approval);
1531        let mut instance = instance(&definition);
1532        instance
1533            .approve_guard(
1534                &definition,
1535                approval.name(),
1536                role_id("facilitator"),
1537                AuditActorKind::Human,
1538                now(),
1539            )
1540            .unwrap();
1541
1542        instance
1543            .apply_transition_as(
1544                &definition,
1545                &role_id("facilitator"),
1546                &trigger("approve"),
1547                datetime!(2026-06-06 12:05:00 UTC),
1548            )
1549            .unwrap();
1550
1551        let [moved] = instance.transitions() else {
1552            panic!("expected one move, got {:?}", instance.transitions());
1553        };
1554        assert_eq!(moved.trigger(), &trigger("approve"));
1555        assert_eq!(moved.from_state(), &state_id("drafting"));
1556        assert_eq!(moved.to_state(), &state_id("done"));
1557        assert_eq!(moved.applied_by(), Some(&role_id("facilitator")));
1558    }
1559
1560    /// And without one when the engine took the move itself. An
1561    /// absence, not a gap — and it is what stops testimony being
1562    /// claimed about something nobody can testify to.
1563    #[test]
1564    fn a_move_the_engine_took_names_nobody() {
1565        let approval =
1566            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1567        let definition = definition_with_human_guard(&approval);
1568        let mut instance = instance(&definition);
1569        instance
1570            .approve_guard(
1571                &definition,
1572                approval.name(),
1573                role_id("facilitator"),
1574                AuditActorKind::Human,
1575                now(),
1576            )
1577            .unwrap();
1578
1579        instance
1580            .apply_transition(&definition, &trigger("approve"), now())
1581            .unwrap();
1582
1583        assert_eq!(instance.transitions()[0].applied_by(), None);
1584    }
1585    /// What kind of party filled the seat is recorded as declared and
1586    /// never inferred.
1587    ///
1588    /// The engine knows this guard demands a human. That says one was
1589    /// required, not that one turned up — and a receipt that read
1590    /// compliance off its own requirement would assert exactly what
1591    /// nobody can demonstrate. So an agent approving a human-approval
1592    /// guard is recorded as an agent, and whether that is acceptable
1593    /// is a question for whoever reads it.
1594    #[test]
1595    fn an_approval_records_the_kind_it_was_told_not_the_one_the_guard_wanted() {
1596        let approval =
1597            CeremonyGuard::new(guard_name("human_approved"), GuardCondition::HumanApproval);
1598        let definition = definition_with_human_guard(&approval);
1599        let mut instance = instance(&definition);
1600
1601        instance
1602            .approve_guard(
1603                &definition,
1604                approval.name(),
1605                role_id("facilitator"),
1606                AuditActorKind::Agent,
1607                now(),
1608            )
1609            .unwrap();
1610
1611        let [recorded] = instance.guard_approvals() else {
1612            panic!("expected one approval");
1613        };
1614        assert_eq!(
1615            recorded.approved_by_kind(),
1616            AuditActorKind::Agent,
1617            "the guard asked for a human and an agent answered; saying otherwise \
1618             would be the engine vouching for something it cannot see"
1619        );
1620        assert!(instance.context().is_guard_approved(approval.name()));
1621    }
1622}