use std::collections::HashMap;
use std::sync::Arc;
use crossbeam_channel::{Receiver, Sender};
use rand::TryRng;
use rand::rngs::SysRng;
use rite_model::{ActId, ActionType, ArtifactId, Ceremony, MaterialId, OutputId, ParamId, RoleId};
use rite_sdk::{Backend, BackendError};
use thiserror::Error;
use crate::actions::ActionMetadata;
use crate::backend::BackendRegistry;
use crate::clock::{Clock, SystemClock};
use crate::entropy::DERIVATION_V1;
use crate::executor::{
ExecutionError, load_material_artifact, step_info_from, write_artifact_to_disk,
};
use crate::expressions;
use crate::output_config::OutputConfig;
use crate::protocol::{ExecEvent, Icon, MaterialOverview, UiCommand, UiSignal};
use crate::reporter::{Reporter, ReporterError};
use crate::state::{ExecutionState, HandlerContext, StepResult};
use crate::step_info::StepInfo;
use crate::system_info::StartupSnapshot;
use crate::transcript_sink::{TranscriptFingerprint, TranscriptSink};
use rite_model::{ErrorRecord, Prompt, StepFact, StepOutcome};
fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), ExecutionError> {
let mut m = [0u8; 32];
if dry_run {
for (slot, byte) in m.iter_mut().zip(b"rite-dry-run-not-real-entropy") {
*slot = *byte;
}
return Ok((m, "dry-run".to_string()));
}
SysRng
.try_fill_bytes(&mut m)
.map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
Ok((m, "os".to_string()))
}
#[derive(Debug, Error)]
pub enum ActionError {
#[error("ceremony aborted by operator")]
Aborted,
#[error("frontend channel disconnected")]
Disconnected,
#[error("transcript write failed: {0}")]
Transcript(std::io::Error),
#[error("backend error: {0}")]
Backend(#[from] BackendError),
#[error("{0}")]
Failed(String),
}
impl From<ReporterError> for ActionError {
fn from(value: ReporterError) -> Self {
match value {
ReporterError::Aborted => ActionError::Aborted,
ReporterError::Disconnected => ActionError::Disconnected,
ReporterError::Transcript(e) => ActionError::Transcript(e),
ReporterError::NoCurrentStep(_)
| ReporterError::DuplicateDraw { .. }
| ReporterError::Unseeded
| ReporterError::DrawTooLong { .. } => ActionError::Failed(value.to_string()),
}
}
}
pub fn parse_params<P: serde::de::DeserializeOwned>(
params: &serde_json::Value,
) -> Result<P, ActionError> {
serde_json::from_value(params.clone())
.map_err(|e| ActionError::Failed(format!("invalid params: {e}")))
}
impl From<ReporterError> for ExecutionError {
fn from(value: ReporterError) -> Self {
match value {
ReporterError::Aborted => ExecutionError::StepAborted(rite_model::StepId::new("")),
ReporterError::Disconnected => {
ExecutionError::TranscriptError("frontend channel disconnected".to_string())
}
ReporterError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
ReporterError::NoCurrentStep(_) => ExecutionError::TranscriptError(value.to_string()),
ReporterError::DuplicateDraw { .. }
| ReporterError::Unseeded
| ReporterError::DrawTooLong { .. } => ExecutionError::EntropyError(value.to_string()),
}
}
}
pub trait Action: Send + Sync {
fn metadata(&self) -> ActionMetadata;
fn apply_defaults(&self, _params: &mut serde_json::Value, _step: &StepInfo) {}
fn execute(
&self,
step: &StepInfo,
ctx: &HandlerContext,
params: &serde_json::Value,
reporter: &mut Reporter<'_>,
backend: Option<&mut dyn Backend>,
) -> Result<StepResult, ActionError>;
}
#[derive(Default)]
pub struct ActionRegistry {
actions: HashMap<ActionType, Arc<dyn Action>>,
}
impl ActionRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, action: Arc<dyn Action>) {
let action_type = action.metadata().action_type;
self.actions.insert(action_type, action);
}
#[must_use]
pub fn get(&self, action: &ActionType) -> Option<&Arc<dyn Action>> {
self.actions.get(action)
}
pub fn action_types(&self) -> impl Iterator<Item = &ActionType> {
self.actions.keys()
}
#[must_use]
pub fn unsupported_actions(&self, steps: &[rite_model::Step]) -> Vec<ActionType> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for step in steps {
if !self.actions.contains_key(&step.action) && seen.insert(step.action) {
result.push(step.action);
}
}
result
}
}
#[derive(Debug)]
pub struct ExecutionSummary {
pub ceremony_name: String,
pub steps_completed: usize,
pub transcript_fingerprint: TranscriptFingerprint,
}
pub struct Executor {
ceremony: Ceremony,
registry: ActionRegistry,
backend_registry: BackendRegistry,
output_config: OutputConfig,
dry_run: bool,
startup: StartupSnapshot,
clock: Arc<dyn Clock>,
}
impl Executor {
#[must_use]
pub fn new(
ceremony: Ceremony,
registry: ActionRegistry,
backend_registry: BackendRegistry,
output_config: OutputConfig,
dry_run: bool,
startup: StartupSnapshot,
) -> Self {
Self {
ceremony,
registry,
backend_registry,
output_config,
dry_run,
startup,
clock: Arc::new(SystemClock),
}
}
#[must_use]
pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
self.clock = clock;
self
}
pub fn run(
self,
cmd_rx: &Receiver<UiCommand>,
event_tx: &Sender<ExecEvent>,
mut transcript_sink: Box<dyn TranscriptSink>,
) -> Result<ExecutionSummary, ExecutionError> {
validate_parameters(&self.ceremony)?;
let resolved_params: HashMap<ParamId, serde_json::Value> = self
.ceremony
.parameters
.iter()
.map(|(id, p)| (id.clone(), p.value.clone()))
.collect();
let roles: HashMap<RoleId, String> = self
.ceremony
.roles
.iter()
.map(|(id, r)| (id.clone(), r.name.clone()))
.collect();
let materials_map: HashMap<MaterialId, String> = self
.ceremony
.materials
.iter()
.map(|(id, m)| (id.clone(), m.display_name().to_string()))
.collect();
let ceremony_name = self.ceremony.metadata.name.clone();
let clock = Arc::clone(&self.clock);
let mut reporter = Reporter::new(
event_tx,
cmd_rx,
transcript_sink.as_mut(),
Arc::clone(&clock),
);
let outcome = self.execute_inner(&mut reporter, resolved_params, roles, materials_map);
match outcome {
Ok(counts) => {
let at = clock.now();
let completed = StepFact::CeremonyCompleted {};
transcript_sink
.record(at, &completed)
.map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
let _ = event_tx.send(ExecEvent::Fact {
at,
fact: completed,
});
let fingerprint = transcript_sink
.finalize()
.map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
let _ = event_tx.send(ExecEvent::Finalized {
fingerprint: fingerprint.as_str().to_string(),
});
Ok(ExecutionSummary {
ceremony_name,
steps_completed: counts.completed,
transcript_fingerprint: fingerprint,
})
}
Err(err) => {
let at = clock.now();
let record = err.to_error_record();
let failed = StepFact::CeremonyFailed { error: record };
let _ = transcript_sink.record(at, &failed);
let _ = event_tx.send(ExecEvent::Fact { at, fact: failed });
if let Ok(fingerprint) = transcript_sink.finalize() {
let _ = event_tx.send(ExecEvent::Finalized {
fingerprint: fingerprint.as_str().to_string(),
});
}
Err(err)
}
}
}
#[allow(clippy::too_many_lines)]
fn execute_inner(
mut self,
reporter: &mut Reporter<'_>,
resolved_params: HashMap<ParamId, serde_json::Value>,
roles: HashMap<RoleId, String>,
materials_map: HashMap<MaterialId, String>,
) -> Result<StepCounts, ExecutionError> {
let dry_run = self.dry_run;
reporter.fact(StepFact::CeremonyStarted {
name: self.ceremony.metadata.name.clone(),
})?;
let (m, source) = gather_machine_entropy(dry_run)?;
reporter.seed_entropy(&m);
reporter.fact(StepFact::EntropySeeded {
m: base16ct::lower::encode_string(&m),
source,
derivation: DERIVATION_V1.to_string(),
})?;
reporter.signal(UiSignal::CeremonyOverview {
description: self.ceremony.metadata.description.clone(),
materials: self
.ceremony
.materials
.iter()
.map(|(_, m)| MaterialOverview::from_material(m))
.collect(),
step_count: self.ceremony.execution_plan.len(),
})?;
reporter.signal(UiSignal::SystemInfo(Box::new(self.startup.system.clone())))?;
reporter.signal(UiSignal::Environment(self.startup.environment.clone()))?;
if !dry_run {
reporter.prompt(&Prompt::Continue {
hint: Some("Press Enter to start the ceremony".to_string()),
})?;
}
let mut state = ExecutionState::new(resolved_params, roles, materials_map, dry_run);
for (id, material) in self.ceremony.materials.iter() {
let artifact = load_material_artifact(id.as_str(), material)?;
state = state.with_material(ArtifactId::new(id.as_str()), artifact);
let display_name = material.display_name();
reporter.log(Icon::Checkmark, format!("Loaded material: {display_name}"))?;
}
let has_acts = !self.ceremony.acts.is_empty();
let mut current_act: Option<ActId> = None;
let mut counts = StepCounts::default();
let plan = std::mem::take(&mut self.ceremony.execution_plan);
for step in &plan {
if has_acts {
let step_act = self
.ceremony
.sections
.get(&step.section)
.and_then(|s| s.act.clone());
if step_act != current_act {
if let Some(act_id) = &step_act
&& let Some(act) = self.ceremony.acts.get(act_id)
{
reporter.fact(StepFact::ActStarted {
id: act_id.clone(),
label: act
.name
.clone()
.unwrap_or_else(|| act_id.as_str().to_string()),
})?;
}
current_act = step_act;
}
}
reporter.set_current_step(Some(step.id.clone()));
let role_id = step.role.clone().unwrap_or_else(|| RoleId::new(""));
let role_name = self
.ceremony
.roles
.get(&role_id)
.map_or_else(|| role_id.as_str().to_string(), |r| r.name.clone());
reporter.fact(StepFact::StepStarted {
id: step.id.clone(),
label: step.step_label.clone(),
role: role_id,
role_name,
})?;
if !step.silent && !dry_run {
reporter.prompt(&Prompt::Continue {
hint: Some(format!("Press Enter to start step {}", step.step_label)),
})?;
}
let handler = self
.registry
.get(&step.action)
.ok_or(ExecutionError::UnknownAction(step.action))?
.clone();
let ctx = state.handler_context();
let step_info = step_info_from(step);
let mut params = expressions::evaluate_expr_value(&step.with, &ctx)?;
handler.apply_defaults(&mut params, &step_info);
let backend = if let Some(name) = &step_info.backend {
Some(self.backend_registry.get_mut(name).map_err(|e| {
ExecutionError::StepFailed {
step: step_info.id.clone(),
reason: e.to_string(),
}
})?)
} else {
None
};
let result = handler
.execute(&step_info, &ctx, ¶ms, reporter, backend)
.map_err(|err| match err {
ActionError::Aborted => ExecutionError::StepAborted(step.id.clone()),
ActionError::Disconnected => ExecutionError::StepFailed {
step: step.id.clone(),
reason: "frontend channel disconnected".to_string(),
},
ActionError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
ActionError::Backend(e) => ExecutionError::BackendError(e),
ActionError::Failed(reason) => ExecutionError::StepFailed {
step: step.id.clone(),
reason,
},
})?;
reporter.fact(StepFact::StepCompleted {
id: step.id.clone(),
outcome: result.outcome.clone(),
})?;
if let StepOutcome::Completed { .. } = &result.outcome {
counts.completed = counts.completed.saturating_add(1);
}
state = merge_step_result(state, result);
if let Some(artifact_id) = &step.creates
&& !dry_run
{
let output_id = OutputId::new(artifact_id.as_str());
if self.ceremony.outputs.contains(&output_id) {
std::fs::create_dir_all(self.output_config.artifacts_dir()).map_err(|e| {
ExecutionError::OutputWriteFailed {
name: "artifacts directory".to_string(),
reason: e.to_string(),
}
})?;
let artifact_value = state.artifacts.get(artifact_id).ok_or_else(|| {
ExecutionError::OutputWriteFailed {
name: artifact_id.as_str().to_string(),
reason: "artifact not produced".to_string(),
}
})?;
let (path, hash, _size, _mime_type) =
write_artifact_to_disk(artifact_id, artifact_value, &self.output_config)?;
reporter.fact(StepFact::ArtifactWritten {
step: step.id.clone(),
name: artifact_id.as_str().to_string(),
path: path.clone(),
sha256: hash,
})?;
}
}
}
reporter.set_current_step(None);
drop(plan);
Ok(counts)
}
}
#[derive(Default, Debug, Clone, Copy)]
struct StepCounts {
completed: usize,
}
impl ExecutionError {
fn to_error_record(&self) -> ErrorRecord {
let kind = match self {
ExecutionError::ValidationFailed(_) => "validation_failed",
ExecutionError::StepAborted(_) => "aborted",
ExecutionError::Io(_) => "io",
ExecutionError::StepFailed { .. } => "step_failed",
ExecutionError::UnknownAction(_) => "unknown_action",
ExecutionError::InvalidParams(_) => "invalid_params",
ExecutionError::EntropyError(_) => "entropy_error",
ExecutionError::MaterialLoadFailed { .. } => "material_load_failed",
ExecutionError::OutputWriteFailed { .. } => "output_write_failed",
ExecutionError::TranscriptError(_) => "transcript_error",
ExecutionError::BackendError(_) => "backend_error",
};
ErrorRecord::new(kind, self.to_string())
}
}
fn validate_parameters(ceremony: &Ceremony) -> Result<(), ExecutionError> {
let missing: Vec<_> = ceremony
.parameters
.iter()
.filter(|(_, p)| p.value.is_null())
.map(|(id, _)| id.as_str().to_string())
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(ExecutionError::ValidationFailed(format!(
"Parameters missing values: {}",
missing.join(", ")
)))
}
}
fn merge_step_result(state: ExecutionState, result: StepResult) -> ExecutionState {
let mut artifacts = state.artifacts;
for (id, value) in result.artifacts {
artifacts.insert(id, value);
}
ExecutionState {
params: state.params,
roles: state.roles,
materials: state.materials,
dry_run: state.dry_run,
artifacts,
}
}
#[cfg(test)]
mod tests {
use crossbeam_channel::unbounded;
use rite_model::ActionType;
use super::*;
use crate::actions::ActionCategory;
use crate::transcript_sink::InMemorySink;
struct PingAction;
impl Action for PingAction {
fn metadata(&self) -> ActionMetadata {
ActionMetadata {
action_type: ActionType::Attest,
description: "test action that always succeeds",
category: ActionCategory::Verification,
}
}
fn execute(
&self,
_step: &StepInfo,
_ctx: &HandlerContext,
_params: &serde_json::Value,
reporter: &mut Reporter<'_>,
_backend: Option<&mut dyn Backend>,
) -> Result<StepResult, ActionError> {
reporter.log(Icon::Info, "ping")?;
Ok(StepResult::completed("pinged".to_string()))
}
}
fn minimal_ceremony() -> Ceremony {
let yaml = r#"
version: "0.2"
name: "Test"
roles:
participant:
person: "Alice"
sections:
main:
role: ${role.participant}
steps:
ping:
action: attest
silent: true
with:
statement: "I confirm."
"#;
rite_resolver::resolve(yaml, None)
.into_result()
.expect("resolve")
}
fn dry_run_output_config() -> OutputConfig {
OutputConfig::new(std::path::PathBuf::from("/tmp/runner-test"))
}
#[test]
fn runs_a_minimal_ceremony_and_emits_lifecycle_facts() {
let ceremony = minimal_ceremony();
let mut registry = ActionRegistry::new();
registry.register(Arc::new(PingAction));
let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let (event_tx, event_rx) = unbounded::<ExecEvent>();
let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
let executor = Executor::new(
ceremony,
registry,
BackendRegistry::new(),
dry_run_output_config(),
true,
StartupSnapshot::placeholder(),
);
let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
let mut facts = Vec::new();
while let Ok(ev) = event_rx.recv() {
if let ExecEvent::Fact { fact, .. } = ev {
facts.push(fact);
}
}
let summary = join.join().expect("executor join").expect("run ok");
assert_eq!(summary.steps_completed, 1);
let kinds: Vec<&'static str> = facts.iter().map(fact_kind).collect();
assert_eq!(
kinds,
vec![
"ceremony_started",
"entropy_seeded",
"step_started",
"step_completed",
"ceremony_completed",
],
);
drop(cmd_tx);
}
#[test]
fn run_stamps_every_fact_from_the_injected_clock() {
use crate::test_support::{FixedClock, fixed_test_time};
let mut registry = ActionRegistry::new();
registry.register(Arc::new(PingAction));
let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let (event_tx, event_rx) = unbounded::<ExecEvent>();
let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
let fixed = fixed_test_time();
let executor = Executor::new(
minimal_ceremony(),
registry,
BackendRegistry::new(),
dry_run_output_config(),
true,
StartupSnapshot::placeholder(),
)
.with_clock(Arc::new(FixedClock(fixed)));
let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
let mut times = Vec::new();
while let Ok(ev) = event_rx.recv() {
if let ExecEvent::Fact { at, .. } = ev {
times.push(at);
}
}
join.join().expect("executor join").expect("run ok");
assert!(!times.is_empty());
assert!(times.iter().all(|&t| t == fixed));
drop(cmd_tx);
}
#[derive(Debug, PartialEq, Eq)]
enum PacingTrace {
CeremonyStarted,
StepStarted(String),
ContinuePrompt { step: Option<String> },
StepCompleted,
CeremonyCompleted,
}
#[test]
#[allow(clippy::too_many_lines)]
fn pacing_prompts_fire_at_step_start_not_step_end() {
let yaml = r#"
version: "0.2"
name: "Pacing"
roles:
participant:
person: "Alice"
sections:
main:
role: ${role.participant}
steps:
first:
action: attest
with:
statement: "first"
second:
action: attest
with:
statement: "second"
"#;
let ceremony = rite_resolver::resolve(yaml, None)
.into_result()
.expect("resolve");
let mut registry = ActionRegistry::new();
registry.register(Arc::new(PingAction));
let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let (event_tx, event_rx) = unbounded::<ExecEvent>();
let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
let executor = Executor::new(
ceremony,
registry,
BackendRegistry::new(),
dry_run_output_config(),
false, StartupSnapshot::placeholder(),
);
let frontend = std::thread::spawn({
let cmd_tx = cmd_tx.clone();
move || {
let mut trace = Vec::new();
while let Ok(event) = event_rx.recv() {
match event {
ExecEvent::Fact {
fact: StepFact::CeremonyStarted { .. },
..
} => {
trace.push(PacingTrace::CeremonyStarted);
}
ExecEvent::Fact {
fact: StepFact::StepStarted { label, .. },
..
} => {
trace.push(PacingTrace::StepStarted(label));
}
ExecEvent::Fact {
fact: StepFact::StepCompleted { .. },
..
} => {
trace.push(PacingTrace::StepCompleted);
}
ExecEvent::Fact {
fact: StepFact::CeremonyCompleted { .. },
..
} => {
trace.push(PacingTrace::CeremonyCompleted);
}
ExecEvent::AwaitPrompt {
prompt_id,
prompt: Prompt::Continue { .. },
step,
..
} => {
trace.push(PacingTrace::ContinuePrompt {
step: step.map(|s| s.as_str().to_string()),
});
let _ = cmd_tx.send(UiCommand::PromptResponse {
prompt_id,
response: crate::protocol::Response::Acknowledge,
});
}
_ => {}
}
}
trace
}
});
let summary = executor
.run(&cmd_rx, &event_tx, sink)
.expect("ceremony runs");
assert_eq!(summary.steps_completed, 2);
drop(event_tx);
drop(cmd_tx);
let trace = frontend.join().expect("frontend join");
let mut iter = trace.iter();
assert_eq!(iter.next(), Some(&PacingTrace::CeremonyStarted));
assert_eq!(
iter.next(),
Some(&PacingTrace::ContinuePrompt { step: None })
);
let first_step = match iter.next() {
Some(PacingTrace::StepStarted(label)) => label.clone(),
other => panic!("expected first StepStarted, got {other:?}"),
};
assert_eq!(
iter.next(),
Some(&PacingTrace::ContinuePrompt {
step: Some("first".to_string()),
}),
"first step's Continue prompt must fire after StepStarted and before the body",
);
assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
let second_step = match iter.next() {
Some(PacingTrace::StepStarted(label)) => label.clone(),
other => panic!("expected second StepStarted, got {other:?}"),
};
assert_eq!(
iter.next(),
Some(&PacingTrace::ContinuePrompt {
step: Some("second".to_string()),
}),
);
assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
assert_eq!(iter.next(), Some(&PacingTrace::CeremonyCompleted));
assert_eq!(iter.next(), None, "no further events expected");
assert_eq!(first_step, "1");
assert_eq!(second_step, "2");
}
#[test]
fn aborts_when_action_returns_aborted() {
struct AbortAction;
impl Action for AbortAction {
fn metadata(&self) -> ActionMetadata {
ActionMetadata {
action_type: ActionType::Attest,
description: "aborts immediately",
category: ActionCategory::Verification,
}
}
fn execute(
&self,
_step: &StepInfo,
_ctx: &HandlerContext,
_params: &serde_json::Value,
_reporter: &mut Reporter<'_>,
_backend: Option<&mut dyn Backend>,
) -> Result<StepResult, ActionError> {
Err(ActionError::Aborted)
}
}
let ceremony = minimal_ceremony();
let mut registry = ActionRegistry::new();
registry.register(Arc::new(AbortAction));
let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
let (event_tx, event_rx) = unbounded::<ExecEvent>();
let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
let executor = Executor::new(
ceremony,
registry,
BackendRegistry::new(),
dry_run_output_config(),
true,
StartupSnapshot::placeholder(),
);
let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
let mut got_failed = false;
while let Ok(ev) = event_rx.recv() {
if let ExecEvent::Fact {
fact: StepFact::CeremonyFailed { error, .. },
..
} = ev
{
assert_eq!(error.kind, "aborted");
got_failed = true;
}
}
assert!(got_failed, "expected CeremonyFailed event");
let result = join.join().expect("executor join");
assert!(matches!(result, Err(ExecutionError::StepAborted(_))));
}
fn fact_kind(fact: &StepFact) -> &'static str {
match fact {
StepFact::CeremonyStarted { .. } => "ceremony_started",
StepFact::ActStarted { .. } => "act_started",
StepFact::StepStarted { .. } => "step_started",
StepFact::PromptAnswered { .. } => "prompt_answered",
StepFact::BackendOperation { .. } => "backend_operation",
StepFact::AttestationRecorded { .. } => "attestation_recorded",
StepFact::ArtifactWritten { .. } => "artifact_written",
StepFact::DeviationRecorded { .. } => "deviation_recorded",
StepFact::StepCompleted { .. } => "step_completed",
StepFact::CeremonyCompleted { .. } => "ceremony_completed",
StepFact::CeremonyFailed { .. } => "ceremony_failed",
StepFact::EntropySeeded { .. } => "entropy_seeded",
StepFact::EntropyContributed { .. } => "entropy_contributed",
StepFact::EntropyDrawn { .. } => "entropy_drawn",
_ => "unknown",
}
}
}