1use 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 #[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 #[serde(default)]
92 guard_approvals: Vec<CeremonyGuardApproval>,
93 #[serde(default)]
100 transitions: Vec<CeremonyTransitionRecord>,
101 #[serde(default)]
108 reasons: Vec<CeremonyReason>,
109 #[serde(default)]
113 participant_bindings: BTreeMap<RoleId, CeremonyParticipantBinding>,
114 context: CeremonyContext,
115 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
171 succession: Option<Box<CeremonySuccession>>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
177 successor_plan: Option<Box<SuccessionPlan>>,
178}
179
180impl CeremonyInstance {
181 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 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 #[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 #[must_use]
233 pub fn succession(&self) -> Option<&CeremonySuccession> {
234 self.succession.as_deref()
235 }
236
237 #[must_use]
239 pub fn successor_plan(&self) -> Option<&SuccessionPlan> {
240 self.successor_plan.as_deref()
241 }
242
243 #[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 #[must_use]
267 pub fn recollection(&self) -> Option<&SessionRecollection> {
268 self.recollection.as_ref()
269 }
270
271 #[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 #[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 #[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 #[must_use]
441 pub fn guard_approvals(&self) -> &[CeremonyGuardApproval] {
442 &self.guard_approvals
443 }
444
445 #[must_use]
447 pub fn transitions(&self) -> &[CeremonyTransitionRecord] {
448 &self.transitions
449 }
450
451 #[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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}