1use std::collections::HashMap;
35use std::sync::Arc;
36
37use crossbeam_channel::{Receiver, Sender};
38use rand::TryRng;
39use rand::rngs::SysRng;
40use rite_model::{
41 ActId, ActionType, ArtifactId, Ceremony, MaterialId, OutputId, ParamId, RoleId, Step,
42};
43use rite_sdk::{Backend, BackendError, Retriability};
44use thiserror::Error;
45
46use crate::actions::ActionMetadata;
47use crate::backend::BackendRegistry;
48use crate::clock::{Clock, SystemClock};
49use crate::entropy::DERIVATION_V1;
50use crate::executor::{
51 ExecutionError, load_material_artifact, step_info_from, write_artifact_to_disk,
52};
53use crate::expressions;
54use crate::output_config::OutputConfig;
55use crate::protocol::{ExecEvent, Icon, MaterialOverview, Response, UiCommand, UiSignal};
56use crate::reporter::{Reporter, ReporterError};
57use crate::state::{ExecutionState, HandlerContext, StepResult};
58use crate::step_info::StepInfo;
59use crate::system_info::StartupSnapshot;
60use crate::transcript_sink::{TranscriptFingerprint, TranscriptSink};
61use rite_model::{ErrorClass, ErrorRecord, Prompt, RetryPolicy, StepFact, StepOutcome};
62
63fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), ExecutionError> {
71 let mut m = [0u8; 32];
72 if dry_run {
73 for (slot, byte) in m.iter_mut().zip(b"rite-dry-run-not-real-entropy") {
74 *slot = *byte;
75 }
76 return Ok((m, "dry-run".to_string()));
77 }
78 SysRng
79 .try_fill_bytes(&mut m)
80 .map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
81 Ok((m, "os".to_string()))
82}
83
84#[derive(Debug, Error)]
86pub enum ActionError {
87 #[error("ceremony aborted by operator")]
90 Aborted,
91 #[error("frontend channel disconnected")]
93 Disconnected,
94 #[error("transcript write failed: {0}")]
96 Transcript(std::io::Error),
97 #[error("backend error: {0}")]
99 Backend(#[from] BackendError),
100 #[error("{0}")]
102 Failed(String),
103}
104
105impl From<ReporterError> for ActionError {
106 fn from(value: ReporterError) -> Self {
107 match value {
108 ReporterError::Aborted => ActionError::Aborted,
109 ReporterError::Disconnected => ActionError::Disconnected,
110 ReporterError::Transcript(e) => ActionError::Transcript(e),
111 ReporterError::NoCurrentStep(_)
112 | ReporterError::DuplicateDraw { .. }
113 | ReporterError::Unseeded
114 | ReporterError::DrawTooLong { .. } => ActionError::Failed(value.to_string()),
115 }
116 }
117}
118
119impl ActionError {
120 #[must_use]
130 pub fn retriability(&self) -> Retriability {
131 match self {
132 ActionError::Backend(e) => e.retriability(),
133 ActionError::Aborted
134 | ActionError::Disconnected
135 | ActionError::Transcript(_)
136 | ActionError::Failed(_) => Retriability::Fatal,
137 }
138 }
139}
140
141pub fn parse_params<P: serde::de::DeserializeOwned>(
150 params: &serde_json::Value,
151) -> Result<P, ActionError> {
152 serde_json::from_value(params.clone())
153 .map_err(|e| ActionError::Failed(format!("invalid params: {e}")))
154}
155
156impl From<ReporterError> for ExecutionError {
157 fn from(value: ReporterError) -> Self {
158 match value {
159 ReporterError::Aborted => ExecutionError::Aborted,
160 ReporterError::Disconnected => {
161 ExecutionError::TranscriptError("frontend channel disconnected".to_string())
162 }
163 ReporterError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
164 ReporterError::NoCurrentStep(_) => ExecutionError::TranscriptError(value.to_string()),
165 ReporterError::DuplicateDraw { .. }
166 | ReporterError::Unseeded
167 | ReporterError::DrawTooLong { .. } => ExecutionError::EntropyError(value.to_string()),
168 }
169 }
170}
171
172pub trait Action: Send + Sync {
179 fn metadata(&self) -> ActionMetadata;
181
182 fn apply_defaults(&self, _params: &mut serde_json::Value, _step: &StepInfo) {}
184
185 fn unsupported_params(&self, _params: &serde_json::Value, _step: &StepInfo) -> Vec<String> {
200 Vec::new()
201 }
202
203 fn execute(
210 &self,
211 step: &StepInfo,
212 ctx: &HandlerContext,
213 params: &serde_json::Value,
214 reporter: &mut Reporter<'_>,
215 backend: Option<&mut dyn Backend>,
216 ) -> Result<StepResult, ActionError>;
217}
218
219#[derive(Debug, Clone)]
227pub struct StepUnsupportedParam {
228 pub step: rite_model::StepId,
230 pub message: String,
232}
233
234#[derive(Default)]
236pub struct ActionRegistry {
237 actions: HashMap<ActionType, Arc<dyn Action>>,
238}
239
240impl ActionRegistry {
241 #[must_use]
243 pub fn new() -> Self {
244 Self::default()
245 }
246
247 pub fn register(&mut self, action: Arc<dyn Action>) {
249 let action_type = action.metadata().action_type;
250 self.actions.insert(action_type, action);
251 }
252
253 #[must_use]
255 pub fn get(&self, action: &ActionType) -> Option<&Arc<dyn Action>> {
256 self.actions.get(action)
257 }
258
259 pub fn action_types(&self) -> impl Iterator<Item = &ActionType> {
261 self.actions.keys()
262 }
263
264 #[must_use]
268 pub fn unsupported_actions(&self, steps: &[rite_model::Step]) -> Vec<ActionType> {
269 let mut seen = std::collections::HashSet::new();
270 let mut result = Vec::new();
271 for step in steps {
272 if !self.actions.contains_key(&step.action) && seen.insert(step.action) {
273 result.push(step.action);
274 }
275 }
276 result
277 }
278
279 #[must_use]
288 pub fn unsupported_step_params(&self, steps: &[rite_model::Step]) -> Vec<StepUnsupportedParam> {
289 let mut found = Vec::new();
290 for step in steps {
291 let Some(handler) = self.actions.get(&step.action) else {
292 continue;
293 };
294 let Some(mut params) = rite_model::expression::literal_expr_value(&step.with) else {
295 continue;
296 };
297 let step_info = crate::executor::step_info_from(step);
298 handler.apply_defaults(&mut params, &step_info);
299 found.extend(
300 handler
301 .unsupported_params(¶ms, &step_info)
302 .into_iter()
303 .map(|message| StepUnsupportedParam {
304 step: step.id.clone(),
305 message,
306 }),
307 );
308 }
309 found
310 }
311}
312
313#[derive(Debug)]
315pub struct ExecutionSummary {
316 pub ceremony_name: String,
318 pub steps_completed: usize,
320 pub transcript_fingerprint: TranscriptFingerprint,
322}
323
324pub struct Executor {
326 ceremony: Ceremony,
327 registry: ActionRegistry,
328 backend_registry: BackendRegistry,
329 output_config: OutputConfig,
330 dry_run: bool,
331 startup: StartupSnapshot,
332 clock: Arc<dyn Clock>,
333}
334
335impl Executor {
336 #[must_use]
342 pub fn new(
343 ceremony: Ceremony,
344 registry: ActionRegistry,
345 backend_registry: BackendRegistry,
346 output_config: OutputConfig,
347 dry_run: bool,
348 startup: StartupSnapshot,
349 ) -> Self {
350 Self {
351 ceremony,
352 registry,
353 backend_registry,
354 output_config,
355 dry_run,
356 startup,
357 clock: Arc::new(SystemClock),
358 }
359 }
360
361 #[must_use]
364 pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
365 self.clock = clock;
366 self
367 }
368
369 pub fn run(
382 self,
383 cmd_rx: &Receiver<UiCommand>,
384 event_tx: &Sender<ExecEvent>,
385 mut transcript_sink: Box<dyn TranscriptSink>,
386 ) -> Result<ExecutionSummary, ExecutionError> {
387 validate_parameters(&self.ceremony)?;
388
389 let resolved_params: HashMap<ParamId, serde_json::Value> = self
390 .ceremony
391 .parameters
392 .iter()
393 .map(|(id, p)| (id.clone(), p.value.clone()))
394 .collect();
395
396 let roles: HashMap<RoleId, String> = self
397 .ceremony
398 .roles
399 .iter()
400 .map(|(id, r)| (id.clone(), r.name.clone()))
401 .collect();
402
403 let materials_map: HashMap<MaterialId, String> = self
404 .ceremony
405 .materials
406 .iter()
407 .map(|(id, m)| (id.clone(), m.display_name().to_string()))
408 .collect();
409
410 let ceremony_name = self.ceremony.metadata.name.clone();
411 let clock = Arc::clone(&self.clock);
414 let mut reporter = Reporter::new(
415 event_tx,
416 cmd_rx,
417 transcript_sink.as_mut(),
418 Arc::clone(&clock),
419 );
420
421 let outcome = self.execute_inner(&mut reporter, resolved_params, roles, materials_map);
422
423 match outcome {
428 Ok(counts) => {
429 let at = clock.now();
430 let completed = StepFact::CeremonyCompleted {};
431 transcript_sink
432 .record(at, &completed)
433 .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
434 let _ = event_tx.send(ExecEvent::Fact {
435 at,
436 fact: completed,
437 });
438 let fingerprint = transcript_sink
439 .finalize()
440 .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
441 let _ = event_tx.send(ExecEvent::Finalized {
442 fingerprint: fingerprint.as_str().to_string(),
443 });
444 Ok(ExecutionSummary {
445 ceremony_name,
446 steps_completed: counts.completed,
447 transcript_fingerprint: fingerprint,
448 })
449 }
450 Err(err) => {
451 let at = clock.now();
452 let record = err.to_error_record();
453 let failed = StepFact::CeremonyFailed { error: record };
454 let _ = transcript_sink.record(at, &failed);
455 let _ = event_tx.send(ExecEvent::Fact { at, fact: failed });
456 if let Ok(fingerprint) = transcript_sink.finalize() {
457 let _ = event_tx.send(ExecEvent::Finalized {
458 fingerprint: fingerprint.as_str().to_string(),
459 });
460 }
461 Err(err)
462 }
463 }
464 }
465
466 #[allow(clippy::too_many_lines)]
467 fn execute_inner(
468 mut self,
469 reporter: &mut Reporter<'_>,
470 resolved_params: HashMap<ParamId, serde_json::Value>,
471 roles: HashMap<RoleId, String>,
472 materials_map: HashMap<MaterialId, String>,
473 ) -> Result<StepCounts, ExecutionError> {
474 let dry_run = self.dry_run;
475
476 reporter.fact(StepFact::CeremonyStarted {
477 name: self.ceremony.metadata.name.clone(),
478 })?;
479
480 let (m, source) = gather_machine_entropy(dry_run)?;
485 reporter.seed_entropy(&m);
486 reporter.fact(StepFact::EntropySeeded {
487 m: base16ct::lower::encode_string(&m),
488 source,
489 derivation: DERIVATION_V1.to_string(),
490 })?;
491
492 reporter.signal(UiSignal::CeremonyOverview {
497 description: self.ceremony.metadata.description.clone(),
498 materials: self
499 .ceremony
500 .materials
501 .iter()
502 .map(|(_, m)| MaterialOverview::from_material(m))
503 .collect(),
504 step_count: self.ceremony.execution_plan.len(),
505 })?;
506
507 reporter.signal(UiSignal::SystemInfo(Box::new(self.startup.system.clone())))?;
512 reporter.signal(UiSignal::Environment(self.startup.environment.clone()))?;
513
514 if !dry_run {
519 reporter.prompt(&Prompt::Continue {
520 hint: Some("Press Enter to start the ceremony".to_string()),
521 })?;
522 }
523
524 let mut state = ExecutionState::new(resolved_params, roles, materials_map, dry_run);
525
526 for (id, material) in self.ceremony.materials.iter() {
528 let artifact = load_material_artifact(id.as_str(), material)?;
529 state = state.with_material(ArtifactId::new(id.as_str()), artifact);
530 let display_name = material.display_name();
531 reporter.log(Icon::Checkmark, format!("Loaded material: {display_name}"))?;
532 }
533
534 let has_acts = !self.ceremony.acts.is_empty();
535 let mut current_act: Option<ActId> = None;
536 let mut counts = StepCounts::default();
537
538 let plan = std::mem::take(&mut self.ceremony.execution_plan);
541 for step in &plan {
542 if has_acts {
544 let step_act = self
545 .ceremony
546 .sections
547 .get(&step.section)
548 .and_then(|s| s.act.clone());
549 if step_act != current_act {
550 if let Some(act_id) = &step_act
551 && let Some(act) = self.ceremony.acts.get(act_id)
552 {
553 reporter.fact(StepFact::ActStarted {
554 id: act_id.clone(),
555 label: act
556 .name
557 .clone()
558 .unwrap_or_else(|| act_id.as_str().to_string()),
559 })?;
560 }
561 current_act = step_act;
562 }
563 }
564
565 reporter.set_current_step(Some(step.id.clone()));
566 let role_id = step.role.clone().unwrap_or_else(|| RoleId::new(""));
567 let role_name = self
568 .ceremony
569 .roles
570 .get(&role_id)
571 .map_or_else(|| role_id.as_str().to_string(), |r| r.name.clone());
572 reporter.fact(StepFact::StepStarted {
573 id: step.id.clone(),
574 label: step.step_label.clone(),
575 role: role_id,
576 role_name,
577 })?;
578
579 if !step.silent && !dry_run {
586 reporter.prompt(&Prompt::Continue {
587 hint: Some(format!("Press Enter to start step {}", step.step_label)),
588 })?;
589 }
590
591 let handler = self
593 .registry
594 .get(&step.action)
595 .ok_or(ExecutionError::UnknownAction(step.action))?
596 .clone();
597
598 let ctx = state.handler_context();
599 let step_info = step_info_from(step);
600 let mut params = expressions::evaluate_expr_value(&step.with, &ctx)?;
601 handler.apply_defaults(&mut params, &step_info);
602
603 let result = execute_step_with_retry(
604 handler.as_ref(),
605 step,
606 &step_info,
607 &ctx,
608 ¶ms,
609 reporter,
610 &mut self.backend_registry,
611 )?;
612
613 reporter.fact(StepFact::StepCompleted {
615 id: step.id.clone(),
616 outcome: result.outcome.clone(),
617 })?;
618
619 if let StepOutcome::Completed { .. } = &result.outcome {
620 counts.completed = counts.completed.saturating_add(1);
621 }
622
623 state = merge_step_result(state, result);
624
625 if let Some(artifact_id) = &step.creates
629 && !dry_run
630 {
631 let output_id = OutputId::new(artifact_id.as_str());
632 if self.ceremony.outputs.contains(&output_id) {
633 std::fs::create_dir_all(self.output_config.artifacts_dir()).map_err(|e| {
634 ExecutionError::OutputWriteFailed {
635 name: "artifacts directory".to_string(),
636 reason: e.to_string(),
637 }
638 })?;
639
640 let artifact_value = state.artifacts.get(artifact_id).ok_or_else(|| {
641 ExecutionError::OutputWriteFailed {
642 name: artifact_id.as_str().to_string(),
643 reason: "artifact not produced".to_string(),
644 }
645 })?;
646
647 let (path, hash, _size, _mime_type) =
648 write_artifact_to_disk(artifact_id, artifact_value, &self.output_config)?;
649
650 let recorded_path = match path.strip_prefix(self.output_config.base_dir()) {
655 Ok(relative) => relative.to_path_buf(),
656 Err(_) => path.clone(),
657 };
658
659 reporter.fact(StepFact::ArtifactWritten {
660 step: step.id.clone(),
661 name: artifact_id.as_str().to_string(),
662 path: recorded_path,
663 sha256: hash,
664 })?;
665 }
666 }
667 }
668
669 reporter.set_current_step(None);
670 drop(plan);
671
672 Ok(counts)
673 }
674}
675
676#[derive(Default, Debug, Clone, Copy)]
677struct StepCounts {
678 completed: usize,
679}
680
681fn execute_step_with_retry(
691 handler: &dyn Action,
692 step: &Step,
693 step_info: &StepInfo,
694 ctx: &HandlerContext,
695 params: &serde_json::Value,
696 reporter: &mut Reporter<'_>,
697 backend_registry: &mut BackendRegistry,
698) -> Result<StepResult, ExecutionError> {
699 let mut attempt: u32 = 1;
700 loop {
701 let side_effects_before = reporter.side_effects_emitted();
702
703 let attempt_result = match &step_info.backend {
707 Some(name) => match backend_registry.get_mut(name) {
708 Ok(backend) => handler.execute(step_info, ctx, params, reporter, Some(backend)),
709 Err(e) => Err(ActionError::Backend(e)),
710 },
711 None => handler.execute(step_info, ctx, params, reporter, None),
712 };
713
714 let action_err = match attempt_result {
715 Ok(result) => return Ok(result),
716 Err(ActionError::Aborted) => return Err(ExecutionError::Aborted),
719 Err(action_err) => action_err,
720 };
721
722 let no_new_side_effects = reporter.side_effects_emitted() == side_effects_before;
725 let retriable = action_err.retriability().is_retriable() && no_new_side_effects;
726 let exec_err = action_error_to_execution(action_err, &step.id);
727 reporter.fact(StepFact::StepAttemptFailed {
728 step: step.id.clone(),
729 attempt,
730 error: exec_err.to_error_record(),
731 })?;
732
733 let policy_allows = match step.retry {
734 RetryPolicy::Never => false,
735 RetryPolicy::MaxAttempts(max) => attempt < max,
736 RetryPolicy::Prompt => true,
737 };
738
739 if retriable && policy_allows {
740 let answer = reporter.prompt(&Prompt::Confirm {
744 question: format!("Step {} failed: {exec_err}. Retry?", step.step_label),
745 default: Some(false),
746 })?;
747 if matches!(answer, Response::Bool(true)) {
748 attempt = attempt.saturating_add(1);
749 continue;
750 }
751 }
752 return Err(exec_err);
753 }
754}
755
756fn action_error_to_execution(err: ActionError, step_id: &rite_model::StepId) -> ExecutionError {
761 match err {
762 ActionError::Aborted => ExecutionError::Aborted,
763 ActionError::Disconnected => ExecutionError::StepFailed {
764 step: step_id.clone(),
765 reason: "frontend channel disconnected".to_string(),
766 },
767 ActionError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
768 ActionError::Backend(e) => ExecutionError::BackendError(e),
769 ActionError::Failed(reason) => ExecutionError::StepFailed {
770 step: step_id.clone(),
771 reason,
772 },
773 }
774}
775
776impl ExecutionError {
777 fn to_error_record(&self) -> ErrorRecord {
783 let (class, kind) = match self {
784 ExecutionError::ValidationFailed(_) => (ErrorClass::Integrity, "validation_failed"),
785 ExecutionError::Aborted => (ErrorClass::Abort, "aborted"),
786 ExecutionError::Io(_) => (ErrorClass::Integrity, "io"),
787 ExecutionError::StepFailed { .. } => (ErrorClass::Procedural, "step_failed"),
788 ExecutionError::UnknownAction(_) => (ErrorClass::Integrity, "unknown_action"),
789 ExecutionError::InvalidParams(_) => (ErrorClass::Integrity, "invalid_params"),
790 ExecutionError::EntropyError(_) => (ErrorClass::Integrity, "entropy_error"),
791 ExecutionError::MaterialLoadFailed { .. } => {
792 (ErrorClass::Integrity, "material_load_failed")
793 }
794 ExecutionError::OutputWriteFailed { .. } => {
795 (ErrorClass::Integrity, "output_write_failed")
796 }
797 ExecutionError::TranscriptError(_) => (ErrorClass::Integrity, "transcript_error"),
798 ExecutionError::BackendError(e) => {
799 let class = if e.retriability().is_retriable() {
800 ErrorClass::Environmental
801 } else {
802 ErrorClass::Integrity
803 };
804 (class, "backend_error")
805 }
806 };
807 ErrorRecord::new(class, kind, self.to_string())
808 }
809}
810
811fn validate_parameters(ceremony: &Ceremony) -> Result<(), ExecutionError> {
812 let missing: Vec<_> = ceremony
813 .parameters
814 .iter()
815 .filter(|(_, p)| p.value.is_null())
816 .map(|(id, _)| id.as_str().to_string())
817 .collect();
818 if missing.is_empty() {
819 Ok(())
820 } else {
821 Err(ExecutionError::ValidationFailed(format!(
822 "Parameters missing values: {}",
823 missing.join(", ")
824 )))
825 }
826}
827
828fn merge_step_result(state: ExecutionState, result: StepResult) -> ExecutionState {
830 let mut artifacts = state.artifacts;
831 for (id, value) in result.artifacts {
832 artifacts.insert(id, value);
833 }
834 ExecutionState {
835 params: state.params,
836 roles: state.roles,
837 materials: state.materials,
838 dry_run: state.dry_run,
839 artifacts,
840 }
841}
842
843#[cfg(test)]
844mod tests {
845 use crossbeam_channel::unbounded;
846 use rite_model::ActionType;
847
848 use super::*;
849 use crate::actions::ActionCategory;
850 use crate::transcript_sink::InMemorySink;
851
852 #[test]
853 fn action_error_retriability_delegates_to_backend() {
854 assert_eq!(
855 ActionError::Backend(BackendError::TokenNotPresent).retriability(),
856 Retriability::Transient,
857 );
858 assert_eq!(
859 ActionError::Backend(BackendError::PinBlocked).retriability(),
860 Retriability::Fatal,
861 );
862 }
863
864 #[test]
865 fn handler_level_errors_are_fatal() {
866 assert_eq!(ActionError::Aborted.retriability(), Retriability::Fatal);
867 assert_eq!(
868 ActionError::Disconnected.retriability(),
869 Retriability::Fatal
870 );
871 assert_eq!(
872 ActionError::Failed("check_value mismatch".into()).retriability(),
873 Retriability::Fatal,
874 );
875 }
876
877 struct PingAction;
878
879 impl Action for PingAction {
880 fn metadata(&self) -> ActionMetadata {
881 ActionMetadata {
882 action_type: ActionType::Attest,
883 description: "test action that always succeeds",
884 category: ActionCategory::Verification,
885 }
886 }
887
888 fn execute(
889 &self,
890 _step: &StepInfo,
891 _ctx: &HandlerContext,
892 _params: &serde_json::Value,
893 reporter: &mut Reporter<'_>,
894 _backend: Option<&mut dyn Backend>,
895 ) -> Result<StepResult, ActionError> {
896 reporter.log(Icon::Info, "ping")?;
897 Ok(StepResult::completed("pinged".to_string()))
898 }
899 }
900
901 fn minimal_ceremony() -> Ceremony {
902 let yaml = r#"
903version: "0.3"
904name: "Test"
905roles:
906 participant:
907 person: "Alice"
908sections:
909 main:
910 role: ${role.participant}
911 steps:
912 ping:
913 action: attest
914 silent: true
915 with:
916 statement: "I confirm."
917"#;
918 rite_resolver::resolve(yaml, None)
919 .into_result()
920 .expect("resolve")
921 }
922
923 struct PickyAction;
926
927 impl Action for PickyAction {
928 fn metadata(&self) -> ActionMetadata {
929 ActionMetadata {
930 action_type: ActionType::Attest,
931 description: "test action with an opinion about its params",
932 category: ActionCategory::Verification,
933 }
934 }
935
936 fn unsupported_params(&self, params: &serde_json::Value, _step: &StepInfo) -> Vec<String> {
937 match params.get("statement").and_then(serde_json::Value::as_str) {
938 Some("puce") => vec!["this build has no puce".to_string()],
939 _ => Vec::new(),
940 }
941 }
942
943 fn execute(
944 &self,
945 _step: &StepInfo,
946 _ctx: &HandlerContext,
947 _params: &serde_json::Value,
948 _reporter: &mut Reporter<'_>,
949 _backend: Option<&mut dyn Backend>,
950 ) -> Result<StepResult, ActionError> {
951 Ok(StepResult::completed("done".to_string()))
952 }
953 }
954
955 fn ceremony_with_statement(statement: &str) -> Ceremony {
956 let yaml = format!(
957 r#"
958version: "0.3"
959name: "Test"
960roles:
961 participant:
962 person: "Alice"
963sections:
964 main:
965 role: ${{role.participant}}
966 steps:
967 ping:
968 action: attest
969 with:
970 statement: "{statement}"
971"#
972 );
973 rite_resolver::resolve(&yaml, None)
974 .into_result()
975 .expect("resolve")
976 }
977
978 #[test]
979 fn reports_what_this_build_cannot_carry_out() {
980 let ceremony = ceremony_with_statement("puce");
981 let mut registry = ActionRegistry::new();
982 registry.register(Arc::new(PickyAction));
983
984 let found = registry.unsupported_step_params(&ceremony.execution_plan);
985 let [issue] = found.as_slice() else {
986 panic!("expected exactly one finding, got {found:?}");
987 };
988 assert_eq!(issue.step.as_str(), "ping");
989 assert_eq!(issue.message, "this build has no puce");
990 }
991
992 #[test]
993 fn accepts_a_block_the_handler_is_happy_with() {
994 let ceremony = ceremony_with_statement("I confirm.");
995 let mut registry = ActionRegistry::new();
996 registry.register(Arc::new(PickyAction));
997
998 assert!(
999 registry
1000 .unsupported_step_params(&ceremony.execution_plan)
1001 .is_empty()
1002 );
1003 }
1004
1005 #[test]
1006 fn skips_an_action_this_build_has_no_handler_for() {
1007 let ceremony = ceremony_with_statement("puce");
1010 let registry = ActionRegistry::new();
1011
1012 assert!(
1013 registry
1014 .unsupported_step_params(&ceremony.execution_plan)
1015 .is_empty()
1016 );
1017 }
1018
1019 fn dry_run_output_config() -> OutputConfig {
1020 OutputConfig::new(std::path::PathBuf::from("/tmp/runner-test"))
1021 }
1022
1023 #[test]
1024 fn runs_a_minimal_ceremony_and_emits_lifecycle_facts() {
1025 let ceremony = minimal_ceremony();
1026 let mut registry = ActionRegistry::new();
1027 registry.register(Arc::new(PingAction));
1028
1029 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1030 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1031 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1032
1033 let executor = Executor::new(
1034 ceremony,
1035 registry,
1036 BackendRegistry::new(),
1037 dry_run_output_config(),
1038 true,
1039 StartupSnapshot::placeholder(),
1040 );
1041
1042 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1043
1044 let mut facts = Vec::new();
1046 while let Ok(ev) = event_rx.recv() {
1047 if let ExecEvent::Fact { fact, .. } = ev {
1048 facts.push(fact);
1049 }
1050 }
1051
1052 let summary = join.join().expect("executor join").expect("run ok");
1053 assert_eq!(summary.steps_completed, 1);
1054
1055 let kinds: Vec<&'static str> = facts.iter().map(fact_kind).collect();
1056 assert_eq!(
1057 kinds,
1058 vec![
1059 "ceremony_started",
1060 "entropy_seeded",
1061 "step_started",
1062 "step_completed",
1063 "ceremony_completed",
1064 ],
1065 );
1066
1067 drop(cmd_tx);
1068 }
1069
1070 #[test]
1071 fn run_stamps_every_fact_from_the_injected_clock() {
1072 use crate::test_support::{FixedClock, fixed_test_time};
1073
1074 let mut registry = ActionRegistry::new();
1075 registry.register(Arc::new(PingAction));
1076
1077 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1078 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1079 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1080
1081 let fixed = fixed_test_time();
1082 let executor = Executor::new(
1083 minimal_ceremony(),
1084 registry,
1085 BackendRegistry::new(),
1086 dry_run_output_config(),
1087 true,
1088 StartupSnapshot::placeholder(),
1089 )
1090 .with_clock(Arc::new(FixedClock(fixed)));
1091
1092 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1093
1094 let mut times = Vec::new();
1095 while let Ok(ev) = event_rx.recv() {
1096 if let ExecEvent::Fact { at, .. } = ev {
1097 times.push(at);
1098 }
1099 }
1100 join.join().expect("executor join").expect("run ok");
1101
1102 assert!(!times.is_empty());
1105 assert!(times.iter().all(|&t| t == fixed));
1106
1107 drop(cmd_tx);
1108 }
1109
1110 #[derive(Debug, PartialEq, Eq)]
1111 enum PacingTrace {
1112 CeremonyStarted,
1113 StepStarted(String),
1114 ContinuePrompt { step: Option<String> },
1115 StepCompleted,
1116 CeremonyCompleted,
1117 }
1118
1119 #[test]
1124 #[allow(clippy::too_many_lines)]
1125 fn pacing_prompts_fire_at_step_start_not_step_end() {
1126 let yaml = r#"
1127version: "0.3"
1128name: "Pacing"
1129roles:
1130 participant:
1131 person: "Alice"
1132sections:
1133 main:
1134 role: ${role.participant}
1135 steps:
1136 first:
1137 action: attest
1138 with:
1139 statement: "first"
1140 second:
1141 action: attest
1142 with:
1143 statement: "second"
1144"#;
1145 let ceremony = rite_resolver::resolve(yaml, None)
1146 .into_result()
1147 .expect("resolve");
1148 let mut registry = ActionRegistry::new();
1149 registry.register(Arc::new(PingAction));
1150
1151 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1152 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1153 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1154
1155 let executor = Executor::new(
1156 ceremony,
1157 registry,
1158 BackendRegistry::new(),
1159 dry_run_output_config(),
1160 false, StartupSnapshot::placeholder(),
1162 );
1163
1164 let frontend = std::thread::spawn({
1168 let cmd_tx = cmd_tx.clone();
1169 move || {
1170 let mut trace = Vec::new();
1171 while let Ok(event) = event_rx.recv() {
1172 match event {
1173 ExecEvent::Fact {
1174 fact: StepFact::CeremonyStarted { .. },
1175 ..
1176 } => {
1177 trace.push(PacingTrace::CeremonyStarted);
1178 }
1179 ExecEvent::Fact {
1180 fact: StepFact::StepStarted { label, .. },
1181 ..
1182 } => {
1183 trace.push(PacingTrace::StepStarted(label));
1184 }
1185 ExecEvent::Fact {
1186 fact: StepFact::StepCompleted { .. },
1187 ..
1188 } => {
1189 trace.push(PacingTrace::StepCompleted);
1190 }
1191 ExecEvent::Fact {
1192 fact: StepFact::CeremonyCompleted { .. },
1193 ..
1194 } => {
1195 trace.push(PacingTrace::CeremonyCompleted);
1196 }
1197 ExecEvent::AwaitPrompt {
1198 prompt_id,
1199 prompt: Prompt::Continue { .. },
1200 step,
1201 ..
1202 } => {
1203 trace.push(PacingTrace::ContinuePrompt {
1204 step: step.map(|s| s.as_str().to_string()),
1205 });
1206 let _ = cmd_tx.send(UiCommand::PromptResponse {
1207 prompt_id,
1208 response: crate::protocol::Response::Acknowledge,
1209 });
1210 }
1211 _ => {}
1212 }
1213 }
1214 trace
1215 }
1216 });
1217
1218 let summary = executor
1219 .run(&cmd_rx, &event_tx, sink)
1220 .expect("ceremony runs");
1221 assert_eq!(summary.steps_completed, 2);
1222 drop(event_tx);
1225 drop(cmd_tx);
1226 let trace = frontend.join().expect("frontend join");
1227
1228 let mut iter = trace.iter();
1234 assert_eq!(iter.next(), Some(&PacingTrace::CeremonyStarted));
1235 assert_eq!(
1236 iter.next(),
1237 Some(&PacingTrace::ContinuePrompt { step: None })
1238 );
1239 let first_step = match iter.next() {
1240 Some(PacingTrace::StepStarted(label)) => label.clone(),
1241 other => panic!("expected first StepStarted, got {other:?}"),
1242 };
1243 assert_eq!(
1244 iter.next(),
1245 Some(&PacingTrace::ContinuePrompt {
1246 step: Some("first".to_string()),
1247 }),
1248 "first step's Continue prompt must fire after StepStarted and before the body",
1249 );
1250 assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1251 let second_step = match iter.next() {
1252 Some(PacingTrace::StepStarted(label)) => label.clone(),
1253 other => panic!("expected second StepStarted, got {other:?}"),
1254 };
1255 assert_eq!(
1256 iter.next(),
1257 Some(&PacingTrace::ContinuePrompt {
1258 step: Some("second".to_string()),
1259 }),
1260 );
1261 assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1262 assert_eq!(iter.next(), Some(&PacingTrace::CeremonyCompleted));
1263 assert_eq!(iter.next(), None, "no further events expected");
1264 assert_eq!(first_step, "1");
1266 assert_eq!(second_step, "2");
1267 }
1268
1269 #[test]
1270 fn aborts_when_action_returns_aborted() {
1271 struct AbortAction;
1272 impl Action for AbortAction {
1273 fn metadata(&self) -> ActionMetadata {
1274 ActionMetadata {
1275 action_type: ActionType::Attest,
1276 description: "aborts immediately",
1277 category: ActionCategory::Verification,
1278 }
1279 }
1280 fn execute(
1281 &self,
1282 _step: &StepInfo,
1283 _ctx: &HandlerContext,
1284 _params: &serde_json::Value,
1285 _reporter: &mut Reporter<'_>,
1286 _backend: Option<&mut dyn Backend>,
1287 ) -> Result<StepResult, ActionError> {
1288 Err(ActionError::Aborted)
1289 }
1290 }
1291
1292 let ceremony = minimal_ceremony();
1293 let mut registry = ActionRegistry::new();
1294 registry.register(Arc::new(AbortAction));
1295
1296 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1297 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1298 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1299
1300 let executor = Executor::new(
1301 ceremony,
1302 registry,
1303 BackendRegistry::new(),
1304 dry_run_output_config(),
1305 true,
1306 StartupSnapshot::placeholder(),
1307 );
1308
1309 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1310
1311 let mut got_failed = false;
1312 while let Ok(ev) = event_rx.recv() {
1313 if let ExecEvent::Fact {
1314 fact: StepFact::CeremonyFailed { error, .. },
1315 ..
1316 } = ev
1317 {
1318 assert_eq!(error.kind, "aborted");
1319 assert_eq!(error.class, ErrorClass::Abort);
1322 assert_eq!(error.message, "Ceremony aborted by the operator");
1323 got_failed = true;
1324 }
1325 }
1326 assert!(got_failed, "expected CeremonyFailed event");
1327
1328 let result = join.join().expect("executor join");
1329 assert!(matches!(result, Err(ExecutionError::Aborted)));
1330 }
1331
1332 enum BeforeFail {
1338 Nothing,
1339 SideEffect,
1341 Prompt,
1344 Deviation,
1347 }
1348
1349 struct FlakyAction {
1352 fails_remaining: std::sync::Mutex<u32>,
1353 error: fn() -> ActionError,
1354 before_fail: BeforeFail,
1355 }
1356
1357 impl FlakyAction {
1358 fn new(fails: u32, error: fn() -> ActionError, before_fail: BeforeFail) -> Arc<Self> {
1359 Arc::new(Self {
1360 fails_remaining: std::sync::Mutex::new(fails),
1361 error,
1362 before_fail,
1363 })
1364 }
1365 }
1366
1367 impl Action for FlakyAction {
1368 fn metadata(&self) -> ActionMetadata {
1369 ActionMetadata {
1370 action_type: ActionType::Attest,
1371 description: "flaky test action",
1372 category: ActionCategory::Verification,
1373 }
1374 }
1375
1376 fn execute(
1377 &self,
1378 step: &StepInfo,
1379 _ctx: &HandlerContext,
1380 _params: &serde_json::Value,
1381 reporter: &mut Reporter<'_>,
1382 _backend: Option<&mut dyn Backend>,
1383 ) -> Result<StepResult, ActionError> {
1384 let mut remaining = self.fails_remaining.lock().expect("lock");
1385 match self.before_fail {
1386 BeforeFail::Nothing => {}
1387 BeforeFail::SideEffect => {
1388 reporter.fact(StepFact::BackendOperation {
1389 step: step.id.clone(),
1390 kind: "test_op".to_string(),
1391 inputs: serde_json::Value::Null,
1392 outputs: serde_json::Value::Null,
1393 fingerprint: None,
1394 })?;
1395 }
1396 BeforeFail::Prompt => {
1397 reporter.prompt(&Prompt::Secret {
1398 label: "Enter PIN".to_string(),
1399 })?;
1400 }
1401 BeforeFail::Deviation => {
1402 reporter.fact(StepFact::DeviationRecorded {
1403 step: None,
1404 text: "operator note".to_string(),
1405 })?;
1406 }
1407 }
1408 if *remaining == 0 {
1409 return Ok(StepResult::completed("done".to_string()));
1410 }
1411 *remaining = remaining.saturating_sub(1);
1412 Err((self.error)())
1413 }
1414 }
1415
1416 fn transient() -> ActionError {
1417 ActionError::Backend(BackendError::TokenNotPresent)
1418 }
1419
1420 fn ceremony_with_retry(retry_clause: &str) -> Ceremony {
1421 let yaml = format!(
1422 r#"
1423version: "0.3"
1424name: "Retry"
1425roles:
1426 participant:
1427 person: "Alice"
1428sections:
1429 main:
1430 role: ${{role.participant}}
1431 steps:
1432 work:
1433 action: attest
1434 silent: true
1435 {retry_clause}
1436 with:
1437 statement: "x"
1438"#
1439 );
1440 rite_resolver::resolve(&yaml, None)
1441 .into_result()
1442 .expect("resolve")
1443 }
1444
1445 fn drive(
1449 ceremony: Ceremony,
1450 action: Arc<dyn Action>,
1451 confirm_answer: bool,
1452 ) -> (
1453 Result<ExecutionSummary, ExecutionError>,
1454 Vec<StepFact>,
1455 usize,
1456 ) {
1457 drive_with_backends(ceremony, action, confirm_answer, BackendRegistry::new())
1458 }
1459
1460 fn drive_with_backends(
1463 ceremony: Ceremony,
1464 action: Arc<dyn Action>,
1465 confirm_answer: bool,
1466 backend_registry: BackendRegistry,
1467 ) -> (
1468 Result<ExecutionSummary, ExecutionError>,
1469 Vec<StepFact>,
1470 usize,
1471 ) {
1472 let mut registry = ActionRegistry::new();
1473 registry.register(action);
1474
1475 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1476 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1477 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1478
1479 let executor = Executor::new(
1480 ceremony,
1481 registry,
1482 backend_registry,
1483 dry_run_output_config(),
1484 true, StartupSnapshot::placeholder(),
1486 );
1487 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1488
1489 let mut facts = Vec::new();
1490 let mut confirms = 0usize;
1491 while let Ok(ev) = event_rx.recv() {
1492 match ev {
1493 ExecEvent::Fact { fact, .. } => facts.push(fact),
1494 ExecEvent::AwaitPrompt {
1495 prompt_id,
1496 prompt: Prompt::Confirm { .. },
1497 ..
1498 } => {
1499 confirms = confirms.saturating_add(1);
1500 cmd_tx
1501 .send(UiCommand::PromptResponse {
1502 prompt_id,
1503 response: Response::Bool(confirm_answer),
1504 })
1505 .expect("send confirm");
1506 }
1507 ExecEvent::AwaitPrompt {
1508 prompt_id,
1509 prompt: Prompt::Secret { .. },
1510 ..
1511 } => {
1512 cmd_tx
1513 .send(UiCommand::PromptResponse {
1514 prompt_id,
1515 response: Response::Secret("123456".to_string().into()),
1516 })
1517 .expect("send secret");
1518 }
1519 _ => {}
1520 }
1521 }
1522 let result = join.join().expect("executor join");
1523 (result, facts, confirms)
1524 }
1525
1526 fn attempt_numbers(facts: &[StepFact]) -> Vec<u32> {
1527 facts
1528 .iter()
1529 .filter_map(|f| match f {
1530 StepFact::StepAttemptFailed { attempt, .. } => Some(*attempt),
1531 _ => None,
1532 })
1533 .collect()
1534 }
1535
1536 #[test]
1537 fn transient_failure_is_retried_then_succeeds() {
1538 let action = FlakyAction::new(1, transient, BeforeFail::Nothing);
1539 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1540
1541 let summary = result.expect("run ok");
1542 assert_eq!(summary.steps_completed, 1);
1543 assert_eq!(confirms, 1, "one retry prompt");
1544 assert_eq!(attempt_numbers(&facts), vec![1]);
1545 let StepFact::StepAttemptFailed { error, .. } = facts
1547 .iter()
1548 .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1549 .expect("attempt fact")
1550 else {
1551 unreachable!()
1552 };
1553 assert_eq!(error.class, ErrorClass::Environmental);
1554 assert!(
1555 facts
1556 .iter()
1557 .any(|f| matches!(f, StepFact::StepCompleted { .. }))
1558 );
1559 }
1560
1561 #[test]
1562 fn retry_is_blocked_once_a_side_effect_was_emitted() {
1563 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::SideEffect);
1567 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1568
1569 assert!(result.is_err(), "run fails");
1570 assert_eq!(confirms, 0, "gate blocks the retry prompt");
1571 assert_eq!(attempt_numbers(&facts), vec![1]);
1572 assert!(
1573 facts
1574 .iter()
1575 .any(|f| matches!(f, StepFact::CeremonyFailed { .. }))
1576 );
1577 }
1578
1579 #[test]
1580 fn prompting_before_a_transient_failure_stays_retriable() {
1581 let action = FlakyAction::new(1, transient, BeforeFail::Prompt);
1585 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1586
1587 let summary = result.expect("run ok");
1588 assert_eq!(summary.steps_completed, 1);
1589 assert_eq!(confirms, 1, "the retry prompt fired");
1590 assert_eq!(attempt_numbers(&facts), vec![1]);
1591 let secret_answers = facts
1592 .iter()
1593 .filter(|f| {
1594 matches!(
1595 f,
1596 StepFact::PromptAnswered {
1597 prompt: Prompt::Secret { .. },
1598 ..
1599 }
1600 )
1601 })
1602 .count();
1603 assert_eq!(secret_answers, 2, "each attempt records its own answer");
1604 }
1605
1606 #[test]
1607 fn a_deviation_note_does_not_block_retry() {
1608 let action = FlakyAction::new(1, transient, BeforeFail::Deviation);
1609 let (result, _facts, confirms) = drive(ceremony_with_retry(""), action, true);
1610
1611 let summary = result.expect("run ok");
1612 assert_eq!(summary.steps_completed, 1);
1613 assert_eq!(confirms, 1, "the retry prompt fired");
1614 }
1615
1616 #[test]
1617 fn backend_acquisition_failure_is_retriable() {
1618 use std::sync::atomic::{AtomicU32, Ordering};
1619
1620 struct NullBackend {
1624 name: String,
1625 }
1626 impl Backend for NullBackend {
1627 fn name(&self) -> &str {
1628 &self.name
1629 }
1630 fn provider(&self) -> &'static str {
1631 "mock"
1632 }
1633 fn fingerprint(&self) -> String {
1634 format!("mock={}", self.name)
1635 }
1636 }
1637
1638 let probes = Arc::new(AtomicU32::new(0));
1639 let probes_in_factory = Arc::clone(&probes);
1640 let mut backends =
1641 crate::backend::BackendRegistry::with_factory(Box::new(move |_name, _config| {
1642 if probes_in_factory.fetch_add(1, Ordering::SeqCst) == 0 {
1643 Err(BackendError::HardwareFailure("no device".to_string()))
1644 } else {
1645 Ok(Box::new(NullBackend {
1646 name: "token".to_string(),
1647 }) as Box<dyn Backend>)
1648 }
1649 }));
1650 backends.declare(
1651 "token".to_string(),
1652 rite_sdk::BackendConfig {
1653 provider: "mock".to_string(),
1654 extra: serde_json::json!({}),
1655 },
1656 );
1657
1658 let yaml = r#"
1659version: "0.3"
1660name: "Retry"
1661backends:
1662 token:
1663 provider: mock
1664roles:
1665 participant:
1666 person: "Alice"
1667sections:
1668 main:
1669 role: ${role.participant}
1670 steps:
1671 work:
1672 action: attest
1673 backend: token
1674 silent: true
1675 with:
1676 statement: "x"
1677"#;
1678 let ceremony = rite_resolver::resolve(yaml, None)
1679 .into_result()
1680 .expect("resolve");
1681
1682 let action = FlakyAction::new(0, transient, BeforeFail::Nothing);
1683 let (result, facts, confirms) = drive_with_backends(ceremony, action, true, backends);
1684
1685 let summary = result.expect("run ok after the operator retried");
1686 assert_eq!(summary.steps_completed, 1);
1687 assert_eq!(confirms, 1, "the retry prompt fired");
1688 assert_eq!(attempt_numbers(&facts), vec![1]);
1689 let StepFact::StepAttemptFailed { error, .. } = facts
1690 .iter()
1691 .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1692 .expect("attempt fact")
1693 else {
1694 unreachable!()
1695 };
1696 assert_eq!(error.class, ErrorClass::Environmental);
1697 assert_eq!(probes.load(Ordering::SeqCst), 2, "hardware probed again");
1698 }
1699
1700 #[test]
1701 fn retry_never_fails_immediately() {
1702 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1703 let (result, facts, confirms) = drive(ceremony_with_retry("retry: never"), action, true);
1704
1705 assert!(result.is_err());
1706 assert_eq!(confirms, 0, "retry: never never prompts");
1707 assert_eq!(attempt_numbers(&facts), vec![1]);
1708 }
1709
1710 #[test]
1711 fn retry_attempts_caps_total_tries() {
1712 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1713 let (result, facts, confirms) =
1714 drive(ceremony_with_retry("retry: {attempts: 2}"), action, true);
1715
1716 assert!(result.is_err());
1717 assert_eq!(confirms, 1);
1720 assert_eq!(attempt_numbers(&facts), vec![1, 2]);
1721 }
1722
1723 #[test]
1724 fn declining_the_retry_prompt_terminates() {
1725 let action = FlakyAction::new(u32::MAX, transient, BeforeFail::Nothing);
1726 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, false);
1727
1728 assert!(result.is_err());
1729 assert_eq!(confirms, 1, "prompted once, operator declined");
1730 assert_eq!(attempt_numbers(&facts), vec![1]);
1731 }
1732
1733 fn fact_kind(fact: &StepFact) -> &'static str {
1734 match fact {
1735 StepFact::CeremonyStarted { .. } => "ceremony_started",
1736 StepFact::ActStarted { .. } => "act_started",
1737 StepFact::StepStarted { .. } => "step_started",
1738 StepFact::PromptAnswered { .. } => "prompt_answered",
1739 StepFact::BackendOperation { .. } => "backend_operation",
1740 StepFact::AttestationRecorded { .. } => "attestation_recorded",
1741 StepFact::ArtifactWritten { .. } => "artifact_written",
1742 StepFact::DeviationRecorded { .. } => "deviation_recorded",
1743 StepFact::StepAttemptFailed { .. } => "step_attempt_failed",
1744 StepFact::StepCompleted { .. } => "step_completed",
1745 StepFact::CeremonyCompleted { .. } => "ceremony_completed",
1746 StepFact::CeremonyFailed { .. } => "ceremony_failed",
1747 StepFact::EntropySeeded { .. } => "entropy_seeded",
1748 StepFact::EntropyContributed { .. } => "entropy_contributed",
1749 StepFact::EntropyDrawn { .. } => "entropy_drawn",
1750 _ => "unknown",
1751 }
1752 }
1753}